Files
gen1recomp/tests/engine/gate_gen2_mod_api.lua
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

993 lines
44 KiB
Lua

-- Parity gate for the Gen 1 / Gen 2 mod API boundary.
--
-- The rule this file exists to hold: hook names, event names and registry
-- names are SHARED across generations, and the only things that differ are
-- where a registry's content lands and whether the mod runs at all. A mod
-- opts into Gen 2 with `gen2compat` in its manifest and is left out of a Gold
-- boot entirely without it, because a mod that half-applies reads as broken.
--
-- Runs ROM-free: the generation is injected through the loader rather than by
-- booting Gold (T.sdk.loadMods opts.generation).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local GameVersion = require("src.core.GameVersion")
local Manifest = require("src.mods.Manifest")
local Schemas = require("src.mods.Schemas")
local StateStack = require("src.core.StateStack")
-- ------- 1. the version table knows its generation
T.eq(GameVersion.generation("red"), 1, "Red is Gen 1")
T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
-- ------- 2. manifest: gen2compat is opt-in and defaults off
local function manifest(extra)
local raw = { id = "fix", name = "Fixture", version = "1.0.0",
entry = "main.lua", api = 2 }
for key, value in pairs(extra or {}) do raw[key] = value end
return Manifest.validate(raw, "mods/fix")
end
T.eq(manifest().gen2compat, false,
"a manifest that says nothing is Gen 1 only")
T.eq(manifest({ gen2compat = true }).gen2compat, true,
"gen2compat = true is carried through")
T.eq(manifest({ gen2compat = false }).gen2compat, false,
"gen2compat = false is carried through")
T.check(not pcall(manifest, { gen2compat = "yes" }),
"a non-boolean gen2compat is rejected")
-- ------- 3. registry target routing
--
-- One routing table per generation (Schemas.routing): Gen 2 keeps the shared
-- target for everything it can serve and reports the rest instead of merging
-- into a table nothing reads, and Gen 1 does the same for the registries that
-- only exist because Gold does. Gen 1 used to consult nothing at all; the
-- catalog now holds content BOTH ways round, so the claim is the symmetrical
-- one -- a registry is gated in a generation exactly when it has no home
-- there, whichever generation that is.
for name, spec in pairs(Schemas.REGISTRIES) do
T.eq(Schemas.targetFor(name, spec, 1), spec.target,
"Gen 1 keeps the catalog target: " .. name)
if Schemas.GEN1[name] == nil then
T.eq(Schemas.gatedFor(name, 1), false,
"a registry Gen 1 routing says nothing about is not gated there: " .. name)
else
-- the mirror of the gated rows below: a Gen 2-only system, so there is no
-- Gen 1 target to keep and a Red mod's write is dropped and reported
-- rather than merged into a namespace no Gen 1 boot reads. The per-
-- registry cases are in tests/engine/gen2_content_registries.lua.
T.eq(Schemas.gatedFor(name, 1), true, "gated under Gen 1: " .. name)
T.eq(spec.target, nil,
"a Gen 1-gated registry carries no Gen 1 target: " .. name)
T.check(Schemas.targetFor(name, spec, 2) ~= nil,
"and has a Gen 2 home, or the name is dead in both: " .. name)
end
end
-- the registries Gold genuinely reads off game.data keep their name AND their
-- path, which is what lets one mod source target both generations.
-- `commands` is here rather than in the routed set below because the Gen 2 VM
-- resolves a mod verb out of the SAME merged data.commands table Gen 1's
-- runner does (src/script/gen2/Vm.lua:runModCommand, reached from the
-- Opcodes.MOD_COMMAND row a cart can never write).
for _, name in ipairs({ "pokemon", "moves", "items", "type_chart", "screens",
"strings", "font", "audio", "music", "sfx", "cries",
"map_songs", "commands",
-- `tokens` because TextBox.substitute reads
-- game.data.tokens on every box in both games, and
-- `growth_rates` because src/mods/Builtins.lua's Gen 2
-- registrant seeds Gold's curves as the same
-- { expForLevel } record Gen 1 uses, so one mod record
-- serves both (src/battle/gen2/Mon.lua:growthFor is the
-- single accessor all six Gen 2 readers go through)
"tokens", "growth_rates",
-- `battle_sprite_scales` because
-- src/ui/gen2/BattleState.lua:imageScale walks
-- data.battle_sprite_scales for a record whose .path
-- matches the pic being drawn, skipping `_owners`,
-- exactly as Gen 1's BattleState.imageBattleScale
-- does, and picScale falls through to the species'
-- battleScaleFront / battleScaleBack after it. Only
-- the default differs (Red draws 32x32 back pics at
-- 2x, Gold's 48x48 ones fill their box at 1x) and the
-- default is not a registry record either side.
--
-- `render_pipelines` because src/core/Game2.lua:load
-- calls Pipelines.install(self.data) AFTER mods:load,
-- so src/render/Pipelines.lua walks the MERGED table,
-- and Game2:draw composites the whole-frame half
-- through Pipelines.wantsPresent / Pipelines.present.
-- Gold does not composite `drawWorld` yet -- its
-- overworld draws straight to the window -- and
-- Game2:load retires a restored drawWorld-only level
-- rather than leaving it on and drawing nothing, so
-- that half is inert rather than broken. Routing it
-- is still right: the registry has a live reader, and
-- a gated registry would drop the `present` half too.
"battle_sprite_scales", "render_pipelines" }) do
local spec = Schemas.REGISTRIES[name]
T.check(spec ~= nil, "catalog still has registry: " .. name)
T.eq(Schemas.targetFor(name, spec, 2), spec.target,
"available under Gen 2 at its Gen 1 path: " .. name)
T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name)
end
-- The tables Gold namespaces: same registry NAME, a Gen 2 Data path
-- underneath it. src/core/Game2.lua:load reads each of these into game.data
-- before mods:load runs, and the consumer holds it by reference --
-- src/world/gen2/World.lua:dataTable for the overworld four, the menus for
-- palettes/icons/battle_anims/constants -- so the merge lands in the table the
-- game walks. The battle-rule six have no table on disk at all: they come
-- into existence AS the merge (src/mods/Builtins.lua seeds Gold's own records
-- there), and each consumer reads them through a per-id lookup that falls back
-- to the module's records, so a mod-free boot behaves identically.
-- The pairing is asserted both ways round: routed is NOT the Gen 1 target
-- (that would mean merging into a table no Gold boot reads) and NOT nil (that
-- would mean the write is being dropped).
for name, path in pairs({ maps = "gen2Maps", tilesets = "gen2Tilesets",
sprites = "gen2Sprites", text = "gen2Text",
encounters = "gen2Encounters",
trainers = "gen2Trainers",
palettes = "gen2Palettes", icons = "gen2Icons",
battle_anims = "gen2BattleAnims",
constants = "gen2Constants",
statuses = "gen2Statuses",
move_effects = "gen2MoveEffects",
item_effects = "gen2ItemEffects",
balls = "gen2Balls",
ai_classes = "gen2AiClasses",
evolution_methods = "gen2EvolutionMethods" }) do
local spec = Schemas.REGISTRIES[name]
T.check(spec ~= nil, "catalog still has registry: " .. name)
T.eq(Schemas.targetFor(name, spec, 2), path,
"available under Gen 2 at its Gen 2 path: " .. name)
T.check(Schemas.targetFor(name, spec, 2) ~= spec.target,
"a routed registry does not keep the Gen 1 path: " .. name)
T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name)
T.eq(Schemas.targetFor(name, spec, 1), spec.target,
"and Gen 1 is untouched by the routing: " .. name)
end
-- The mirror set: six registries that exist because GOLD does. They carry no
-- Gen 1 target at all, so the routed path is the only path they ever have, and
-- Schemas.GEN1 gates them on Red the way Schemas.GEN2 gates `map_scripts` on Gold.
-- Each is held to a live consumer, which is the claim that matters: a routed
-- registry nothing reads is the silent no-op the whole routing table exists to
-- prevent. The per-consumer cases are in
-- tests/engine/gen2_content_registries.lua; here the pairing itself is pinned.
-- held_items src/core/gen2/ItemEffects.lua:heldItemFor / applyHeldItems,
-- written back onto data.items for Battle:itemDef
-- phone_contacts src/core/gen2/Phone.lua:useRegistry
-- decorations src/core/gen2/Decorations.lua:attributes
-- apricorns src/core/gen2/Apricorns.lua:useRegistry
-- landmarks src/core/gen2/Nests.lua:landmarkId / landmark
-- radio_channels src/ui/gen2/MapRadio.lua:channelRecord
for name, path in pairs({ held_items = "gen2HeldItems",
phone_contacts = "gen2PhoneContacts",
decorations = "gen2Decorations",
apricorns = "gen2Apricorns",
landmarks = "gen2Landmarks.landmarks",
radio_channels = "gen2RadioChannels" }) do
local spec = Schemas.REGISTRIES[name]
T.check(spec ~= nil, "catalog still has registry: " .. name)
T.eq(Schemas.targetFor(name, spec, 2), path,
"a Gen 2-only registry is available under Gen 2: " .. name)
T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name)
T.eq(spec.target, nil,
"and carries no Gen 1 target to fall back on: " .. name)
T.eq(Schemas.gatedFor(name, 1), true,
"so a Red boot reports the write rather than merging it: " .. name)
T.eq(Schemas.targetFor(name, spec, 1), nil,
"and has no Gen 1 path at all: " .. name)
end
-- and the ones Gold has no home for are gated, not silently retargeted.
-- One cause is left behind these: Gold reimplements the system WITHOUT
-- reading a registry, so there is no table a merge could land in that anything
-- would read. Closing one is a consumer change in the Gen 2 module first and
-- a routing row second, which is exactly how growth_rates closed --
-- src/battle/gen2/Mon.lua grew growthFor / registerInto and takes an
-- expForLevel record ahead of the coefficient row, so the registry now routes
-- to the SHARED Gen 1 path and one mod record serves both games.
--
-- `tokens` was on this list by mistake rather than by cause: TextBox.new runs
-- TextBox.substitute on every box in both generations and substitute reads
-- game.data.tokens, so the shared target was live on Gold the whole time.
-- `battle_sprite_scales` and `render_pipelines` came off it the way
-- growth_rates did, consumer first: src/ui/gen2/BattleState.lua:imageScale now
-- reads data.battle_sprite_scales, and src/core/Game2.lua:load installs
-- src/render/Pipelines.lua on Gold's merged dataset after mods:load so
-- Game2:draw composites a `present` pipeline. Both are asserted in the shared
-- set above.
--
-- `transitions` stays because Gold's own intro
-- (src/ui/gen2/BattleTransition.lua) keys STYLES as a boolean SET of the four
-- cart wipes rather than the { frames, draw, sound, flash } record this
-- registry carries, and has no styleDef lookup a mod id could reach.
--
-- map_scripts is the one genuine script-side gap left: src/script/gen2/Vm.lua
-- runs the cart's bytecode out of data.gen2Scripts keyed by ROM pointer, and a
-- Lua row list merged into that pool is not something the VM can run.
for _, name in ipairs({ "map_scripts", "rulesets", "transitions",
"field", "text_pointers", "link_fields" }) do
local spec = Schemas.REGISTRIES[name]
T.check(spec ~= nil, "catalog still has registry: " .. name)
T.eq(Schemas.targetFor(name, spec, 2), nil,
"gated registry has no Gen 2 target: " .. name)
T.eq(Schemas.gatedFor(name, 2), true, "gated under Gen 2: " .. name)
end
-- A routed registry is only routed if the records already sitting at that
-- path pass the shared schema, so this pins the two optional warp fields Gen 2
-- carries (the ROM map-group pair) that a strict record would otherwise
-- reject on every one of Gold's 368 maps.
do
local spec = Schemas.REGISTRIES.maps
local gen2Map = {
id = "MOD_TOWN", tileset = "TILESET_JOHTO", width = 2, height = 2,
blocks = { 1, 2, 3, 4 },
warps = { { x = 6, y = 3, destMap = "ELMS_LAB", destWarp = 1,
destGroup = 24, destMapNum = 5 } },
}
T.check(Schemas.check(spec, "maps", "MOD_TOWN", gen2Map, "register"),
"a Gen 2 warp row validates against the shared maps schema")
local gen1Map = {
id = "MOD_TOWN", tileset = "OVERWORLD", width = 2, height = 2,
blocks = { 1, 2, 3, 4 },
warps = { { x = 1, y = 1, destMap = "PALLET_TOWN", destWarp = 1 } },
}
T.check(Schemas.check(spec, "maps", "MOD_TOWN", gen1Map, "register"),
"and the Gen 1 warp row still does, the added fields being optional")
end
-- ------- 3b. the per-generation RECORD shape
--
-- Routing says where a registration lands; this says what a record there looks
-- like. A registry whose Gen 2 records differ carries gen2Fields / gen2Keys /
-- gen2Write beside the Gen 1 slots and Schemas.shapeFor folds them onto the
-- canonical names, which is what let the six shaped registries above be routed
-- at all: without it a Gold species would be judged against Red's `special`.
do
local spec = Schemas.REGISTRIES.pokemon
T.eq(Schemas.shapeFor("pokemon", spec, 1), spec,
"Gen 1 gets the catalog spec itself, not a copy")
local gen2 = Schemas.shapeFor("pokemon", spec, 2)
T.check(gen2 ~= spec, "Gen 2 gets a derived spec")
T.eq(Schemas.shapeFor("pokemon", gen2, 2), gen2,
"resolving a derived spec again is a no-op")
T.check(gen2.fields.levelMoves ~= nil and gen2.fields.level1Moves == nil,
"the Gen 2 species shape is folded onto `fields`")
T.eq(gen2.gen2Fields, nil, "and the gen2* keys are gone from the derived spec")
T.eq(gen2.target, "pokemon",
"a reshaped registry that is not rerouted keeps its path")
-- the split special stats, which is the difference that makes register
-- usable on Gold at all
local gold = {
id = "MODMON", name = "MODMON", dex = 252,
types = { "GRASS" },
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45,
specialAttack = 65, specialDefense = 65 },
catchRate = 45, baseExp = 64, growthRate = "MEDIUM_SLOW",
levelMoves = { { level = 1, move = "FIX_TACKLE" } },
evolutions = {},
spriteFront = "a.png", spriteBack = "b.png", picSize = 5,
}
T.check(Schemas.check(spec, "pokemon", "MODMON", gold, "register", 2),
"a Gen 2 species record registers under Gen 2")
T.check(not Schemas.check(spec, "pokemon", "MODMON", gold, "register", 1),
"and the same record is not a Gen 1 species")
local _, err = Schemas.check(spec, "pokemon", "FIXMON_A",
{ baseStats = { special = 80 } }, "patch", 2)
T.check(err ~= nil and err:match("special"),
"a Gen 1 baseStats.special is rejected under Gen 2: " .. tostring(err))
end
do
-- trainers routes one level further in, into .classes, and battle_anims
-- CLEARS the Gen 1 write (there the ids are the subtables the Gen 1 write
-- would have routed into)
local trainers = Schemas.shapeFor("trainers", Schemas.REGISTRIES.trainers, 2)
T.eq(trainers.target, "gen2Trainers", "the derived spec carries the routed path")
T.check(trainers.write ~= nil and trainers.baseAt ~= nil
and trainers.baseIds ~= nil,
"trainers reaches into .classes through write/baseAt/baseIds")
local anims = Schemas.shapeFor("battle_anims", Schemas.REGISTRIES.battle_anims, 2)
T.eq(anims.write, nil, "gen2Write = false clears the Gen 1 write")
T.eq(anims.baseAt, nil, "and the Gen 1 baseAt with it")
T.check(anims.keys ~= nil and anims.value == nil,
"a Gen 2 shape described by keys clears the Gen 1 value slot")
end
-- a Gen 2 shape on a registry with no Gen 2 home would be dead code: nothing
-- ever validates against it, because the write is dropped before it is checked
for name, spec in pairs(Schemas.REGISTRIES) do
if Schemas.hasGen2Shape(spec) then
T.eq(Schemas.gatedFor(name, 2), false,
"a registry with a Gen 2 shape is not gated: " .. name)
end
end
-- every routing entry names a real registry, so a rename cannot leave a
-- stale row behind that silently stops gating anything
for name in pairs(Schemas.GEN2) do
T.check(Schemas.REGISTRIES[name] ~= nil,
"Schemas.GEN2 names a real registry: " .. name)
end
-- a routed path must not be some other registry's path: two registries
-- folding into one table would let the second one's ids overwrite the first's
do
local claimed = {}
for name, spec in pairs(Schemas.REGISTRIES) do
local path = Schemas.targetFor(name, spec, 2)
if path then
T.check(claimed[path] == nil or claimed[path] == name,
("two registries share one Gen 2 path (%s): %s and %s")
:format(path, tostring(claimed[path]), name))
claimed[path] = name
end
end
end
-- ------- 4. the shared names, raised from Gold's own call sites
--
-- The rule: when a Gen 2 call site lands for something Gen 1 already names,
-- it reuses the EXACT name, so one mod's subscription serves both games. The
-- catalog reads the names back out of the source (tests/modkit/catalog.lua
-- scans for Runtime.emit / Runtime.call), so each name below is held to
-- having BOTH a site inside a Gen 2 module and a site outside one. A
-- "gen2.world.stepped" would satisfy the first half and fail the second,
-- which is exactly the drift this gate exists to catch; so would quietly
-- deleting Gold's site while docs/mod-api-gen2-compat.md still promises it.
--
-- Payload parity cannot be checked here (the shapes come from a live Gold
-- boot, and this file is ROM-free); tests/engine/gate_hooks.lua and
-- gate_events.lua carry the per-payload cases, and the Gen 2 sites were
-- proved against the gold_* drivers.
local Catalog = T.catalog
-- Gold's modules live under a gen2/ directory, except the two that own the
-- boot and the extractor and carry the generation in their name
local function isGen2Site(path)
return path:match("gen2") ~= nil or path:match("Gen2") ~= nil
or path:match("Game2") ~= nil
end
local GEN2_EVENTS = {
-- overworld
"map.entered", "map.exited", "map.reloaded", "player.warped",
"world.stepped", "world.interacted", "world.npc_spawned",
"world.trainer_engaged", "world.blacked_out", "world.block_replaced",
"world.boulder_moved", "world.tod_changed", "world.object_toggled",
"flag.changed",
-- battle
"battle.started", "battle.ended", "battle.turn_started", "battle.turn_ended",
"battle.move_used", "battle.damage_dealt", "battle.fainted",
"battle.status_inflicted", "battle.battler_switched", "battle.ball_thrown",
"battle.exp_gained", "pokemon.level_up", "pokemon.move_learned",
-- the catch and the evolution themselves: pushCaught emits after the mon is
-- in the party or the box, Evolution.apply after the species swap, both
-- matching the Gen 1 payload keys
"pokemon.caught", "pokemon.evolved",
-- boot, save and the script VM
"game.ready", "save.created", "save.loaded", "save.loading", "save.writing",
"script.started", "script.ended",
-- The new-game speech. Gold's is a different scene (Elm, not Oak, and its
-- own src/ui/gen2/OakSpeech.lua), but it is the SAME moment -- the intro
-- asking the player for the answers a save is built from -- so it keeps Gen
-- 1's four names and payload keys rather than inventing "intro.elm_speech".
"intro.oak_speech.started", "intro.oak_speech.step",
"intro.oak_speech.answered", "intro.oak_speech.finished",
}
local GEN2_HOOKS = {
-- overworld
"warp.destination", "movement.collision", "movement.speed",
"encounter.roll", "encounter.species", "encounter.fishing",
"world.tod", "map.palette", "fieldmove.eligibility",
-- menus and the battle intro
"ui.start_menu.items", "ui.title_menu.items", "ui.options.rows",
"ui.party.submenu", "ui.naming.grid", "ui.pc.items", "ui.list_menu",
"transition.style",
-- battle
"battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order",
"battle.enemy_action", "battle.run", "battle.exp_award", "exp.gain",
"catch.rate", "trainer.party",
-- one wrap cancels or forces an evolution in either game: Gold passes `data`
-- where Gen 1 passes `game`, and positions 2-4 (mon, row, trigger) match
"evolution.check",
-- save and the script VM
"save.write", "save.new_game", "script.command",
-- the intro's step list, wrapped before the first card draws. Same hook,
-- same (steps, speech) arguments and same "return the list" contract as
-- src/ui/OakSpeech.lua's, so one wrapper reorders either game's speech.
"intro.oak_speech.build",
-- Battle seams Gold raises from src/battle/gen2/Battle.lua and
-- src/ui/gen2/BattleState.lua. battle.low_health_alarm carries `data` on
-- Gold where Gen 1's vanilla link reads ctx.battle.data: Gold's battle
-- screen has no .data field, so the key is ADDED beside the Gen 1 ones
-- rather than the payload being reshaped (docs/mod-api-gen2-compat.md warns
-- that a Gen 1 mod reaching through ctx.battle.data instead of calling
-- nextFn gets nil there).
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
-- One pic path resolver for both games: the Gen 1 site is the SHARED
-- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook
-- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no
-- concept of.
"pokemon.sprite",
-- The player's own trainer pic, same story: the Runtime.call is the shared
-- src/pokemon/Sprites.lua and Gold's battle back pic, Hall of Fame and intro
-- resolve their own path into Sprites.playerPic with the Gen 1 ctx keys.
"player.sprite",
-- The frame itself, from src/core/Game2.lua, in the same places
-- src/core/Game.lua raises them: the logic tick before the pad is read, a
-- pointer with the touch overlay given first refusal, the palette zone list
-- handed to the present pass, the letterbox and the HUD rect.
"input.step", "input.pointer",
"render.zones", "render.compose", "render.letterbox", "render.hud",
}
local function assertShared(name, sites, kind)
local gen2, gen1 = 0, 0
for _, path in ipairs(sites) do
if isGen2Site(path) then gen2 = gen2 + 1 else gen1 = gen1 + 1 end
end
T.check(gen2 > 0, ("Gold raises the %s: %s"):format(kind, name))
T.check(gen1 > 0,
("the %s %s is shared, not a Gen 2 invention (no Gen 1 site)")
:format(kind, name))
end
for _, name in ipairs(GEN2_EVENTS) do
assertShared(name, Catalog.eventSites(name), "event")
end
for _, name in ipairs(GEN2_HOOKS) do
assertShared(name, Catalog.hookSites(name), "hook")
end
-- and the lists are COMPLETE, not a sample. Without this half the gate only
-- catches a seam being taken away; a Gen 2 site landing for a Gen 1 name and
-- never reaching docs/mod-api-gen2-compat.md is the other drift, and it is the
-- more likely one -- the doc is where an author looks to decide whether a
-- subscription serves both games, so an unlisted shared seam reads as absent.
local function assertListed(names, catalogNames, sites, kind)
local listed = {}
for _, name in ipairs(names) do listed[name] = true end
for _, name in ipairs(catalogNames) do
if not Catalog.isModEvent(name) then
local gen2, gen1 = false, false
for _, path in ipairs(sites(name)) do
if isGen2Site(path) then gen2 = true else gen1 = true end
end
if gen2 and gen1 then
T.check(listed[name],
("%s %s has a site in both generations but is not in this gate's "
.. "list; add it here and to docs/mod-api-gen2-compat.md")
:format(kind, name))
end
end
end
end
assertListed(GEN2_EVENTS, Catalog.events(), Catalog.eventSites, "event")
assertListed(GEN2_HOOKS, Catalog.hooks(), Catalog.hookSites, "hook")
-- and nothing anywhere invents a generation-prefixed name. New-in-Gen-2
-- systems (held_item.trigger, egg.hatched) get plain names of their own;
-- "gen2." would be a namespace no Gen 1 mod could ever match.
for _, name in ipairs(Catalog.events()) do
T.check(name:sub(1, 5) ~= "gen2.", "no generation-prefixed event: " .. name)
end
for _, name in ipairs(Catalog.hooks()) do
T.check(name:sub(1, 5) ~= "gen2.", "no generation-prefixed hook: " .. name)
end
-- ------- 4b. the seams Gen 2 invents
--
-- The other half of the shared-name rule. Section 4 holds a name Gen 1
-- already has to keeping it; these are the systems Red does not have at all
-- (friendship, breeding, the Pokegear, the radio, Pokerus, the roamers, Kurt,
-- the Bug Contest, the Unown puzzle, mail, held items, shininess and gender),
-- so a NEW name is justified -- and the discipline is the same one from the
-- other side: a plain name, never a "gen2." namespace no Gen 1 mod could
-- match, so that when Red ever grows the system the name is already right.
--
-- Three things are asserted per seam, and each one has failed at some point in
-- a review of this programme:
--
-- 1. the site exists at all. docs/mod-api-gen2-compat.md promises these by
-- name, so a deleted emit is doc drift the moment it happens.
-- 2. every site is inside a Gen 2 module. If a Gen 1 site ever appears the
-- seam is no longer Gen 2-only and belongs in the shared lists above,
-- where BOTH halves are checked -- this is the tripwire for that move.
-- 3. the site is guarded by Runtime.wants / wantsHook for its own name, so a
-- mod-free boot allocates no payload table. Several of these sit in the
-- step loop (happiness.changed, roamer.moved) or in the damage path
-- (held_item.trigger, eight triggers a turn), where an unguarded emit is
-- a per-frame cost every player pays for a feature nobody enabled.
--
-- Payload keys are not checkable here (this file is ROM-free);
-- tests/engine/gen2_new_seams.lua drives each one through a live bus and
-- asserts the payload the call site documents.
local GEN2_ONLY_EVENTS = {
"happiness.changed", "breeding.egg_created", "egg.hatched",
"phone.call_received", "clock.day_changed", "pokerus.infected",
"roamer.moved", "roamer.encountered", "apricorn.converted",
"bug_contest.scored", "unown.unlocked", "radio.channel",
"mail.written", "mail.read",
-- The GS boot cinema, card by card. Red boots straight into its title
-- screen, so there is no Gen 1 moment for these to share a name with; they
-- are plain names rather than "gen2." ones so that the day Red grows a
-- cinema the name is already right. Each fires as its card comes UP, with
-- movie_ended the one card END worth a name of its own (it is where the
-- attract loop restarts).
"intro.boot.copyright", "intro.boot.gamefreak", "intro.boot.movie",
"intro.boot.movie_ended", "intro.boot.title",
}
local GEN2_ONLY_HOOKS = {
"held_item.trigger", "breeding.compatibility", "phone.contact_list",
"shiny.roll", "gender.roll",
}
local sourceCache = {}
local function sourceOf(path)
if sourceCache[path] == nil then
local handle = io.open(path, "r")
sourceCache[path] = handle and handle:read("*a") or false
if handle then handle:close() end
end
return sourceCache[path] or nil
end
local function assertGen2Only(name, sites, kind, guard)
T.check(#sites > 0, ("Gold raises the Gen 2-only %s: %s"):format(kind, name))
local guarded = false
for _, path in ipairs(sites) do
T.check(isGen2Site(path),
("a Gen 2-only %s is raised from a Gen 2 module (%s is not one): %s")
:format(kind, path, name))
local body = sourceOf(path)
if body and body:find(('%s("%s")'):format(guard, name), 1, true) then
guarded = true
end
end
T.check(guarded,
("the %s %s is guarded by %s, so a mod-free boot pays nothing")
:format(kind, name, guard))
end
for _, name in ipairs(GEN2_ONLY_EVENTS) do
assertGen2Only(name, Catalog.eventSites(name), "event", "Runtime.wants")
end
for _, name in ipairs(GEN2_ONLY_HOOKS) do
assertGen2Only(name, Catalog.hookSites(name), "hook", "Runtime.wantsHook")
end
-- complete both ways, like the shared lists: a seam raised ONLY from Gen 2
-- modules is by definition a Gen 2-only one, so if it is not listed above it
-- has skipped the guard check, the doc's payload table and gen2_new_seams.lua
-- all at once.
local function assertGen2OnlyListed(names, catalogNames, sites, kind)
local listed = {}
for _, name in ipairs(names) do listed[name] = true end
for _, name in ipairs(catalogNames) do
if not Catalog.isModEvent(name) then
local anyGen1 = false
for _, path in ipairs(sites(name)) do
if not isGen2Site(path) then anyGen1 = true end
end
if not anyGen1 then
T.check(listed[name],
("%s %s is raised from Gen 2 modules alone but is not listed as a "
.. "Gen 2-only seam; add it here and to "
.. "docs/mod-api-gen2-compat.md"):format(kind, name))
end
end
end
end
assertGen2OnlyListed(GEN2_ONLY_EVENTS, Catalog.events(), Catalog.eventSites,
"event")
assertGen2OnlyListed(GEN2_ONLY_HOOKS, Catalog.hooks(), Catalog.hookSites,
"hook")
-- ------- 5. the gate, through a real load
local GEN1_ONLY = {
["mods/fix_gen1_only/manifest.json"] = [[{
"id": "fix_gen1_only",
"name": "Fixture Gen 1 Only",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/fix_gen1_only/main.lua"] = [[
local mod = ...
mod.content.pokemon:patch("FIXMON_A", { catchRate = 111 })
]],
}
local GEN2_READY = {
["mods/fix_gen2_ready/manifest.json"] = [[{
"id": "fix_gen2_ready",
"name": "Fixture Gen 2 Ready",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"gen2compat": true
}]],
["mods/fix_gen2_ready/main.lua"] = [[
local mod = ...
-- one registry with a Gen 2 home, one without: the first applies in both
-- generations, the second applies in Gen 1 and reports in Gen 2.
-- `transitions` is the gated one because Gold draws its own battle intro
-- (src/ui/gen2/BattleTransition.lua) and never composes through the Gen 1
-- Renderer, so nothing on a Gold boot would ever read the merged record.
mod.content.pokemon:patch("FIXMON_A", { catchRate = 123 })
mod.content.transitions:register("FIXTURE_WIPE", { frames = 30 })
]],
}
local function files(...)
local out = {}
for _, set in ipairs({ ... }) do
for path, body in pairs(set) do out[path] = body end
end
return out
end
local function statusOf(run, id)
for _, entry in ipairs(run.loader:status().available) do
if entry.id == id then return entry end
end
return nil
end
-- Gen 1: both mods run, both patches land
do
local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen2_ready" }, {
fs = T.sdk.memfs(files(GEN1_ONLY, GEN2_READY)),
generation = 1,
})
T.eq(statusOf(run, "fix_gen1_only").state, "loaded",
"Gen 1: a mod with no gen2compat loads")
T.eq(statusOf(run, "fix_gen2_ready").state, "loaded",
"Gen 1: a gen2compat mod loads too")
T.eq(run.data.pokemon.FIXMON_A.catchRate, 123,
"Gen 1: the later mod's patch merged")
T.check(run.data.transitions ~= nil
and run.data.transitions.FIXTURE_WIPE ~= nil,
"Gen 1: transitions merged")
run.release()
end
-- Gen 2: the undeclared mod is skipped whole, the declared one runs
do
local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen2_ready" }, {
fs = T.sdk.memfs(files(GEN1_ONLY, GEN2_READY)),
generation = 2,
})
local skipped = statusOf(run, "fix_gen1_only")
T.eq(skipped.state, "wrong_generation",
"Gen 2: a mod with no gen2compat is not loaded")
T.check(skipped.note ~= nil and skipped.note:match("gen2compat"),
"Gen 2: the skip says why")
T.eq(skipped.error, nil,
"Gen 2: a skip is not reported as a failure")
T.eq(skipped.enabled, true,
"Gen 2: the player's enable flag is untouched by the skip")
T.eq(statusOf(run, "fix_gen2_ready").state, "loaded",
"Gen 2: the declared mod loads")
-- the skipped mod's registration must leave no trace: 111 would mean it ran
T.eq(run.data.pokemon.FIXMON_A.catchRate, 123,
"Gen 2: only the declared mod's patch merged")
-- a gated registry takes the write, drops it, and says so
T.check(run.data.transitions == nil
or run.data.transitions.FIXTURE_WIPE == nil,
"Gen 2: a gated registry merges nothing")
local told = false
for _, message in ipairs(run.errors) do
if message:match("transitions") and message:match("Gen 2") then told = true end
end
T.check(told, "Gen 2: the dropped registration is reported, not silent")
run.release()
end
-- The drop is worded from the loader's own generation, because the gating runs
-- both ways: a Red boot rejecting a write to a Gen 2-only registry must not
-- claim the registry has "no Gen 2 target". The registry name and the drop
-- were always right; the sentence was one-directional.
do
local MIRROR = {
["mods/fix_gen1_drop/manifest.json"] = [[{
"id": "fix_gen1_drop",
"name": "Fixture Gen 1 Drop",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/fix_gen1_drop/main.lua"] = [[
local mod = ...
mod.content.decorations:patch("deco:2", { name = "COZY" })
]],
}
local run = T.sdk.loadMods({ "mods/fix_gen1_drop" },
{ fs = T.sdk.memfs(MIRROR), generation = 1 })
local told
for _, message in ipairs(run.errors) do
if message:match("decorations") then told = message end
end
T.check(told ~= nil,
"Gen 1: a write to a Gen 2-only registry is reported")
T.check(told and told:match("Gen 1"),
"Gen 1: and the report names Gen 1, not Gen 2: " .. tostring(told))
T.eq(run.data.gen2Decorations, nil, "Gen 1: and nothing merged")
run.release()
end
-- ------- 5b. the vanilla records at a routed path are GOLD's
--
-- Six of the routed registries are the battle rules, and there the registry is
-- not just a merge target: src/battle/gen2/Catching.lua:recordFor,
-- Battle.statusRecordFor / moveEffectRecordFor, Ai.layersFor,
-- Evolution.methodFor and src/core/gen2/ItemEffects.lua:recordFor all read the
-- merged table. So WHICH module seeds it is load bearing, and it is not the
-- one that seeds Red: the ids collide. src/mods/Builtins.lua swaps the
-- registrant per generation and this holds it to that -- seeding Red's
-- GREAT_BALL would leave Gold's x1.5 multiplier nil, which reads as a ball
-- that quietly stopped working.
do
local gen1 = T.sdk.loadNone({ generation = 1 })
local gen2 = T.sdk.loadNone({ generation = 2 })
local ball1 = gen1.loader.content.balls:get("GREAT_BALL")
local ball2 = gen2.loader.content.balls:get("GREAT_BALL")
T.check(ball1 ~= nil and ball1.hpFactor ~= nil and ball1.multiplier == nil,
"Gen 1 seeds Red's GREAT_BALL (an HP factor, no multiplier)")
T.check(ball2 ~= nil and ball2.multiplier == 1.5,
"Gen 2 seeds Gold's GREAT_BALL (the x1.5 the cart multiplies by)")
-- statuses are the clearest case of the shared NAME over different ids:
-- Red writes BRN into mon.status where Gold writes "burn"
T.check(gen1.loader.content.statuses:get("BRN") ~= nil,
"Gen 1 seeds Red's status ids")
T.check(gen2.loader.content.statuses:get("burn") ~= nil
and gen2.loader.content.statuses:get("BRN") == nil,
"Gen 2 seeds Gold's status ids and none of Red's")
-- Ai.layersFor walks the merged table for mod-registered scoring passes, so
-- Red's LAYER_1..LAYER_3 landing there would join Gold's ten
T.check(gen1.loader.content.ai_classes:get("LAYER_1") ~= nil,
"Gen 1 seeds Red's move-scoring layers")
T.check(gen2.loader.content.ai_classes:get("LAYER_1") == nil
and gen2.loader.content.ai_classes:get("SMART") ~= nil,
"Gen 2 seeds Gold's scoring passes instead")
-- and the Gen 2 VM's verb table is the mod verbs alone: a Gen 1 row-list
-- verb handed Gold's ctx would find no runner on it
T.check(gen1.loader.content.commands:get("show_text") ~= nil,
"Gen 1 seeds the row-list verbs")
T.eq(gen2.loader.content.commands:get("show_text"), nil,
"Gen 2 seeds none of them")
T.eq(gen2.data.commands, nil,
"and a mod-free Gold boot leaves data.commands absent entirely")
-- the seeded records land at the routed path, not the Gen 1 one
T.check(gen2.data.gen2Statuses ~= nil and gen2.data.gen2Statuses.burn ~= nil,
"the Gen 2 records merge into their Gen 2 path")
T.eq(gen2.data.statuses, nil,
"and nothing is written to the Gen 1 path a Gold boot never reads")
T.check(gen1.data.statuses ~= nil and gen1.data.statuses.BRN ~= nil,
"while Gen 1 is untouched by any of it")
gen1.release()
gen2.release()
end
-- A gated registry is an absent id space, not an empty one. Gold's species
-- carry a growthRate exactly as Red's do, so a patch that keeps one must not
-- be reported as referencing something that does not exist just because the
-- Gen 1 `growth_rates` namespace has no Gen 2 home. A ROUTED registry is the
-- opposite: `evolution_methods` has real ids on Gold now, so the same pass
-- resolves an evolution's method against them and a typo is caught.
local function refsFixture(body)
return {
["mods/fix_refs/manifest.json"] = [[{
"id": "fix_refs",
"name": "Fixture Refs",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"gen2compat": true
}]],
["mods/fix_refs/main.lua"] = body,
}
end
-- The ROM-free fixture dataset is Gen 1 shaped, and Gold hangs its experience
-- curves off data.pokemon.growthRates (which src/mods/Builtins.lua's Gen 2
-- registrant seeds the growth_rates registry from). A generation-2 run over
-- unmodified fixtures therefore seeds no curves, and every fixture species'
-- growthRate reads as a dangling reference -- an artifact of the dataset, not
-- of the engine: on a real Gold boot the ids line up exactly (both sides say
-- GROWTH_MEDIUM_SLOW). Added per-run rather than to tests/fixture_data, whose
-- shape is Gen 1's and whose fingerprint is a committed golden.
local function gen2Fixtures()
local data = T.fixtures.fresh()
-- pokegold data/growth_rates.asm's MEDIUM_SLOW row, under the id the fixture
-- species reference
data.pokemon.growthRates = {
MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15,
linear = 100, constant = 140 },
}
return data
end
local function danglingRefs(run)
local dangling = {}
for _, message in ipairs(run.errors) do
if message:match("unresolved reference") then
dangling[#dangling + 1] = message
end
end
return dangling
end
do
-- the Gen 2 evolution row shape: `into` rather than `species`, and Gold's
-- own EVOLVE_* method ids, which src/core/gen2/Evolution.lua seeds
local run = T.sdk.loadMods({ "mods/fix_refs" }, {
fs = T.sdk.memfs(refsFixture([[
local mod = ...
local base = mod.content.pokemon:get("FIXMON_A")
mod.content.pokemon:patch("FIXMON_A", {
catchRate = 90,
growthRate = base.growthRate,
evolutions = { { method = "EVOLVE_LEVEL", level = 16,
into = "FIXMON_B" } },
})
]])),
data = gen2Fixtures(),
generation = 2,
})
local dangling = danglingRefs(run)
T.eq(#dangling, 0,
"Gen 2: a record whose refs all resolve reports nothing ("
.. table.concat(dangling, "; ") .. ")")
T.eq(run.data.pokemon.FIXMON_A.catchRate, 90, "Gen 2: the patch still landed")
run.release()
end
do
local run = T.sdk.loadMods({ "mods/fix_refs" }, {
fs = T.sdk.memfs(refsFixture([[
local mod = ...
mod.content.pokemon:patch("FIXMON_A", {
evolutions = { { method = "EVOLVE_BY_VIBES", level = 16,
into = "FIXMON_B" } },
})
]])),
data = gen2Fixtures(),
generation = 2,
})
local dangling = danglingRefs(run)
T.eq(#dangling, 1,
"Gen 2: a routed registry HAS an id space, so a bad method is caught")
T.check(dangling[1] and dangling[1]:match("evolution_methods"),
"Gen 2: and the report names the registry it could not resolve against")
run.release()
end
-- the skip is contagious as a SKIP. A mod that DID claim gen2compat but sits
-- on one that did not is left out with the dependency's own wording, not
-- failed with "dependency X failed to load": neither mod has a bug and neither
-- belongs on the boot error list the player is shown.
do
local DEPENDENT = {
["mods/fix_gen2_dependent/manifest.json"] = [[{
"id": "fix_gen2_dependent",
"name": "Fixture Gen 2 Dependent",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"gen2compat": true,
"dependencies": ["fix_gen1_only"]
}]],
["mods/fix_gen2_dependent/main.lua"] = [[
local mod = ...
mod.content.pokemon:patch("FIXMON_A", { catchRate = 222 })
]],
}
local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen2_dependent" }, {
fs = T.sdk.memfs(files(GEN1_ONLY, DEPENDENT)),
generation = 2,
})
local dependent = statusOf(run, "fix_gen2_dependent")
T.eq(dependent.state, "wrong_generation",
"Gen 2: a dependent of a gate-skipped mod is skipped, not failed")
T.eq(dependent.error, nil,
"Gen 2: the dependent's skip is not reported as a failure")
T.check(dependent.note ~= nil and dependent.note:match("gen2compat"),
"Gen 2: the dependent's skip names the dependency's reason")
T.eq(#run.errors, 0, "Gen 2: neither mod contributes a boot error")
run.release()
end
-- the player's override: options.modsGen2 forces a mod past the gate, because
-- the manifest flag is the AUTHOR's claim and a mod written before the field
-- existed can never carry one
do
local fs = T.sdk.memfs(files(GEN1_ONLY))
fs.write("options.lua", require("src.core.SaveSerializer").encode({
mods = {}, modsGen2 = { fix_gen1_only = true },
}))
local run = T.sdk.loadMods({ "mods/fix_gen1_only" },
{ fs = fs, generation = 2 })
local forced = statusOf(run, "fix_gen1_only")
T.eq(forced.state, "loaded", "Gen 2: the override loads an unclaimed mod")
T.eq(forced.gen2Forced, true, "Gen 2: the manager sees the override")
T.check(forced.note ~= nil and forced.note:match("not verified"),
"Gen 2: a forced mod still says its author never claimed this game")
T.eq(run.data.pokemon.FIXMON_A.catchRate, 111,
"Gen 2: the forced mod's patch merged")
run.release()
end
-- a skipped mod is skipped before validation, so a Gen 1 mod with a broken
-- manifest does not ALSO shout about its entry file on a Gold boot
do
local BROKEN = {
["mods/fix_broken/manifest.json"] = [[{
"id": "fix_broken",
"name": "Fixture Broken",
"version": "1.0.0",
"entry": "missing.lua",
"api": 2
}]],
}
local run = T.sdk.loadMods({ "mods/fix_broken" },
{ fs = T.sdk.memfs(BROKEN), generation = 2 })
T.eq(statusOf(run, "fix_broken").state, "wrong_generation",
"Gen 2: the generation gate runs before entry-file validation")
T.eq(#run.errors, 0, "Gen 2: a skipped mod contributes no boot errors")
run.release()
end
-- ------- 6. StateStack:clear, which is what Gold's boot cinema hands off
-- through now that it runs the engine stack
do
local stack = setmetatable({}, { __index = StateStack })
stack:init()
local order = {}
local function state(name)
return { isOpaque = true, exit = function() order[#order + 1] = name end }
end
stack:push(state("a"))
stack:push(state("b"))
stack:push(state("c"))
stack:clear()
T.eq(stack:top(), nil, "clear empties the stack")
T.eq(table.concat(order, ","), "c,b,a", "clear unwinds top-first")
end
-- Without this the file printed its FAILs and exited 0, so the runner marked
-- the gate "ok" while it was red -- a gate that cannot fail is not a gate.
T.finish("gate_gen2_mod_api")