Files
gen1recomp/tests/save_compat/gen2_state_progress_test.lua
T
2026-10-01 17:30:52 -04:00

772 lines
35 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local check, eq, same = T.check, T.eq, T.same
local K = require("tests.save_compat._codec")
local G2 = require("tests.fixtures.save.gen2_build")
local B = require("tests.fixtures.save.bytes")
local Gen2Save = require("src.save_convert.Gen2Save")
local Gen2State = require("src.save_convert.Gen2State")
local P = require("src.save_convert.gen2_state.progress")
local Save = require("src.core.gen2.Save")
local HallOfFame = require("src.core.gen2.HallOfFame")
local NpcTrade = require("src.core.gen2.NpcTrade")
local Decorations = require("src.core.gen2.Decorations")
local BattleTower = require("src.core.gen2.BattleTower")
local Mon = require("src.battle.gen2.Mon")
local World = require("src.world.gen2.World")
local Vm = require("src.script.gen2.Vm")
local Events = require("src.world.gen2.Events")
local Pokegear = require("src.ui.gen2.Pokegear")
local Music = require("src.core.Music")
local VERSIONS = { "gold", "silver", "crystal" }
local REGION_NAMES = {
"savedAtLeastOnce", "lastDexModeAndRegistered", "hallOfFameCount", "tradeFlags",
"decorations", "spawnAndBackupMap", "mysteryGift", "linkBattleStats", "hallOfFame",
"gsBallFlag", "gsBallFlagBackup", "crystalData", "battleTower",
}
local DATA = {
items = {},
pokemon = {
CYNDAQUIL = { index = 155, dex = 155, name = "CYNDAQUIL", genderRatio = 0x1F },
TOTODILE = { index = 158, dex = 158, name = "TOTODILE", genderRatio = 0x1F },
CHIKORITA = { index = 152, dex = 152, name = "CHIKORITA", genderRatio = 0x1F },
PIKACHU = { index = 25, dex = 25, name = "PIKACHU", genderRatio = 0x7F },
STARMIE = { index = 121, dex = 121, name = "STARMIE", genderRatio = 0xFF },
TYRANITAR = { index = 248, dex = 248, name = "TYRANITAR", genderRatio = 0x7F },
},
maps = {
[K.GEN2_MAP] = K.gen2Data.maps[K.GEN2_MAP],
TEST_CITY = { group = 3, map = 1 },
TEST_CENTER = { group = 3, map = 4 },
TEST_CENTER_2F = { group = 20, map = 1 },
TEST_CAVE = { group = 7, map = 9 },
},
}
for id, def in pairs(G2.ITEMS) do DATA.items[id] = def end
DATA.items.MAX_POTION.canSelect = true
DATA.items.POTION.canSelect = true
DATA.items.BICYCLE.canSelect = true
local function symsFor(v) return Gen2State.symsFor(v) end
local function ctxFor(v) return { S = symsFor(v), crystal = v == "crystal" } end
local function import(v, bytes)
local save, err = Gen2Save.decode(bytes, v, DATA)
check(save ~= nil, v .. " import -- " .. tostring(err))
return save
end
local function export(v, save, template)
local out, err = Gen2Save.encode(save, v, template, DATA)
check(out ~= nil, v .. " export -- " .. tostring(err))
return out, err
end
local function u8(s, at) return s:byte(at + 1) end
local function regionBytes(s, spans)
local out = {}
for _, sp in ipairs(spans) do out[#out + 1] = s:sub(sp[1] + 1, sp[1] + sp[2]) end
return table.concat(out)
end
local function hex(s) return (s:gsub(".", function(c) return ("%02X"):format(c:byte()) end)) end
local function activeRegions(v)
local out = {}
for _, region in ipairs(P.REGIONS) do
local spans = P.spansFor(ctxFor(v), region.id)
if spans then out[#out + 1] = { id = region.id, spans = spans } end
end
return out
end
local function teamRow(b, base, win, mons)
B.fill(b, base, 0x62, 0)
B.put(b, base, win)
for i, m in ipairs(mons) do
local o = base + 1 + (i - 1) * 0x10
B.put(b, o, m[1])
B.be(b, o + 1, m[2], 2)
B.put(b, o + 3, m[3], m[4], m[5])
B.putGbName(b, o + 6, m[6], 10)
end
B.put(b, base + 1 + #mons * 0x10, 0xFF)
end
local PAINT = {}
PAINT.realistic = function(v)
return function(b, L)
local S = symsFor(v)
B.put(b, S.wSavedAtLeastOnce, 1)
B.put(b, S.wSpawnAfterChampion, 1)
B.put(b, S.wRadioTuningKnob, 16)
B.put(b, S.wLastDexMode, 2, 0)
B.put(b, S.wWhichRegisteredItem, 0x01, G2.ITEMS.POTION.index)
B.put(b, S.wHallOfFameCount, 2, 0)
teamRow(b, S.sHallOfFame, 2, { { 155, 0x1234, 0x9F, 0x6A, 50, "FLAME" }, { 25, 0x1234, 0xAA, 0xAA, 48, "SPARKY" } })
teamRow(b, S.sHallOfFame + 0x62, 1, { { 155, 0x1234, 0x9F, 0x6A, 45, "FLAME" } })
B.put(b, S.wTradeFlags, 0x05)
B.put(b, S.wMooMooBerries, 3)
B.put(b, S.wUndergroundSwitchPositions, 0x0F)
if S.wFarfetchdPosition then B.put(b, S.wFarfetchdPosition, 5) end
if S.wJackFightCount then
B.put(b, S.wJackFightCount, 1, 0, 2, 3, 0, 1, 4, 2)
B.put(b, S.wErinFightCount, 3)
end
if S.wCelebiEvent then B.put(b, S.wCelebiEvent, 0x04) end
B.put(b, S.wBikeFlags, 0x02)
B.put(b, S.wDecoBed, 3, 8, 13, 16, 22, 30, 31, 26)
B.put(b, S.wDigWarpNumber, 2, 3, 1, 1, 3, 4)
B.put(b, S.wLastSpawnMapGroup, 3, 1)
B.put(b, S.wWarpNumber, 2)
B.put(b, S.sMysteryGiftTrainerHouseFlag, 1)
B.putGbName(b, S.sMysteryGiftPartnerName, "KEN", 11)
B.put(b, S.sMysteryGiftTimer, 0, 7)
B.put(b, S.sMysteryGiftDecorationsReceived, 0x21)
B.be(b, S.sLinkBattleStats, 3, 2)
B.be(b, S.sLinkBattleStats + 2, 1, 2)
if S.sGSBallFlag then
B.put(b, S.sGSBallFlag, 0x0B)
B.put(b, S.sGSBallFlagBackup, 0x0B)
B.put(b, S.sCrystalData + 1, 21, 13, 0x12, 0x34, 0x56, 0x78)
B.put(b, S.sBattleTowerChallengeState, 2, 3, 5)
B.put(b, S.sBTTrainers, 4, 9, 12, 0xFF, 0xFF, 0xFF, 0xFF)
B.put(b, S.sBattleTowerSaveFileFlags, 3, G2.ITEMS.MAX_POTION.index)
B.put(b, S.sBTMonOfTrainers, 155, 158, 152, 25, 248, 121)
end
end
end
PAINT.glitch = function(v)
return function(b, L)
local S = symsFor(v)
B.put(b, S.wSavedAtLeastOnce, 0x07)
B.put(b, S.wSpawnAfterChampion, 0x09)
B.put(b, S.wRadioTuningKnob, 0x81)
B.put(b, S.wLastDexMode, 7, 0xAA)
B.put(b, S.wWhichRegisteredItem, 0x45, 0x99)
B.put(b, S.wHallOfFameCount, 0xC8, 0x5A)
teamRow(b, S.sHallOfFame, 0xC8, { { 252, 0xFFFF, 0x12, 0x34, 0, "AB" } })
B.put(b, S.sHallOfFame + 1 + 6 + 3, 0x7E, 0x00, 0x13)
B.put(b, S.sHallOfFame + 0x30, 0x66)
B.put(b, S.sHallOfFame + 0x62 * 3 + 5, 0x77)
B.put(b, S.wTradeFlags, 0xFF)
for i = 1, (S.wMooMooBerries - S.wTradeFlags - 1) do B.put(b, S.wTradeFlags + i, (i * 31) % 256) end
B.put(b, S.wMooMooBerries, 0xFE)
B.put(b, S.wUndergroundSwitchPositions, 0x80)
if v ~= "crystal" then B.put(b, S.wUndergroundSwitchPositions + 1, 0x11, 0x22) end
if S.wFarfetchdPosition then B.put(b, S.wFarfetchdPosition, 0x33, 0x44, 0x55) end
if S.wJackFightCount then
for i = 0, 27 do B.put(b, S.wJackFightCount + i, (i * 67 + 200) % 256) end
end
if S.wCelebiEvent then B.put(b, S.wCelebiEvent, 0xFB, 0x91) end
B.put(b, S.wBikeFlags, 0xF8, 0x3C)
B.put(b, S.wDecoBed, 0xFF, 0, 0xFE, 0, 0x80, 0x7F, 0x01, 0x99)
B.put(b, S.wDigWarpNumber, 0x90, 0x77, 0x66, 0x55, 0x77, 0x44, 0xDE, 0xAD, 0xBE)
B.put(b, S.wLastSpawnMapGroup, 0x77, 0x76)
if v ~= "crystal" then B.put(b, S.wLastSpawnMapGroup + 2, 0x12, 0x34) end
B.put(b, S.wWarpNumber, 0xEE)
for i = 0, (S.sBackupMysteryGiftItemEnd - S.sMysteryGiftData) - 1 do
B.put(b, S.sMysteryGiftData + i, (i * 53 + 7) % 256)
end
for i = 0, (S.sLinkBattleStatsEnd - S.sLinkBattleStats) - 1 do
B.put(b, S.sLinkBattleStats + i, (i * 17 + 3) % 256)
end
if S.sGSBallFlag then
B.put(b, S.sGSBallFlag, 0x05)
B.put(b, S.sGSBallFlagBackup, 0x0B)
B.put(b, S.sCrystalData + 1, 0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA)
for i = 0, 17 do B.put(b, S.sBattleTowerChallengeState + i, (i * 41 + 9) % 256) end
end
end
end
for _, v in ipairs(VERSIONS) do
for _, kind in ipairs({ "realistic", "glitch" }) do
local bytes = G2.build({ version = v, patch = PAINT[kind](v) })
local save = import(v, bytes)
if save then
local out = export(v, save, bytes)
local fresh1, fresh2
local copy = {}
for k, x in pairs(save) do copy[k] = x end
copy.rawImport = nil
fresh1 = export(v, copy, nil)
local back = fresh1 and import(v, fresh1)
fresh2 = back and export(v, back, nil)
for _, r in ipairs(activeRegions(v)) do
local label = ("%s %s %s"):format(v, kind, r.id)
local want = regionBytes(bytes, r.spans)
if out then eq(hex(regionBytes(out, r.spans)), hex(want), label .. ": R1 over the template is byte-identical") end
if fresh1 then eq(hex(regionBytes(fresh1, r.spans)), hex(want), label .. ": templateless export reproduces the cart bytes") end
if fresh2 then eq(hex(regionBytes(fresh2, r.spans)), hex(regionBytes(fresh1, r.spans)), label .. ": templateless export is a fixed point") end
end
end
end
end
for _, v in ipairs(VERSIONS) do
local S = symsFor(v)
local save = import(v, G2.build({ version = v, patch = PAINT.realistic(v) }))
local gs = v ~= "crystal"
eq(save.spawnAfterChampion, "SPAWN_LANCE", v .. " wSpawnAfterChampion 1 is SPAWN_LANCE")
eq(save.radioTuningKnob, 16, v .. " wRadioTuningKnob")
eq(save.lastDexMode, "A-Z", v .. " wLastDexMode DEXMODE_ABC")
same(save.registeredItem, { id = "POTION" }, v .. " wRegisteredItem")
eq(save.hallOfFame.count, 2, v .. " wHallOfFameCount")
eq(#save.hallOfFame.teams, 2, v .. " sHallOfFame rows up to the first zero win count")
eq(save.hallOfFame.entered, true, v .. " a counted Hall of Fame has been entered")
local m = save.hallOfFame.teams[1].mons[2]
eq(m.species, "PIKACHU", v .. " HoF species")
eq(m.nickname, "SPARKY", v .. " HoF nickname")
eq(m.level, 48, v .. " HoF level")
eq(m.otId, 0x1234, v .. " HoF ID")
eq(m.shiny, true, v .. " HoF shiny from DVs")
same(m.dvs, { attack = 10, defense = 10, speed = 10, special = 10, hp = 0 }, v .. " HoF DVs")
same(save.tradeFlags, { [0] = true, [2] = true }, v .. " wTradeFlags")
local mem = gs and { [0xD6A7] = 3, [0xD6A8] = 0x0F }
or { [0xD962] = 3, [0xD963] = 0x0F, [0xD964] = 5, [0xD9F2] = 1, [0xD9F5] = 3, [0xD9F8] = 4, [0xDA0D] = 3 }
if not gs then
eq(save.scriptMem[0xD9F3], nil, v .. " a zero fight count is not stored")
eq(P.wramOf(ctxFor(v), "wJackFightCount"), 0xD9F2, v .. " wJackFightCount derives to the scripts' $D9F2")
eq(P.wramOf(ctxFor(v), "wErinFightCount"), 0xDA0D, v .. " wErinFightCount derives to the scripts' $DA0D")
end
for addr, val in pairs(mem) do eq(save.scriptMem[addr], val, ("%s scriptMem $%04X"):format(v, addr)) end
local bike = gs and 24 or 25
eq(save.engineFlags[bike], true, v .. " BIKEFLAGS_ALWAYS_ON_BIKE_F is ENGINE_ALWAYS_ON_BIKE")
if not gs then eq(save.engineFlags[100], true, v .. " ENGINE_FOREST_IS_RESTLESS") end
same(save.decorations, { bed = 3, carpet = 8, plant = 13, poster = 16, console = 22,
leftOrnament = 30, rightOrnament = 31, bigDoll = 26 }, v .. " wDeco*")
same(save.backupWarp, { warp = 1, map = "TEST_CENTER" }, v .. " wBackupWarpNumber triple")
eq(save.blackoutMap, "TEST_CITY", v .. " wLastSpawnMapGroup pair")
same(save.mysteryGift, { trainerHouse = true, partnerName = "KEN" }, v .. " sMysteryGiftTrainerHouseFlag")
if gs then
eq(save.battleTower, nil, v .. " has no Battle Tower")
eq(save.crystal, nil, v .. " has no Crystal block")
else
eq(save.crystal.gsBall, "have", v .. " sGSBallFlag GS_BALL_AVAILABLE")
same(save.battleTower, {
challenge = 2, streak = 3, levelGroup = 5, trainers = { 4, 9, 12 }, saveFileFlags = 3,
reward = "MAX_POTION", best = 0, inChallenge = false, reentry = false,
prevTeams = { prev = { "CYNDAQUIL", "TOTODILE", "CHIKORITA" }, prevPrev = { "PIKACHU", "TYRANITAR", "STARMIE" } },
}, v .. " SRAM Battle Tower")
end
check(type(save[P.CARRIER]) == "table", v .. " carries the raw region bytes")
check(S.sHallOfFame ~= nil, v .. " sHallOfFame resolves through Gen2Syms")
end
for _, v in ipairs(VERSIONS) do
local save = import(v, G2.build({ version = v, patch = PAINT.glitch(v) }))
eq(save.spawnAfterChampion, nil, v .. " an unknown wSpawnAfterChampion is an ordinary continue")
eq(save.lastDexMode, "NEW", v .. " an unknown wLastDexMode reads as DEXMODE_NEW")
same(save.registeredItem, { id = 0x99 }, v .. " an unnamed registered item keeps its raw id")
eq(save.backupWarp, nil, v .. " an unknown backup map is not handed to the engine")
eq(save.blackoutMap, nil, v .. " an unknown whiteout map is not handed to the engine")
same(save.tradeFlags, v == "crystal" and { [0] = true, [1] = true, [2] = true, [3] = true, [4] = true, [5] = true, [6] = true }
or { [0] = true, [1] = true, [2] = true, [3] = true, [4] = true, [5] = true },
v .. " wTradeFlags bits past NUM_NPC_TRADES stay raw")
eq(save.hallOfFame.teams[1].mons[1].species, 252, v .. " an unknown HoF species keeps its number")
end
local function baseSave(v)
local s = Save.newGame({ playerName = "ASH", trainerId = 0x4242 })
s.version = v
s.position = { map = K.GEN2_MAP, x = 3, y = 4 }
s.inventory = { POTION = 3, BICYCLE = 1, POKE_BALL = 5 }
s.bagOrder = { "POTION", "BICYCLE", "POKE_BALL" }
return Save.normalize(s)
end
local function partyMon(species, level, nick, dvs, otId)
local mon = { species = species, level = level, nickname = nick, otId = otId, dvs = dvs }
dvs.hp = Mon.hpDV(dvs)
return Mon.syncIdentity(mon, DATA)
end
local function drive(script)
local vm = Vm.new({ ["s:t"] = script }, {}, Events.new(), {
showText = function(_, onDone) onDone() end, waitSfx = function() return true end,
})
vm:start("s:t")
for _ = 1, 200 do
if not vm:running() then break end
vm:update()
end
return vm
end
local function fakeWorld(save)
return setmetatable({ game = { save = save, data = DATA }, maps = DATA.maps }, { __index = World })
end
local function decoView(state)
local out = {}
for _, slot in ipairs(Decorations.SLOTS) do out[slot] = (state or {})[slot] or 0 end
return out
end
local ROSTER = {
sampleTrainers = 3,
trainers = { { index = 0, name = "A" }, { index = 1, name = "B" }, { index = 2, name = "C" } },
groups = { [1] = { { species = "CYNDAQUIL" }, { species = "TOTODILE" }, { species = "CHIKORITA" },
{ species = "PIKACHU" } } },
}
local function engineBuilt(v)
local save = baseSave(v)
local crystal = v == "crystal"
local party = {
partyMon("CYNDAQUIL", 50, "FLAME", { attack = 9, defense = 15, speed = 6, special = 10 }, 0x4242),
{ species = "TOTODILE", isEgg = true, level = 5, dvs = { attack = 1, defense = 1, speed = 1, special = 1 } },
partyMon("PIKACHU", 47, "SPARKY", { attack = 10, defense = 10, speed = 10, special = 10 }, 0x0101),
}
HallOfFame.induct(save, party)
HallOfFame.induct(save, { partyMon("TYRANITAR", 55, "ROCKY", { attack = 3, defense = 4, speed = 5, special = 6 }, 0x4242) })
HallOfFame.markRedCredits(save)
NpcTrade.markDone(save, 1)
NpcTrade.markDone(save, 4)
local deco = Decorations.state(save)
Decorations.apply(deco, 4)
Decorations.apply(deco, 8)
Decorations.apply(deco, 15)
Decorations.apply(deco, 30, "left")
Decorations.clearOtherSide(deco, 30, "left")
Decorations.apply(deco, 26)
check(World.registerItem(fakeWorld(save), "BICYCLE"), v .. " World:registerItem accepts the BICYCLE")
local w = fakeWorld(save)
World.setBlackoutMap(w, 3, 1)
World.recordWarpBackup(w, "TEST_CENTER", 2, { destWarp = 0xFF }, "TEST_CENTER_2F")
save.backupWarp = w.backupWarp
local S = symsFor(v)
local berries = crystal and 0xD962 or 0xD6A7
local switches = crystal and 0xD963 or 0xD6A8
local vm = drive({
{ op = "readmem", args = { switches % 256, math.floor(switches / 256) } },
{ op = "addval", args = { 3 } },
{ op = "writemem", args = { switches % 256, math.floor(switches / 256) } },
{ op = "setval", value = 6 },
{ op = "writemem", args = { berries % 256, math.floor(berries / 256) } },
{ op = "end" },
})
if crystal then
vm.mem[0xD964] = nil
local v2 = drive({ { op = "loadmem", args = { 0x64, 0xD9, 9 } }, { op = "loadmem", args = { 0xF2, 0xD9, 4 } },
{ op = "readmem", args = { 0x0D, 0xDA } }, { op = "addval", args = { 2 } },
{ op = "writemem", args = { 0x0D, 0xDA } }, { op = "end" } })
for k, x in pairs(v2.mem) do vm.mem[k] = x end
end
save.scriptMem = vm:serializeMem()
local oldPlay, oldStop = Music.play, Music.stop
Music.play, Music.stop = function() end, function() end
local gear = Pokegear.new({ save = save, data = {}, input = { wasPressed = function() return false end } },
{ clock = { hour = 14, minute = 0, weekday = 1 } })
gear.tuningKnob = 28
gear:tuneRadio()
Music.play, Music.stop = oldPlay, oldStop
save.lastDexMode = "OLD"
local bikeId = crystal and 25 or 24
World.setEngineFlag(fakeWorld(save), bikeId, true)
save.mysteryGift = { trainerHouse = true, partnerName = "RIVAL" }
if crystal then
World.setEngineFlag(fakeWorld(save), 100, true)
Save.crystalState(save).gsBall = "given"
local seq = { 2, 1, 3, 2, 1, 1 }
local i = 0
local function random(n) i = i + 1; return math.min(seq[i] or 1, n) end
BattleTower.resetTrainers(save)
BattleTower.setChallengeState(save, BattleTower.CHALLENGE_IN_PROGRESS)
BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_REGISTERED)
BattleTower.state(save).levelGroup = 4
BattleTower.chooseTrainer(save, ROSTER, random)
BattleTower.chooseTeam(save, ROSTER, 1, random)
BattleTower.beginBattle(save)
BattleTower.chooseTrainer(save, ROSTER, random)
BattleTower.chooseTeam(save, ROSTER, 1, random)
BattleTower.state(save).reward = "MAX_POTION"
end
return save
end
for _, v in ipairs(VERSIONS) do
local save = engineBuilt(v)
local out = export(v, save, nil)
local back = out and import(v, out)
if back then
local label = v .. " R2 "
eq(back.spawnAfterChampion, save.spawnAfterChampion, label .. "spawnAfterChampion")
eq(back.radioTuningKnob, save.radioTuningKnob, label .. "radioTuningKnob")
eq(back.lastDexMode, save.lastDexMode, label .. "lastDexMode")
same(back.registeredItem, save.registeredItem, label .. "registeredItem")
same(back.hallOfFame, save.hallOfFame, label .. "hallOfFame")
same(back.tradeFlags, save.tradeFlags, label .. "tradeFlags")
same(back.scriptMem, save.scriptMem, label .. "scriptMem")
for _, id in ipairs(v == "crystal" and { 24, 25, 26, 100 } or { 23, 24, 25 }) do
eq(back.engineFlags[id], save.engineFlags[id], label .. "engineFlags " .. id)
end
same(decoView(back.decorations), decoView(save.decorations), label .. "decorations")
same(back.backupWarp, save.backupWarp, label .. "backupWarp")
eq(back.blackoutMap, save.blackoutMap, label .. "blackoutMap")
same(back.mysteryGift, save.mysteryGift, label .. "mysteryGift")
if v == "crystal" then
check(Save.GS_BALL_STATES[back.crystal.gsBall], label .. "crystal.gsBall stays a GS Ball state")
local want = BattleTower.state(save)
local got = BattleTower.state(back)
for _, k in ipairs({ "challenge", "streak", "levelGroup", "saveFileFlags", "reward" }) do
eq(got[k], want[k], label .. "battleTower." .. k)
end
same(got.trainers, want.trainers, label .. "battleTower.trainers")
same(got.prevTeams, want.prevTeams, label .. "battleTower.prevTeams")
end
if v == "crystal" then same(back.battleTower, save.battleTower, label .. "battleTower whole") end
local r = require("tests.save_compat._gen2_reference").decode(out, v)
check(r.checksum1 and r.checksum2, label .. "the independent reader accepts both checksums")
local S = symsFor(v)
-- pokecrystal macros/ram.asm:242
eq(u8(out, S.sHallOfFame), 2, label .. "raw row 1 win count")
eq(u8(out, S.sHallOfFame + 1), 248, label .. "raw row 1 species")
eq(u8(out, S.sHallOfFame + 6), 55, label .. "raw row 1 level")
eq(u8(out, S.sHallOfFame + 17), 0xFF, label .. "raw row 1 terminator")
eq(u8(out, S.sHallOfFame + 0x62), 1, label .. "raw row 2 win count")
eq(u8(out, S.sHallOfFame + 0x62 + 17), 25, label .. "raw row 2 skips the egg")
eq(u8(out, S.sHallOfFame + 0x62 * 2), 0, label .. "raw row 3 absent")
local again = export(v, back, nil)
if again then
for _, r in ipairs(activeRegions(v)) do
eq(hex(regionBytes(again, r.spans)), hex(regionBytes(out, r.spans)), label .. r.id .. " fixed point")
end
end
end
end
for _, v in ipairs(VERSIONS) do
local S = symsFor(v)
local crystal = v == "crystal"
local bytes = G2.build({ version = v, patch = PAINT.realistic(v) })
local save = import(v, bytes)
save.radioTuningKnob = 40
save.lastDexMode = "OLD"
save.registeredItem = { id = "BICYCLE" }
HallOfFame.induct(save, { partyMon("TOTODILE", 61, "JAWS", { attack = 12, defense = 5, speed = 7, special = 2 }, 0xBEEF) })
HallOfFame.markRedCredits(save)
save.tradeFlags = { [1] = true, [4] = true }
save.scriptMem[crystal and 0xD962 or 0xD6A7] = 9
save.scriptMem[crystal and 0xD963 or 0xD6A8] = nil
local bike = crystal and 24 or 23
save.engineFlags[bike] = true
save.engineFlags[bike + 1] = nil
save.engineFlags[bike + 2] = true
if crystal then
save.engineFlags[100] = nil
save.scriptMem[0xD9F2] = 4
save.scriptMem[0xDA0D] = nil
end
save.decorations = { bed = 5, carpet = 0, plant = 12, poster = 17, console = 21,
leftOrnament = 0, rightOrnament = 33, bigDoll = 27 }
save.backupWarp = { warp = 3, map = "TEST_CAVE" }
save.blackoutMap = "TEST_CENTER_2F"
save.mysteryGift = { trainerHouse = false }
if crystal then
save.crystal.gsBall = nil
local tower = BattleTower.state(save)
tower.challenge, tower.streak, tower.levelGroup = 3, 7, 9
tower.trainers = { 1, 2, 3, 4, 5, 6, 7 }
tower.saveFileFlags, tower.reward = 1, "POTION"
tower.prevTeams = { prev = { "PIKACHU" }, prevPrev = {} }
end
local out = export(v, save, bytes)
if out then
local label = v .. " change "
-- pokecrystal engine/events/halloffame.asm:53
eq(u8(out, S.wSpawnAfterChampion), 2, label .. "SPAWN_RED is 2")
-- pokecrystal engine/pokegear/pokegear.asm:1399
eq(u8(out, S.wRadioTuningKnob), 40, label .. "knob byte")
-- pokecrystal engine/pokedex/pokedex.asm:61
eq(u8(out, S.wLastDexMode), 1, label .. "DEXMODE_OLD")
eq(u8(out, S.wLastDexMode + 1), 0, label .. "the byte after wLastDexMode is untouched")
-- pokecrystal engine/items/pack.asm:547
eq(u8(out, S.wWhichRegisteredItem), 0x81, label .. "KEY_ITEM_POCKET << 6 | slot 1")
eq(u8(out, S.wRegisteredItem), G2.ITEMS.BICYCLE.index, label .. "registered item id")
eq(u8(out, S.wHallOfFameCount), 3, label .. "wHallOfFameCount bumped")
local row = S.sHallOfFame
-- pokecrystal engine/events/halloffame.asm:137
eq(u8(out, row), 3, label .. "new row win count")
eq(u8(out, row + 1), 158, label .. "new row species")
eq(u8(out, row + 2) * 256 + u8(out, row + 3), 0xBEEF, label .. "new row ID big-endian")
eq(u8(out, row + 4), 0xC5, label .. "new row DV byte 1")
eq(u8(out, row + 5), 0x72, label .. "new row DV byte 2")
eq(u8(out, row + 6), 61, label .. "new row level")
eq(u8(out, row + 7), 0x89, label .. "new row nickname J")
eq(u8(out, row + 11), 0x50, label .. "new row nickname terminator")
eq(u8(out, row + 17), 0xFF, label .. "new row ends in -1")
eq(u8(out, row + 0x62), 2, label .. "AddHallOfFameEntry shifted the old first row down")
eq(hex(out:sub(row + 0x62 + 1, row + 0x62 * 3)), hex(bytes:sub(row + 1, row + 0x62 * 2)),
label .. "the old rows moved whole")
eq(u8(out, S.wTradeFlags), 0x12, label .. "trade bits 1 and 4")
eq(u8(out, S.wMooMooBerries), 9, label .. "wMooMooBerries")
eq(u8(out, S.wUndergroundSwitchPositions), 0, label .. "wUndergroundSwitchPositions cleared")
-- pokecrystal constants/ram_constants.asm:311
eq(u8(out, S.wBikeFlags), 0x05, label .. "strength and downhill bits")
if crystal then
eq(u8(out, S.wCelebiEvent), 0x00, label .. "forest no longer restless")
eq(u8(out, S.wJackFightCount), 4, label .. "scriptMem[$D9F2] is wJackFightCount")
eq(u8(out, S.wErinFightCount), 0, label .. "a cleared $DA0D zeroes wErinFightCount")
eq(u8(out, S.wJackFightCount + 2), 2, label .. "an untouched fight count keeps its byte")
end
eq(hex(out:sub(S.wDecoBed + 1, S.wDecoBed + 8)), "05000C111500211B", label .. "wDeco* in ram order")
eq(hex(out:sub(S.wDigWarpNumber + 1, S.wDigWarpNumber + 6)), "030709030709", label .. "both warp triples")
eq(hex(out:sub(S.wLastSpawnMapGroup + 1, S.wLastSpawnMapGroup + 2)), "1401", label .. "wLastSpawnMap pair")
eq(u8(out, S.sMysteryGiftTrainerHouseFlag), 0, label .. "trainer house flag cleared")
eq(hex(out:sub(S.sMysteryGiftTimer + 1, S.sMysteryGiftTimer + 2)), "0007", label .. "the rest of the gift block is kept")
if crystal then
eq(u8(out, S.sGSBallFlag), 0, label .. "sGSBallFlag")
eq(u8(out, S.sGSBallFlagBackup), 0, label .. "sGSBallFlagBackup")
eq(hex(out:sub(S.sBattleTowerChallengeState + 1, S.sBattleTowerChallengeState + 18)),
"030709010203040506070112190000000000", label .. "SRAM Battle Tower in sram.asm order")
end
local back = import(v, out)
if back then
eq(back.hallOfFame.count, 3, label .. "reimported count")
eq(back.hallOfFame.teams[1].mons[1].nickname, "JAWS", label .. "reimported row")
end
end
end
for _, v in ipairs(VERSIONS) do
local S = symsFor(v)
local bytes = G2.build({ version = v, patch = PAINT.realistic(v),
items = { { G2.ITEMS.REPEL.index, 1 }, { G2.ITEMS.POTION.index, 3 } } })
local save = import(v, bytes)
save.registeredItem = { id = "POTION" }
local out = export(v, save, bytes)
eq(u8(out, S.wWhichRegisteredItem), 0x02, v .. " the POTION is in slot 2 now")
save.inventory.POTION = nil
save.bagOrder = { "REPEL" }
out = export(v, save, bytes)
eq(u8(out, S.wWhichRegisteredItem), 0x01, v .. " a used-up registered item keeps the cart's stale slot")
eq(u8(out, S.wRegisteredItem), G2.ITEMS.POTION.index, v .. " and its id")
end
local SWEEP = {
gs = {
[0xCE51] = "wOtherPlayerLinkMode", [0xD0D8] = "wStrengthSpecies", [0xD117] = "wTempWildMonSpecies",
[0xD6A7] = "wMooMooBerries", [0xD6A8] = "wUndergroundSwitchPositions",
},
crystal = {
[0xCF51] = "wOtherPlayerLinkMode", [0xCF64] = "wNrOfBeatenBattleTowerTrainers",
[0xD1EF] = "wStrengthSpecies", [0xD22E] = "wTempWildMonSpecies",
[0xD962] = "wMooMooBerries", [0xD963] = "wUndergroundSwitchPositions", [0xD964] = "wFarfetchdPosition",
},
}
do
local names = { "wJackFightCount", "wBeverlyFightCount", "wHueyFightCount", "wGavenFightCount",
"wBethFightCount", "wJoseFightCount", "wReenaFightCount", "wJoeyFightCount", "wWadeFightCount",
"wRalphFightCount", "wLizFightCount", "wAnthonyFightCount", "wToddFightCount", "wGinaFightCount",
"wIrwinFightCount", "wArnieFightCount", "wAlanFightCount", "wDanaFightCount", "wChadFightCount",
"wDerekFightCount", "wTullyFightCount", "wBrentFightCount", "wTiffanyFightCount", "wVanceFightCount",
"wWiltonFightCount", "wKenjiFightCount", "wParryFightCount", "wErinFightCount" }
for i, name in ipairs(names) do SWEEP.crystal[0xD9F2 + i - 1] = name end
end
local home = os.getenv("HOME") or ""
local CACHES = {
gold = os.getenv("GOLD_CACHE") or home .. "/Library/Application Support/LOVE/gold-bsa0925-gameplay/gold",
silver = os.getenv("SILVER_CACHE") or home .. "/Library/Application Support/LOVE/pokemon-love2d/silver",
crystal = os.getenv("CRYSTAL_CACHE") or home .. "/Library/Application Support/LOVE/crystal-bsa0925-gameplay/crystal",
}
local MEM_OPS = { readmem = true, writemem = true, loadmem = true }
local function cacheScripts(dir)
local out, found = {}, false
for _, f in ipairs({ "scripts.lua", "std_scripts.lua" }) do
local fn = loadfile(dir .. "/data/generated/" .. f)
if fn then found = true; out[#out + 1] = fn() end
end
return found and out or nil
end
local function walkOps(roots, fn)
local seen = {}
local function walk(t)
if type(t) ~= "table" or seen[t] then return end
seen[t] = true
if type(t.op) == "string" then fn(t) end
for _, x in pairs(t) do walk(x) end
end
for _, r in ipairs(roots) do walk(r) end
end
for _, v in ipairs(VERSIONS) do
local ctx = ctxFor(v)
local fam = v == "crystal" and "crystal" or "gs"
local lo, hi = P.savedWram(ctx, G2.layout(v))
local mapped = P.scriptMemAddresses(ctx)
for addr, name in pairs(SWEEP[fam]) do
local label = ("%s $%04X %s"):format(v, addr, name)
if addr >= lo and addr < hi then
eq(mapped[addr], name, label .. " is in the saved block and scriptMem maps it to its label")
local save = baseSave(v)
save.scriptMem[addr] = 0x5C
local out = export(v, save, nil)
if out then
eq(u8(out, ctx.S[name]), 0x5C, label .. " lands on the label's file byte")
local back = import(v, out)
eq(back and back.scriptMem[addr], 0x5C, label .. " round-trips through scriptMem")
end
else
check(addr < lo or addr >= hi, label .. " is session WRAM outside sGameData")
eq(mapped[addr], nil, label .. " is not written to the cartridge")
end
end
for addr in pairs(mapped) do
check(SWEEP[fam][addr] ~= nil, ("%s $%04X: every mapped scriptMem address is in the sweep"):format(v, addr))
end
check(BattleTower.WRAM_NR_BEATEN < lo or BattleTower.WRAM_NR_BEATEN >= hi or v ~= "crystal",
v .. " the Battle Tower's direct vm.mem byte is session WRAM")
local roots = CACHES[v] and cacheScripts(CACHES[v])
if roots then
local mems, warpmods = 0, 0
walkOps(roots, function(cmd)
if MEM_OPS[cmd.op] and cmd.args then
mems = mems + 1
local addr = (cmd.args[1] or 0) + (cmd.args[2] or 0) * 256
check(SWEEP[fam][addr] ~= nil, ("%s cache %s $%04X is in the enumerated sweep"):format(v, cmd.op, addr))
elseif cmd.op == "warpmod" then
warpmods = warpmods + 1
end
end)
check(mems > 0, v .. " the cache sweep saw the script memory ops")
eq(warpmods, 0, v .. " no extracted script runs warpmod, so save.warpMod is never written")
else
print(("[skip] %s script cache absent: the enumerated sweep still ran"):format(v))
end
end
do
local count = 0
local home = "../"
for _, repo in ipairs({ "pokegold", "pokecrystal" }) do
local p = io.popen(("grep -rln '\\bwarpmod\\b' '%s%s/maps' 2>/dev/null"):format(home, repo))
if p then
for _ in p:lines() do count = count + 1 end
p:close()
end
end
eq(count, 0, "no pret map script uses warpmod")
end
for _, v in ipairs(VERSIONS) do
local save = baseSave(v)
save.backupWarp = { warp = 2, map = "TEST_CENTER" }
save.warpMod = { warp = 2, map = "TEST_CENTER", group = 3, mapNumber = 4 }
local out = export(v, save, nil)
check(out ~= nil, v .. " a warpMod the backup triple already holds exports")
save.warpMod = { warp = 1, map = "TEST_CITY", group = 3, mapNumber = 1 }
local bad, err = Gen2Save.encode(save, v, nil, DATA)
check(bad == nil and tostring(err):find("warpmod", 1, true) ~= nil,
v .. " a warpMod the backup triple does not hold is refused -- " .. tostring(err))
end
for _, v in ipairs(VERSIONS) do
local function refused(edit, fragment, label)
local save = baseSave(v)
edit(save)
local out, err = Gen2Save.encode(save, v, nil, DATA)
check(out == nil and tostring(err):find(fragment, 1, true) ~= nil, v .. " refuses " .. label .. " -- " .. tostring(err))
end
refused(function(s) s.blackoutMap = "NOWHERE" end, "whiteout map", "an unknown whiteout map")
refused(function(s) s.backupWarp = { warp = 1, map = "NOWHERE" } end, "backup warp map", "an unknown backup map")
refused(function(s) s.tradeFlags = { [9] = true } end, "NPC trade 9", "a trade past NUM_NPC_TRADES")
refused(function(s) s.radioTuningKnob = 300 end, "radio tuning knob", "a knob past a byte")
refused(function(s) s.lastDexMode = "UNOWN" end, "Pokedex mode", "a dex mode the cart has no byte for")
refused(function(s) s.spawnAfterChampion = "SPAWN_HOME" end, "post-champion spawn", "an unknown post-champion spawn")
refused(function(s)
local teams = {}
for i = 1, 31 do teams[i] = { winCount = i, mons = {} } end
s.hallOfFame = { count = 31, teams = teams }
end, "Hall of Fame holds 31", "a 31-team roster")
refused(function(s) s.hallOfFame = { count = 1, teams = { { winCount = 0, mons = {} } } } end,
"win count of 0", "a zero win count")
if v == "crystal" then
refused(function(s) Save.crystalState(s).gsBall = "maybe" end, "GS Ball state", "an unknown GS Ball state")
refused(function(s) BattleTower.state(s).trainers = { [9] = 1 } end, "trainer slot 9", "a Battle Tower slot past 7")
end
end
do
local files = {}
local p = io.popen("find src -name '*.lua'")
for line in p:lines() do files[#files + 1] = line end
p:close()
check(#files > 100, "the source scan found the engine")
local PATTERNS = {
{ "savedAtLeastOnce", "[%w_%.]*[Ss]avedAtLeastOnce%s*=" },
{ "warpNumber", "save%.warpNumber%s*=" },
{ "linkBattleStats", "save%.linkBattle[%w_]*%s*=" },
{ "linkBattleStats", "linkBattleStats%s*=" },
{ "linkBattleStats", "linkBattleRecords?%s*=" },
{ "linkBattleStats", "save%.[%w_]*[Ww]ins%s*=" },
{ "linkBattleStats", "save%.[%w_]*[Ll]osses%s*=" },
{ "linkBattleStats", "save%.[%w_]*[Dd]raws%s*=" },
{ "crystalData", "playerAge%s*=" },
{ "crystalData", "prefecture%s*=" },
{ "crystalData", "postalCode%s*=" },
}
local SKIP = { "^src/save_convert/", "^src/core/game3/", "^src/import/gba/", "^src/ui/game3/",
"^src/save_convert/gen3", "^src/core/Game3" }
for _, path in ipairs(files) do
local skip = false
for _, pat in ipairs(SKIP) do if path:find(pat) then skip = true end end
if not skip then
local f = io.open(path, "r")
local body = f:read("*a")
f:close()
for _, row in ipairs(PATTERNS) do
local at = body:find(row[2])
check(at == nil, ("%s: %s writes %s state at byte %s"):format(row[1], path, row[1], tostring(at)))
end
end
end
for _, name in ipairs(REGION_NAMES) do check(type(name) == "string", "region " .. name) end
local ids = {}
for _, region in ipairs(P.REGIONS) do ids[region.id] = region.read == nil end
eq(ids.savedAtLeastOnce, true, "wSavedAtLeastOnce is static")
eq(ids.warpNumber, true, "wWarpNumber is static")
eq(ids.linkBattleStats, true, "sLinkBattleStats is static")
eq(ids.crystalProfile, true, "sCrystalData's mobile profile is static")
end
for _, v in ipairs(VERSIONS) do
local S = symsFor(v)
local fam = v == "crystal" and "crystal" or "gs"
local runs = {}
for i, row in ipairs(P.coverage) do
local r = row.both or row[fam]
if r then
local from = S[r[1]]
local to = r.len and from and from + r.len or S[r[2]]
check(from ~= nil and to ~= nil and to > from, ("%s coverage row %d resolves"):format(v, i))
runs[#runs + 1] = { from or 0, to or 0, i }
check(row.mode == "modeled" or (row.mode == "static" and type(row.why) == "string"),
("%s coverage row %d has a mode"):format(v, i))
end
end
table.sort(runs, function(a, b) return a[1] < b[1] end)
for i = 2, #runs do
check(runs[i][1] >= runs[i - 1][2], ("%s coverage rows %d and %d do not overlap"):format(v, runs[i - 1][3], runs[i][3]))
end
for _, run in ipairs(runs) do
check(run[1] >= symsFor(v).wGameData and run[2] <= G2.layout(v).sGameDataEnd,
("%s coverage row %d sits inside sGameData"):format(v, run[3]))
end
end
T.finish()