diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index ffb42e8d..25121725 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -433,8 +433,13 @@ differences an author meets: - **`encounters`.** The id is the encounter *kind*, not the map: `mod.content.encounters:patch("grass", { ROUTE_29 = { rates = { NITE = 40 } } })`. A map's row carries a `rates` set per time of day and one slot list. - `fishGroups`, `trees` / `treeSets`, `rocks`, `bugContest` and `roamMaps` are - ids of their own. + `fishGroups`, `trees` / `treeSets`, `rocks`, `bugContest`, `roamMaps` and + `roamMons` are ids of their own. This is the one registry whose ids are a + **closed set** -- the kinds above are exactly the lookups the engine makes + into `Data.gen2Encounters`, so an id outside the set would be a write + nothing reads. Patching an unknown id (a Gen 1 encounters mod ported + unchanged passes the *map* here) fails the mod and names the ids that do + exist, rather than accepting the call and leaving the game vanilla. - **`trainers`.** The id is the trainer *class*, and the record is `{ name, index, attributes, baseMoney, encounterMusic, trainers, items }`, with one entry per named trainer of the class. The registry writes one level diff --git a/docs/modding/reference/registries.md b/docs/modding/reference/registries.md index 257bcd9b..ed9b33d4 100644 --- a/docs/modding/reference/registries.md +++ b/docs/modding/reference/registries.md @@ -366,8 +366,10 @@ mod.content.encounters:patch("ROUTE_1", { grass = { rate = 30 } }) The record differs; the registry name, the verbs and the id space do not. -Id = a top-level key of the target table. Keys not listed here are -accepted and merged as-is. +Id = a top-level key of the target table. The set below is **closed**: +an id that is not one of these is rejected rather than merged, because +the engine reads this table by name and a key it does not name is a +write nothing reads. | key | type | |---|---| @@ -376,6 +378,7 @@ accepted and merged as-is. | `generation` | integer >= 1 | | `grass` | map of string -> {map?, rates, slots} | | `roamMaps` | list of {map, to} | +| `roamMons` | list of {level?, map?, mapGroup?, mapNumber?, species?} | | `rocks` | map of string -> string | | `source` | string | | `swarmGrass` | map of string -> {map?, rates, slots} | @@ -391,6 +394,12 @@ accepted and merged as-is. mod.content.encounters:patch("grass", { ROUTE_29 = { rates = { NITE = 40 } } }) ``` +Gold's encounter ids are a **closed set**: the kinds above are +the complete set of lookups the engine makes into `Data.gen2Encounters`, so an +id that is not one of them is a write nothing reads. An id outside the set is +rejected -- a Gen 1 mod ported unchanged passes the map where Gold wants the +kind, and that call is refused rather than silently dropped. + ## evolution_methods - semantics: `record` diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index fc608712..9e04416b 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -273,8 +273,24 @@ function Schemas.check(spec, registryName, id, value, mode, generation) -- 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). + -- + -- `keysClosed` is the opt-out, for the registries where an unknown id + -- cannot be a mod's own data because NOTHING reads it: Gold's encounters + -- table is consumed by a fixed set of lookups (encounters.grass, + -- encounters.water, ...), so a key the catalog does not describe is a + -- write that lands nowhere and does nothing. That silence is the whole + -- bug in #2369 -- a Gen 1 author patching "ROUTE_29" where Gold wants + -- the encounter KIND, "grass" -- so the ids are named back instead. local keyType = (spec.keys and spec.keys[id]) or spec.keyValue - if keyType then checkValue(keyType, value, path, patchMode, errors, true) end + if keyType then + checkValue(keyType, value, path, patchMode, errors, true) + elseif spec.keysClosed then + local names = {} + for keyName in pairs(spec.keys) do names[#names + 1] = keyName end + table.sort(names) + errors[#errors + 1] = ("%s: unknown id; this registry's ids are %s") + :format(path, table.concat(names, ", ")) + end elseif spec.value then checkValue(spec.value, value, path, patchMode, errors, true) if #errors == 0 and not patchMode and spec.extra then @@ -700,10 +716,10 @@ end -- -- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry -- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside --- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `reservedIds` / --- `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. +-- `semantics` / `extra` / `keysClosed` / `write` / `baseAt` / `baseIds` / +-- `reservedIds` / `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. -- @@ -723,6 +739,7 @@ end local SHAPE_SLOTS = { Value = "value", Fields = "fields", Keys = "keys", KeyValue = "keyValue", Extra = "extra", + KeysClosed = "keysClosed", Semantics = "semantics", Write = "write", BaseAt = "baseAt", BaseIds = "baseIds", ReservedIds = "reservedIds", @@ -2015,6 +2032,17 @@ R.encounters = { -- 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. + -- + -- This is the one registry whose ids are CLOSED (`gen2KeysClosed`): the + -- kind list below is not an open namespace a mod may add to, it is the + -- complete set of lookups Gold makes into the table (encounters.grass, + -- encounters.water, encounters.trees, ...). A Gen 1 author porting an + -- encounters mod writes the MAP where Gold wants the KIND -- patch + -- ("ROUTE_29", { grass = ... }) -- and under an open id space that call was + -- accepted, written to gen2Encounters.ROUTE_29 and read by nothing: the + -- game stayed vanilla with no error anywhere (#2369). Closing the set + -- turns that silence into the one thing the author needs, the list of ids + -- that do exist. gen2Keys = { grass = f.map(f.str, gen2GrassRow), -- the swarm variants shadow their base table while a swarm is running @@ -2046,8 +2074,22 @@ R.encounters = { 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) }), + -- the three beasts' starting slots, straight out of InitRoamMons + -- (src/import/RomExtractorGen2.lua readRoamMons). Absent from an older + -- cache, which is why src/core/gen2/Roamers.lua keeps a fallback table. + roamMons = f.opt(f.list(f.rec{ species = f.opt(f.id("pokemon")), + level = f.opt(f.int(1)), + mapGroup = f.opt(f.int(0, 255)), + mapNumber = f.opt(f.int(0, 255)), + map = f.opt(f.str) })), source = f.str, generation = f.int(1), }, + gen2KeysClosed = true, + gen2Notes = [[Gold's encounter ids are a **closed set**: the kinds above are +the complete set of lookups the engine makes into `Data.gen2Encounters`, so an +id that is not one of them is a write nothing reads. An id outside the set is +rejected -- a Gen 1 mod ported unchanged passes the map where Gold wants the +kind, and that call is refused rather than silently dropped.]], example = 'mod.content.encounters:patch("ROUTE_1", { grass = { rate = 30 } })', gen2Example = 'mod.content.encounters:patch("grass", ' .. '{ ROUTE_29 = { rates = { NITE = 40 } } })', diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index 35a87b7a..e852910e 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -1026,6 +1026,130 @@ do run.release() end +-- ------- 5c. Gold's encounters ids are a closed set (#2369) +-- +-- Gold keys wild encounters by KIND first and map second, so the id a mod +-- patches is "grass", not "ROUTE_29". The id space was open, and a Gen 1 +-- encounters mod ported unchanged writes the MAP there: the call was accepted, +-- merged into data.gen2Encounters.ROUTE_29 and read by nothing -- vanilla +-- game, no error, nothing in the Mod Manager. That key cannot be a mod's own +-- data the way an extra palette id can, because the engine reads this table by +-- name and the set of names is fixed, so the id space is closed and an unknown +-- id fails the mod instead. +do + local function encountersData() + return { + gen2Encounters = { + grass = { + ROUTE_29 = { + rates = { MORN = 51, DAY = 51, NITE = 51 }, + slots = { + MORN = { { level = 3, species = "HOOTHOOT" } }, + DAY = { { level = 3, species = "PIDGEY" } }, + NITE = { { level = 3, species = "HOOTHOOT" } }, + }, + }, + }, + }, + } + end + + local function encountersMod(body) + return { + ["mods/fix_encounters/manifest.json"] = [[{ + "id": "fix_encounters", + "name": "Fixture Encounters", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "gen2compat": true + }]], + ["mods/fix_encounters/main.lua"] = body, + } + end + + -- the shape itself, before any load: closed on Gold, unchanged on Red + do + local spec = Schemas.REGISTRIES.encounters + local ok, err = Schemas.check(spec, "encounters", "ROUTE_29", + { grass = { rate = 30 } }, "patch", 2) + T.check(not ok and err and err:match("unknown id"), + "Gen 2: an unknown encounters id is refused") + T.check(err and err:match("grass") and err:match("bugContest"), + "Gen 2: the refusal lists the ids that do exist: " .. tostring(err)) + T.check(Schemas.check(spec, "encounters", "ROUTE_29", + { grass = { rate = 30 } }, "patch", 1) == true, + "Gen 1: the same call is the right form there and still passes") + T.check(Schemas.check(spec, "encounters", "roamMons", + { { species = "RAIKOU" } }, "patch", 2) == true, + "Gen 2: roamMons is a known id, not an unknown one") + T.check(Schemas.check(Schemas.REGISTRIES.palettes, "palettes", + "MOD_PALETTE", { colors = { 1, 2, 3, 4 } }, "patch", 2) == true, + "Gen 2: other id namespaces stay open -- an unknown id is a mod's own") + end + + -- the documented Crystal form: the id is the kind, the map is a field + do + local run = T.sdk.loadMods({ "mods/fix_encounters" }, { + fs = T.sdk.memfs(encountersMod([[ + local mod = ... + mod.content.encounters:patch("grass", + { ROUTE_29 = { rates = { NITE = 40 } } }) + ]])), + data = encountersData(), generation = 2, + }) + T.eq(statusOf(run, "fix_encounters").state, "loaded", + "Gen 2: the documented encounters form loads") + local row = run.data.gen2Encounters.grass.ROUTE_29 + T.eq(row.rates.NITE, 40, "Gen 2: the patched rate lands") + T.eq(row.rates.DAY, 51, "Gen 2: the rates not named are untouched") + T.eq(row.slots.NITE[1].species, "HOOTHOOT", + "Gen 2: and the slot list survives the merge") + run.release() + end + + -- a Gen 1 encounters mod ported unchanged: the map where Gold wants the kind + do + local run = T.sdk.loadMods({ "mods/fix_encounters" }, { + fs = T.sdk.memfs(encountersMod([[ + local mod = ... + mod.content.encounters:patch("ROUTE_29", { grass = { rate = 30 } }) + ]])), + data = encountersData(), generation = 2, + }) + T.eq(statusOf(run, "fix_encounters").state, "failed", + "Gen 2: an unknown encounters id fails the mod rather than no-opping") + local told + for _, message in ipairs(run.errors) do + if message:match("encounters%.ROUTE_29") then told = message end + end + T.check(told ~= nil, "Gen 2: the failure names the id that was refused") + T.check(told and told:match("grass"), + "Gen 2: and lists the ids that do exist: " .. tostring(told)) + T.eq(run.data.gen2Encounters.ROUTE_29, nil, + "Gen 2: nothing lands at the unknown id") + T.eq(run.data.gen2Encounters.grass.ROUTE_29.rates.NITE, 51, + "Gen 2: and the vanilla table is untouched") + run.release() + end + + -- a malformed payload at a KNOWN id: the id resolves, so the type check runs + do + local run = T.sdk.loadMods({ "mods/fix_encounters" }, { + fs = T.sdk.memfs(encountersMod([[ + local mod = ... + mod.content.encounters:patch("grass", "FORCE_ERROR_STRING") + ]])), + data = encountersData(), generation = 2, + }) + T.eq(statusOf(run, "fix_encounters").state, "failed", + "Gen 2: a malformed encounters payload fails the mod") + T.eq(run.data.gen2Encounters.grass.ROUTE_29.rates.NITE, 51, + "Gen 2: and the vanilla table is untouched") + run.release() + end +end + -- ------- 6. StateStack:clear, which is what Gold's boot cinema hands off -- through now that it runs the engine stack diff --git a/tools/gen_registry_docs.lua b/tools/gen_registry_docs.lua index 6c5a2e34..d4b62a38 100644 --- a/tools/gen_registry_docs.lua +++ b/tools/gen_registry_docs.lua @@ -92,8 +92,15 @@ end local function renderSchema(spec) if spec.keys then line("") - line("Id = a top-level key of the target table. Keys not listed here are") - line("accepted and merged as-is.") + 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("|---|---|")