Files
gen1recomp/src/mods/Schemas.lua
T
2026-08-14 17:07:06 -04:00

2162 lines
96 KiB
Lua

-- Single source of truth for the registry catalog: per-registry merge
-- semantics (record | deep | compose), the Data target path each merge
-- writes, and the value schema every mod registration is checked against.
-- The loader builds its registries from this table and the reference docs
-- are generated from it, so neither can drift from the engine.
-- Pure Lua, no love.*, so the headless loader and doc generator run it.
local Merge = require("src.mods.Merge")
local Schemas = {}
-- ------- field-type combinators
local f = {}
Schemas.f = f
local function leaf(kind, desc, check)
return { kind = kind, desc = desc, check = check }
end
f.str = leaf("str", "string", function(v) return type(v) == "string" end)
f.num = leaf("num", "number", function(v) return type(v) == "number" end)
f.bool = leaf("bool", "boolean", function(v) return type(v) == "boolean" end)
f.fn = leaf("fn", "function", function(v) return type(v) == "function" end)
f.any = leaf("any", "any value", function() return true end)
f.path = leaf("path", "file path", function(v)
return type(v) == "string" and v ~= ""
end)
f.token = leaf("token", "text token name", function(v)
return type(v) == "string" and v:match("^[%w_:%*]+$") ~= nil
end)
function f.int(min, max)
local desc = "integer"
if min and max then desc = ("integer %d..%d"):format(min, max)
elseif min then desc = ("integer >= %d"):format(min) end
return { kind = "int", min = min, max = max, desc = desc,
check = function(v)
return type(v) == "number" and v % 1 == 0
and (min == nil or v >= min) and (max == nil or v <= max)
end }
end
-- a bounded float; like f.int but keeps the fractional part (scales,
-- gains). Rejects out-of-range with the "expected number a..b" message.
function f.numRange(min, max)
local desc = "number"
if min and max then desc = ("number %s..%s"):format(min, max)
elseif min then desc = ("number >= %s"):format(min) end
return { kind = "num", min = min, max = max, desc = desc,
check = function(v)
return type(v) == "number"
and (min == nil or v >= min) and (max == nil or v <= max)
end }
end
function f.enum(values)
local set = {}
for _, value in ipairs(values) do set[value] = true end
local desc = 'one of "' .. table.concat(values, '" | "') .. '"'
return { kind = "enum", set = set, values = values, desc = desc,
check = function(v) return set[v] == true end }
end
function f.opt(inner)
return { kind = "opt", inner = inner, desc = inner.desc }
end
function f.list(inner)
return { kind = "list", inner = inner, desc = "list of " .. inner.desc }
end
function f.map(key, value)
return { kind = "map", key = key, value = value,
desc = ("map of %s -> %s"):format(key.desc, value.desc) }
end
-- opts.strict closes the record to unknown fields even at the extensible
-- top level. A union alternative whose fields are ALL optional needs this:
-- with top-level leniency it matches every table, so the union stops
-- rejecting anything (the font "ttf" shape was the first such alternative).
function f.rec(fields, opts)
local names = {}
for name in pairs(fields) do names[#names + 1] = name end
table.sort(names)
local parts = {}
for _, name in ipairs(names) do
local ft = fields[name]
parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "")
end
return { kind = "rec", fields = fields, strict = opts and opts.strict or nil,
desc = "{" .. table.concat(parts, ", ") .. "}" }
end
function f.union(alts)
local parts = {}
for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end
return { kind = "union", alts = alts, desc = table.concat(parts, " | ") }
end
-- cross-registry reference; the type check at register time is string-only,
-- resolution happens in the post-merge pass so forward references work
function f.id(registry)
return { kind = "id", registry = registry, desc = registry .. " id",
check = function(v) return type(v) == "string" and v ~= "" end }
end
-- ------- validation
-- snake_case/camelCase typos normalize to the same key, which is how the
-- classic base_stats-for-baseStats mistake gets a suggestion
local function normalizeName(name)
return tostring(name):lower():gsub("_", "")
end
local function suggest(fields, unknown)
local want = normalizeName(unknown)
for known in pairs(fields) do
if normalizeName(known) == want then return known end
end
return nil
end
local function got(value)
if type(value) == "string" then return string.format("%q", value) end
if type(value) == "table" then return "table" end
return tostring(value)
end
local function fail(errors, path, expected, value)
errors[#errors + 1] = ("%s: expected %s, got %s"):format(path, expected, got(value))
end
-- top marks the outermost value of a spec.value registry; opt and union
-- re-dispatch on the same value so they carry it, descending drops it
local checkValue
checkValue = function(t, value, path, patchMode, errors, top)
if value == Merge.DELETE then
if not patchMode then fail(errors, path, t.desc, value) end
return
end
local kind = t.kind
if kind == "any" then return end
if kind == "opt" then return checkValue(t.inner, value, path, patchMode, errors, top) end
if kind == "list" then
if type(value) ~= "table" then return fail(errors, path, t.desc, value) end
-- lists replace wholesale, so every row is a complete value even
-- inside a patch; an extension wrapper carries the same rows and is
-- typed the same way instead of slipping through unseen
if Merge.isWrapper(value) then
for _, key in ipairs({ "__prepend", "__append" }) do
for i, element in ipairs(value[key] or {}) do
checkValue(t.inner, element, ("%s.%s[%d]"):format(path, key, i),
false, errors)
end
end
return
end
for i, element in ipairs(value) do
checkValue(t.inner, element, path .. "[" .. i .. "]", false, errors)
end
return
end
if kind == "map" then
if type(value) ~= "table" then return fail(errors, path, t.desc, value) end
for k, v in pairs(value) do
if not t.key.check(k) then
fail(errors, path .. "." .. tostring(k), "key " .. t.key.desc, k)
end
checkValue(t.value, v, path .. "." .. tostring(k), patchMode, errors)
end
return
end
if kind == "rec" then
if type(value) ~= "table" then return fail(errors, path, t.desc, value) end
for key, sub in pairs(value) do
local ft = t.fields[key]
if ft == nil then
-- the top-level record stays extensible like the spec.fields path:
-- unknown keys are preserved unless they read as a typo of a known
-- field. Nested recs stay strict, that is where typos hide.
local hint = suggest(t.fields, key)
if hint or not top or t.strict then
errors[#errors + 1] = ("%s.%s: unknown field%s"):format(path, tostring(key),
hint and (' (did you mean "' .. hint .. '"?)') or "")
end
else
checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors)
end
end
if not patchMode then
for key, ft in pairs(t.fields) do
if value[key] == nil and ft.kind ~= "opt" then
errors[#errors + 1] = ("%s.%s: missing required field (%s)")
:format(path, key, ft.desc)
end
end
end
return
end
if kind == "union" then
for _, alt in ipairs(t.alts) do
local scratch = {}
checkValue(alt, value, path, patchMode, scratch, top)
if #scratch == 0 then return end
end
return fail(errors, path, t.desc, value)
end
if not t.check(value) then fail(errors, path, t.desc, value) end
end
-- mode is "register" | "override" (full record: required fields enforced),
-- "patch" (only provided leaves checked, DELETE legal) or "remove" (no
-- value). Unknown top-level fields are allowed and preserved -- extensible
-- records are a feature -- but a patch key that is only a case/underscore
-- variant of a schema field is the classic typo and gets rejected with a
-- suggestion.
--
-- `generation` is optional and only ever narrows: passing it resolves the
-- per-generation shape first (Schemas.shapeFor), and omitting it validates
-- against the Gen 1 shape, which is what every Gen 1 call site wants and what
-- a caller already holding a derived spec has anyway.
function Schemas.check(spec, registryName, id, value, mode, generation)
if generation ~= nil then
spec = Schemas.shapeFor(registryName, spec, generation)
end
if mode == "remove" or spec == nil then return true end
-- register and patch are synonyms on a deep registry, so a partial
-- payload is the normal case there and only override is a full value
local patchMode = mode == "patch"
or (spec.semantics == "deep" and mode == "register")
local errors = {}
local path = registryName .. "." .. tostring(id)
if spec.keys or spec.keyValue then
-- deep registries are open namespaces: a key the catalog does not
-- describe is a mod's own data, not a mistake. keyValue types every
-- key alike, for namespaces whose keys are content (one per map).
local keyType = (spec.keys and spec.keys[id]) or spec.keyValue
if keyType then checkValue(keyType, value, path, patchMode, errors, true) end
elseif spec.value then
checkValue(spec.value, value, path, patchMode, errors, true)
if #errors == 0 and not patchMode and spec.extra then
local problem = spec.extra(id, value)
if problem then errors[#errors + 1] = ("%s: %s"):format(path, problem) end
end
elseif spec.fields then
if type(value) ~= "table" then
fail(errors, path, "record table", value)
else
for key, sub in pairs(value) do
local ft = spec.fields[key]
if ft ~= nil then
checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors)
elseif patchMode then
local hint = suggest(spec.fields, key)
if hint then
errors[#errors + 1] = ('%s.%s: unknown field (did you mean "%s"?)')
:format(path, tostring(key), hint)
end
end
end
if not patchMode then
for key, ft in pairs(spec.fields) do
if value[key] == nil and ft.kind ~= "opt" then
errors[#errors + 1] = ("%s.%s: missing required field (%s)")
:format(path, key, ft.desc)
end
end
end
if #errors == 0 and not patchMode and spec.extra then
local problem = spec.extra(id, value)
if problem then
errors[#errors + 1] = ("%s: %s"):format(path, problem)
end
end
end
end
if #errors == 0 then return true end
return nil, table.concat(errors, "; ")
end
-- ------- cross-reference pass
local collectRefs
collectRefs = function(t, value, path, out)
if value == nil or value == Merge.DELETE then return end
local kind = t.kind
if kind == "opt" then return collectRefs(t.inner, value, path, out) end
if kind == "id" then
if type(value) == "string" then
out[#out + 1] = { registry = t.registry, ref = value, path = path }
end
return
end
if kind == "list" and type(value) == "table" then
for i, element in ipairs(value) do
collectRefs(t.inner, element, path .. "[" .. i .. "]", out)
end
elseif kind == "map" and type(value) == "table" then
for k, v in pairs(value) do
collectRefs(t.value, v, path .. "." .. tostring(k), out)
end
elseif kind == "rec" and type(value) == "table" then
for key, ft in pairs(t.fields) do
collectRefs(ft, value[key], path .. "." .. tostring(key), out)
end
elseif kind == "union" then
-- refs live in whichever alternative the value satisfies
for _, alt in ipairs(t.alts) do
local scratch = {}
checkValue(alt, value, path, true, scratch)
if #scratch == 0 then return collectRefs(alt, value, path, out) end
end
end
end
-- every f.id ref reachable from one record, tagged with its field path
local function refsFor(spec, name, id, value)
local refs = {}
if spec.keys or spec.keyValue then
local keyType = (spec.keys and spec.keys[id]) or spec.keyValue
if keyType then
collectRefs(keyType, value, name .. "." .. tostring(id), refs)
end
elseif spec.fields and type(value) == "table" then
for key, ft in pairs(spec.fields) do
collectRefs(ft, value[key], name .. "." .. tostring(id) .. "." .. key, refs)
end
elseif spec.value then
collectRefs(spec.value, value, name .. "." .. tostring(id), refs)
end
return refs
end
-- a structured target (battle_anims' per-kind subtables, Gold's trainer
-- classes) hides its ids one level down, so the pristine scan asks the spec
-- instead of the raw keys
local function baseEntries(spec, base)
if not spec.baseIds then return pairs(base) end
local ids = spec.baseIds(base)
local i = 0
return function()
i = i + 1
local id = ids[i]
if id == nil then return nil end
return id, spec.baseAt(base, id)
end
end
-- runs once after the merge. Records mods touched are scanned for dangling
-- refs (the op logs limit that, so a mod-free boot does zero work); if any
-- id folded to nil the scan widens to the untouched base records too, so a
-- remove that strands a vanilla reference is caught and attributed to the
-- removing mod instead of surfacing as an unowned crash later. References
-- into registries the catalog does not declare yet are skipped, not guessed.
function Schemas.crossValidate(loader, data)
local problems = {}
local tombstoned, removed = {}, false
for name, registry in pairs(loader.content) do
for id in pairs(registry.ops) do
if registry:get(id) == nil then
tombstoned[name] = tombstoned[name] or {}
tombstoned[name][id] = true
removed = true
end
end
end
for name, registry in pairs(loader.content) do
-- the shape this boot's generation validates by, so a Gen 2 record's
-- refs are read out of the Gen 2 fields (a species' `into`, not
-- `species`) instead of being missed entirely
local spec = Schemas.shapeFor(name, registry.spec, loader.generation)
for id in pairs(registry.ops) do
local value = registry:get(id)
if value ~= nil and registry.owners[id] ~= Schemas.ENGINE then
for _, ref in ipairs(refsFor(spec, name, id, value)) do
local refRegistry = Schemas.REGISTRIES[ref.registry]
and loader.content[ref.registry]
-- A registry with no home in this generation has no id space to
-- check against: its base view resolves to nothing, so EVERY
-- reference into it would read as dangling. Gold's species carry a
-- growthRate and an evolution method like Red's do; the ids are
-- fine, it is the Gen 1 `growth_rates` / `evolution_methods`
-- namespaces that are not there to confirm them. Skipped for the
-- same reason an undeclared registry is: unknown, not wrong.
if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then
refRegistry = nil
end
if refRegistry and refRegistry:get(ref.ref) == nil then
problems[#problems + 1] = {
owner = registry.owners[id],
message = ("%s: unresolved reference to %s %q")
:format(ref.path, ref.registry, ref.ref),
}
end
end
end
end
if removed then
local base = registry.base and registry.base()
if base then
for id, value in baseEntries(spec, base) do
if registry.ops[id] == nil then
for _, ref in ipairs(refsFor(spec, name, id, value)) do
local set = tombstoned[ref.registry]
if set and set[ref.ref] then
problems[#problems + 1] = {
owner = loader.content[ref.registry].owners[ref.ref],
message = ("%s: unresolved reference to removed %s %q")
:format(ref.path, ref.registry, ref.ref),
}
end
end
end
end
end
end
end
return problems
end
-- ------- the catalog
-- v1 registry names that live on as thin views of a renamed registry; both
-- names share one op log and diagnostics report the canonical name
Schemas.ALIASES = { scripts = "map_scripts", ui = "screens" }
-- owner of the engine's own registrations (src/mods/Builtins.lua); vanilla
-- content is internally consistent by construction, so the cross-reference
-- pass skips it and stays zero-work on a mod-free boot
Schemas.ENGINE = "engine"
-- ------- generation routing
--
-- Registry NAMES are shared across generations on purpose: a mod writes
-- mod.content.pokemon whichever game is running, and mod.content.encounters
-- means "wild encounters" in both. What can differ is the Data path the
-- merge lands on, because Gold namespaces the tables whose Gen 1 counterpart
-- means something else (data.gen2Palettes beside data.palettes).
--
-- One routing table per generation, read through Schemas.routing, and both
-- are read the same way:
--
-- absent -> keeps spec.target in that generation
-- mapped to a path -> merges there instead
-- mapped to false -> no home in that generation; the write is taken,
-- dropped and reported
--
-- That last case is the whole point of the manifest's gen2compat opt-in: a
-- mod that claims Gen 2 gets told which registry has no home there instead of
-- merging into a table nothing reads and appearing to work. It runs in both
-- directions, because the catalog now holds content BOTH ways round: Gold has
-- systems Red never had (the phone book, the decorations, the radio dial), and
-- those registries are the mirror image of the rows below -- declared once,
-- gated under GEN ONE, reported to a Red mod in the same sentence a Gold mod
-- gets about `tokens`. Schemas.GEN1 below Schemas.GEN2 carries them.
--
-- The `false` rows used to have three causes and now have one. The first is
-- gone: Gold's overworld tables no longer load off disk into World fields --
-- src/core/Game2.lua:load reads every one of them into game.data BEFORE it
-- calls mods:load(self.data), and src/world/gen2/World.lua:dataTable takes
-- them by reference and never copies, so a routed row merges into the very
-- table the world walks. The second is gone too: a registry whose Gen 2
-- records are shaped differently now says so in its own spec (the gen2Fields /
-- gen2Keys layer below, resolved by Schemas.shapeFor), so routing it validates
-- a mod's record against the GEN 2 shape rather than against Red's. What is
-- left is the systems Gold has not reimplemented through a registry at all.
Schemas.GEN2 = {
-- Namespaced on Gold and merged there. The registry NAME stays shared --
-- mod.content.maps means "maps" in both games -- and only the Data path
-- underneath it differs, which is the whole reason this table maps to paths
-- rather than renaming anything. Two id-space notes an author needs, and
-- docs/mod-api-gen2-compat.md spells out: Gold's `text` ids are ROM pointer
-- strings ("55:4067") rather than TEXT_* names, and a Gen 2 tileset carries
-- its walkability as `collision` where Gen 1 says `walkable`.
maps = "gen2Maps", tilesets = "gen2Tilesets", sprites = "gen2Sprites",
text = "gen2Text",
-- Namespaced AND differently shaped, and the shape is what these waited on.
-- Each carries a Gen 2 record schema in its catalog entry now, so the id
-- space is the one Gold actually keys by: the encounter KIND (.grass), the
-- trainer CLASS (one level into .classes, through gen2Write), a species id
-- or an ICON_ sheet name, and for palettes / battle_anims / constants the
-- target's own subtable names.
encounters = "gen2Encounters", trainers = "gen2Trainers",
palettes = "gen2Palettes", icons = "gen2Icons",
battle_anims = "gen2BattleAnims", constants = "gen2Constants",
-- Gold reimplements the system, and reads its rules back through the same
-- registry: src/battle/gen2/Battle.lua:statusRecordFor / moveEffectRecordFor,
-- Catching.recordFor, Ai.layersFor, Evolution.methodFor and
-- src/core/gen2/ItemEffects.lua:recordFor each read the merged table here
-- and fall back to their own module records when no loader ran. The vanilla
-- records at these paths are GOLD's, not Red's -- src/mods/Builtins.lua
-- swaps the registrant per generation, which it has to: both games call it
-- GREAT_BALL.
statuses = "gen2Statuses", move_effects = "gen2MoveEffects",
item_effects = "gen2ItemEffects", balls = "gen2Balls",
ai_classes = "gen2AiClasses", evolution_methods = "gen2EvolutionMethods",
-- The Gen 2-only six. They have no Gen 1 target to keep (their specs carry
-- none), so the routed path IS the only path they ever have, and the
-- Schemas.GEN1 rows below are what makes writing to one on Red a reported
-- drop rather than a merge into a table Red has never heard of. Two of them
-- merge onto a table that already exists when mods:load runs -- landmarks
-- onto the cache's own gen2Landmarks.landmarks, held_items onto the view
-- src/core/Game2.lua builds from data.items -- and the other four come
-- into existence AS the merge, seeded from their module's literals by
-- src/mods/Builtins.lua the way the battle-rule six are.
held_items = "gen2HeldItems", phone_contacts = "gen2PhoneContacts",
decorations = "gen2Decorations", apricorns = "gen2Apricorns",
landmarks = "gen2Landmarks.landmarks", radio_channels = "gen2RadioChannels",
-- Still no Gen 2 home. Every one of these is a system Gold reimplements
-- WITHOUT reading a registry: the Gen 1 target is still built and merged
-- into, but nothing in a Gold boot ever looks at it. Closing one is a
-- consumer change in the Gen 2 module first and a row here second, which is
-- exactly how battle_sprite_scales and render_pipelines came off this list
-- (see the note under it).
-- rulesets no Gen 2 ruleset dispatch exists
-- transitions Gold draws its own battle intro
-- (src/ui/gen2/BattleTransition.lua) and its STYLES table
-- is a boolean SET of the four cart wipes, not the
-- { frames, draw, sound, flash } record this registry
-- carries; there is no styleDef lookup for a mod id to
-- reach, so a registered style would fall back to vanilla
-- field the overworld grab bag; Gold's equivalents live in
-- gen2Maps and the VM's own tables
-- text_pointers Gen 1's TEXT_* indirection; Gold's text IS pointers
-- link_fields link play is Gen 1 only
rulesets = false, transitions = false,
field = false, text_pointers = false, link_fields = false,
-- battle_sprite_scales and render_pipelines are ABSENT from this table on
-- purpose: both keep the shared Gen 1 target because Gold reads the merged
-- table at that exact path.
-- battle_sprite_scales src/ui/gen2/BattleState.lua:imageScale reads
-- data.battle_sprite_scales with the same
-- image-then-species-then-default order as Gen 1's
-- BattleState.imageBattleScale / resolveBattleScale,
-- skipping the `_owners` bookkeeping row the same
-- way. Only the DEFAULT differs and neither side
-- reads it from here: Red's 32x32 back pics draw at
-- 2x, Gold's 48x48 ones fill their 6x6 box at 1x.
-- render_pipelines src/core/Game2.lua:load calls Pipelines.install
-- AFTER the merge, so data.render_pipelines is the
-- merged table, and Game2:draw composites `present`
-- through Pipelines.wantsPresent / Pipelines.present.
-- The `drawWorld` half is not composited yet (Gold's
-- overworld draws straight to the window rather than
-- into a canvas), and Game2 RETIRES a restored
-- drawWorld-only level rather than leaving it
-- switched on and rendering nothing; a mod that
-- registers only drawWorld is therefore inert on
-- Gold, which docs/mod-api-gen2-compat.md says in
-- those words. Gold also has no OPTION row for a
-- pipeline (Pipelines.rows is read only from
-- src/ui/OptionsMenu.lua), so a Gold player reaches
-- one by its hotkey.
-- src/script/gen2/Vm.lua is a bytecode VM over the cart's own opcodes, not
-- the Gen 1 row-list runner. `commands` IS routed (it is absent from this
-- table, so it keeps the shared data.commands target): the VM dispatches the
-- Opcodes.MOD_COMMAND row -- an op name with no cart byte behind it --
-- through that merged table, so mod.commands:register works on both games.
-- data.gen2Scripts is that bytecode pool keyed by ROM pointer, so
-- `map_scripts` has no home there: a Lua row list merged into it is not
-- something the VM can run. (`tokens` used to sit on this line and does
-- not belong there -- TextBox.new runs TextBox.substitute on EVERY box in
-- both generations and substitute reads game.data.tokens, so the shared
-- target was already live on Gold. It keeps that target, absent from this
-- table, and src/core/Game2.lua seeds data.tokens with a copy of
-- TextBox.TOKENS so the merge cannot mutate the module table.)
map_scripts = false,
-- Everything not listed keeps its Gen 1 target and works on Gold today:
-- pokemon, moves, items, type_chart, audio + music/sfx/cries/map_songs,
-- screens (the Gen2* ids in src/ui/Screens.lua), strings, font and commands.
}
-- The mirror of Schemas.GEN2: what a GEN 1 boot does with the registries that
-- only exist because Gold exists. Same three readings as the table above, and
-- only the third is used today -- there is no Gen 2-only registry with a
-- useful Red target to reroute to, because the systems themselves are absent
-- from Red rather than spelled differently there.
--
-- held_items Red's items carry no held attributes at all; the whole
-- hold/trigger machinery is Gen 2 (src/battle/gen2/Battle.lua
-- heldEffect)
-- phone_contacts no Pokegear, no phone
-- decorations no bedroom PC decoration menu
-- apricorns no Kurt, no apricorn balls
-- landmarks Red's town map is a Gen 1 town-map table, not the
-- LANDMARK_* index space the Pokegear and the #DEX AREA
-- page share
-- radio_channels no radio
Schemas.GEN1 = {
held_items = false, phone_contacts = false, decorations = false,
apricorns = false, landmarks = false, radio_channels = false,
}
-- The routing table for a generation: which one is consulted is the only
-- difference between the two directions. An unknown generation routes
-- nothing, so every registry keeps its catalog target.
local NO_ROUTING = {}
function Schemas.routing(generation)
if generation == 2 then return Schemas.GEN2 end
if generation == 1 then return Schemas.GEN1 end
return NO_ROUTING
end
-- The Data path `name` merges into for a generation, or nil when the registry
-- has no home there.
function Schemas.targetFor(name, spec, generation)
local routed = Schemas.routing(generation)[name]
if routed == nil then return spec.target end
return routed or nil
end
-- true when the registry exists but this generation has nowhere to put it,
-- which is a different diagnostic from a registry that has no target at all
function Schemas.gatedFor(name, generation)
return Schemas.routing(generation)[name] == false
end
-- ------- per-generation record shapes
--
-- Routing says WHERE a registration lands; this says what a record at that
-- path LOOKS like. The two are separate questions and only the second one is
-- gating the rest of the catalog: Gold's tables are the Gen 2 ROM's own
-- layout, so a species carries specialAttack/specialDefense where Red carries
-- one `special`, wild encounters key by encounter kind and time of day rather
-- than by map, and the palette table is GBC four-colour rows in a dozen named
-- subtables. Validating any of those against the Gen 1 schema judges a mod's
-- record against the wrong shape, which is worse than refusing the write.
--
-- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry
-- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside
-- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `example` /
-- `notes` the matching `gen2*`. Absent means "the Gen 1 shape is right here
-- too", which is the common case and why most registries carry none of this.
-- The registry NAME, the verbs and (wherever the id space allows it) the ids
-- stay shared, exactly as the routing table keeps them shared.
--
-- `gen2X = false` CLEARS the Gen 1 slot rather than setting it, the same
-- reading `false` has in the routing table above: battle_anims' Gen 1 `write`
-- routes ids into per-kind subtables by prefix, and under Gen 2 the ids ARE
-- the subtables, so the right Gen 2 write is the default one. No slot here
-- ever carries a meaningful `false` (they are functions, strings and tables),
-- so the two readings cannot collide.
--
-- Schemas.shapeFor resolves it. It hands back the spec unchanged for Gen 1
-- and for any registry with no Gen 2 shape; otherwise a derived spec with the
-- gen2* keys folded onto the canonical names, memoized per spec so the
-- resolve is one table lookup after the first call. Everything downstream --
-- Schemas.check, Registry's fold and baseAt, the loader's merge and write --
-- then reads one spec and never learns about generations.
local GEN2_SHAPE = {
gen2Value = "value", gen2Fields = "fields", gen2Keys = "keys",
gen2KeyValue = "keyValue", gen2Extra = "extra",
gen2Semantics = "semantics", gen2Write = "write",
gen2BaseAt = "baseAt", gen2BaseIds = "baseIds",
gen2Example = "example", gen2Notes = "notes",
}
-- Schemas.check reads these four in a fixed order (keys/keyValue, then value,
-- then fields), so a Gen 2 shape that describes its records with `keys` must
-- clear the Gen 1 `value` rather than sit beside it: otherwise the first
-- branch that matches wins and the new schema is never consulted.
local VALUE_SLOTS = { value = true, fields = true, keys = true, keyValue = true }
-- Weak keys: a derived spec lives exactly as long as the catalog entry it
-- came from, which in a headless harness is per require rather than forever.
-- Keyed by spec alone, which is sound because the catalog gives every
-- registry its own table -- the two ALIASES resolve to the canonical name
-- before anything reaches here, and `target` is the only name-dependent
-- field a derived spec carries.
local derivedSpecs = setmetatable({}, { __mode = "k" })
-- does this registry describe its Gen 2 records differently at all?
function Schemas.hasGen2Shape(spec)
if type(spec) ~= "table" then return false end
for source in pairs(GEN2_SHAPE) do
if spec[source] ~= nil then return true end
end
return false
end
-- The spec to validate and merge `name` with under `generation`. Idempotent:
-- a derived spec carries no gen2* keys, so resolving one again returns it.
function Schemas.shapeFor(name, spec, generation)
if generation ~= 2 or not Schemas.hasGen2Shape(spec) then return spec end
local hit = derivedSpecs[spec]
if hit then return hit end
local out = {}
for key, value in pairs(spec) do out[key] = value end
local replacesValue = false
for source, slot in pairs(GEN2_SHAPE) do
if spec[source] ~= nil and VALUE_SLOTS[slot] then replacesValue = true end
end
if replacesValue then
for slot in pairs(VALUE_SLOTS) do out[slot] = nil end
end
for source, slot in pairs(GEN2_SHAPE) do
out[source] = nil
-- `or nil` is the clear: gen2Write = false leaves the slot empty
if spec[source] ~= nil then out[slot] = spec[source] or nil end
end
-- self-describing: a derived spec's `target` is the routed one, so a caller
-- holding it alone never reads the Gen 1 path by accident. targetFor stays
-- authoritative and stays idempotent over the result.
out.target = Schemas.targetFor(name, spec, generation)
derivedSpecs[spec] = out
return out
end
local R = {}
Schemas.REGISTRIES = R
-- ------- shared Gen 2 leaves
--
-- The ROM name spaces Gold's tables key by. They are enums rather than
-- f.str so a typo ("MORNING") fails at register time instead of writing a
-- subtable nothing ever reads; the ordered lists themselves ship as
-- data.gen2Constants (eggGroupOrder, trainerTypeOrder, ...).
-- wild encounters, overworld palettes and the roof pair all bucket by time of
-- day; DARK is the fourth palette bucket and never an encounter one, so the
-- encounter maps take the three-value list (constants/time_of_day.asm)
local gen2Tod = f.enum{ "MORN", "DAY", "NITE" }
local gen2PaletteTod = f.enum{ "MORN", "DAY", "NITE", "DARK" }
-- one GBC colour as the extractor writes it: a positional {r,g,b} triple
-- already expanded from 5-bit BGR to 0..255 (src/render/GbcPalette.lua)
local gen2Color = f.list(f.int(0, 255))
-- a palette row. The OBJ rows the sprite and mon pics use carry two colours
-- (the cart supplies white and black), the BG rows carry all four.
local gen2PaletteRow = f.list(gen2Color)
R.pokemon = {
semantics = "record", target = "pokemon",
fields = {
id = f.str, name = f.str, dex = f.int(1),
index = f.opt(f.int(0, 255)),
types = f.list(f.id("type_chart")),
baseStats = f.rec{ hp = f.int(1, 255), attack = f.int(1, 255),
defense = f.int(1, 255), speed = f.int(1, 255),
special = f.int(1, 255) },
catchRate = f.int(0, 255), baseExp = f.int(0, 255),
level1Moves = f.list(f.id("moves")),
growthRate = f.id("growth_rates"),
tmhm = f.opt(f.list(f.id("moves"))),
learnset = f.list(f.rec{ level = f.int(1), move = f.id("moves") }),
evolutions = f.list(f.rec{ method = f.id("evolution_methods"),
level = f.opt(f.int(1)),
item = f.opt(f.id("items")),
species = f.id("pokemon") }),
spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7),
dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0),
heightIn = f.int(0, 11), weight = f.num,
heightM = f.opt(f.num), weightKg = f.opt(f.num),
text = f.str }),
icon = f.opt(f.union{ f.str, f.rec{ image = f.path,
frames = f.opt(f.int(1)) } }),
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
trueColor = f.opt(f.bool),
-- battle-pic scale overrides for this species' own pics: front is the
-- enemy pic (default 1x), back is the player pic (default 2x). An
-- image-level battle_sprite_scales entry for the same path beats these.
-- The pic stays grounded (feet pinned) at any scale; see docs/modding.md.
battleScaleFront = f.opt(f.numRange(0.25, 4.0)),
battleScaleBack = f.opt(f.numRange(0.25, 4.0)),
},
-- Same registry, same target (data.pokemon), same species ids: only the
-- record differs, and it differs in four places, every one of them a real
-- Gen 2 change rather than an extractor spelling. Gen 2 splits `special`
-- into specialAttack/specialDefense (BaseData in pokegold's
-- data/pokemon/base_stats/), names the level-up table `levelMoves` and the
-- pic size `picSize`, has no separate level-1 move list (level 1 rows live
-- in levelMoves), and points an evolution at `into` rather than `species`.
-- Beside that it carries the breeding block Gen 1 has no analogue for
-- (eggGroups/eggMoves/eggSteps, genderRatio) and a held-item pair.
--
-- Without this, mod.content.pokemon:register is unusable for a Gold
-- species -- every record fails on the missing `special` -- while patch
-- happens to work, which is the worst of both.
gen2Fields = {
id = f.str, name = f.str, dex = f.int(1),
index = f.opt(f.int(0, 255)),
types = f.list(f.id("type_chart")),
baseStats = f.rec{ hp = f.int(1, 255), attack = f.int(1, 255),
defense = f.int(1, 255), speed = f.int(1, 255),
specialAttack = f.int(1, 255),
specialDefense = f.int(1, 255) },
catchRate = f.int(0, 255), baseExp = f.int(0, 255),
growthRate = f.id("growth_rates"), growthRateId = f.opt(f.int(0, 255)),
levelMoves = f.list(f.rec{ level = f.int(1), move = f.id("moves") }),
tmhm = f.opt(f.list(f.id("moves"))),
-- the raw TM/HM bitfield bytes, kept beside the resolved list so a
-- re-export round-trips; the engine reads `tmhm`
tmhmRaw = f.opt(f.list(f.int(0, 255))),
evolutions = f.list(f.rec{ method = f.id("evolution_methods"),
into = f.id("pokemon"),
level = f.opt(f.int(1)),
item = f.opt(f.id("items")),
-- EVOLVE_HAPPINESS' window and EVOLVE_STAT's
-- attack-vs-defence test
time = f.opt(f.enum{ "ANYTIME", "MORNDAY",
"NITE" }),
comparison = f.opt(f.enum{ "ATK_LT_DEF",
"ATK_GT_DEF",
"ATK_EQ_DEF" }) }),
-- breeding: two egg groups (the raw byte packs both nibbles), the egg
-- move list, and the cycle count src/core/gen2/Breeding.lua counts down
eggGroups = f.opt(f.list(f.str)), eggGroupsRaw = f.opt(f.int(0, 255)),
eggMoves = f.opt(f.list(f.id("moves"))), eggSteps = f.opt(f.int(0)),
genderRatio = f.opt(f.int(0, 255)),
-- the two wild held items, in the ROM's own order (rare then common)
items = f.opt(f.list(f.id("items"))),
spriteFront = f.path, spriteBack = f.path, picSize = f.int(1, 7),
source = f.opt(f.str),
cry = f.opt(f.id("cries")), trueColor = f.opt(f.bool),
battleScaleFront = f.opt(f.numRange(0.25, 4.0)),
battleScaleBack = f.opt(f.numRange(0.25, 4.0)),
},
example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })',
gen2Example = 'mod.content.pokemon:patch("TOTODILE", '
.. '{ baseStats = { specialAttack = 80 } })',
}
R.moves = {
semantics = "record", target = "moves",
fields = {
id = f.str, name = f.str,
index = f.opt(f.int(0, 255)),
type = f.id("type_chart"),
power = f.int(0, 255),
accuracy = f.int(0, 100),
pp = f.int(0, 64),
effect = f.id("move_effects"),
anim = f.opt(f.any),
category = f.opt(f.enum{ "physical", "special", "status" }),
priority = f.opt(f.int(-7, 7)),
highCrit = f.opt(f.bool),
fixedDamage = f.opt(f.union{ f.int(1), f.fn }),
chargeText = f.opt(f.str),
semiInvulnerable = f.opt(f.bool),
-- a fixed count or the distribution a uniform roll picks from
multiHit = f.opt(f.union{ f.int(1), f.list(f.int(1)) }),
counterable = f.opt(f.bool),
},
example = 'mod.content.moves:patch("BLIZZARD", { accuracy = 70 })',
}
R.items = {
semantics = "record", target = "items",
fields = {
id = f.str, name = f.str,
index = f.opt(f.int(0, 255)),
price = f.int(0),
machine = f.opt(f.rec{ kind = f.str, move = f.id("moves"),
number = f.int(0) }),
effect = f.opt(f.id("item_effects")),
ball = f.opt(f.id("balls")),
tossable = f.opt(f.bool),
needsTarget = f.opt(f.bool),
},
example = 'mod.content.items:patch("POTION", { price = 100 })',
}
R.maps = {
semantics = "record", target = "maps",
fields = {
-- the byte cap is a ROM table artifact; mod maps use ids at or above
-- 1000, and the indoor/connection range compares read the number
id = f.str, label = f.opt(f.str), index = f.opt(f.int(0)),
tileset = f.id("tilesets"),
width = f.int(1), height = f.int(1),
blocks = f.list(f.int(0, 255)),
borderBlock = f.opt(f.int(0, 255)),
-- A named SGB palette, which wins over the field.palettes cascade
-- (OverworldController.lua:506 reads map.def.palette first). Deliberately
-- a plain string rather than f.id("palettes"): the ROM-free fixture base
-- carries no palettes at all, so an id reference would fail validation for
-- a perfectly good mod wherever there is no imported dataset.
palette = f.opt(f.str),
-- destGroup / destMapNum are the ROM map-group pair Gen 2 carries beside
-- the destination it actually warps through (World:resolveWarp reads
-- destMap and destWarp, the same two keys Gen 1 does). Optional and
-- additive rather than a second warp shape: `maps` routes to
-- data.gen2Maps under Gen 2, so the records a mod patches there are the
-- extractor's own, and a strict rec would reject every one of them.
warps = f.opt(f.list(f.rec{ x = f.int(0), y = f.int(0),
destMap = f.str, destWarp = f.int(0),
destGroup = f.opt(f.int(0)),
destMapNum = f.opt(f.int(0)) })),
objects = f.opt(f.list(f.any)),
signs = f.opt(f.list(f.any)),
connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)),
},
extra = function(_, value)
if type(value.blocks) == "table" and type(value.width) == "number"
and type(value.height) == "number"
and #value.blocks ~= value.width * value.height then
return ("blocks has %d entries, expected width*height = %d")
:format(#value.blocks, value.width * value.height)
end
end,
example = 'mod.content.maps:register("MY_CAVE", { tileset = "CAVERN", ... })',
}
R.tilesets = {
semantics = "record", target = "tilesets",
fields = {
id = f.opt(f.str), image = f.path,
imageWidth = f.opt(f.int(1)), imageHeight = f.opt(f.int(1)),
tilesPerRow = f.opt(f.int(1)),
blocks = f.list(f.any),
walkable = f.opt(f.any), counterTiles = f.opt(f.any),
doorTiles = f.opt(f.any), warpTiles = f.opt(f.any),
animation = f.opt(f.str),
trueColor = f.opt(f.bool),
},
extra = function(_, value)
if type(value.blocks) == "table" then
for i, row in ipairs(value.blocks) do
if type(row) ~= "table" or #row ~= 16 then
return ("blocks[%d] must be a row of 16 tile ids"):format(i)
end
end
end
end,
example = 'mod.content.tilesets:register("MY_TILES", { image = "...", blocks = { ... } })',
}
-- ------- Gen 2 wild encounters
--
-- One wild slot. A fishing slot's species may be the literal 0 the ROM uses
-- for "no fish here, roll the map's water table instead" (pokegold
-- data/wild/fish.asm), which is why species is a union rather than a bare id.
local gen2Slot = f.rec{ level = f.int(1), species = f.id("pokemon") }
-- the sentinel row carries level 0 as well as species 0, so both floors drop
local gen2FishSlot = f.rec{ chance = f.int(0, 255), level = f.int(0),
species = f.union{ f.id("pokemon"), f.int(0, 0) } }
-- Headbutt/Rock Smash slots. species is optional and the level floor is 0
-- because TreeMonSet_Rock has no `rare` half in the ROM (pokegold
-- data/wild/treemons.asm ends the table after the common rows), so the four
-- Rock Smash maps that point at it carry a rare table read out of whatever
-- follows: levels past 100 and rows with no species at all. Rejecting it
-- would mean the extractor's own table could never be re-registered.
local gen2TreeSlot = f.rec{ chance = f.int(0, 255), level = f.int(0),
species = f.opt(f.id("pokemon")) }
-- a grass row: one encounter rate and one seven-slot table PER time of day,
-- which is the whole reason this cannot share the Gen 1 shape
local gen2GrassRow = f.rec{
map = f.opt(f.str),
rates = f.map(gen2Tod, f.int(0, 255)),
slots = f.map(gen2Tod, f.list(gen2Slot)),
}
-- water has no time-of-day split: one rate, one three-slot table
local gen2WaterRow = f.rec{
map = f.opt(f.str), rate = f.int(0, 255), slots = f.list(gen2Slot),
}
R.encounters = {
semantics = "record", target = "encounters",
fields = {
id = f.opt(f.str),
grass = f.opt(f.rec{ rate = f.int(0, 255),
slots = f.list(f.rec{ level = f.int(1),
species = f.id("pokemon") }) }),
water = f.opt(f.rec{ rate = f.int(0, 255),
slots = f.list(f.rec{ level = f.int(1),
species = f.id("pokemon") }) }),
},
-- Gold keys wild encounters by encounter KIND first and by map second
-- (data.gen2Encounters.grass.ROUTE_29), because the cart ships one table
-- per kind and a map appears in as many of them as it has water, swarms,
-- fishing spots and headbuttable trees. There is no per-map record to key
-- the registry by, so the id is the kind and `patch` folds per map instead
-- of replacing the kind's whole table.
--
-- Semantics stay "record" rather than becoming "deep" even though the id is
-- a namespace: a slot table is an ORDERED list whose position is the
-- encounter roll, and Merge.deepMerge appends lists under "deep" semantics,
-- so a mod rewriting a seven-slot table would get a fourteen-slot one.
gen2Keys = {
grass = f.map(f.str, gen2GrassRow),
-- the swarm variants shadow their base table while a swarm is running
swarmGrass = f.map(f.str, gen2GrassRow),
water = f.map(f.str, gen2WaterRow),
swarmWater = f.map(f.str, gen2WaterRow),
-- fishing: a map's rod points at a named group, and the group carries a
-- chance-ordered table per rod
fishGroups = f.map(f.str, f.rec{
id = f.opt(f.str), index = f.opt(f.int(0, 255)),
chance = f.int(0, 255),
old = f.list(gen2FishSlot), good = f.list(gen2FishSlot),
super = f.list(gen2FishSlot) }),
-- headbutt: map -> tree set id, and the set's common/rare tables. rocks
-- is the same indirection for Rock Smash.
trees = f.map(f.str, f.str),
rocks = f.map(f.str, f.str),
treeSets = f.map(f.str, f.rec{ common = f.list(gen2TreeSlot),
rare = f.list(gen2TreeSlot) }),
-- the Bug-Catching Contest pool (min/max level, not one level per slot)
bugContest = f.list(f.rec{ species = f.id("pokemon"),
min = f.int(1), max = f.int(1),
chance = f.int(0, 255) }),
-- where a roaming beast may walk next, keyed by the map it is on
roamMaps = f.list(f.rec{ map = f.str, to = f.list(f.str) }),
source = f.str, generation = f.int(1),
},
example = 'mod.content.encounters:patch("ROUTE_1", { grass = { rate = 30 } })',
gen2Example = 'mod.content.encounters:patch("grass", '
.. '{ ROUTE_29 = { rates = { NITE = 40 } } })',
}
R.trainers = {
semantics = "record", target = "trainers",
fields = {
id = f.str, name = f.str,
index = f.opt(f.int(0, 255)),
-- unused vanilla classes ship without a pic, so it cannot be required
pic = f.opt(f.path),
-- Full-color portrait: skip the 4-shade SGB/GBC remap, same flag pokemon
-- and sprites already carry.
trueColor = f.opt(f.bool),
-- Optional Advanced-mode OBJ palette source for a custom trainer portrait.
-- It follows the same ROM crosswalk form as sprites.paletteSource.
paletteSource = f.opt(f.str),
-- Reuse a base trainer class's portrait without redistributing its asset.
basePic = f.opt(f.id("trainers")),
baseMoney = f.opt(f.int(0)),
parties = f.list(f.list(f.rec{ level = f.int(1),
species = f.id("pokemon") })),
aiMods = f.opt(f.any),
aiClass = f.opt(f.id("ai_classes")),
brain = f.opt(f.fn),
-- Per-trainer battle theme (an audio.songs id): overrides the
-- kind-based default (wild/trainer/gym/final) for this trainer's
-- battles. The victory jingle stays kind-based.
battleTheme = f.opt(f.id("music")),
},
-- Gold hangs its rosters off data.gen2Trainers.classes, one record per
-- trainer CLASS carrying every named trainer of that class. The id space
-- is still the class id, so the registry keeps the Gen 1 call shape --
-- mod.content.trainers:patch("BEAUTY", { baseMoney = 99 }) -- and only the
-- one level of indirection to `.classes` is new. That is the same trick
-- battle_anims plays with its per-kind subtables, and it is why these three
-- callbacks exist rather than a `classes` key nobody would guess.
gen2BaseAt = function(base, id)
return base.classes and base.classes[id] or nil
end,
gen2BaseIds = function(base)
local ids = {}
for id in pairs(base.classes or {}) do ids[#ids + 1] = id end
return ids
end,
gen2Write = function(target, registry)
local classes = target.classes
if not classes then
classes = {}
target.classes = classes
end
local tombstones = {}
for id in pairs(registry.ops) do
local value = registry:get(id)
if value == nil then
tombstones[#tombstones + 1] = id
else
classes[id] = value
end
end
for _, id in ipairs(tombstones) do classes[id] = nil end
end,
gen2Fields = {
id = f.opt(f.str), name = f.str,
index = f.opt(f.int(0, 255)),
-- class frontpic; when set, this wins over menu_gfx.battleHud.trainerPics
pic = f.opt(f.path),
-- Full-color portrait: skip the GBC 4-shade remap, same flag Gen 1
-- trainers and pokemon already carry.
trueColor = f.opt(f.bool),
baseMoney = f.opt(f.int(0)),
-- the class's battle theme; Gen 1 spells the same idea `battleTheme`,
-- but this is the extractor's own key and a strict rename would reject
-- every one of Gold's 66 classes
encounterMusic = f.opt(f.id("music")),
-- the items the class's AI may use mid-battle, and the seven raw AI
-- bytes behind them (pokegold data/trainers/attributes.asm)
items = f.opt(f.list(f.id("items"))),
attributes = f.opt(f.list(f.int(0, 255))),
-- one entry per named trainer of the class. trainerType decides which
-- optional party fields the cart actually stores, so `moves` and `item`
-- are optional here rather than four party shapes in a union.
trainers = f.list(f.rec{
id = f.opt(f.str), name = f.str, index = f.opt(f.int(0, 255)),
trainerType = f.opt(f.enum{ "TRAINERTYPE_NORMAL", "TRAINERTYPE_MOVES",
"TRAINERTYPE_ITEM",
"TRAINERTYPE_ITEM_MOVES" }),
party = f.list(f.rec{ level = f.int(1), species = f.id("pokemon"),
item = f.opt(f.id("items")),
moves = f.opt(f.list(f.id("moves"))) }) }),
},
example = 'mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })',
gen2Example = 'mod.content.trainers:patch("BEAUTY", { baseMoney = 99 })',
}
R.sprites = {
semantics = "record", target = "sprites",
fields = {
id = f.opt(f.str),
image = f.path,
frames = f.int(1),
walker = f.opt(f.bool),
-- Optional sheet geometry for mod actors. Defaults match the vanilla
-- 16x16 grounded walker; anchors are measured from each frame's
-- top-left in pixels (default: bottom-center).
frameWidth = f.opt(f.int(1)),
frameHeight = f.opt(f.int(1)),
anchorX = f.opt(f.num),
anchorY = f.opt(f.num),
trueColor = f.opt(f.bool),
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
-- palette assignment without claiming that the image itself came from
-- the ROM (which is what `source` documents on imported records).
paletteSource = f.opt(f.str),
},
-- Same name, same ids, and (unlike the rest of this section) already
-- routed: `sprites` merges into data.gen2Sprites and Gold walks that very
-- table. The Gen 1 schema accepts a Gen 2 record only because the extra
-- keys fall through as unknown-but-preserved, which means none of them is
-- checked -- a mod could write paletteId = "blue" and find out at draw
-- time. This types them.
gen2Fields = {
id = f.opt(f.str), image = f.path, frames = f.int(1),
walker = f.opt(f.bool), trueColor = f.opt(f.bool),
paletteSource = f.opt(f.str),
-- the OBJ palette this sprite draws with, by name and by the slot index
-- src/world/gen2/Palettes.lua indexes into (PAL_OW_RED is slot 0)
palette = f.opt(f.str), paletteId = f.opt(f.int(0, 7)),
-- how the overworld animates it: WALKING_SPRITE has the four facings and
-- a step cycle, STANDING_SPRITE only the facings, STILL_SPRITE one frame,
-- POKEMON_SPRITE the party-icon pair (pokegold constants/sprite_constants)
spriteType = f.opt(f.enum{ "WALKING_SPRITE", "STANDING_SPRITE",
"STILL_SPRITE", "POKEMON_SPRITE" }),
-- a POKEMON_SPRITE names the species it follows and the party icon it
-- borrows its art from
species = f.opt(f.id("pokemon")), icon = f.opt(f.str),
source = f.opt(f.str),
},
example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })',
gen2Example = 'mod.content.sprites:patch("SPRITE_BEAUTY", '
.. '{ palette = "PAL_OW_RED", paletteId = 0 })',
}
R.text = {
semantics = "record", target = "text",
value = f.str,
example = 'mod.content.text:override("_PalletTownText1", "HELLO!")',
}
-- The engine's own authored text, the half of the game `text` does not
-- cover (src/core/Strings.lua). The id is the English source string, so a
-- translation supplies one entry per literal the engine draws and anything
-- it has not reached yet keeps rendering in English. Ids that collide in
-- English but not elsewhere carry a "context|source" prefix.
R.strings = {
semantics = "record", target = "strings",
value = f.str,
example = 'mod.content.strings:override("But, it failed!", "Echec !")',
}
-- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2);
-- shared by every namespace that plays a chip program
local chipProgram = f.rec{
blob = f.str, channels = f.any, waves = f.opt(f.any),
drums = f.opt(f.any), engine = f.opt(f.num),
}
-- value union dispatched per def shape: rom chip ref, file-backed song, or
-- an authored chip program (ChipAsm)
R.music = {
semantics = "record", target = "audio.songs",
value = f.union{
f.rec{ address = f.int(0), bank = f.int(0), engine = f.opt(f.num) },
f.rec{ file = f.path, loopFile = f.opt(f.path), seconds = f.opt(f.num),
loopSeconds = f.opt(f.num), intro = f.opt(f.any) },
f.rec{ program = f.any, channels = f.any, waves = f.opt(f.any),
drums = f.opt(f.any) },
f.rec{ chip = chipProgram },
},
example = 'mod.content.music:register("MOD_SONG", { file = "song.ogg" })',
}
-- whole-key replacement of Data.audio, the v1 escape hatch. Kept working
-- forever; the granular sfx / cries / map_songs registries supersede it and
-- whole-key swaps (programFile, bankOrder) remain its one honest use.
R.audio = {
semantics = "record", target = "audio",
value = f.any,
deprecated = { useInstead = "sfx / cries / map_songs / music" },
example = 'mod.content.audio:override("mapSongs", { ... })',
}
-- compose: registrations accumulate into per-map chains instead of
-- replacing each other. Data.map_scripts is the interim home consumed by
-- data/scripts/init.lua until the M5 dispatcher reads chain() directly.
-- rows, a handler, or false: false is the explicit suppression that wins the
-- single-winner resolution and hides every lower-precedence entry (09 4.4)
local scriptEntry = f.union{
f.list(f.any), f.fn,
leaf("suppress", "false", function(v) return v == false end),
}
R.map_scripts = {
semantics = "compose", target = "map_scripts",
value = f.rec{
talk = f.opt(f.map(f.str, scriptEntry)),
scripts = f.opt(f.map(f.str, scriptEntry)),
onEnter = f.opt(f.fn), onStep = f.opt(f.fn), onInteract = f.opt(f.fn),
onVictory = f.opt(f.fn), onBoulderMoved = f.opt(f.fn),
-- the flute wake sequence ItemEffects/BagMenu look up by map id; the
-- other legacy ad-hoc keys stay unknown-but-preserved
snorlaxWake = f.opt(f.rec{
objName = f.opt(f.str), beatFlag = f.opt(f.str), script = f.list(f.any),
}),
priority = f.opt(f.num),
},
example = 'mod.content.map_scripts:register("PALLET_TOWN", { talk = { ... } })',
}
R.screens = {
semantics = "record", target = "screens",
value = f.union{ f.fn, f.rec{ new = f.fn } },
example = 'mod.content.screens:register("QuestLog", { new = function(game) ... end })',
}
-- ------- battle
-- Two id forms share one registry: "ATTACKER>DEFENDER" matchup rows and
-- bare type ids. Neither lives at a key of Data.type_chart (the rows are
-- an ordered array), so the registry owns the whole target: reads see only
-- registrations -- the engine makes them all -- and the merge rebuilds
-- matchups and types from the op order.
R.type_chart = {
semantics = "record", target = "type_chart",
value = f.union{
f.rec{ multiplier = f.int(0) },
f.rec{ name = f.opt(f.str), category = f.enum{ "physical", "special" },
index = f.opt(f.int(0, 255)) },
},
baseAt = function() return nil end,
baseIds = function() return {} end,
write = function(target, registry)
local matchups, types = {}, {}
for _, id in ipairs(registry.order) do
local value = registry:get(id)
if value ~= nil then
local attacker, defender = id:match("^([^>]+)>([^>]+)$")
if attacker then
local row = Merge.deepCopy(value)
row.attacker, row.defender = attacker, defender
matchups[#matchups + 1] = row
else
types[id] = value
end
end
end
target.matchups, target.types = matchups, types
end,
example = 'mod.content.type_chart:register("BUG>PSYCHIC_TYPE", { multiplier = 20 })',
}
R.statuses = {
semantics = "record", target = "statuses",
fields = {
id = f.opt(f.str), label = f.str,
hudLabel = f.opt(f.str),
canInflict = f.opt(f.fn), onInflict = f.opt(f.fn),
beforeMove = f.opt(f.fn), beforeMovePriority = f.opt(f.int(0)),
residual = f.opt(f.fn),
catchBonus = f.opt(f.int(0, 255)), shakeBonus = f.opt(f.int(0, 255)),
statPenalty = f.opt(f.rec{ stat = f.str, div = f.int(1) }),
cureOnSwitch = f.opt(f.bool),
},
example = 'mod.content.statuses:patch("BRN", { catchBonus = 12 })',
}
-- run is optional because the "full" effects are steered from inside the
-- damage pipeline and have no standalone handler to register yet; M7 gives
-- them the effect context that makes one possible
R.move_effects = {
semantics = "record", target = "move_effects",
fields = {
kind = f.enum{ "primary", "secondary", "full" },
accuracyChecked = f.opt(f.bool),
run = f.opt(f.fn),
},
example = 'mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = fn })',
}
R.item_effects = {
semantics = "record", target = "item_effects",
fields = {
use = f.fn,
needsTarget = f.opt(f.bool), battle = f.opt(f.bool), field = f.opt(f.bool),
},
example = 'mod.content.item_effects:register("MOON_FLUTE", { use = fn, field = true })',
}
-- MASTER_BALL catches unconditionally and never rolls, so randMax 0 is a
-- legal record and hpFactor is only read on the wobble path
R.balls = {
semantics = "record", target = "balls",
fields = {
randMax = f.int(0, 255),
hpFactor = f.opt(f.int(1)), wobbleFactor = f.opt(f.int(1)),
autoCatch = f.opt(f.bool), flicker = f.opt(f.bool),
tossAnim = f.opt(f.str), attempt = f.opt(f.fn),
},
example = 'mod.content.balls:override("GREAT_BALL", { randMax = 180, hpFactor = 12 })',
}
R.rulesets = {
semantics = "record", target = "rulesets",
fields = { name = f.str },
example = 'mod.content.rulesets:register("no_crits", { name = "no crits", critRate = 0 })',
}
-- three record kinds share the registry: "class" is the per-trainer
-- item/switch behavior, "layer" a move-scoring pass (the vanilla three are
-- LAYER_1..LAYER_3), "brain" a full action chooser
R.ai_classes = {
semantics = "record", target = "ai_classes",
fields = {
kind = f.opt(f.enum{ "class", "layer", "brain" }),
uses = f.opt(f.int(0)), chance = f.opt(f.int(0, 256)),
item = f.opt(f.id("items")),
switch = f.opt(f.bool), switchChance = f.opt(f.int(0, 256)),
switchBelow = f.opt(f.int(1)), hpBelow = f.opt(f.int(1)),
onStatus = f.opt(f.bool),
score = f.opt(f.fn), choose = f.opt(f.fn), brain = f.opt(f.fn),
},
example = 'mod.content.ai_classes:patch("OPP_BROCK", { uses = 9 })',
}
-- ids route into the target's per-kind subtables: a bare move id is a move
-- animation, "subanim:<n>" and "tilesheet:<n>" address the shared pieces
local function animRoute(id)
local kind, index = tostring(id):match("^(%a+):(%d+)$")
if kind == "subanim" then return "subanims", tonumber(index) end
if kind == "tilesheet" then return "tilesheets", tonumber(index) end
return "moveAnims", id
end
R.battle_anims = {
semantics = "record", target = "battle_anims",
value = f.union{
f.rec{ seq = f.list(f.any), source = f.opt(f.str) },
f.rec{ blocks = f.list(f.any), type = f.opt(f.str) },
f.rec{ path = f.path, width = f.int(1), height = f.int(1),
tiles = f.int(1), source = f.opt(f.str) },
},
baseAt = function(base, id)
local sub, key = animRoute(id)
local table_ = base[sub]
return table_ and table_[key] or nil
end,
baseIds = function(base)
local ids = {}
for id in pairs(base.moveAnims or {}) do ids[#ids + 1] = id end
for index in pairs(base.subanims or {}) do ids[#ids + 1] = "subanim:" .. index end
for index in pairs(base.tilesheets or {}) do ids[#ids + 1] = "tilesheet:" .. index end
return ids
end,
write = function(target, registry)
for _, id in ipairs(registry.order) do
local sub, key = animRoute(id)
local into = target[sub]
if not into then
into = {}
target[sub] = into
end
into[key] = registry:get(id)
end
end,
-- Gold's battle animations are the cart's own bytecode, not a Lua sequence:
-- data.gen2BattleAnims is a script POOL keyed by ROM pointer plus the name
-- tables that index into it (a move id or an ANIM_* id resolves to a
-- pointer), and the object/frameset/OAM/graphics tables the scripts spawn
-- from. src/battle/gen2/AnimRunner.lua walks exactly those. The id is the
-- table, and `patch` adds one object without restating the pool.
--
-- The Gen 1 write/baseAt/baseIds trio is cleared rather than reused: it
-- routes an id into a per-kind subtable by prefix, and here the ids ARE the
-- subtables, so the plain record placement is the correct one.
gen2Write = false, gen2BaseAt = false, gen2BaseIds = false,
gen2Keys = {
-- pointer -> the decoded command rows the runner steps; each row is a
-- verb string followed by its operands
scripts = f.map(f.str, f.list(f.list(f.any))),
-- the pool in ROM order, which is what a re-export writes back
scriptOrder = f.list(f.str),
-- move id -> script pointer, and ANIM_* id -> script pointer
moves = f.map(f.str, f.str),
ids = f.map(f.str, f.str),
-- an animation object: which graphics, palette, frameset and update
-- function it spawns with
objects = f.map(f.str, f.rec{ gfx = f.str, palette = f.str,
frameset = f.str, func = f.str,
fixY = f.opt(f.int(0, 255)),
flags = f.opt(f.int(0, 255)) }),
-- a frameset is its own little row list (frame / wait / delete)
framesets = f.map(f.str, f.list(f.list(f.any))),
-- OAM: the sprite rectangle an object draws, and the VRAM tile it starts
-- at. x/y are the cart's unsigned bytes, so 240 means -16.
oamsets = f.map(f.str, f.rec{ vtile = f.int(0, 255),
sprites = f.list(f.rec{
x = f.int(0, 255), y = f.int(0, 255),
tile = f.int(0, 255),
attr = f.int(0, 255) }) }),
gfx = f.map(f.str, f.rec{ image = f.path, tiles = f.int(1),
wide = f.int(1) }),
bank = f.int(0), source = f.str, generation = f.int(1),
},
example = 'mod.content.battle_anims:register("SHADOW_BALL", { seq = { ... } })',
gen2Example = 'mod.content.battle_anims:patch("moves", '
.. '{ SHADOW_BALL = "5e86" })',
}
R.transitions = {
semantics = "record", target = "transitions",
fields = {
frames = f.int(1), draw = f.opt(f.fn), sound = f.opt(f.str),
flash = f.opt(f.bool),
},
example = 'mod.content.transitions:register("dissolve", { frames = 30, draw = fn })',
}
-- ------- rendering pipelines
--
-- A pipeline is a display mode that owns part of the frame: it may replace
-- the overworld's world pass with geometry of its own (drawWorld) and/or
-- post-process the finished composite (present). Everything around that --
-- the OFF/1/2/3 ladder, its options row, its hotkey, persistence in
-- save.options.pipelines and the gating that keeps it out of battles and
-- menus -- is engine plumbing driven from this record, so a renderer mod
-- declares what it is and writes only the two draw functions.
--
-- Both callbacks are optional and independent: a present-only pipeline is a
-- post-process (bloom, tilt-shift, a CRT curve) that leaves whatever
-- rendered the frame alone, and a drawWorld-only pipeline is a world
-- renderer that composites straight. See src/render/Pipelines.lua for the
-- ctx each receives and docs/modding.md for the worked example.
R.render_pipelines = {
semantics = "record", target = "render_pipelines",
fields = {
-- shown in the options menu; the ladder labels default to OFF/ON
label = f.str,
levels = f.opt(f.list(f.str)),
-- keyboard key that cycles the ladder, checked after the engine's own
-- display hotkeys so a pipeline can never shadow one
hotkey = f.opt(f.str),
-- higher wins when two world pipelines are somehow active at once;
-- also the options-row order, so a mode and its post-process sort
-- together instead of by id
priority = f.opt(f.num),
-- hardware/driver gate, checked every frame: false keeps the vanilla
-- 2D path, which is what a headless run and a driver with no depth
-- canvas both get
available = f.opt(f.fn),
-- (top, overworld) -> boolean: whether the player may CHANGE the mode
-- right now. Defaults to the survey-zoom gate (free-roam overworld
-- only), which keeps a hotkey press from switching modes mid-warp or
-- mid-cutscene. It has no say over whether an already-on mode draws:
-- a mode that stopped rendering during a warp would flash the flat 2D
-- world every time the player walked through a door.
gate = f.opt(f.fn),
-- (dt, level): presentational tweens, ticked on real frame time
update = f.opt(f.fn),
-- (ctx) -> canvas | nil: render the world. nil falls back to the
-- vanilla flat/tilt draw for this frame.
drawWorld = f.opt(f.fn),
-- (canvas, ctx) -> canvas: post-process the WORLD image, before the UI
-- composites over it -- a depth-of-field or colour grade that must not
-- touch the dialog boxes and menus sitting on top. Only runs when some
-- pipeline rendered the world, since the vanilla world pass has no
-- single finished image to hand over.
worldPresent = f.opt(f.fn),
-- (canvas, ctx) -> canvas: post-process the whole finished composite,
-- world and UI alike (a CRT curve, a full-screen grade). Must return a
-- canvas; the input unchanged is the correct answer when the effect is
-- off.
present = f.opt(f.fn),
-- drop GPU objects (window resize, hot reload, mode switch)
invalidate = f.opt(f.fn),
},
-- a pipeline that does neither half is dead weight and would silently
-- occupy an options row and a hotkey
extra = function(_, value)
if value.drawWorld == nil and value.present == nil
and value.worldPresent == nil then
return "a render pipeline needs drawWorld, worldPresent or present"
end
end,
-- A pipeline callback fails at play time, long after the load phase has
-- handed its report to the mod manager, so the merge leaves behind who
-- wrote each record for Pipelines to name in the failure -- the same
-- provenance trick the audio registries use (Loader.stampAudioOwners).
-- Placement is otherwise the default record merge.
write = function(target, registry)
local owners, tombstones = {}, {}
for id in pairs(registry.ops) do
local value = registry:get(id)
if value == nil then
tombstones[#tombstones + 1] = id
else
target[id] = value
local owner = registry.owners[id]
if owner and owner ~= Schemas.ENGINE then owners[id] = owner end
end
end
for _, id in ipairs(tombstones) do target[id] = nil end
target._owners = owners
end,
example = 'mod.content.render_pipelines:register("voxel", ' ..
'{ label = "VOXEL", levels = { "OFF", "15", "35", "50" }, drawWorld = fn })',
}
-- ------- battle sprite scales
--
-- Per-image battle-pic scale overrides, keyed by record id and consulted
-- by asset path at draw time. Where a species' battleScaleFront /
-- battleScaleBack scales its own front/back pic, this scales ANY battle
-- pic by the path it is drawn from -- the only handle on the non-species
-- pics like the player's trainer back sprite. Image-level beats
-- species-level; both compose with the send-out grow and keep the sprite
-- grounded (feet pinned) at whatever scale. See docs/modding.md.
R.battle_sprite_scales = {
semantics = "record", target = "battle_sprite_scales",
fields = {
-- the asset path exactly as data references it, e.g.
-- "assets/generated/battle/back/abrab.png"
path = f.path,
-- 1 = native pixels; the drawn size relative to the pic's own pixels
scale = f.numRange(0.25, 4.0),
},
example = [[
mod.content.battle_sprite_scales:register("abra_back", {
path = "assets/generated/battle/back/abrab.png",
scale = 1.5,
})]],
notes = [[
Scales one battle pic by its asset path, overriding the species-level
`battleScaleFront`/`battleScaleBack` (see [pokemon](#pokemon)) for that
image. The only way to scale a pic that is not species-keyed, like the
player's trainer back sprite. Resolution order at draw time:
image-level, then species-level, then the defaults (1x front, 2x back).
The pic stays grounded at any scale: player feet stay flush on the
text-box top, the enemy pic stays bottom-pinned in its slot, and the
scale composes with the send-out grow animation.]],
}
-- ------- progression
R.evolution_methods = {
semantics = "record", target = "evolution_methods",
fields = { check = f.fn, describe = f.opt(f.fn) },
example = 'mod.content.evolution_methods:register("FRIENDSHIP", { check = fn })',
}
R.growth_rates = {
semantics = "record", target = "growth_rates",
fields = { expForLevel = f.fn },
-- a curve that does not grow makes levelForExp loop forever
extra = function(_, value)
if type(value.expForLevel) == "function" then
local ok, low, high = pcall(function()
return value.expForLevel(1), value.expForLevel(2)
end)
if ok and type(low) == "number" and type(high) == "number"
and high <= low then
return "expForLevel must increase with level"
end
end
end,
example = 'mod.content.growth_rates:register("ERRATIC", { expForLevel = fn })',
}
-- ------- audio (per-def shapes, dispatched by the consumer)
R.sfx = {
semantics = "record", target = "audio.sfx",
value = f.union{
f.str,
f.rec{ address = f.int(0), bank = f.int(0), engine = f.opt(f.num) },
f.rec{ file = f.path },
f.rec{ chip = chipProgram },
},
example = 'mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" })',
}
-- base names the species whose header a derived cry borrows, so it resolves
-- against this same registry (13.9)
R.cries = {
semantics = "record", target = "audio.cries",
value = f.union{
f.rec{ header = f.any, pitch = f.int(0, 255), length = f.int(0, 255) },
f.rec{ file = f.path },
f.rec{ base = f.id("cries"), pitch = f.opt(f.int(0, 255)),
length = f.opt(f.int(0, 255)) },
f.rec{ chip = chipProgram, pitch = f.opt(f.int(0, 255)),
length = f.opt(f.int(0, 255)) },
},
example = 'mod.content.cries:patch("PIKACHU", { pitch = 200 })',
}
R.map_songs = {
semantics = "record", target = "audio.mapSongs",
value = f.id("music"),
example = 'mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1")',
}
-- ------- presentation
-- vanilla palettes are four raw {r,g,b} triples; the named-record form is
-- the v2 shape a mod may register instead
R.palettes = {
semantics = "record", target = "palettes.palettes",
value = f.union{
f.list(f.list(f.int(0, 255))),
f.rec{ colors = f.list(f.rec{ r = f.int(0, 255), g = f.int(0, 255),
b = f.int(0, 255) }) },
},
extra = function(_, value)
local colors = value.colors or value
if type(colors) == "table" and #colors ~= 4 then
return ("needs exactly 4 colors, got %d"):format(#colors)
end
end,
-- Gold's palette table is not a flat name -> four colours map: the GBC has
-- eight BG and eight OBJ slots and the cart reloads them per context, so
-- the extractor writes one subtable per context (mon pics with their shiny
-- twin, trainer pics, the BG rows a map's environment indexes into, the
-- overworld OBJ rows per time of day, the town roof pair, the HP and EXP
-- bars). The id is the context, so a mod that recolours one species
-- patches `pokemon` and leaves the other 250 alone. The Gen 1 four-colour
-- `extra` is cleared: it reads the record as one palette, and here a record
-- is a whole subtable of them.
gen2Extra = false,
gen2Keys = {
-- every species has both a normal and a shiny row; the shiny one is what
-- src/render/GbcPalette.lua swaps in on a shiny battler
pokemon = f.map(f.str, f.rec{ normal = gen2PaletteRow,
shiny = gen2PaletteRow }),
trainers = f.map(f.str, gen2PaletteRow),
-- the BG rows, indexed by number: `environments` names eight of them per
-- environment per time of day, which is how a map gets its palette
bg = f.list(gen2PaletteRow),
environments = f.map(f.str, f.map(gen2PaletteTod, f.list(f.int(0)))),
-- the eight overworld OBJ rows per time of day; a sprite's paletteId
-- indexes this
objects = f.map(gen2PaletteTod, f.list(gen2PaletteRow)),
-- one pair per roof group (keyed by the group number, 0 included), and
-- the BG slot the roof colours are written into
roofs = f.map(f.int(0), f.rec{ mornDay = gen2PaletteRow,
nite = gen2PaletteRow }),
roofSlot = f.int(0, 7),
hpBar = f.map(f.enum{ "green", "yellow", "red", "blue" }, gen2PaletteRow),
expBar = gen2PaletteRow,
partyMenu = f.list(gen2PaletteRow),
battleObjects = f.map(f.str, gen2PaletteRow),
-- the ordered name lists the numeric indices above resolve through
daytimes = f.list(f.str), slotNames = f.list(f.str),
source = f.str, generation = f.int(1),
},
example = 'mod.content.palettes:override("MEWMON", { {255,255,255}, ... })',
gen2Example = 'mod.content.palettes:patch("pokemon", '
.. '{ TOTODILE = { shiny = { {255,255,255}, {255,0,0} } } })',
}
-- keyed by species id, unlike the vanilla byDex array: a species past the
-- end of the dex gets an icon without punching a hole in the list. The party
-- menu (src/ui/PartyMenu.lua) reads this per-species entry before the vanilla
-- dex-indexed default. The value is a built-in icon NAME -- one of BALL, BIRD,
-- BUG, FAIRY, GRASS, HELIX, MON, QUADRUPED, SNAKE, WATER (uppercase) -- or a
-- { image = <bundled file path>, frames? } table of your own art.
-- Gold splits the same idea in two: data.gen2Icons.icons is the 39 icon
-- SHEETS (each its own two-frame image) and data.gen2Icons.species is the
-- species -> sheet name assignment. Both halves keep the Gen 1 id space --
-- a species id names an assignment, a sheet id names a sheet -- so one
-- registry serves both, routed by the ICON_ prefix every sheet name carries.
-- Two id forms in one registry is the same shape font and battle_anims use.
local function gen2IconIsSheet(id)
return tostring(id):match("^ICON_") ~= nil
end
R.icons = {
semantics = "record", target = "icons.bySpecies",
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
gen2Value = f.union{
-- the assignment form: a species id mapped to a sheet name
f.str,
-- the sheet form: width/height are the sheet's pixel size, and every
-- vanilla sheet is a 16x32 two-frame strip
f.rec{ id = f.opt(f.str), index = f.opt(f.int(0, 255)), image = f.path,
width = f.int(1), height = f.int(1), frames = f.int(1) },
},
gen2Extra = function(id, value)
if gen2IconIsSheet(id) then
if type(value) ~= "table" then
return "an ICON_ id is a sheet and needs an image, width, height and frames"
end
elseif type(value) ~= "string" then
return "a species id takes the NAME of an ICON_ sheet, not a sheet"
end
end,
gen2BaseAt = function(base, id)
if gen2IconIsSheet(id) then return base.icons and base.icons[id] or nil end
return base.species and base.species[id] or nil
end,
gen2BaseIds = function(base)
local ids = {}
for id in pairs(base.icons or {}) do ids[#ids + 1] = id end
for id in pairs(base.species or {}) do ids[#ids + 1] = id end
return ids
end,
gen2Write = function(target, registry)
local sheets, species = target.icons, target.species
if not sheets then
sheets = {}
target.icons = sheets
end
if not species then
species = {}
target.species = species
end
for _, id in ipairs(registry.order) do
local into = gen2IconIsSheet(id) and sheets or species
into[id] = registry:get(id)
end
end,
example = 'mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 }',
gen2Example = 'mod.content.icons:override("TOTODILE", "ICON_MONSTER")',
}
-- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a
-- registered page takes a range of its own above them (a kana block at
-- $100), so neither a base nor a charmap code is capped at one byte.
-- Two id forms share the registry (14 §registry schemas): a bare id is a
-- page, "charmap:<name>" is one sequence->code row. A page carries its own
-- charmap only as a convenience -- replacing a sheet must not force an
-- author to restate the table.
local function fontIsCharmap(id)
return tostring(id):match("^charmap:.+$") ~= nil
end
-- the third id form: "ttf" switches text rendering to a real TTF (the
-- bundled Plain Pixel when `file` is omitted -- src/render/Font.lua)
local function fontIsTtf(id)
return tostring(id) == "ttf"
end
R.font = {
semantics = "record", target = "font",
value = f.union{
f.rec{ image = f.path, base = f.int(0), glyphsPerRow = f.opt(f.int(1)),
advance = f.opt(f.int(1)),
charmap = f.opt(f.list(f.rec{ code = f.int(0), seq = f.str })) },
f.rec{ seq = f.str, code = f.int(0) },
-- strict: every field here is optional ({} is a legal "ttf" entry), so
-- with the usual top-level leniency this alternative would match ANY
-- table and let malformed pages through the union unchecked
f.rec({ file = f.opt(f.path), size = f.opt(f.int(1)),
spacing = f.opt(f.num), yOffset = f.opt(f.num),
bold = f.opt(f.bool),
-- characters that keep their ROM tile instead of coming from the
-- TTF: a string of them, or a list when a multi-character charmap
-- sequence is meant (src/render/Font.lua)
tiles = f.opt(f.union{ f.str, f.list(f.str) }) },
{ strict = true }),
},
extra = function(id, value)
if fontIsCharmap(id) then
if type(value.seq) ~= "string" or value.seq == "" then
return "a charmap: entry needs a non-empty seq"
end
if type(value.code) ~= "number" then
return "a charmap: entry needs a code"
end
elseif fontIsTtf(id) then
-- every field optional: {} is "the bundled font at its native size"
if value.image ~= nil or value.base ~= nil then
return 'the "ttf" entry takes file/size/spacing/yOffset/bold/tiles, not a page'
end
elseif value.image == nil or value.base == nil then
return "a font page needs an image and a base"
end
end,
baseAt = function(base, id)
if fontIsCharmap(id) then return nil end
if fontIsTtf(id) then return base.ttf end
return base.pages and base.pages[id] or nil
end,
baseIds = function(base)
local ids = {}
for id in pairs(base.pages or {}) do ids[#ids + 1] = id end
return ids
end,
write = function(target, registry)
local pages = target.pages or {}
target.pages = pages
-- the extractor never emits a ttf entry, so like the charmap rows it is
-- rebuilt from the registry each merge: disabling the mod disables it
target.ttf = nil
-- the extractor's rows have no id and stay put; the registry's own are
-- rebuilt every merge so a re-merge replaces them instead of stacking
local rows = {}
for _, entry in ipairs(target.charmap or {}) do
if type(entry) ~= "table" or entry.id == nil then rows[#rows + 1] = entry end
end
for _, id in ipairs(registry.order) do
local value = registry:get(id)
if fontIsCharmap(id) then
if value ~= nil then
rows[#rows + 1] = { id = id, seq = value.seq, code = value.code }
end
elseif fontIsTtf(id) then
target.ttf = value
else
pages[id] = value
end
end
target.charmap = rows
end,
example = 'mod.content.font:register("charmap:hiragana_a", { seq = "\227\129\130", code = 256 })',
}
-- ------- scripting and text plumbing
-- a record is the bare handler (the v1 shape every engine verb still uses)
-- or the flagged table Commands.resolve already unpacks (09 §4.2)
R.commands = {
semantics = "record", target = "commands",
value = f.union{ f.fn, f.rec{ fn = f.fn, foreground = f.opt(f.bool),
blocking = f.opt(f.bool) } },
example = 'mod.content.commands:register("shake_screen", function(ctx, frames) ... end)',
}
R.tokens = {
semantics = "record", target = "tokens",
value = f.fn,
example = 'mod.content.tokens:register("CLOCK", function(game) return "12" end)',
}
-- ------- deep registries: id is a top-level key of the target table
-- Gold's `constants` is not the Gen 1 rule block at all: it is the ROM's own
-- ordered name lists, one per enum the cart indexes by number. A script
-- opcode that says "special 12" or an animation that says "object 41" is
-- resolved through these, so replacing an entry renames what that number
-- means. Every one of them is a dense list of ids in ROM order, which is why
-- they can be built from a name list instead of restated one by one.
local GEN2_CONSTANT_ORDERS = {
"battleAnimBgPaletteOrder", "battleAnimFramesetOrder", "battleAnimFuncOrder",
"battleAnimGfxOrder", "battleAnimOamsetOrder", "battleAnimObPaletteOrder",
"battleAnimObjectOrder", "battleBgEffectOrder", "cmdQueueOrder",
"decoDescOrder", "eggGroupOrder", "environmentOrder", "evolveMethodOrder",
"fishGroupOrder", "floorOrder", "growthRateOrder", "heldEffectOrder",
"iconOrder", "itemMenuOrder", "itemOrder", "landmarkOrder",
"mapCallbackOrder", "mapOrder", "moveEffectOrder", "moveOrder", "musicOrder",
"paletteOrder", "phoneContactOrder", "pocketOrder", "sfxOrder", "spawnOrder",
"specialCallOrder", "specialOrder", "speciesOrder", "spriteOrder",
"stdScriptOrder", "tilesetOrder", "tradeDialogOrder", "tradeGenderOrder",
"trainerClassOrder", "trainerTypeOrder", "treeMonSetOrder",
}
local gen2ConstantKeys = {
-- the map table the group/number pair in a warp resolves through
mapGroups = f.list(f.rec{ group = f.int(0), map = f.int(0), name = f.str,
width = f.int(1), height = f.int(1) }),
-- class id -> its named trainers, in the order the class's table stores them
trainerClassMembers = f.map(f.str, f.list(f.str)),
-- type id -> its ROM byte; the only one of these that is a lookup rather
-- than an ordered list, because the type numbers are not contiguous
types = f.map(f.str, f.int(0)),
-- counts the extractor stamps beside the lists
itemNameCount = f.int(0), numOverworldSprites = f.int(0),
spritePokemon = f.int(0),
source = f.str, generation = f.int(1),
}
for _, name in ipairs(GEN2_CONSTANT_ORDERS) do
gen2ConstantKeys[name] = f.list(f.str)
end
-- The rules the engine used to hard-code as Kanto/Red literals. Keys the
-- importer does not stamp are seeded with their vanilla value at data load
-- (src/core/Data.lua) so a patch always has something to fold over.
R.constants = {
semantics = "deep", target = "constants",
keys = {
bagSize = f.int(1), partyMax = f.int(1),
boxCount = f.int(1), boxSize = f.int(1),
moveMax = f.int(1),
dexSize = f.int(1), dexDigits = f.int(1),
levelCap = f.int(1), coinCap = f.int(0), moneyCap = f.int(0),
-- ordered: list position is the badge number the trainer card draws
badges = f.list(f.rec{ id = f.id("items"), name = f.opt(f.str),
icon = f.opt(f.path), item = f.opt(f.id("items")) }),
hmMoves = f.list(f.id("moves")),
encounterBuckets = f.list(f.int(1, 256)),
},
-- Gold's keys are ordered lists where position IS the id a script byte
-- resolves through, so they must replace rather than append -- which is
-- what "deep" semantics would do to them (Merge.deepMerge concatenates
-- lists there, and Gen 1's `field` rows genuinely want that). A key
-- neither catalog names is still a mod's own data and merges as-is.
gen2Semantics = "record",
gen2Keys = gen2ConstantKeys,
example = 'mod.content.constants:patch("levelCap", 80)',
gen2Example = 'mod.content.constants:patch("speciesOrder", '
.. '{ [252] = "MODMON" })',
}
-- The overworld's data grab bag. Only the keys this milestone routes are
-- typed; the rest of the 37-subtable inventory stays open until its
-- consumers move off their literals.
R.field = {
semantics = "deep", target = "field",
keys = {
ledges = f.list(f.rec{
facing = f.enum{ "up", "down", "left", "right" },
input = f.enum{ "up", "down", "left", "right" },
standingTile = f.int(0), ledgeTile = f.int(0),
tileset = f.opt(f.id("tilesets")) }),
hiddenItems = f.map(f.str, f.list(f.rec{
x = f.int(0), y = f.int(0), item = f.id("items") })),
badgeGates = f.map(f.str, f.rec{
badge = f.opt(f.id("items")), text = f.opt(f.str),
passText = f.opt(f.str), failText = f.opt(f.str),
-- omit it and the gate gets "PASSED_<mapId>"; Route 22 keeps its
-- pre-v2 spelling only because saves already carry that flag
passedFlag = f.opt(f.str),
coords = f.opt(f.list(f.rec{ x = f.int(0), y = f.int(0) })),
guards = f.opt(f.list(f.any)) }),
townMap = f.rec{
background = f.opt(f.any),
gridPixelSize = f.opt(f.int(1)),
cursorOrder = f.opt(f.list(f.str)),
locations = f.opt(f.map(f.str, f.rec{ x = f.int(0), y = f.int(0),
name = f.opt(f.str) })),
nest = f.opt(f.any) },
flyOrder = f.list(f.str),
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
-- back pic, the catch tutorial's old man, Yellow's PROF.OAK variant of
-- it (#557), and the front pic the intro, trainer card and Hall of Fame
-- share. Every key is optional so a conversion can replace one pic and
-- inherit the rest.
playerPics = f.rec{
back = f.opt(f.str), demoBack = f.opt(f.str),
oakBack = f.opt(f.str), front = f.opt(f.str) },
-- the new-game and boot config a total conversion replaces
boot = f.rec{
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
startFacing = f.opt(f.enum{ "up", "down", "left", "right" }),
playerName = f.opt(f.str), rivalName = f.opt(f.str),
startMoney = f.opt(f.int(0)),
lastHeal = f.opt(f.rec{ map = f.str, x = f.int(0), y = f.int(0) }),
namePresets = f.opt(f.rec{ player = f.opt(f.list(f.str)),
rival = f.opt(f.list(f.str)) }),
screens = f.opt(f.rec{ splash = f.opt(f.str), title = f.opt(f.str),
newGame = f.opt(f.str) }),
starterScript = f.opt(f.str),
title = f.opt(f.any) },
},
example = 'mod.content.field:patch("boot", { startMap = "SABLE_COVE" })',
}
-- Every key is a map label carrying the same per-TEXT-constant shape, so
-- one keyValue types them all: a mod adds a single sign binding without
-- restating the map. label is the extractor's field, text the authored
-- one; Data:resolveText reads text and falls back to the hand-ported
-- script when only asm is set.
R.text_pointers = {
semantics = "deep", target = "text_pointers",
keyValue = f.map(f.str, f.rec{
text = f.opt(f.str), label = f.opt(f.str), asm = f.opt(f.bool),
mart = f.opt(f.list(f.id("items"))),
nurse = f.opt(f.bool), pc = f.opt(f.bool), cableClub = f.opt(f.bool),
}),
example = 'mod.content.text_pointers:patch("PalletTown", { TEXT_PALLETTOWN_SIGN = { text = "_MySign" } })',
}
-- ------- Gen 2 only content
--
-- The mirror of the gated rows in Schemas.GEN2: six systems Gold has and Red
-- does not, so there is no Gen 1 table to share a target with and no Gen 1
-- consumer to read one. Each spec therefore carries NO `target` at all -- the
-- routed Schemas.GEN2 path is its only home -- and a Schemas.GEN1 row of
-- `false`, which is what turns a Red mod's write into the same reported drop a
-- Gold mod gets for `tokens` instead of a silent merge into a namespace
-- nothing on Red would ever read.
--
-- Names stay plain for the same reason hook and event names do: `decorations`
-- is what the thing is called, and a "gen2Decorations" registry NAME would be
-- a namespace no mod could ever share if Gen 1 grew the system later. Only
-- the Data path underneath carries the gen2 prefix.
-- data/items/attributes.asm's last two columns, split out of the item record
-- so a mod can give an item a held behaviour without owning the whole item.
-- src/core/Game2.lua seeds the merge target from data.items and writes the
-- merged rows back onto it, and src/battle/gen2/Battle.lua's heldEffect (the
-- one read all eight held-item sites go through, and the held_item.trigger
-- hook's own site) reads it from there.
R.held_items = {
semantics = "record",
fields = {
-- the HELD_* name the battle compares against, out of
-- data.gen2Constants.heldEffectOrder; a mod may invent its own and steer
-- it from the held_item.trigger hook
heldEffect = f.str,
-- ItemAttributes' parameter byte: the boost percentage, the heal amount,
-- the BrightPowder odds -- whatever the effect reads it as
heldParameter = f.opt(f.int(0, 255)),
},
example = 'mod.content.held_items:override("LEFTOVERS", '
.. '{ heldEffect = "HELD_LEFTOVERS", heldParameter = 0 })',
}
-- data/phone/phone_contacts.asm, one record per PHONE_* row. The id space is
-- data.gen2Constants.phoneContactOrder, so PHONE_YOUNGSTER_JOEY names the row
-- the cart calls PHONE_YOUNGSTER_JOEY; `index` is that row's byte, which is
-- what the save's contact list holds and what src/core/gen2/Phone.lua keys
-- every one of its own lookups by. The four PHONE_UNUSED const_skip holes are
-- not registered -- they are copies of the wrong-number filler row, and one id
-- cannot name four of them.
R.phone_contacts = {
semantics = "record",
fields = {
index = f.int(0),
-- non-trainer rows (MOM, BILL, ELM, the BIKE SHOP) carry a PHONECONTACT_*
-- number instead of a trainer; trainer rows carry the class and the
-- roster member, which is what the rematch machinery and the caller's
-- name are looked up by
number = f.opt(f.int(0, 255)),
class = f.opt(f.str), member = f.opt(f.str),
map = f.opt(f.id("maps")),
-- the SCRIPT1 / SCRIPT2 time masks: MORN | DAY | NITE, 0 for "never"
calleeTime = f.opt(f.int(0, 7)), callerTime = f.opt(f.int(0, 7)),
-- the script LABEL (Phone.SCRIPT_KEYS resolves it) and, once the cache
-- has been read, the "<bank>:<addr>" pointer it resolved to
callee = f.opt(f.str), caller = f.opt(f.str),
calleeKey = f.opt(f.str), callerKey = f.opt(f.str),
},
example = 'mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", '
.. '{ map = "ROUTE_31" })',
}
-- data/decorations/attributes.asm, one record per DECO_* row. The cart's
-- decoration constants are a bare const_def block with no name table behind
-- them -- nothing in the ROM spells DECO_FEATHERY_BED -- so the id is the
-- attribute row's own index, written "deco:<n>" the way battle_anims writes
-- "subanim:<n>". That index IS wMenuSelection, which is what every caller
-- passes src/core/gen2/Decorations.lua.
R.decorations = {
semantics = "record",
fields = {
-- constants/deco_constants.asm decoration types: 1 PLANT, 2 BED,
-- 3 CARPET, 4 POSTER, 5 DOLL, 6 BIGDOLL. The type decides how GetDecoName
-- spells the row and whether `sprite` is a block id or a sprite one.
type = f.int(1, 6),
name = f.str,
-- DECOATTR_ACTION, as the Decorations.ACTIONS key rather than the
-- jumptable index; nil on the CANCEL row alone
action = f.opt(f.str),
-- DECOATTR_EVENT_FLAG: the wEventFlags bit that says the player owns it
flag = f.int(0),
-- DECOATTR_SPRITE: a BLOCK id for the four kinds the map paints, a
-- SPRITE_* byte for the four an object stands on
sprite = f.int(0, 255),
},
example = 'mod.content.decorations:patch("deco:2", { name = "COZY" })',
}
-- data/items/apricorn_balls.asm. Id = the apricorn item, because that is what
-- the player hands Kurt and what FindApricornsInBag walks the bag for;
-- `index` is the row's position in that table, which is load bearing twice
-- (Kurt's menu order and the checkevent chain in maps/KurtsHouse.asm).
R.apricorns = {
semantics = "record",
fields = {
apricorn = f.id("items"), ball = f.id("items"),
-- constants/event_flags.asm index of this apricorn's EVENT_GAVE_KURT_*
event = f.int(0),
index = f.int(1),
},
example = 'mod.content.apricorns:override("RED_APRICORN", '
.. '{ apricorn = "RED_APRICORN", ball = "ULTRA_BALL", event = 600, index = 1 })',
}
-- data/maps/landmarks.asm. The town-map places, which on Gold are one index
-- space shared by the Pokegear MAP card, the #DEX AREA page and every map
-- header's `landmark` byte. The merge lands inside the cache's own landmark
-- table (gen2Landmarks.landmarks), so a registered record is one the map card
-- can already draw.
R.landmarks = {
semantics = "record",
fields = {
id = f.opt(f.str),
-- the two-line name the town map prints, "\n" and all
name = f.str,
-- the marker's tile position on the 20x18 town map
x = f.int(0), y = f.int(0),
-- LANDMARK_*: the byte a map header carries, and what
-- src/core/gen2/Nests.lua's region split reads
index = f.int(0),
},
example = 'mod.content.landmarks:patch("LANDMARK_ROUTE_29", { x = 12 })',
}
-- PlayRadioStationPointers (engine/pokegear/pokegear.asm). Id = the station
-- the dial resolves to, which is the LoadStation_* id the show state machine
-- is keyed by; `channel` is its MAPRADIO_* dial position, the byte a wall
-- radio's `setval` passes to the MapRadio special. Position 0 is not a
-- station: it resolves by region and time of day, so no record claims it.
R.radio_channels = {
semantics = "record",
fields = {
channel = f.int(0, 255),
-- the name quoted in the text box; without one the Pokegear's own
-- STATION_NAMES row is used, which is where the vanilla eight get theirs
name = f.opt(f.str),
},
example = 'mod.content.radio_channels:register("PIRATE_RADIO", '
.. '{ channel = 9, name = "PIRATE RADIO" })',
}
-- ------- persistence
-- compose, keyed by the owning mod id: the runner walks each owner's chain
-- in semver order against the versions recorded in the save
R.migrations = {
semantics = "compose",
value = f.rec{ since = f.str, run = f.fn },
example = 'mod.content.migrations:register("my_mod", { since = "1.0.0", run = fn })',
}
-- ------- link play
-- id = the extra-bag mon field a mod wants to force fingerprint agreement on.
-- Only rev reaches the digest: pack/unpack are Lua, and function bytes are not
-- portably hashable, so the author bumps rev when the codec's meaning changes
-- (the affects_link mod version is the backstop when they forget). No engine
-- content, so the registry is empty on a mod-free boot and the fingerprint's
-- link_fields section is absent on both peers.
R.link_fields = {
semantics = "record", target = "link_fields",
fields = {
rev = f.union{ f.int(0), f.str },
pack = f.opt(f.fn), unpack = f.opt(f.fn),
},
example = 'mod.content.link_fields:register("held_item", { rev = 1, pack = fn, unpack = fn })',
}
return Schemas