diff --git a/docs/modding.md b/docs/modding.md index 2845f9f2..952f876f 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -228,7 +228,9 @@ and data-only record shapes as `mod.content`, but only `get`, `has`, and `each`. Returned records are detached copies and cannot mutate either dataset. Every generated base record passes the selected generation's existing public schema before it is returned; extractor metadata beside record maps stays out -of the registry id space. A malformed record makes `get` return nil, `has` +of the registry id space and is reserved against `register`, `override`, +`patch`, and `remove` writes through the active registry. A malformed record +makes `get` return nil, `has` return false, and `each` return no rows, and invalidates that dataset view. Records containing functions, userdata, threads, metatables, or cycles are not exposed. Each open call receives an independent facade, so one mod cannot diff --git a/docs/rfcs/0015-imported-dataset-views.md b/docs/rfcs/0015-imported-dataset-views.md index 0bf7d9d9..dd25b1f9 100644 --- a/docs/rfcs/0015-imported-dataset-views.md +++ b/docs/rfcs/0015-imported-dataset-views.md @@ -71,8 +71,9 @@ the selected version's generation routing and the engine's existing `Schemas`, `Registry`, and `Builtins` normalization, so structured sources such as type matchups retain the same public ids used by the active `mod.content` facade and extractor metadata beside record maps is not exposed -as a record id. Every generated base record is checked with that selected -generation's existing public schema before it can cross `get`, `has`, or +as a record id or writable through the active registry's mutation verbs. Every +generated base record is checked with that selected generation's existing +public schema before it can cross `get`, `has`, or `each`; there is no dataset-specific duplicate schema. No register, patch, override, or remove verb is exposed. Each call returns an independent facade over the cached internal dataset, so facade mutation cannot cross mod diff --git a/src/mods/Registry.lua b/src/mods/Registry.lua index 3c7ff4bb..a98e0ca6 100644 --- a/src/mods/Registry.lua +++ b/src/mods/Registry.lua @@ -12,6 +12,12 @@ Registry.__index = Registry -- exposed to mods as mod.DELETE: a patch value that unsets a field Registry.DELETE = Merge.DELETE +local function assertWritable(self, id) + if self.spec.reservedIds and self.spec.reservedIds[id] then + error(("%s id is reserved engine metadata: %s"):format(self.name, id)) + end +end + -- spec comes from Schemas.REGISTRIES[name]; bare Registry.new(name) keeps -- the v1 record behavior for standalone use in tests and tools function Registry.new(name, spec) @@ -33,6 +39,7 @@ local function append(self, id, op, value, owner) error(self.name .. ": content is frozen after load") end assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assertWritable(self, id) local list = self.ops[id] if not list then list = {} diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index b3fbf90d..2b6e8d38 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -676,9 +676,10 @@ end -- -- 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. +-- `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. -- The registry NAME, the verbs and (wherever the id space allows it) the ids -- stay shared, exactly as the routing table keeps them shared. -- @@ -700,6 +701,7 @@ local GEN2_SHAPE = { gen2KeyValue = "keyValue", gen2Extra = "extra", gen2Semantics = "semantics", gen2Write = "write", gen2BaseAt = "baseAt", gen2BaseIds = "baseIds", + gen2ReservedIds = "reservedIds", gen2Example = "example", gen2Notes = "notes", } @@ -773,14 +775,14 @@ local function recordMapExcept(...) if not excluded[id] then ids[#ids + 1] = id end end return ids - end + end, excluded end -local pokemonGen2BaseAt, pokemonGen2BaseIds = +local pokemonGen2BaseAt, pokemonGen2BaseIds, pokemonGen2ReservedIds = recordMapExcept("growthRates", "tmhmMoves") -local movesGen2BaseAt, movesGen2BaseIds = +local movesGen2BaseAt, movesGen2BaseIds, movesGen2ReservedIds = recordMapExcept("generation", "source") -local itemsGen2BaseAt, itemsGen2BaseIds = +local itemsGen2BaseAt, itemsGen2BaseIds, itemsGen2ReservedIds = recordMapExcept("generation", "source", "pockets") -- ------- shared Gen 2 leaves @@ -806,6 +808,7 @@ local gen2PaletteRow = f.list(gen2Color) R.pokemon = { semantics = "record", target = "pokemon", gen2BaseAt = pokemonGen2BaseAt, gen2BaseIds = pokemonGen2BaseIds, + gen2ReservedIds = pokemonGen2ReservedIds, fields = { id = f.str, name = f.str, dex = f.int(1), index = f.opt(f.int(0, 255)), @@ -898,6 +901,7 @@ R.pokemon = { R.moves = { semantics = "record", target = "moves", gen2BaseAt = movesGen2BaseAt, gen2BaseIds = movesGen2BaseIds, + gen2ReservedIds = movesGen2ReservedIds, fields = { id = f.str, name = f.str, index = f.opt(f.int(0, 255)), @@ -923,6 +927,7 @@ R.moves = { R.items = { semantics = "record", target = "items", gen2BaseAt = itemsGen2BaseAt, gen2BaseIds = itemsGen2BaseIds, + gen2ReservedIds = itemsGen2ReservedIds, fields = { id = f.str, name = f.str, index = f.opt(f.int(0, 255)), diff --git a/tests/engine/gen2_reserved_metadata_ids.lua b/tests/engine/gen2_reserved_metadata_ids.lua new file mode 100644 index 00000000..88495e3a --- /dev/null +++ b/tests/engine/gen2_reserved_metadata_ids.lua @@ -0,0 +1,112 @@ +-- Extractor metadata hidden from record id spaces remains engine-owned: mods +-- cannot claim, update, or remove it through the public content registries. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Mon = require("src.battle.gen2.Mon") + +local function goldData() + local data = T.fixtures.fresh() + data.pokemon.growthRates = { + GROWTH_MEDIUM_FAST = { + numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0, + }, + } + data.pokemon.tmhmMoves = { "FIX_TACKLE", "FIX_CUT" } + data.moves.generation, data.moves.source = 2, "ROM:Moves" + data.items.generation, data.items.source = 2, "ROM:Items" + data.items.pockets = { "ITEM", "BALL", "KEY_ITEM", "TM_HM" } + return data +end + +local function assertMetadata(data, refs, label) + T.eq(data.pokemon.growthRates, refs.growthRates, + label .. ": growth-rate table identity is preserved") + T.eq(data.pokemon.tmhmMoves, refs.tmhmMoves, + label .. ": TM/HM order identity is preserved") + T.eq(data.moves.generation, 2, label .. ": move generation is preserved") + T.eq(data.moves.source, "ROM:Moves", label .. ": move source is preserved") + T.eq(data.items.generation, 2, label .. ": item generation is preserved") + T.eq(data.items.source, "ROM:Items", label .. ": item source is preserved") + T.eq(data.items.pockets, refs.pockets, + label .. ": item pocket order identity is preserved") + T.eq(Mon.experienceForLevel( + Mon.growthFor(data, "GROWTH_MEDIUM_FAST"), 10), 1000, + label .. ": active Gold growth behavior is preserved") +end + +-- A no-mod load characterizes the unchanged active-boot behavior. +do + local data = goldData() + local refs = { + growthRates = data.pokemon.growthRates, + tmhmMoves = data.pokemon.tmhmMoves, + pockets = data.items.pockets, + } + local run = T.sdk.loadNone({ data = data, generation = 2 }) + T.eq(#run.errors, 0, "no-mod Gold load remains clean") + assertMetadata(run.data, refs, "no-mod Gold") + run.release() +end + +local files, paths = {}, {} +local attempts = { + register_growth_rates = [[ +local mod = ... +mod.content.pokemon:register("growthRates", { + id = "growthRates", name = "CLAIMED", dex = 999, + types = {}, + baseStats = { hp = 1, attack = 1, defense = 1, speed = 1, + specialAttack = 1, specialDefense = 1 }, + catchRate = 1, baseExp = 1, growthRate = "GROWTH_MEDIUM_FAST", + levelMoves = {}, evolutions = {}, + spriteFront = "assets/generated/battle/front/claimed.png", + spriteBack = "assets/generated/battle/back/claimed.png", picSize = 5, +}) +]], + override_move_generation = [[ +local mod = ... +mod.content.moves:override("generation", { + id = "generation", name = "CLAIMED", type = "NORMAL", + power = 1, accuracy = 100, pp = 1, effect = "NO_ADDITIONAL_EFFECT", +}) +]], + patch_item_pockets = [[ +local mod = ... +mod.content.items:patch("pockets", { price = 999 }) +]], + remove_tmhm_moves = [[ +local mod = ... +mod.content.pokemon:remove("tmhmMoves") +]], +} + +for id, body in pairs(attempts) do + files["mods/" .. id .. "/manifest.json"] = ([[{ + "id": %q, "name": %q, "version": "1.0.0", "entry": "main.lua", + "api": 2, "gen2compat": true + }]]):format(id, id) + files["mods/" .. id .. "/main.lua"] = body + paths[#paths + 1] = "mods/" .. id +end +table.sort(paths) + +local data = goldData() +local refs = { + growthRates = data.pokemon.growthRates, + tmhmMoves = data.pokemon.tmhmMoves, + pockets = data.items.pockets, +} +local run = T.sdk.loadMods(paths, { + fs = T.sdk.memfs(files), data = data, generation = 2, +}) +for id in pairs(attempts) do + local mod = run.loader.mods[id] + T.eq(mod and mod.state, "failed", id .. " is rejected") + T.check(mod and tostring(mod.failure):match("reserved") ~= nil, + id .. " reports the reserved metadata boundary") +end +assertMetadata(run.data, refs, "rejected metadata writes") +run.release() + +T.finish("gen2_reserved_metadata_ids")