PalForge
Concepts

What goes in a definition

Every field you can write, what each one does, and the exact message you get when something is wrong

What you can do after this page

  • Write a pal, an item or a building and have the game accept it the first time
  • Read the message PalForge prints when a definition is wrong, and change the exact line it names
  • Ask the game, while it is running, which fields a pal or a building takes
  • Reuse one model across several pals instead of writing it out again
  • Build a definition without registering it, and record which pack owns an id
  • Check your own pack's settings the same way, so a typo stops at load instead of mid-game

Every X{ ... } call — Pal{ ... }, Item{ ... }, Building{ ... } and the rest — is checked against a list of allowed fields before anything reaches the game. When everything fits, you get your definition back. When it does not, the call stops and raises a message that names the domain (the kind of thing you were defining), the field, and the reason. A definition never half-succeeds: either all of it is registered or none of it is.

The message carries no file name or line number in front of it. It lands in the UE4SS log — the text file the mod loader writes while the game runs — as one line you can search for.

What a field list looks like

Each domain writes down the fields it accepts, once, as plain data. Here is the real list for a mesh (the 3D model attached to a pal or a building). It shows every key you can put on a field in one place.

Scripts/palforge/api/mesh.lua
local schema = require("palforge.core.schema")

local Spec = schema.define("Mesh.Spec", {
    { "id",        type = "string", check = schema.nonEmpty,
                   doc = "mesh id, e.g. \"pack:name\" (required when defined directly; omit when inline)" },
    { "kind",      type = "string", values = { "procedural", "static", "skeletal", "obj" },
                   default = "skeletal", doc = "which core.mesh backend renders it" },
    { "model",     type = "string", required = true,
                   doc = "a /Game/... USkeletalMesh or UStaticMesh path (see Mesh.assets); for procedural / obj, an .obj file path - absolute, or relative to the .lua file that declares it" },
    { "animClass", type = "string",
                   doc = "a /Game/... ABP path, with or without the _C tail (see Mesh.assets.ABP); skeletal only" },
    { "scale",     type = "number", doc = "uniform scale applied to the attached mesh" },
    { "offset",    type = "table",  doc = "{ x, y, z } offset from the mesh's normal position, in cm" },
    { "texture",   type = "string",
                   doc = "a /Game/... UTexture2D path (see Mesh.assets.T), or a png of your own - absolute, or relative to the .lua file that declares it" },
    { "color",     type = "table",  doc = "tint { r, g, b, a } in 0..1" },
    { "material",  type = "string", doc = "base material asset path to instance from" },
    { "params",    type = "table",
                   doc = "extra material parameters: { vector = { name = {r,g,b,a} }, scalar = { name = n }, texture = { name = \"/Game/... or <abs png>\" } }" },
}, { handle = "Mesh.Handle" })

The field name is the first item in each row. Everything else is a named key on that same row.

fields is an array, not a map, so the order you write is the order you get back — it drives :help() and the completions your editor shows.

schema.define(name, fields, opts) hands back a spec object with this surface:

Spec:validate(t, context)   -- a validated COPY with defaults filled; raises on any problem
Spec:help()                 -- the printable field list, one line per field
Spec:field("model")         -- one field descriptor by name, or nil
Spec.fields                 -- the ordered array of descriptors
Spec.name                   -- "Mesh.Spec"
Spec.handle                 -- "Mesh.Handle", or nil

Every X{ ... } call runs its own list first, then builds the definition out of the copy it gets back:

Scripts/palforge/api/pal.lua
local function define(spec)
    spec = Spec:validate(spec, "Pal")
    -- spec is now a fresh plain table: unknown keys are impossible, defaults are filled
    ...
end

The second argument is the name printed at the front of every message from that call. Passing "Pal" is why you read PalForge: Pal: ... and not PalForge: Pal.Spec: .... Leave it out and the shape's own name is used.

Shape names are unique across everything declared. Asking for "Pal.Spec" a second time raises PalForge: schema.define: "Pal.Spec" is already declared, so pick a name nothing else uses when you declare a shape of your own.

What you can put on a field

A field row holds its name plus up to ten keys. These are all of them.

Prop

Type

type

The notation is a plain string. Write a union with a pipe, and the value passes when it matches any one alternative.

{ "maxStack", type = "number" }                    -- Item.Spec
{ "state",    type = "table|function" }            -- Building.Spec
{ "icon" }                                         -- no type: anything is accepted

A table you can call stands in for a function wherever "function" is expected, so a table with a __call metamethod passes as an event handler:

local counter = setmetatable({ n = 0 }, {
    __call = function(self, pal, ctx) self.n = self.n + 1 end,
})

Pal{ id = "example:Boss", events = { onSpawned = counter } }   -- accepted

required

Checked only when you left the field out, and before default is looked at, so a field is never both required and defaulted.

{ "model", type = "string", required = true,
           doc = "a /Game/... USkeletalMesh or UStaticMesh path (see Mesh.assets); ..." }

id is required on every domain. Mesh.Spec is the one place it is optional, because a mesh written inline inside a pal or a building has nothing to name. Write Mesh{ ... } on its own and you still need an id: api/mesh checks for it separately, right after the field list runs.

default

A default fills a field you left out. The filled value then goes through the same type, values, arrayOf/mapOf, of and check steps as a value you wrote yourself.

{ "kind",         type = "string", default = "skeletal" }   -- Mesh.Spec
{ "tickInterval", type = "number", default = 1 }            -- Building.Spec
{ "stackable",    type = "boolean", default = false }       -- Effect.Spec

A function default is called, so each definition gets a fresh value of its own instead of sharing one table:

{ "tags", type = "table", default = function() return {} end }

None of PalForge's own field lists use a function default today, but yours can. Spec:help() prints default= only for a plain value, and skips the line for a function.

values

The value has to equal one of the entries. They are compared with ==, in order.

{ "kind",     type = "string", values = { "procedural", "static", "skeletal", "obj" } }  -- Mesh.Spec
{ "category", type = "string",
              values = { "material", "consumable", "equipment", "ammo", "ingredient", "other" } }  -- Item.Spec
{ "kind",     type = "string", values = { "active", "passive" } }                        -- Skill.Spec
{ "kind",     type = "string", values = { "se", "bgm" } }                                -- Audio.Spec

of

The value is checked against another shape, and the checked copy replaces it in the result. The message grows to name the inner shape, so a complaint about a field inside a nested table tells you which list to go and read.

{ "mesh",   type = "table", of = Mesh,   doc = "the mesh worn by a spawned pawn ..." }
{ "events", type = "table", of = Events, doc = "lifecycle handlers (grouped)" }
{ "recipe", type = "table", of = Recipe, doc = "the recipe that produces THIS item ..." }

arrayOf

Every element of the array part is type-checked with ipairs, and the index becomes part of the field name in the message.

{ "skills",   type = "table", arrayOf = "string" }   -- Pal.Spec
{ "buildIds", type = "table", arrayOf = "string" }   -- Building.Spec
Pal{ id = "example:Boss", skills = { "example:Fire", "example:Gust" } }   -- fine

Because it walks ipairs, a string key sitting inside an arrayOf table is never visited. Only the run of numbered entries is checked.

mapOf

Every value in the table is type-checked with pairs, and the key becomes part of the field name in the message. The array part is visited too, so materials = { "Wood" } reports materials.1.

{ "materials", type = "table", mapOf = "number", required = true,
               doc = "{ <itemId> = <count> } consumed by one craft" }   -- Item.Spec.Recipe
Item{
    id     = "example:Torch",
    recipe = { materials = { Wood = 3, Stone = 1 }, count = 2, station = "Workbench" },
}

doc

One line, shown by Spec:help() and written as the trailing comment on the generated ---@field line your editor reads. Validation uses it in exactly one place: the "is required" message quotes it in parentheses. A failing check quotes the check's own reason instead, never the doc.

sig

The signature LuaLS — the Lua language server behind editor completion — shows for a function field. Validation ignores it completely. Only tools/gen-types.lua reads it, and only to write a better type into types.lua.

{ "onSpawned", type = "function", sig = "fun(self: Pal.Handle, ctx: table)",
               doc = "LIVE - a pal finished initialising (ctx.actor); may repeat per pawn, keep it idempotent" }

check

A test of your own that runs after everything else, including nested of. Return true to accept. Return false plus a reason string to reject, and that reason is quoted back in the message.

core/schema.lua publishes two of them. schema.nonEmpty is the weak one — a non-empty string and nothing else:

Scripts/palforge/core/schema.lua
function M.nonEmpty(v)
    if type(v) == "string" and #v > 0 then return true end
    return false, "must be a non-empty string"
end

schema.validId is the strong one, and it is how id validation happens at define time. An id with no colon is a literal game id and only has to be non-empty. An id with a colon is namespaced, and both halves must match ^[%w_]+$ — the exact shape core/object_manager.resolve needs, because the row PalSchema writes for pack:name is spelled pack_name. schema.validId delegates to om.validId and keeps no second copy of the pattern. Five domains hang it on the field:

Scripts/palforge/api/pal.lua
{ "id",  type = "string", required = true, check = schema.validId,
         doc = "pal id: a game CharacterID (\"ChickenPal\") or \"pack:name\"" },

Item.Spec, Skill.Spec, Effect.Spec and Audio.Spec declare id the same way. The other three — Mesh, Building and UI — call om.validId directly inside their own define, because each wants a different sentence in front of the reason. All eight refuse the same ids:

PalForge: Pal: field "id" is invalid: invalid pack id 'my-pack' in 'my-pack:Boss' (letters/digits/_ only)
PalForge: Building: field "id" is invalid: invalid pack id 'my-pack' in 'my-pack:Bench' (letters/digits/_ only)
PalForge: Mesh: id "my-pack:body" is not a valid PalForge id: invalid pack id 'my-pack' in 'my-pack:body' (letters/digits/_ only)

A hyphen in the pack half is the case this catches. Building{ id = "my-pack:Bench" } registers cleanly and is then dead at every engine boundary — the icon lookup, the technology unlock and the build-id match all miss, with nothing in the log. The check turns that into a line you read while writing the definition.

Your own check follows the same contract:

{ "packId", type = "string", check = function(v)
      if v:find(":", 1, true) then return true end
      return false, "must be namespaced as pack:name"
  end },

One more option: handle

The third argument to schema.define takes one key today.

Prop

Type

}, { handle = "Mesh.Handle" })

Declare it once on the shape and every field pointing at that shape through of shows the union in your editor without restating it — Pal.Spec.mesh becomes Mesh.Spec|Mesh.Handle. A derived shape inherits the base's handle, so Building.Spec.Mesh reports Mesh.Handle too.

The second argument to a definition

X{ ... } is the one-argument form of X(spec, opts). All eight domain constructors accept that second argument, it is optional, and it controls registration only — never the definition itself. The handle you get back is identical either way.

Item{ id = "example:Torch", maxStack = 20 }                             -- define and register

local h = Item({ id = "example:Torch", maxStack = 20 },
               { register = false })                                    -- build the handle, register nothing

Item({ id = "example:Torch", maxStack = 20 }, { pack = "mypack" })      -- register with an owner

Prop

Type

register = false is what keeps a read from becoming a write. The native catalogs fabricate a definition on demand — native.buildings.Foundation is Building{ id = ... } under the hood — and for buildings registering is not inert: core/event's reconstruction scan picks the new definition up, and every matching actor already standing in the world becomes a tracked instance that is persisted to the save. Reading one building id in a tooltip therefore started writing a record for every foundation in the base. The catalogs pass { register = false }, so a lookup is a lookup again.

pack is filled in for you by the scoped surface: PalForge.pack("mypack").Item is the same constructor with { pack = "mypack" } already attached.

Pal, Item, Skill, Effect and Audio route the argument through schema.defineOpts, which is also what rejects a misspelling — an option silently ignored is the failure this whole layer exists to prevent:

Item({ id = "example:Torch" }, { registr = false })
Item({ id = "example:Torch" }, { register = "no" })
Item({ id = "example:Torch" }, "nope")
PalForge: Item: unknown define option "registr". Valid options: register, pack
PalForge: Item: define option "register" expects boolean, got string
PalForge: Item: the second argument is the options table { register = false, pack = "packid" }, got string

Mesh, Building and UI read the same two keys inside their own define instead of calling schema.defineOpts, so they accept register and pack identically but do not raise on a third key.

What happens when you call a definition

Spec:validate(t, context) never changes the table you passed in. It returns a new plain table, so nothing downstream can tell a built value from a hand-written one.

A nil input counts as an empty table, so required fields still fail. Every key is checked before any field is examined, which is why a typo is reported even when the rest of the definition is broken too. A key that is not a string is rejected on sight, before the walk tries to match it against a declared name.

Each declared field then runs the same steps, in this exact order:

Two things follow from that order:

  • The unwrap for of happens before the type check, so a handle passed where a table is declared is already a plain table by the time type is tested.
  • check sees the value after nesting, so a check on a field with of receives the validated copy, not the raw input.

The copy is one level deep. A nested of shape is re-checked into a fresh table, but a plain table field such as data, color or offset is carried across by reference — the table you wrote is the table the definition holds.

local payload = { charges = 3 }
local pal = Pal{ id = "example:Boss", data = payload }
payload.charges = 5   -- the definition sees 5: `data` is not deep-copied

What you will see when you get it wrong

Here is every message a definition can raise, the code that causes it, and what to change. Each block of plain text is exactly what the game prints, so you can copy a phrase out of it straight into a log search.

Not a table

Pal("example:Boss")
PalForge: Pal: expected a table, got string. Fields: id, name, description, skills, mesh, material, color, texture, icon, events, data

Fix: pass a table. Write Pal{ id = "example:Boss" } with braces, not parentheses.

A non-string key

Pal{ id = "example:Boss", [1] = "x" }
PalForge: Pal: keys must be strings, got a number key

Fix: give every entry a name. A bare value inside the braces becomes entry 1.

Unknown field, with a did-you-mean

Pal{ id = "example:Boss", meshSpec = { model = "/Game/X" } }
PalForge: Pal: unknown field "meshSpec" (did you mean "mesh"?). Valid fields: id, name, description, skills, mesh, material, color, texture, icon, events, data
Building{ id = "example:Bench", tickInverval = 4 }
PalForge: Building: unknown field "tickInverval" (did you mean "tickInterval"?). Valid fields: id, name, description, gridCm, buildIds, tickInterval, mesh, material, color, texture, icon, state, events, data

Fix: rename the field to the suggestion.

Unknown field, no candidate close enough

Pal{ id = "example:Boss", zzzzzzzzzz = 1 }
PalForge: Pal: unknown field "zzzzzzzzzz". Valid fields: id, name, description, skills, mesh, material, color, texture, icon, events, data

Fix: the accepted names are listed after Valid fields:. Pick the one you meant.

Missing required field

Pal{ name = "Boss" }
PalForge: Pal: field "id" is required (pal id: a game CharacterID ("ChickenPal") or "pack:name")

Fix: add the field. The text in parentheses is that field's doc, and it tells you what belongs there — here, either a game id or "pack:name". When a field has no doc, its type is printed instead, then any.

Wrong type

Pal{ id = "example:Boss", name = 42 }
PalForge: Pal: field "name" expects string, got number

A union is reported verbatim:

Building{ id = "example:Bench", state = "uses" }
PalForge: Building: field "state" expects table|function, got string

Fix: give the field a value of a type it lists. state = { uses = 0 } or state = function() return { uses = 0 } end.

Value outside values

Mesh{ id = "example:body", model = "/Game/X", kind = "rigid" }
PalForge: Mesh: field "kind" must be one of { "procedural", "static", "skeletal", "obj" }, got "rigid"
Skill{ id = "example:Fire", kind = "ultimate" }
PalForge: Skill: field "kind" must be one of { "active", "passive" }, got "ultimate"

Fix: use one of the values in the braces. They are the only ones accepted.

Bad arrayOf element

The index is folded into the field name, so the whole path sits inside one pair of quotes.

Pal{ id = "example:Boss", skills = { "example:Fire", 3 } }
PalForge: Pal: field "skills[2]" expects string, got number

Fix: skills[2] is the second entry in the list. Correct that entry.

Bad mapOf value

Item{ id = "example:Torch", recipe = { materials = { Wood = "three" } } }
PalForge: Item: field "recipe" (Item.Spec.Recipe): field "materials.Wood" expects number, got string

An array element in a mapOf table is reported by its numeric key:

Item{ id = "example:Torch", recipe = { materials = { "Wood" } } }
PalForge: Item: field "recipe" (Item.Spec.Recipe): field "materials.1" expects number, got string

Fix: materials is { <itemId> = <count> }, so write materials = { Wood = 3 }.

Failing check

Pal{ id = "" }
PalForge: Pal: field "id" is invalid: must be a non-empty string

Fix: the text after the colon is the reason the check returned. When a check returns false on its own, the message ends in failed check instead.

Nested spec errors name the inner shape

The message builds up in order: the outer domain, the outer field, the inner shape's name, then the inner complaint. Read it from the right — the last part is what to change.

Pal{ id = "example:Boss", mesh = { kind = "skeletal" } }
PalForge: Pal: field "mesh" (Mesh.Spec): field "model" is required (a /Game/... USkeletalMesh or UStaticMesh path (see Mesh.assets); for procedural / obj, an .obj file path - absolute, or relative to the .lua file that declares it)
Building{ id = "example:Bench", mesh = { model = "/Game/X", colour = { 1, 0, 0, 1 } } }
PalForge: Building: field "mesh" (Building.Spec.Mesh): unknown field "colour" (did you mean "color"?). Valid fields: id, kind, model, animClass, scale, offset, texture, color, material, params

The events group is a nested shape like any other, so a misspelled handler name is an error rather than a handler that silently never runs:

Pal{ id = "example:Boss", events = { onSpawn = function(pal, ctx) end } }
PalForge: Pal: field "events" (Pal.Spec.Events): unknown field "onSpawn" (did you mean "onSpawned"?). Valid fields: onSpawned, onDamaged, onDeath, onCaptured, onTick
Building{ id = "example:Bench", events = { onPlace = "hello" } }
PalForge: Building: field "events" (Building.Spec.Events): field "onPlace" expects function, got string

Fix: the name in (...) is the shape to look up with schema.help, and the valid names are listed at the end of the line.

How the suggestion is picked

An unknown field always tries to name the field you meant, so a misspelling is caught the moment you load the pack.

A name that contains a real field name wins over one that is merely spelled close. The most common slip is a decorated name — meshSpec for mesh, iconPath for icon — which reads as obvious to you but is several edits away from the real field. Containment is tested in both directions, so a prefix, a suffix and a plural all match. Only declared names of three characters or more take part, so a two-letter field like id cannot swallow half the declaration.

When nothing contains anything, the Levenshtein distance decides — how many single-letter edits apart the two names are. Only a genuinely close match is accepted: the limit is max(2, floor(#name / 3)).

Both stages compare in lowercase, so a capitalisation slip is caught too.

You wroteSuggestedWhy
meshSpecmeshcontains mesh
iconPathiconcontains icon
displayNamenamecontains name
texturestexturecontains texture
durationSecondsdurationcontains duration
Descriptiondescriptioncontains description once lowercased
namnamename contains nam — containment runs both ways
tickInvervaltickIntervaldistance 1
soundsoundPathcontains sound
zzzzzzzzzznonenothing within the distance threshold

For a very short unknown key the limit is a flat 2, so the suggestion can be a coincidence rather than an insight — Pal{ xy = 1 } suggests "id". Treat a suggestion on a two- or three-character key as a hint, not an answer.

Putting one definition inside another

A nested shape can be written inline, or passed as the object a define call returned:

-- inline
Pal{ id = "example:Boss", mesh = { model = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal" } }

-- as a named definition
local body = Mesh{
    id      = "example:body",
    model   = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal",
    texture = "art/body.png",
}

Pal{ id = "example:Boss", mesh = body }
Pal{ id = "example:Add",  mesh = Mesh.get("example:body") }

Both reach the nested shape the same way. A handle whose metatable carries __spec is swapped for the table it stands for before anything is checked:

Scripts/palforge/core/schema.lua
local function unwrap(v)
    if type(v) ~= "table" then return v end
    local mt = getmetatable(v)
    local tospec = mt and rawget(mt, "__spec")
    if type(tospec) == "function" then return tospec(v) end
    return v
end
Scripts/palforge/api/mesh.lua
Handle.__spec = function(self) return self._cls:source() end

The swap happens at two points — on the whole input at the top of validate, and per field whenever of is set — so Spec:validate(someHandle) works as well as mesh = someHandle.

Because the nested value is then checked again, what the outer definition holds is a copy. You cannot reach the mesh definition through the pal wearing it:

local body = Mesh{ id = "example:body", model = "/Game/X/SK_X" }
local pal  = Pal{ id = "example:Boss", mesh = body }

pal:mesh().scale = 4     -- mutates the pal's copy, not the "example:body" definition

Mesh.Handle is the only handle that carries __spec today. Pal, item, building, skill, effect, audio and UI handles do not, so passing one of those into another definition is checked as a raw table and fails on its unknown keys. Write the nested part inline instead.

A default only fills an absent field. Mesh{ ... } has already filled kind = "skeletal", so nesting that handle into a building keeps skeletal even though Building.Spec.Mesh defaults kind to "static". Write kind = "static" on the mesh, or declare the building's mesh inline, when you want the building policy.

Reusing a field list with different defaults

schema.derive(name, base, overrides) declares a new shape as a copy of another with per-field policy replaced. Each field is copied whole, then the matching override table is merged over it.

Buildings do this with the mesh:

Scripts/palforge/api/building.lua
local Mesh = schema.derive("Building.Spec.Mesh", schema.get("Mesh.Spec"), {
    kind   = { default = "static" },
    model  = { doc = "UStaticMesh asset path, or an OBJ path for the procedural backend" },
    offset = { doc = "{ x, y, z } offset from the actor's origin" },
})

A building is a static mesh where a pal is a skeletal one, so only that default changes. The ten fields, their types, model being required and the Mesh.Handle option all come across untouched, and a field added to Mesh.Spec later reaches buildings as well.

The difference shows in the two help dumps:

Mesh.Spec {
  kind          string     (default=skeletal, one of { "procedural", "static", "skeletal", "obj" }) which core.mesh backend renders it
  model         string     (required) a /Game/... USkeletalMesh or UStaticMesh path (see Mesh.assets); for procedural / obj, an .obj file path - absolute, or relative to the .lua file that declares it
  offset        table      { x, y, z } offset from the mesh's normal position, in cm
  ...
}

Building.Spec.Mesh {
  kind          string     (default=static, one of { "procedural", "static", "skeletal", "obj" }) which core.mesh backend renders it
  model         string     (required) UStaticMesh asset path, or an OBJ path for the procedural backend
  offset        table      { x, y, z } offset from the actor's origin
  ...
}

Any key can be overridden, not only default — the merge copies whatever keys the override table holds. An override naming a field the base does not declare is caught immediately:

schema.derive("Test.Spec", schema.get("Mesh.Spec"), { kindd = { default = "x" } })
schema.derive(Test.Spec): "kindd" is not a field of Mesh.Spec

derive goes through define, so the new name must still be free — taking one twice raises PalForge: schema.define: "Test.Spec" is already declared with no file and line. Two other checks use assert: the base must be a spec built by schema.define, and an override must name a field the base declares. Those two arrive prefixed with core/schema.lua's own file and line, so the text above is the tail of the message, not the whole line.

Asking the game what a shape takes

Every shape is recorded under its name as it is declared, and you can read it back while the game is running.

local schema = require("palforge.core.schema")

schema.get("Pal.Spec")          -- the spec object, or nil
schema.get("Pal.Spec").fields   -- the ordered descriptors, for tooling
schema.help("Pal.Spec")         -- the printable field list
schema.all()                    -- every declared spec, in declaration order

schema.help(name) is the runtime answer to "what can I pass here?":

print(schema.help("Pal.Spec"))
Pal.Spec {
  id            string     (required) pal id: a game CharacterID ("ChickenPal") or "pack:name"
  name          string     shown in UI (defaults to id)
  description   string     one-line description, for UI and tooling
  skills        table      (string[]) skill ids this pal owns (see Skill)
  mesh          table      (Mesh.Spec) the mesh worn by a spawned pawn (inline, or a Mesh{ ... } handle); attached automatically on pal.spawned
  material      table      (Pal.Spec.Material) material override applied to that mesh
  color         table      base tint { r, g, b, a } (shorthand for material.color)
  texture       string     png path applied to the mesh (shorthand for material.texture)
  icon          string     /Game/... texture path used when the icon DataTable has no row for this id
  events        table      (Pal.Spec.Events) lifecycle handlers (grouped)
  data          table      free-form payload of your own, carried onto the definition
}

The marks in parentheses come in a fixed order: required, then default=<value> for a plain default, then one of { ... }, then the nested shape's name, then <type>[] for arrayOf, then map of <type> for mapOf. A field with no marks prints just its name, type and doc.

A name that is not declared does not raise. You get back a string listing every declared name, sorted — useful when you cannot remember the exact spelling:

print(schema.help("Pal.Specc"))
PalForge: no spec named "Pal.Specc". Declared: Audio.Spec, Building.Spec, Building.Spec.Events, Building.Spec.Material, Building.Spec.Mesh, Coord, Effect.Spec, Effect.Spec.Events, Item.Spec, Item.Spec.Events, Item.Spec.Recipe, Mesh.Spec, Pal.Spec, Pal.Spec.Events, Pal.Spec.Material, Skill.Spec, Skill.Spec.Events, UI.Node.Border, UI.Node.Button, UI.Node.Frame, UI.Node.GameWidget, UI.Node.HBox, UI.Node.Label, UI.Node.Overlay, UI.Node.ScrollBox, UI.Node.SizeBox, UI.Node.Sprite, UI.Node.VBox, UI.Spec, UI.Spec.Host

schema.all() returns a fresh list in declaration order, not sorted. A nested shape is always declared before the shape that references it, which is what lets the type generator emit each class before it is used:

for _, spec in ipairs(schema.all()) do
    print(spec.name, spec.handle)
end
Mesh.Spec              Mesh.Handle
Pal.Spec.Material      nil
Pal.Spec.Events        nil
Pal.Spec               nil
Item.Spec.Recipe       nil
Item.Spec.Events       nil
Item.Spec              nil
Building.Spec.Mesh     Mesh.Handle
Building.Spec.Material nil
Building.Spec.Events   nil
Building.Spec          nil
Skill.Spec.Events      nil
Skill.Spec             nil
Effect.Spec.Events     nil
Effect.Spec            nil
Audio.Spec             nil
UI.Node.VBox           nil
UI.Node.HBox           nil
UI.Node.Overlay        nil
UI.Node.ScrollBox      nil
UI.Node.Border         nil
UI.Node.SizeBox        nil
UI.Node.Label          nil
UI.Node.Frame          nil
UI.Node.Button         nil
UI.Node.Sprite         nil
UI.Node.GameWidget     nil
UI.Spec.Host           nil
UI.Spec                nil
Coord                  nil

Thirty shapes, and eleven of them are the UI.Node.* constructors — one per node kind UI.VBox, UI.Label, UI.Button and the rest accept — plus UI.Spec.Host, the table form of a panel's host field. They are declared by api/ui.lua like any other spec, so schema.help("UI.Node.Button") answers the same way schema.help("Pal.Spec") does.

Coord is the one shape with no domain in front of its name. It is the world coordinate Player.coordinate() gives you and Pal.Handle:spawn takes, declared in api/player.lua so that schema.help("Coord") and your editor both know its fields.

Field lists in your editor

Scripts/palforge/types.lua is generated from the declared shapes by tools/gen-types.lua:

lua5.4 tools/gen-types.lua

It is annotations only — nothing requires it at runtime. LuaLS just has to see it in the workspace for Pal{ ... } to complete every field with its doc.

Each field maps to an editor type by the first rule that applies:

DescriptorEmitted type
of = InnerInner.Spec, plus |Inner.Handle when the inner spec declared a handle
values = { ... }an alias named after the spec and field, e.g. Mesh.Spec.Kind
sig = "fun(...)"the signature verbatim
arrayOf = "string"string[]
mapOf = "number"table<string, number>
no typeany
otherwisethe type string as written

required decides whether the field gets a ?, and a plain default is appended to the doc comment. The result for Pal.Spec:

Scripts/palforge/types.lua
---@class Pal.Spec
---@field id string # pal id: a game CharacterID ("ChickenPal") or "pack:name"
---@field name? string # shown in UI (defaults to id)
---@field description? string # one-line description, for UI and tooling
---@field skills? string[] # skill ids this pal owns (see Skill)
---@field mesh? Mesh.Spec|Mesh.Handle # the mesh worn by a spawned pawn (inline, or a Mesh{ ... } handle); attached automatically on pal.spawned
---@field material? Pal.Spec.Material # material override applied to that mesh
---@field color? table # base tint { r, g, b, a } (shorthand for material.color)
---@field texture? string # png path applied to the mesh (shorthand for material.texture)
---@field icon? string # /Game/... texture path used when the icon DataTable has no row for this id
---@field events? Pal.Spec.Events # lifecycle handlers (grouped)
---@field data? table # free-form payload of your own, carried onto the definition

The handle option is what puts |Mesh.Handle on the mesh field, and Mesh.Spec.Kind is the alias generated from that shape's values list:

Scripts/palforge/types.lua
---@alias Mesh.Spec.Kind "procedural"|"static"|"skeletal"|"obj"

Re-run the generator whenever a shape changes, and what your editor offers stays the same as what a definition actually accepts.

Recipes

content/dev.lua
local schema = require("palforge.core.schema")
local log    = require("palforge.utils.log").scope("dev")

-- one shape
log.info(schema.help("Building.Spec"))

-- the shapes a building definition can reach
for _, name in ipairs({ "Building.Spec", "Building.Spec.Mesh",
                        "Building.Spec.Material", "Building.Spec.Events" }) do
    log.info(schema.help(name))
end

Dump every declared shape to the UE4SS log

content/dev.lua
local schema = require("palforge.core.schema")
local log    = require("palforge.utils.log").scope("schema")

for _, spec in ipairs(schema.all()) do
    log.info(spec:help())
end

Build your own reference table from the descriptors

content/dev.lua
local schema = require("palforge.core.schema")

local function describe(specName)
    local spec = schema.get(specName)
    if not spec then return print(schema.help(specName)) end

    print(spec.name)
    for _, f in ipairs(spec.fields) do
        local flags = {}
        if f.required then flags[#flags + 1] = "required" end
        if f.default ~= nil then flags[#flags + 1] = "default=" .. tostring(f.default) end
        if f.values then flags[#flags + 1] = "enum" end
        if f.of then flags[#flags + 1] = f.of.name end
        if f.check then flags[#flags + 1] = "checked" end
        print(string.format("  %-13s %-14s %-22s %s",
            f.name, f.type or "any", table.concat(flags, ","), f.doc or ""))
    end
end

describe("Item.Spec")
describe("Item.Spec.Recipe")

Validate your own pack's config the same way

schema.define is not reserved for PalForge's own domains. Declare your pack's own shape and a typo in your settings stops at load, with the same message and the same did-you-mean. Pick a name no other shape uses.

content/config.lua
local schema = require("palforge.core.schema")

local Config = schema.define("ExamplePack.Config", {
    { "reward",     type = "string", required = true, check = schema.nonEmpty,
                    doc = "item id handed out when the boss dies" },
    { "rewardCount", type = "number", default = 5, doc = "how many of it" },
    { "biome",      type = "string", values = { "forest", "desert", "volcano" },
                    default = "forest", doc = "where the boss appears" },
    { "spawnAt",    type = "table", of = schema.get("Coord"),
                    doc = "fixed spawn point; omit to spawn near the player" },
})

---@param opts table
local function setup(opts)
    local cfg = Config:validate(opts, "ExamplePack")

    Pal{
        id     = "example:Boss",
        name   = "Example Boss",
        events = {
            onDeath = function(pal, ctx)
                Item.get(cfg.reward):give(cfg.rewardCount)
            end,
        },
    }
end

setup{ reward = "Wood", rewardCount = 10, biome = "volcano" }

A typo then behaves exactly like a typo in a Pal{ ... } call:

setup{ reward = "Wood", rewardCounts = 10 }
PalForge: ExamplePack: unknown field "rewardCounts" (did you mean "rewardCount"?). Valid fields: reward, rewardCount, biome, spawnAt

Fail loudly at load, not silently in game

A bad definition raises, which stops the file it is in. Wrap a content file in one pcall at the top level and log the message: the rest of your pack still loads, and the reason is in the log word for word.

content/pals.lua
local log = require("palforge.utils.log").scope("example")

local ok, err = pcall(function()
    Pal{
        id          = "ChickenPal",
        name        = "Reskinned Chicken",
        description = "vanilla chicken with a new coat",
        mesh        = Mesh{
            id      = "example:chicken",
            model   = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal",
            texture = "art/chicken.png",
        },
        events = {
            onSpawned = function(pal, ctx) log.info("chicken spawned") end,
        },
    }
end)

if not ok then log.err(tostring(err)) end

Summary

  • Every X{ ... } call is checked before anything is registered. It either succeeds whole or raises and registers nothing.
  • id is required on every domain, and its shape is checked while you write it: an id with a colon whose halves are not letters, digits and _ raises instead of registering something the game can never reach.
  • X{ ... } is the one-argument form of X(spec, opts). The optional second argument controls registration and nothing else: { register = false } builds the handle and registers nothing, { pack = "mypack" } records the owner.
  • A field you leave out is either optional or filled by its default.
  • A misspelled field name is an error, and the message usually names the field you meant.
  • Every message starts with PalForge: , then the domain, then the field, then the reason. When a shape name appears in (...), that is the inner list to look up.
  • print(schema.help("Pal.Spec")) prints every field of a shape while the game runs, and a wrong name prints the list of names instead of raising.
  • A Mesh{ ... } handle can be nested into a pal or a building, and the outer definition keeps a copy of it.

Next, Lifecycle shows which of the handlers you can write in events actually run in the game.

On this page