Files
gen1recomp/tests/engine/game3_version_dispatch_test.lua
T
Shane McGovern e2454bf568 fix(game3): review v3 fixes (battle, field, script, save) + gated T6 tier
Second wave of fixes from the Gen 3 codebase review (210 findings), the
regressions the full-suite sweep surfaced, and a T6 tier so the top-level
tests/game3_*.lua suites run inside ./scripts/test.sh instead of outside it.

Battle: Knock Off and Thief/Trick item persistence follow the ROM, not the
review - the party slot is never written through during battle, and the
knocked-off mask suppresses the item on later send-outs
(pokefirered/src/battle_script_commands.c:2731 MOVE_EFFECT_KNOCK_OFF with the
STICKY_HOLD guard at :2732, opponent-steal guard at :2610-2622, mask use at
:4489). Rapid Spin keeps its one-per-use chain order (:8435-8474); Growl
keeps pret's target (src/data/battle_moves.h, include/battle.h:63).

Scripting: operand layouts match pret/asm/macros/event.inc - givemon 15 bytes
(:989-997), comparestat {B,W} (:1573-1576), setptr/loadbytefromptr/setptrbyte
take a word pointer (:118-137). 18 of the 20 E10 ops are wired per
src/scrcmd.c (per-op citations in docs/game3/e10-opcode-spec.md), money and
random and the warp family read their operands as VarGets
(src/scrcmd.c:1798-1830, :455-461, :719-731), and the day-care party-full
guard is in place (src/daycare.c:525,:1081).

Field and UI: fishing counts rounds as the ROM does
(src/field_player_avatar.c:1740-1765), an unresolved map section no longer
reports Pallet Town (src/region_map.c:3782), and a font provider sits behind
a FireRed capability/profile seam.

Save data: profile-driven Options.block, a save version round-trip, and slot
id validation that blocks path traversal (slotDiskPath("firered", "../evil")
returns nil).

Importer: the parallel import path now writes both completion markers,
object kind/clone bytes decode as pret defines them
(include/global.fieldmap.h:110-130), and the HM table drops Whirlpool
(include/constants/items.h:411-418).

Tests: six new scenario suites (battle_ai, capture, event, menu, move,
overworld) and eight engine suites (profile, capabilities, cache paths, font
provider, options block, save version round-trip, version dispatch,
versions_game). T6 runs every top-level game3 suite at GAME3_JOBS default 8
with per-suite logs, failure cause classification and a KNOWN_GAME3_FAILURES
ceiling of 23 as a shrinking guard.

Docs: the working notes under docs/game3 (triage ledger, pret citation audit,
e10 opcode spec, RSE seams, sweep v3/v113, test baseline, artifact
conversions, merge trial).

Full gate at this snapshot: exit 0, all tiers passed - engine 624/624,
T6 279/279 (0 known, 0 fresh failures), gen2 146/146, modkit 37/37, luacheck
clean, privacy gate 3/3 over a 9,711-file publication set.
2026-09-22 12:22:50 +01:00

125 lines
6.0 KiB
Lua

-- J10: the mod API dispatches Gen 3 on (engine, versionId), with a graceful
-- fallback to the generation's default row so FireRed behaviour is unchanged.
--
-- The seam is three tables: Loader.apiModule (facade module paths),
-- Schemas.GEN3_ROUTING (per-game registry routing) and
-- Schemas.GEN3_LIVE_MODULES (per-game live-module bindings). This suite pins
-- the defaults and proves an overlay can override one registry/key without
-- forking the shared tables.
-- luajit tests/engine/game3_version_dispatch_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local GameVersion = require("src.core.GameVersion")
local Schemas = require("src.mods.Schemas")
local Loader = require("src.mods.Loader")
local prevVersion = GameVersion.get()
-- ------------------------------------------------------- facade dispatch
eq(Loader.apiModule("battle", 3, nil), "src.battle.game3.BattleAPI",
"gen3 battle facade default is the FireRed-backed module")
eq(Loader.apiModule("world", 3, nil), "src.world.game3.WorldAPI",
"gen3 world facade default is the FireRed-backed module")
eq(Loader.apiModule("battle", 3, "firered"), "src.battle.game3.BattleAPI",
"firered resolves the FireRed-backed facade")
eq(Loader.apiModule("battle", 3, "leafgreen"), "src.battle.game3.BattleAPI",
"leafgreen shares the FireRed-backed facade")
eq(Loader.apiModule("world", 3, "leafgreen"), "src.world.game3.WorldAPI",
"leafgreen world facade")
eq(Loader.apiModule("battle", 3, "ruby"), "src.battle.game3.BattleAPI",
"an RSE id with no row falls back to the FireRed-backed facade")
eq(Loader.apiModule("world", 3, "not-a-game"), "src.world.game3.WorldAPI",
"an unknown id falls back to the FireRed-backed facade")
-- other generations keep their existing arms
eq(Loader.apiModule("battle", 2, nil), "src.battle.gen2.BattleAPI", "gen2 battle facade")
eq(Loader.apiModule("world", 2, nil), "src.world.gen2.WorldAPI", "gen2 world facade")
eq(Loader.apiModule("battle", 1, nil), "src.battle.BattleAPI", "gen1 battle facade")
eq(Loader.apiModule("world", 1, nil), "src.world.WorldAPI", "gen1 world facade")
-- ------------------------------------------------------- routing dispatch
check(Schemas.routing(3, nil) == Schemas.GEN3, "gen3 routing without a version is GEN3")
check(Schemas.routing(3, "firered") == Schemas.GEN3, "firered has no overlay, so GEN3")
check(Schemas.routing(3, "ruby") == Schemas.GEN3, "an overlay-less RSE id reads GEN3")
check(Schemas.routing(2, nil) == Schemas.GEN2, "gen2 routing unchanged")
check(Schemas.routing(1, nil) == Schemas.GEN1, "gen1 routing unchanged")
local spec = Schemas.REGISTRIES.pokemon
check(spec ~= nil, "the pokemon registry spec exists to test against")
eq(Schemas.targetFor("pokemon", spec, 3, "firered"), "gen3Pokemon",
"firered routes pokemon to the shared gen3Pokemon root")
eq(Schemas.targetFor("pokemon", spec, 3, "ruby"), "gen3Pokemon",
"an overlay-less RSE id routes pokemon to the shared root")
check(Schemas.gatedFor("pokemon", 3, "firered") == false, "pokemon is not gated under FireRed")
-- a sparse overlay changes one registry and inherits the rest
Schemas.GEN3_ROUTING["testgame"] = { pokemon = "gen3PokemonTest", moves = false }
local routed = Schemas.routing(3, "testgame")
check(routed ~= Schemas.GEN3, "an overlay produces a distinct merged view")
eq(routed.pokemon, "gen3PokemonTest", "the overlay's row wins")
eq(routed.moves, false, "the overlay can gate a registry")
eq(routed.items, Schemas.GEN3.items, "registries the overlay omits are inherited")
eq(Schemas.targetFor("pokemon", spec, 3, "testgame"), "gen3PokemonTest",
"targetFor consults the overlay")
check(Schemas.gatedFor("moves", 3, "testgame") == true,
"the overlay can gate a registry the default leaves open")
check(Schemas.gatedFor("moves", 3, "firered") == false,
"the default view is untouched by the overlay")
check(Schemas.routing(3, "firered") == Schemas.GEN3, "the merged view is never written back")
Schemas.GEN3_ROUTING["testgame"] = nil
-- ---------------------------------------------------- live module dispatch
local probe = { measure = function() return 1 end }
package.loaded["tests.dispatch_probe"] = probe
Schemas.GEN3_LIVE_MODULES["testgame"] = { gen3Pokemon = "tests.dispatch_probe" }
check(Schemas.liveModuleFor("gen3Pokemon", "testgame") == probe,
"a version overlay selects its own live module")
check(Schemas.liveModuleFor("gen3Pokemon", "firered") ~= probe,
"the FireRed live module is unaffected")
Schemas.GEN3_LIVE_MODULES["testgame"] = nil
package.loaded["tests.dispatch_probe"] = nil
-- bindGen3 records the game so live-module lookups can narrow by it
local data = { gen3Pokemon = { names = {} } }
Schemas.bindGen3(data, "testgame")
eq(Schemas.boundVersion(data), "testgame", "bindGen3 records the version id")
local legacy = { gen3Pokemon = { names = {} } }
Schemas.bindGen3(legacy)
eq(Schemas.boundVersion(legacy), nil, "a version-less bind reports nil")
-- ------------------------------------------------------- loader plumbing
local function memfs(files)
return {
read = function(_, path) return files[path] end,
write = function(_, path, data) files[path] = data; return true end,
getInfo = function(_, path) return files[path] and { type = "file" } or nil end,
getDirectoryItems = function()
local items = {}
for key in pairs(files) do items[#items + 1] = key end
table.sort(items)
return items
end,
}
end
GameVersion.set("firered")
local loader = Loader.new({ fs = memfs({}) })
eq(loader.generation, 3, "a Gen 3 boot builds a Gen 3 loader")
eq(loader.version, "firered", "the loader carries the active game id")
local injected = Loader.new({ fs = memfs({}), generation = 3, version = "testgame" })
eq(injected.version, "testgame", "opts.version is the test seam")
eq(Loader.apiModule("world", injected.generation, injected.version), "src.world.game3.WorldAPI",
"an injected game with no row still resolves the default facade")
GameVersion.set(prevVersion)
T.finish("game3_version_dispatch_test")