Files
gen1recomp/tests/game3_stitchsave_schema_fields_test.lua
bryanthaboi dee935a4ea CLOSES #2430
2026-09-23 11:58:32 -04:00

309 lines
12 KiB
Lua

#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
require("tests.game3_cache").stubSpeciesNames()
package.loaded["src.core.game3.audio"] = setmetatable({}, {
__index = function() return function() end end,
})
local failed, passed = 0, 0
local function check(cond, msg)
if cond then
passed = passed + 1
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local function eq(a, b, msg)
check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b)))
end
local Schema = require("src.core.game3.save_schema_firered")
local Rng = require("src.core.game3.rng")
local Flags = require("src.core.game3.scripting.flags")
local FLAG_SYS_SAFARI_MODE = 0x800
local VAR_SAFARI_ENTRANCE = 0x406E
print("[test] 1. New Game rolls a secret id in pret's order")
do
-- pokefirered/src/new_game.c:103 SeedWildEncounterRng, then :123 InitPlayerTrainerId
Rng.SeedRng(0x2468)
Rng.Random()
local expected = Rng.Random()
local session = Schema.newGame({ rngSeed = 0x2468 })
eq(session.trainerId, 0x2468, "trainerId is the timer seed")
check(type(session.secretId) == "number", "newGame stamps a numeric secretId")
check(type(session.secretId) == "number"
and session.secretId >= 0 and session.secretId < 0x10000, "secretId is 16 bit")
eq(session.secretId, expected, "secretId is the Random() after the wild seed")
local other = Schema.newGame({ rngSeed = 0x1357 })
check(other.secretId ~= session.secretId, "a different seed rolls a different secret id")
end
print("[test] 2. secretId survives save and load")
do
local session = Schema.newGame({ rngSeed = 0x4242 })
local secret = session.secretId
local save = Schema.toSaveTable(session)
eq(save.secretId, secret, "toSaveTable writes secretId")
local loaded = Schema.fromSaveTable(save)
eq(loaded.secretId, secret, "fromSaveTable restores secretId")
eq(loaded.trainerId, session.trainerId, "and trainerId beside it")
local Catching = require("src.core.game3.battle.catching")
loaded.party = {}
loaded.storage = nil
eq(Catching.playerSecretId(loaded), secret,
"an empty party still reports the saved secret id")
end
print("[test] 3. An old save with none of the three fields loads clean")
do
local old = {
schemaVersion = 1, engine = "game3", version = "firered",
name = "RED", rivalName = "BLUE", gender = 0, money = 3000, coins = 0,
trainerId = 31337,
party = { { species = 1, speciesId = 1, level = 7, hp = 22, maxHp = 22,
otId = 31337, otSecretId = 4242 } },
dex = { seen = {}, owned = {} },
map = "FR_PALLET_TOWN", x = 5, y = 6, facing = "down",
flags = {}, vars = {},
}
local loaded = Schema.fromSaveTable(old)
eq(loaded.secretId, nil, "no secretId in, no secretId out")
eq(loaded.safari, nil, "no safari block")
eq(loaded.mail, nil, "no mail pool")
eq(#loaded.party, 1, "the party is not quarantined")
eq(loaded.party[1].otSecretId, 4242, "the mon keeps its own secret id")
eq(loaded.orphaned, nil, "nothing was orphaned")
local Catching = require("src.core.game3.battle.catching")
eq(Catching.playerSecretId(loaded), 4242,
"the old-save recovery path still reads it off an owned mon")
local again = Schema.fromSaveTable(Schema.toSaveTable(loaded))
eq(again.secretId, 4242, "and the next save persists what was recovered")
end
print("[test] 4. Continue clears safari mode, whatever spelling the save used")
do
local save = {
schemaVersion = 1, engine = "game3", version = "firered",
party = {}, dex = { seen = {}, owned = {} },
map = "FR_FUCHSIA_CITY", x = 4, y = 4,
flags = {
[FLAG_SYS_SAFARI_MODE] = true,
[tostring(FLAG_SYS_SAFARI_MODE)] = true,
["FLAG_SYS_SAFARI_MODE"] = true,
[tostring(0x828)] = true,
},
vars = {
[tostring(VAR_SAFARI_ENTRANCE)] = 1,
["VAR_MAP_SCENE_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE"] = 1,
[tostring(0x4001)] = 3,
},
safari = { balls = 17, steps = 412 },
}
local loaded = Schema.fromSaveTable(save)
-- pokefirered/src/overworld.c:1695 CB2_ContinueSavedGame
check(Flags.getFlag(loaded, nil, FLAG_SYS_SAFARI_MODE) == false,
"ResetSafariZoneFlag_ clears FLAG_SYS_SAFARI_MODE on Continue")
eq(loaded.flags[FLAG_SYS_SAFARI_MODE], nil, "the numeric key is gone")
eq(loaded.flags[tostring(FLAG_SYS_SAFARI_MODE)], nil, "the decimal key is gone")
eq(loaded.flags["FLAG_SYS_SAFARI_MODE"], nil, "the name key is gone")
eq(loaded.flags[tostring(0x828)], true, "an unrelated flag is untouched")
eq(Flags.getVar(loaded, nil, VAR_SAFARI_ENTRANCE), 0,
"Overworld_ResetStateOnContinue zeroes the entrance scene var")
eq(loaded.vars[tostring(VAR_SAFARI_ENTRANCE)], nil, "no stale decimal var key")
eq(loaded.vars["VAR_MAP_SCENE_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE"], nil,
"no stale named var key")
eq(loaded.vars[tostring(0x4001)], 3, "an unrelated var is untouched")
eq(loaded.safari, nil, "gNumSafariBalls and the step counter do not come back")
local session = Schema.newGame({ rngSeed = 7 })
session.safari = { balls = 30, steps = 600 }
eq(Schema.toSaveTable(session).safari, nil,
"and they are EWRAM, so no save block ever carries them")
local stranded = Schema.fromSaveTable({
schemaVersion = 1, engine = "game3", version = "firered",
party = {}, dex = { seen = {}, owned = {} },
map = "FR_SAFARI_ZONE_CENTER", x = 26, y = 30, facing = "up",
flags = {}, vars = {},
})
-- pokefirered/data/scripts/safari_zone.inc:7 SafariZone_EventScript_Exit
eq(stranded.map, "FR_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE", "a save left in the zone resumes at the gate")
eq(stranded.x, 4, "gate x")
eq(stranded.y, 1, "gate y")
eq(Flags.getVar(stranded, nil, VAR_SAFARI_ENTRANCE), 1, "entrance ExitWarpIn scene queued")
local again = Schema.fromSaveTable(Schema.toSaveTable(stranded))
eq(again.map, "FR_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE", "second Continue stays at the gate")
end
print("[test] 5. The mail pool rides the save through mail.lua")
do
local KEY = "src.core.game3.mail"
local prev = package.loaded[KEY]
local exported, restored = nil, nil
package.loaded[KEY] = {
export = function(session)
exported = session
return { pool = { [1] = { item = 121, words = { 1, 2, 3 } } } }
end,
restore = function(saved)
restored = saved
return saved and { pool = saved.pool, live = true } or nil
end,
}
local session = Schema.newGame({ rngSeed = 9 })
local save = Schema.toSaveTable(session)
check(exported == session, "toSaveTable hands the session to Mail.export")
check(type(save.mail) == "table" and save.mail.pool[1].item == 121,
"the exported pool lands in the save table")
local loaded = Schema.fromSaveTable(save)
check(restored == save.mail, "fromSaveTable hands the saved pool to Mail.restore")
check(type(loaded.mail) == "table" and loaded.mail.live == true,
"and the restored pool lands on the session")
package.loaded[KEY] = { GIVE_MAIL = 1 }
local bare = Schema.newGame({ rngSeed = 11 })
bare.mail = { pool = {} }
local bareSave = Schema.toSaveTable(bare)
check(bareSave.mail == bare.mail,
"a mail module without export is a no-op, the raw field passes through")
eq(Schema.fromSaveTable(bareSave).mail, bare.mail,
"and so is one without restore")
package.loaded[KEY] = prev
end
print("[test] 6. A script gift mon carries the player's secret id")
do
local Pokemon = require("src.core.game3.pokemon")
Pokemon.install(nil)
local Party = require("src.core.game3.party")
local session = Schema.newGame({ rngSeed = 0x0F0F })
-- pokefirered/src/script_pokemon_util.c:48 ScriptGiveMon
local code, mon = Party.giveMonToPlayer(session, 1, 5, "BULBASAUR")
eq(code, Party.MON_GIVEN_TO_PARTY, "the gift went to the party")
eq(mon.otId, session.trainerId, "stamped with the visible trainer id")
check(type(mon.otSecretId) == "number", "and with a numeric secret half")
eq(mon.otSecretId, session.secretId, "which is the player's own")
end
print("[test] 7. the SaveBlock1 warp slots ride the save")
do
-- pokefirered/include/global.h:764
local session = Schema.newGame({ rngSeed = 0x77 })
eq(session.dynamicWarp, nil, "a New Game has no dynamic warp")
eq(session.escapeWarp, nil, "and no escape warp")
session.map = "FR_SILPH_CO_ELEVATOR"
-- pokefirered/src/overworld.c:600 SetDynamicWarp
session.dynamicWarp = { map = "FR_SILPH_CO_5F", warpId = 255, x = 22, y = 3 }
-- pokefirered/src/overworld.c:651 SetEscapeWarp
session.escapeWarp = { map = "FR_PEWTER_CITY", warpId = 255, x = 14, y = 8 }
local save = Schema.toSaveTable(session)
eq((save.dynamicWarp or {}).map, "FR_SILPH_CO_5F", "toSaveTable writes the dynamic warp")
eq((save.escapeWarp or {}).map, "FR_PEWTER_CITY", "and the escape warp")
local loaded = Schema.fromSaveTable(save)
eq((loaded.dynamicWarp or {}).map, "FR_SILPH_CO_5F", "the lift exit survives the reload")
eq((loaded.dynamicWarp or {}).x, 22, "with its recorded x")
eq((loaded.dynamicWarp or {}).y, 3, "and its y")
eq((loaded.escapeWarp or {}).map, "FR_PEWTER_CITY", "so does the escape warp")
eq((loaded.escapeWarp or {}).y, 8, "with its doorstep y")
local old = Schema.fromSaveTable({
schemaVersion = 1, engine = "game3", version = "firered",
party = {}, dex = { seen = {}, owned = {} },
map = "FR_PALLET_TOWN", x = 5, y = 6, facing = "down", flags = {}, vars = {},
})
eq(old.dynamicWarp, nil, "an old save without the slots loads with none")
eq(old.escapeWarp, nil, "neither half is invented")
local junk = Schema.fromSaveTable({
schemaVersion = 1, engine = "game3", version = "firered",
party = {}, dex = { seen = {}, owned = {} },
map = "FR_PALLET_TOWN", x = 5, y = 6, facing = "down", flags = {}, vars = {},
dynamicWarp = "FR_SILPH_CO_5F", escapeWarp = 7,
})
eq(junk.dynamicWarp, nil, "a non-table dynamic warp is dropped")
eq(junk.escapeWarp, nil, "and so is a non-table escape warp")
end
print("[test] 8. the flash level is saved with the block it lives in")
do
-- pokefirered/include/global.h:770
local Runtime = require("src.core.game3.runtime")
local FieldView = require("src.core.game3.field_view")
local prev = Runtime.session
local session = Schema.newGame({ rngSeed = 0x78 })
eq(session.flashLevel, 0, "a New Game starts with no flash level")
Runtime.session = session
-- pokefirered/src/overworld.c:966 SetFlashLevel
FieldView.setFlashLevel(FieldView.MAX_FLASH_LEVEL)
eq(session.flashLevel, FieldView.MAX_FLASH_LEVEL,
"SetFlashLevel writes the level into the save block")
local loaded = Schema.fromSaveTable(Schema.toSaveTable(session))
eq(loaded.flashLevel, FieldView.MAX_FLASH_LEVEL,
"saving in a dark cave and reloading keeps the level")
local old = Schema.fromSaveTable({
schemaVersion = 1, engine = "game3", version = "firered",
party = {}, dex = { seen = {}, owned = {} },
map = "FR_PALLET_TOWN", x = 5, y = 6, facing = "down", flags = {}, vars = {},
})
eq(old.flashLevel, nil, "a save with no flashLevel key loads without one")
-- pokefirered/src/overworld.c:956 SetDefaultFlashLevel
Runtime.session = old
eq(FieldView.setDefaultFlashLevel(nil, "FR_PALLET_TOWN"), 0,
"so the map load falls back to the default for the map")
eq(old.flashLevel, 0, "and that default is what the next save carries")
Runtime.session = prev
end
print("[test] berryPowder round-trips through the schema")
do
-- include/global.h:354, src/berry_powder.c:50
local session = Schema.newGame({ rngSeed = 0x99 })
eq(session.berryPowder, 0, "a New Game starts with 0 berry powder")
session.berryPowder = 40
local save = Schema.toSaveTable(session)
eq(save.berryPowder, 40, "toSaveTable writes berryPowder")
local loaded = Schema.fromSaveTable(save)
eq(loaded.berryPowder, 40, "fromSaveTable restores berryPowder")
local old = Schema.fromSaveTable({
schemaVersion = 1, engine = "game3", version = "firered",
party = {}, dex = { seen = {}, owned = {} },
map = "FR_PALLET_TOWN", x = 5, y = 6, facing = "down", flags = {}, vars = {},
})
eq(old.berryPowder, 0, "a save with no berryPowder key loads 0")
end
print(string.format("[test] %d passed, %d failed", passed, failed))
if failed > 0 then
print("[test] FAILED " .. failed)
os.exit(1)
end
print("[test] all passed")