Files
gen1recomp/tools/gen_registry_docs.lua
T
Shane McGovern e12cda633a Close Gold's encounters id space so an unknown id fails the mod
mod.content.encounters:patch on a Gold boot keys by encounter KIND
(encounters.grass.ROUTE_29), but the id space was open: a key the
catalog did not describe was treated as a mod's own data and merged
as-is.  A Gen 1 encounters mod ported unchanged writes the MAP where
Gold wants the kind, so the call was accepted, merged into
data.gen2Encounters.ROUTE_29 and read by nothing -- vanilla game, no
error, nothing in the Mod Manager (#2369).

That key cannot be a mod's own data the way an extra palette id can:
src/battle/gen2/Encounter.lua, src/core/gen2/Roamers.lua,
src/core/gen2/BugContest.lua and src/world/gen2/World.lua read this
table by name and the set of names is fixed, so an unknown id is a
write nothing reads.  Add an opt-in `keysClosed` shape slot (folded by
Schemas.shapeFor like the other gen2* slots) that Schemas.check honours
in its keys/keyValue branch, and set it on encounters' Gen 2 spec.  An
unknown id now names the ids that do exist instead of silently
no-opping; api 2 fails the mod, api 1 keeps the warning.

Also catalogue roamMons, which the extractor has always emitted
(RomExtractorGen2:readRoamMons) and Roamers.roster has always read, but
which was missing from gen2Keys.

Only encounters opts in: palettes, battle_anims and constants keep
their open id spaces, and Red's encounters id space is untouched.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-21 09:14:53 +01:00

239 lines
9.2 KiB
Lua

-- Renders the registry reference from Schemas.REGISTRIES so the page cannot
-- drift from the engine. Run from the repo root:
-- luajit tools/gen_registry_docs.lua -- in-repo, the default
-- luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
-- POKEPORT_DOCS_DIR=../project.wiki luajit tools/gen_registry_docs.lua
-- The full doc pipeline moves into the modkit CLI later; this is the
-- Schemas -> markdown seed it will absorb.
package.path = "./?.lua;./?/init.lua;" .. package.path
local Schemas = require("src.mods.Schemas")
-- No argument writes INSIDE the repo (a default landing in a sibling dir
-- creates one on the first run); `modkit docs` reads this exact path
local DEFAULT_DIR = "docs/modding/reference"
local DEFAULT_FILE = "registries.md"
-- an explicit target is the GitHub wiki checkout, whose page names are flat
local WIKI_FILE = "Reference-Registries.md"
-- precedence: argv, env, in-repo default -- so a wiki checkout is one flag
-- away and a CI job can set it once for every generator on this convention
local outDir = (... or nil)
if outDir == nil or outDir == "" then outDir = os.getenv("POKEPORT_DOCS_DIR") end
local explicit = outDir ~= nil and outDir ~= ""
if not explicit then outDir = DEFAULT_DIR end
outDir = outDir:gsub("/+$", "")
local OUT = outDir .. "/" .. (explicit and WIKI_FILE or DEFAULT_FILE)
local names = {}
for name in pairs(Schemas.REGISTRIES) do names[#names + 1] = name end
table.sort(names)
local out = {}
local function line(fmt, ...)
if select("#", ...) > 0 then
out[#out + 1] = fmt:format(...)
else
out[#out + 1] = fmt
end
end
line("<!-- Generated by tools/gen_registry_docs.lua from src/mods/Schemas.lua.")
line(" Do not edit by hand; regenerate after any schema change. -->")
line("")
line("# Registry reference")
line("")
line("One section per registry: merge semantics, the `Data` table the merge")
line("writes, and the value schema. Where Gold differs -- a different table, a")
line("different record, or no home at all -- the registry carries a Gen 2")
line("subsection built from the same catalog entry. Concepts and verbs:")
line("[Concepts: Registries](Concepts-Registries).")
line("")
line("## Reading this reference")
line("")
if explicit then
line("Start with [Choose a Registry](Choose-A-Registry) when you know what")
line("you want to make but not the registry name. This page is the precise")
line("schema catalog once you have chosen one.")
else
line("Use the wiki's `Choose-A-Registry` guide when you know what you want")
line("to make but not the registry name. This page is the precise schema")
line("catalog once you have chosen one.")
end
line("")
line("### Type notation")
line("")
line("- `string`, `number`, `boolean`, and `function` name the Lua value to")
line(" supply. A function is code the engine calls later; see the linked")
line(" concept/reference page for its arguments and return value.")
line("- `{ field, otherField? }` is a record (a Lua table with named fields).")
line(" A `?` means that field is optional. `list of T` is an ordered Lua")
line(" table of values shaped like `T`; `map of K -> V` maps each key to a")
line(" value shaped like `V`.")
line("- `A | B` means either shape is accepted. `moves id`, `items id`, and")
line(" similar phrases mean the internal id of a record in that registry,")
line(" not its displayed name.")
line("")
line("### Example status")
line("")
line("Every example below is **shape only**. Put an adapted call in your mod")
line("entry file. Replace `...` and names such as `fn` with real values, and")
line("supply every field marked required when registering a new record.")
local function tableCell(value)
return tostring(value):gsub("|", "\\|")
end
-- the value schema of one spec, whichever generation's shape it carries.
-- Schemas.check reads keys/keyValue, then value, then fields in that order,
-- so this renders them in the same order or the page would describe a branch
-- that never runs.
local function renderSchema(spec)
if spec.keys then
line("")
if spec.keysClosed then
line("Id = a top-level key of the target table. The set below is **closed**:")
line("an id that is not one of these is rejected rather than merged, because")
line("the engine reads this table by name and a key it does not name is a")
line("write nothing reads.")
else
line("Id = a top-level key of the target table. Keys not listed here are")
line("accepted and merged as-is.")
end
line("")
line("| key | type |")
line("|---|---|")
local keyNames = {}
for keyName in pairs(spec.keys) do keyNames[#keyNames + 1] = keyName end
table.sort(keyNames)
for _, keyName in ipairs(keyNames) do
line("| `%s` | %s |", keyName, tableCell(spec.keys[keyName].desc))
end
elseif spec.fields then
line("")
line("| field | type | required |")
line("|---|---|---|")
local fieldNames = {}
for fieldName in pairs(spec.fields) do fieldNames[#fieldNames + 1] = fieldName end
table.sort(fieldNames)
for _, fieldName in ipairs(fieldNames) do
local ft = spec.fields[fieldName]
line("| `%s` | %s | %s |", fieldName, tableCell(ft.desc),
ft.kind == "opt" and "no" or "yes")
end
elseif spec.keyValue then
line("")
line("Id = a top-level key of the target table; every key carries the same")
line("shape.")
line("")
line("- value: %s", spec.keyValue.desc)
elseif spec.value then
line("- value: %s", spec.value.desc)
end
end
-- prose a registry needs beyond its schema (resolution order, the guarantees
-- a value carries). It lives on the spec rather than in the page because
-- this file is regenerated: hand-written paragraphs in the output are deleted
-- by the next run.
local function renderExample(example, notes)
if example then
line("")
line("<!-- snippet: illustrative -->")
line("```lua")
line("%s", example)
line("```")
end
if notes then
line("")
line("%s", notes)
end
end
for _, name in ipairs(names) do
local spec = Schemas.REGISTRIES[name]
line("")
line("## %s", name)
line("")
line("- semantics: `%s`", spec.semantics)
line("- target: %s", spec.target and ("`Data." .. spec.target .. "`") or "none")
if spec.deprecated then
line("- **deprecated** -- use %s", spec.deprecated.useInstead)
end
if name == "link_fields" then
line("")
line("**Cart protocol only:** this registry does not make a freely enabled")
line("loose mod eligible for player link play. Player links are set up in the")
line("launcher with a vanilla cart or a sealed custom cart.")
end
-- The mirror of the Gen 2 gating below. Six registries exist because GOLD
-- does (the phone book, the decorations, the radio dial), so they carry no
-- Gen 1 target and "- target: none" on its own reads as a broken entry
-- rather than as the deliberate one-generation registry it is.
if Schemas.gatedFor(name, 1) then
line("")
line("Gen 2 only: Red, Blue and Yellow have no such system, so there is no")
line("Gen 1 table to merge into and a write here on a Gen 1 boot is dropped")
line("and reported. See the Gold subsection below for where it does land.")
end
renderSchema(spec)
renderExample(spec.example, spec.notes)
-- Gold. The registry NAME and the verbs are shared across generations, so
-- what a Gen 2 subsection says is only ever WHERE the merge lands and what
-- a record there looks like -- or that there is nowhere to land, which is a
-- write the loader drops and reports rather than a name a mod may not use.
local gen2Target = Schemas.targetFor(name, spec, 2)
local shaped = Schemas.hasGen2Shape(spec)
if Schemas.gatedFor(name, 2) then
line("")
line("### On Gold (Gen 2)")
line("")
line("No Gen 2 home: Gold reimplements this system without reading the")
line("registry, so a write here is dropped and reported on a Gold boot.")
line("`docs/mod-api-gen2-compat.md` in the engine repo lists what is left")
line("to do for each one.")
elseif gen2Target ~= spec.target or shaped then
local gen2 = Schemas.shapeFor(name, spec, 2)
line("")
line("### On Gold (Gen 2)")
line("")
line("- semantics: `%s`", gen2.semantics)
line("- target: `Data.%s`", gen2Target)
if shaped then
line("")
line("The record differs; the registry name, the verbs and the id space")
line("do not.")
renderSchema(gen2)
renderExample(spec.gen2Example, spec.gen2Notes)
else
renderExample(spec.gen2Example, spec.gen2Notes)
end
end
end
line("")
line("## v1 aliases")
line("")
line("| alias | canonical |")
line("|---|---|")
local aliases = {}
for alias in pairs(Schemas.ALIASES) do aliases[#aliases + 1] = alias end
table.sort(aliases)
for _, alias in ipairs(aliases) do
line("| `%s` | `%s` |", alias, Schemas.ALIASES[alias])
end
-- a wiki checkout may not have the directory yet; create it before the
-- open so pointing at a fresh clone is not a two-step
local file = io.open(OUT, "w")
if not file then
os.execute('mkdir -p "' .. outDir:gsub('"', '\\"') .. '"')
file = assert(io.open(OUT, "w"),
"cannot write " .. OUT .. " (is the output directory reachable?)")
end
file:write(table.concat(out, "\n") .. "\n")
file:close()
print("wrote " .. OUT)