Files
gen1recomp/tests/engine/game3_profile_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

151 lines
6.9 KiB
Lua

-- FireRed profile row: resolution rules + the value contract the wiring
-- tickets in docs/game3/rse-seams.md depend on.
--
-- The profile is deliberately inert until the handoff tickets wire it, so
-- this suite is the guard that keeps its FireRed row equal to the constants
-- the engine reads today. When a wiring ticket lands, the assertion it
-- replaces moves from a literal to a comparison against the live module.
-- luajit tests/engine/game3_profile_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 Profile = require("src.core.game3.profile")
local MapIds = require("src.core.game3.map_ids")
local prevVersion = GameVersion.get()
-- ---------------------------------------------------------------- resolution
Profile.reset()
GameVersion.set("firered")
local active = Profile.active()
eq(active.id, "firered", "active() resolves the active Gen 3 game")
check(Profile.of("firered") == active, "for(id) and active() share one cached row")
check(Profile.isGame3Version("firered") == true, "isGame3Version(firered) is true")
check(Profile.isGame3Version("red") == false, "isGame3Version(red) is false")
check(Profile.isGame3Version("not-a-game") == false, "isGame3Version(unknown) is false")
-- Cached identity: repeated lookups return the same table.
check(Profile.of("firered") == active, "resolution is cached")
-- Unknown game ids fail closed to the active game, not to nil.
local unknown = Profile.of("not-a-game")
eq(unknown.id, "firered", "an unknown game id falls back to FireRed")
-- A fresh process with no Gen 3 game selected still resolves FireRed, so
-- shared code can require the module during boot and in headless suites.
Profile.reset()
GameVersion.set("red")
eq(Profile.active().id, "firered", "non-Gen3 process fails closed to FireRed")
-- Session-scoped capability lookup reads session.version, not the process.
local caps = Profile.capabilitiesFor({ version = "firered" })
check(caps.fameChecker == true, "capabilitiesFor(session) reads the session game")
check(Profile.has({ version = "firered" }, "vsSeeker") == true, "has() reads a flag")
check(Profile.has({ version = "firered" }, "contests") == false, "has() is false for absent flags")
-- ---------------------------------------------------- FireRed value contract
Profile.reset()
GameVersion.set("firered")
local row = Profile.active()
-- Map identity: the profile describes exactly what MapIds accepts today.
for _, prefix in ipairs(row.map.prefixes) do
check(MapIds.isGame3Map(prefix .. "ANYTHING") == true,
"profile prefix " .. prefix .. " is a Game3 map for MapIds")
end
check(MapIds.isGame3Map("NOT_A_MAP") == false, "a foreign id is not a Game3 map")
eq(row.map.enginePrefix, "FR_", "map id synthesis prefix is FR_")
eq(row.map.legacyPrefixes[1], "SEVII_", "Sevii is the one legacy namespace")
eq(row.map.newGameStart.map, MapIds.NEW_GAME_START.map, "new-game start map matches MapIds")
eq(row.map.newGameStart.x, MapIds.NEW_GAME_START.x, "new-game start x matches MapIds")
eq(row.map.newGameStart.y, MapIds.NEW_GAME_START.y, "new-game start y matches MapIds")
eq(row.map.newGameStart.healMap, MapIds.NEW_GAME_START.healMap, "heal map matches MapIds")
eq(row.map.newGameStart.healX, MapIds.NEW_GAME_START.healX, "heal x matches MapIds")
eq(row.map.newGameStart.healY, MapIds.NEW_GAME_START.healY, "heal y matches MapIds")
eq(row.optionsBlock, "firered", "options block is the FireRed key")
eq(row.species.num, 412, "species count is FRLG's 412")
eq(row.species.egg, 412, "egg species is FRLG's 412")
eq(row.dexArea.defaultKey, "kanto", "dex area default is kanto")
eq(row.dexArea.dexMax, 151, "dex area max is 151")
local okGroups, groups = pcall(require, row.dexArea.mapGroups)
check(okGroups and type(groups) == "table" and next(groups) ~= nil,
"dexArea.mapGroups names a loadable map-group module")
eq(row.badges.count, 8, "eight badges")
eq(row.badges.flagBase, 0x820, "badge flag base is 0x820")
local badgeNames = {
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
"SOUL", "MARSH", "VOLCANO", "EARTH",
}
for i, name in ipairs(badgeNames) do
eq(row.badges.names[i], name, "badge " .. i .. " name is " .. name)
end
eq(row.trainers.rivalIds.squirtle, 326, "rival squirtle id")
eq(row.trainers.rivalIds.bulbasaur, 327, "rival bulbasaur id")
eq(row.trainers.rivalIds.charmander, 328, "rival charmander id")
eq(row.trainers.fallback.class, 81, "rival fallback class")
eq(row.trainers.fallback.pic, 106, "rival fallback pic")
eq(row.trainers.fallback.name, "TERRY", "rival fallback name")
eq(row.trainers.music.encounter.girl, 284, "encounter music girl")
eq(row.trainers.music.encounter.rocket, 283, "encounter music rocket")
eq(row.trainers.music.encounter.boy, 285, "encounter music boy")
eq(row.trainers.music.battle.gym, 296, "gym battle music")
eq(row.trainers.music.victory.gym, 312, "gym victory music")
eq(row.trainers.music.victory.trainer, 310, "trainer victory music")
eq(row.regionMap.switchFlag, "FLAG_SYS_SEVII_MAP_123", "region-map switch flag")
-- FRLG-only features are on for FireRed, off by default for anything else.
for _, flag in ipairs({ "fameChecker", "teachyTV", "vsSeeker", "trainerTower", "seagallop" }) do
check(row.capabilities[flag] == true, "FireRed capability " .. flag .. " is on")
end
-- The native module list the registry merges today (natives.lua:803-820).
local expectedNatives = {
"natives_corner", "natives_cutscene", "natives_daycare", "natives_elevator",
"natives_events", "natives_fame", "natives_fan_club", "natives_gift",
"natives_link", "natives_listmenu", "natives_moveteach", "natives_queries",
"natives_seagallop", "natives_size_record", "natives_tower", "natives_trade",
}
eq(#row.nativeModules, #expectedNatives, "native module count matches the registry")
for i, name in ipairs(expectedNatives) do
eq(row.nativeModules[i], name, "native module " .. i .. " is " .. name)
end
-- ------------------------------------------------------------ static guards
-- FireRed must stay the fallback row: a missing profiles/firered.lua is a
-- hard error, not a silent nil.
Profile.reset()
local okMissing = pcall(function()
local warn = Profile.of("not-a-game")
return warn
end)
check(okMissing == true, "unknown ids resolve without raising")
-- The profile names the modules the wiring tickets will create; keep them
-- syntactically valid package names so a typo fails here, not at boot.
for _, rel in ipairs({
row.font.module,
row.dexArea.mapGroups,
row.saveRules,
}) do
check(type(rel) == "string" and rel:match("^[%w_%.]+$") ~= nil,
"module path is well-formed: " .. tostring(rel))
end
check(type(row.font.widths) == "string" and #row.font.widths > 0, "font widths path is set")
check(type(row.font.smallWidths) == "string" and #row.font.smallWidths > 0, "small font path is set")
check(type(row.extractors) == "table" and #row.extractors > 0, "aux extractor list is set")
GameVersion.set(prevVersion)
T.finish("game3_profile_test")