fix(mod-api): reserve generated metadata ids

This commit is contained in:
MaxTomahawk
2026-08-24 10:10:23 +02:00
parent 7e069df740
commit 38d515f547
5 changed files with 137 additions and 10 deletions
+3 -1
View File
@@ -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. `each`. Returned records are detached copies and cannot mutate either dataset.
Every generated base record passes the selected generation's existing public Every generated base record passes the selected generation's existing public
schema before it is returned; extractor metadata beside record maps stays out 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. return false, and `each` return no rows, and invalidates that dataset view.
Records containing functions, userdata, threads, metatables, or cycles are not Records containing functions, userdata, threads, metatables, or cycles are not
exposed. Each open call receives an independent facade, so one mod cannot exposed. Each open call receives an independent facade, so one mod cannot
+3 -2
View File
@@ -71,8 +71,9 @@ the selected version's generation routing and the engine's existing
`Schemas`, `Registry`, and `Builtins` normalization, so structured sources `Schemas`, `Registry`, and `Builtins` normalization, so structured sources
such as type matchups retain the same public ids used by the active 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 `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 as a record id or writable through the active registry's mutation verbs. Every
generation's existing public schema before it can cross `get`, `has`, or 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, `each`; there is no dataset-specific duplicate schema. No register, patch,
override, or remove verb is exposed. Each call returns an independent facade override, or remove verb is exposed. Each call returns an independent facade
over the cached internal dataset, so facade mutation cannot cross mod over the cached internal dataset, so facade mutation cannot cross mod
+7
View File
@@ -12,6 +12,12 @@ Registry.__index = Registry
-- exposed to mods as mod.DELETE: a patch value that unsets a field -- exposed to mods as mod.DELETE: a patch value that unsets a field
Registry.DELETE = Merge.DELETE 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 -- spec comes from Schemas.REGISTRIES[name]; bare Registry.new(name) keeps
-- the v1 record behavior for standalone use in tests and tools -- the v1 record behavior for standalone use in tests and tools
function Registry.new(name, spec) 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") error(self.name .. ": content is frozen after load")
end end
assert(type(id) == "string" and id ~= "", self.name .. " id is required") assert(type(id) == "string" and id ~= "", self.name .. " id is required")
assertWritable(self, id)
local list = self.ops[id] local list = self.ops[id]
if not list then if not list then
list = {} list = {}
+12 -7
View File
@@ -676,9 +676,10 @@ end
-- --
-- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry -- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry
-- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside -- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside
-- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `example` / -- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `reservedIds` /
-- `notes` the matching `gen2*`. Absent means "the Gen 1 shape is right here -- `example` / `notes` the matching `gen2*`. Absent means "the Gen 1 shape is
-- too", which is the common case and why most registries carry none of this. -- 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 -- The registry NAME, the verbs and (wherever the id space allows it) the ids
-- stay shared, exactly as the routing table keeps them shared. -- stay shared, exactly as the routing table keeps them shared.
-- --
@@ -700,6 +701,7 @@ local GEN2_SHAPE = {
gen2KeyValue = "keyValue", gen2Extra = "extra", gen2KeyValue = "keyValue", gen2Extra = "extra",
gen2Semantics = "semantics", gen2Write = "write", gen2Semantics = "semantics", gen2Write = "write",
gen2BaseAt = "baseAt", gen2BaseIds = "baseIds", gen2BaseAt = "baseAt", gen2BaseIds = "baseIds",
gen2ReservedIds = "reservedIds",
gen2Example = "example", gen2Notes = "notes", gen2Example = "example", gen2Notes = "notes",
} }
@@ -773,14 +775,14 @@ local function recordMapExcept(...)
if not excluded[id] then ids[#ids + 1] = id end if not excluded[id] then ids[#ids + 1] = id end
end end
return ids return ids
end end, excluded
end end
local pokemonGen2BaseAt, pokemonGen2BaseIds = local pokemonGen2BaseAt, pokemonGen2BaseIds, pokemonGen2ReservedIds =
recordMapExcept("growthRates", "tmhmMoves") recordMapExcept("growthRates", "tmhmMoves")
local movesGen2BaseAt, movesGen2BaseIds = local movesGen2BaseAt, movesGen2BaseIds, movesGen2ReservedIds =
recordMapExcept("generation", "source") recordMapExcept("generation", "source")
local itemsGen2BaseAt, itemsGen2BaseIds = local itemsGen2BaseAt, itemsGen2BaseIds, itemsGen2ReservedIds =
recordMapExcept("generation", "source", "pockets") recordMapExcept("generation", "source", "pockets")
-- ------- shared Gen 2 leaves -- ------- shared Gen 2 leaves
@@ -806,6 +808,7 @@ local gen2PaletteRow = f.list(gen2Color)
R.pokemon = { R.pokemon = {
semantics = "record", target = "pokemon", semantics = "record", target = "pokemon",
gen2BaseAt = pokemonGen2BaseAt, gen2BaseIds = pokemonGen2BaseIds, gen2BaseAt = pokemonGen2BaseAt, gen2BaseIds = pokemonGen2BaseIds,
gen2ReservedIds = pokemonGen2ReservedIds,
fields = { fields = {
id = f.str, name = f.str, dex = f.int(1), id = f.str, name = f.str, dex = f.int(1),
index = f.opt(f.int(0, 255)), index = f.opt(f.int(0, 255)),
@@ -898,6 +901,7 @@ R.pokemon = {
R.moves = { R.moves = {
semantics = "record", target = "moves", semantics = "record", target = "moves",
gen2BaseAt = movesGen2BaseAt, gen2BaseIds = movesGen2BaseIds, gen2BaseAt = movesGen2BaseAt, gen2BaseIds = movesGen2BaseIds,
gen2ReservedIds = movesGen2ReservedIds,
fields = { fields = {
id = f.str, name = f.str, id = f.str, name = f.str,
index = f.opt(f.int(0, 255)), index = f.opt(f.int(0, 255)),
@@ -923,6 +927,7 @@ R.moves = {
R.items = { R.items = {
semantics = "record", target = "items", semantics = "record", target = "items",
gen2BaseAt = itemsGen2BaseAt, gen2BaseIds = itemsGen2BaseIds, gen2BaseAt = itemsGen2BaseAt, gen2BaseIds = itemsGen2BaseIds,
gen2ReservedIds = itemsGen2ReservedIds,
fields = { fields = {
id = f.str, name = f.str, id = f.str, name = f.str,
index = f.opt(f.int(0, 255)), index = f.opt(f.int(0, 255)),
+112
View File
@@ -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")