CityRP Documentation Repository

Item Metadata Schema

Last updated

This document describes the schema-based item metadata system.

Why this exists

Legacy metadata was stored as opaque strings (for example plate=ABC-123) and parsed manually in many files. That made refactors brittle and made field removal unsafe.

The schema system allows items or bases to declare supported metadata fields and silently ignore removed/unknown fields.

Core APIs

Shared library: core/libraries/sh_itemmeta.lua

  • cityrp.itemmeta.GetSchema(itemOrID)
  • cityrp.itemmeta.GetEditableFields(itemOrID)
  • cityrp.itemmeta.Encode(data)
  • cityrp.itemmeta.Normalize(itemOrID, rawMeta)
  • cityrp.itemmeta.NormalizeKey(itemOrID, rawMeta)
  • cityrp.itemmeta.Decode(itemOrID, rawMeta)
  • cityrp.itemmeta.GetField(itemOrID, rawMeta, field, default)
  • cityrp.itemmeta.SetField(itemOrID, rawMeta, field, value)
  • cityrp.itemmeta.RemoveField(itemOrID, rawMeta, field)
  • cityrp.itemmeta.NewContext(itemOrID, rawMeta)
  • cityrp.itemmeta.Commit(ply, itemID, amount, oldMeta, context)
  • cityrp.itemmeta.FormatName(itemOrID, baseName, rawMeta)
  • cityrp.itemmeta.FormatDetails(itemOrID, rawMeta)
  • cityrp.itemmeta.NormalizeDisplayKey(itemOrID, rawMeta)
  • cityrp.itemmeta.GetMatchingStacks(itemOrID, stacks, inputMeta)
  • cityrp.itemmeta.ResolveInventoryKey(itemOrID, stacks, inputMeta)

Item hooks receive (..., meta, metaContext) — raw meta string first, then the mutable context. Dirty context is committed automatically from onUse when the item is kept (return false) and from onGive before the inventory transfer. Paths that remove the item (destroy/sell/drop/place) do not commit because the stack is leaving inventory.

Declaring schema on items

Use item metatable helpers:

  • ITEM:SetMetaSchema(schema)
  • ITEM:AddMetaField(name, definition)
  • ITEM:RemoveMetaField(name)
  • ITEM:SetMetaFieldOrder(order)
  • ITEM:MetaContext(meta)

Schema shape:

:SetMetaSchema({
	fields = {
		plate = {
			type = "string",
			label = "Plate"
		},
		turbo = {
			type = "boolean",
			title = false
		}
	},
	order = {"plate", "turbo"}
})

Fields are optional by default: a missing value is omitted unless default is set.

Hidden fields

Mark field definitions with hidden = true to keep them in the storage/stacking key while excluding them from F1 names, F1 detail text, and display-key grouping:

appearance = {
	type = "string",
	hidden = true
}

Storage still uses the full canonical key (NormalizeKey). UI and fuzzy matching use the visible subset (NormalizeDisplayKey).

Title vs details

Mark a field with title = false to keep it in F1 details / display keys while omitting it from the short inventory title:

springs = {
	type = "number",
	label = "Springs",
	title = false
}

Example with base_vehicle (plate in title, springs/turbo details-only):

  • title: 2013 Government Taurus (TEST)
  • details: Plate: TEST, Springs: 3, Turbo: On

springs and turbo are applied at spawn through cityrp.vehicleparams (see vehicle-params.md). Additional upgrade fields should register modifiers there rather than patching SpawnVehicle directly.

Fuzzy inventory matching

GetMatchingStacks and ResolveInventoryKey support:

  • exact canonical keys
  • legacy raw keys before normalization
  • partial visible-field matches (for example plate=ABC-123)
  • bare values for single visible-field items (for example ABC-123 on license plates)

When multiple hidden variants share the same visible metadata, binds that only specify visible fields resolve to the first stack for single-quantity use/pickup/remove commands. Multi-quantity or trade commands require a specific stack or the F1 menu.

Testing metadata (including multi-field)

/giveitem accepts a meta: argument immediately after the amount. Multiple fields are separated with ; (also & / |):

/giveitem <player> Policecar 1 meta:plate=TEST;springs=3;turbo=1 testing multi meta
/giveitem <player> license_plate 1 meta:plate=ABC123 testing plate item

base_vehicle already declares plate, springs, and turbo, so any vehicle item is a convenient multi-meta test target. After giving, F1 should show:

  • title: 2013 Government Taurus (TEST) (springs/turbo use title = false)
  • details line: Plate: TEST, Springs: 3, Turbo: On

Boolean fields accept 1 / 0 / true / false. Do not put spaces inside the meta: token (argument parsing splits on spaces).

Building the metadata in a menu instead

Running /giveitem or /takeitem with no arguments at all opens a picker (the itemgrantmenu plugin) instead of the usage error. It builds one input per schema field from GetEditableFields, so the field names, types, enums, min/max and defaults come straight from the item rather than having to be remembered — a boolean or enum field gets a drop-down, a number field an entry that coerces and clamps to its declared range, and a string field free text.

The metadata section only appears for items that declare a schema. There is no free-text passthrough for items that don't: a value with no field to hold it and no type to check it against is exactly what the schema exists to prevent. Taking a stack that already carries schemaless metadata still works — the stored value is passed back untouched — it just cannot be typed in by hand. Use the meta: token for that.

A field that declares no label is captioned from its own key (serial_number becomes "Serial number"), which is also the fallback FormatDetails uses, so the menu and the F1 detail line caption a field the same way.

The menu submits through the ordinary command handler, so every check, notification and log entry is identical to typing the command. It is not restricted by the token's space rule, though: because the arguments are already split when they reach the handler, a value with a space in it (BAD C0P) can be entered in the menu even though it cannot be typed as meta:plate=BAD C0P.

In take mode the item list starts on the target's actual stacks, showing each one's quantity and its formatted detail text, so an item held under several metadata variants can be picked apart precisely.

Silent field removal

Mark field definitions with removed = true (or use ITEM:RemoveMetaField("field")).

When metadata is normalized:

  • removed fields are dropped silently
  • unknown fields are dropped silently for schema-based items
  • invalid values are dropped silently (or clamped when min/max is set)

Migration file

Inventory meta is normalized by migration 0006_normalize_item_meta_schema:

  • empty/NULL meta values become the canonical empty key "0"
  • stacks that would collide on (steamid, item, meta) after that rewrite are merged (quantities summed) before the update
  • plate values are left unchanged — spaces in plates are intentional (e.g. BAD C0P ≠ BADC0P)

It runs automatically via the migrations runner (core/libraries/sv_migrations.lua).