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

402 lines
18 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local K = require("tests.save_compat._codec")
local D = require("tests.save_compat._gen3_decode")
local G3 = require("tests.fixtures.save.gen3_build")
local B = require("tests.fixtures.save.bytes")
local Compat = require("src.save_convert.Compat")
local Gen3Save = require("src.save_convert.Gen3Save")
local SaveConvert = require("src.save_convert.SaveConvert")
local VERSIONS = { "firered", "leafgreen", "emerald" }
local FAMILY = { firered = "frlg", leafgreen = "frlg", emerald = "emerald" }
local fixtures = {}
for _, c in ipairs(G3.cases()) do fixtures[c.id] = c end
local function fx(v, name) return fixtures["g3." .. v .. "." .. name].bytes end
local function import(v, bytes) return assert(K.import(3, v, bytes)) end
local function fresh(v, bytes)
local s = import(v, bytes)
s.modData.cartImage = nil
return s
end
local function errors(bytes, v) return #Compat.check(bytes, v).errors end
local function w32(s, o) return s:byte(o + 1) + s:byte(o + 2) * 256 + s:byte(o + 3) * 65536 + s:byte(o + 4) * 16777216 end
local function build(v, edit)
local w = G3.base(v)
if edit then edit(w) end
return G3.emit(w)
end
local function player(v)
local w = G3.base(v)
return B.getLE(w.sb2, 0x0A, 2), B.getLE(w.sb2, 0x0C, 2)
end
for _, v in ipairs(VERSIONS) do
local fam = FAMILY[v]
local s = fresh(v, fx(v, "basic"))
s.encryptionKey, s.modData.cartKey = v == "emerald" and 0 or nil, nil
local out = assert(K.export(3, v, s, false))
local d = assert(D.decode(out, fam))
check(Gen3Save.forVersion(v).keyValid(d.key, d.powder), v .. " derived key is valid")
if fam == "frlg" then
eq(d.code, 1, v .. " SB2 0xAC is the FRLG marker")
check(d.powderWord ~= 0, v .. " SB2 0xAF8 is nonzero")
else
check(d.key ~= 0 and d.key ~= 1, v .. " SB2 0xAC is a real Emerald key")
end
eq(errors(out, v), 0, v .. " key-0 slot exports a reader-valid file")
eq(K.export(3, v, s, false), out, v .. " the derived key is deterministic")
eq(d.money, s.money, v .. " money is encrypted with the derived key")
local back = import(v, out)
eq(Gen3Save.forVersion(v).storedKey(back), d.key, v .. " import stores the key in the save")
local s2 = fresh(v, fx(v, "basic"))
s2.encryptionKey, s2.modData.cartKey = nil, nil
s2.meta = { playthroughId = "other-run" }
local d2 = D.decode(assert(K.export(3, v, s2, false)), fam)
check(d2.key ~= d.key, v .. " a different playthrough derives a different key")
local tid, sid = player(v)
local s3 = fresh(v, fx(v, "basic"))
s3.encryptionKey, s3.modData.cartKey = nil, nil
s3.berryPowder = Gen3Save.forVersion(v).deriveKey(tid, sid, nil, 0)
local d3 = D.decode(assert(K.export(3, v, s3, false)), fam)
check(d3.key ~= s3.berryPowder, v .. " the derived key never equals the berry powder")
check(d3.powderWord ~= 0, v .. " the powder word stays nonzero")
eq(d3.powder, s3.berryPowder, v .. " berry powder survives")
for _, bad in ipairs(fam == "frlg" and { 0 } or { 0, 1 }) do
local cart = build(v, function(w)
local o = w.F.key
local key = B.getLE(w.sb2, o, 4)
B.le(w.sb2, o, bad, 4)
w.key = bad
if fam == "emerald" then w.key = bad end
G3.setMoney(w, 4242)
G3.setPocket(w, 1, { { 13, 7 } })
for i = 0, 63 do B.le(w.sb1, w.F.gameStats + i * 4, i * 3 + key % 7, 4) end
end)
local a = D.decode(cart, fam)
local sv = import(v, cart)
local out2 = assert(K.export(3, v, sv, cart))
local b2 = D.decode(out2, fam)
check(b2.key ~= bad and Gen3Save.forVersion(v).keyValid(b2.key, b2.powder), ("%s template key %d is replaced"):format(v, bad))
eq(b2.money, 4242, v .. " re-keyed money")
eq(b2.pockets[1][1][2], 7, v .. " re-keyed bag quantity")
local statsSame = true
for i = 0, 63 do if a.stats[i] ~= b2.stats[i] then statsSame = false end end
check(statsSame, v .. " re-keyed game stats")
eq(errors(out2, v), 0, v .. " re-keyed export is reader-valid")
end
end
do
local rs = build("emerald", function(w)
for i = 0x890, w.sb2.size - 1 do w.sb2[i] = 0 end
for i = 0x3AC0, w.sb1.size - 1 do w.sb1[i] = 0 end
end)
eq(Gen3Save.sniff(rs), "rs", "an RS-shaped image sniffs as Ruby/Sapphire")
local s, err = SaveConvert.importSav(rs, "emerald", "emerald")
eq(s, nil, "Emerald refuses a Ruby/Sapphire save")
eq(err, Gen3Save.forVersion("emerald").MSG.rs, "the refusal names Ruby/Sapphire")
local s2, err2 = SaveConvert.importSav(rs, "firered", "firered")
eq(s2, nil, "FireRed refuses a Ruby/Sapphire save")
eq(err2, Gen3Save.MSG.rs, "FireRed names Ruby/Sapphire")
check(require("src.core.GameVersion").VERSIONS.ruby == nil, "no Ruby GameVersion exists")
end
do
local out = assert(K.export(3, "emerald", fresh("emerald", fx("emerald", "basic")), false))
local blk = Compat.gen3Blocks(out, "emerald")
eq(blk.sb2:byte(0xEE1 + 1), 0xFF, "opponentNames[0][0] is EOS")
eq(blk.sb2:byte(0xEE9 + 1), 0xFF, "opponentNames[1][0] is EOS")
end
for _, v in ipairs(VERSIONS) do
local junk = {}
for i = 1, 0x2000 do junk[i] = string.char((i * 37) % 256) end
local cart = fx(v, "basic")
cart = cart:sub(1, 0x1E000) .. table.concat(junk) .. cart:sub(0x20001)
local out = assert(K.export(3, v, import(v, cart), cart))
eq(out:sub(0x1E001, 0x20000), cart:sub(0x1E001, 0x20000), v .. " sectors 30/31 ride verbatim")
local rep = Compat.check(out, v)
eq(#rep.errors, 0, v .. " carried extra sectors are not an error")
local warned = false
for _, w in ipairs(rep.warnings) do if w.rule == "gen3.extraSector" then warned = true end end
check(warned, v .. " carried invalid extra sectors raise a validator warning")
local bare = assert(K.export(3, v, fresh(v, cart), false))
eq(bare:sub(0x1E001, 0x20000), string.rep("\255", 0x2000), v .. " a templateless export leaves sectors 30/31 blank")
end
for _, v in ipairs(VERSIONS) do
local cart = fx(v, "max_stacks")
local s = import(v, cart)
eq(Gen3Save.forVersion(v).slotTemplate(s), cart, v .. " import stores the cart image in the slot")
local viaSlot = assert(K.export(3, v, s, nil))
eq(viaSlot, assert(K.export(3, v, import(v, cart), cart)), v .. " the slot image is the same template as the file")
eq(Gen3Save.forVersion(v).readBlocks(viaSlot).counter, Gen3Save.forVersion(v).readBlocks(cart).counter + 1,
v .. " the slot-template export is the cart's next save")
local other = import(v, cart)
other.trainerId = (other.trainerId + 1) % 65536
local d = Gen3Save.forVersion(v).decode(assert(K.export(3, v, other, nil)))
eq(d.counter, 1, v .. " a slot image of another player is not used")
end
do
local tid, sid = player("firered")
local cart = build("firered", function(w)
local mons = {}
for i = 1, 3 do mons[i] = G3.mon({ pid = 0x7100 + i, tid = tid, sid = sid, origins = 5 + 5 * 128 + 4 * 2048 }) end
G3.setParty(w, mons)
for b = 1, 14 do for sl = 1, 30 do G3.setBoxMon(w, b, sl, nil) end end
end)
local s = SaveConvert.importSav(cart, "firered", "firered")
eq(s.modData.cartGame, "leafgreen", "a cart whose own mons were met in LeafGreen is marked LeafGreen")
local lg = SaveConvert.importSav(cart, "leafgreen", "leafgreen")
eq(lg.modData.cartGame, "leafgreen", "LeafGreen into LeafGreen")
local fr = SaveConvert.importSav(fx("firered", "basic"), "leafgreen", "leafgreen")
eq(fr.modData.cartGame, "firered", "a FireRed cart imported into LeafGreen stays marked FireRed")
end
do
local s = import("firered", fx("firered", "basic"))
s.map = "FR_VIRIDIAN_CITY"
local out, err = K.export(3, "firered", s, fx("firered", "basic"))
eq(out, nil, "a map with no layout id refuses even with a template")
eq(err, Gen3Save.MSG.noData, "the refusal asks for the ROM")
local s2 = import("firered", fx("firered", "basic"))
s2.x = s2.x + 1
local opts = K.gen3Opts("firered", fx("firered", "basic"))
opts.mapLayoutId = function() return nil end
local ok = assert(Gen3Save.forVersion("firered").exportPort(s2, opts))
local c = Gen3Save.decode(ok)
eq(c.posX, s2.x, "same map: the template layout id carries the new position")
end
do
local s = import("firered", fx("firered", "basic"))
s.party[1].speciesNumbering, s.party[1].species = "national", 999
local opts = K.gen3Opts("firered", nil)
opts.speciesFromNational = function() return nil end
local out, err = Gen3Save.forVersion("firered").exportPort(s, opts)
eq(out, nil, "an unconvertible species refuses the export")
check(type(err) == "string" and err:find("999", 1, true) and err:find("party slot 1", 1, true), "the error names the mon")
end
do
local cart = build("firered", function(w)
local r = 0x30D0
B.le(w.sb1, r, 31 + 7 * 32 + 3 * 1024 + 2 ^ 30, 4)
B.le(w.sb1, r + 4, 0xC8323459, 4)
B.le(w.sb1, r + 8, 245, 2)
B.le(w.sb1, r + 0x0A, 0xA4, 2)
B.put(w.sb1, r + 0x0C, 50, 0x40)
B.put(w.sb1, r + 0x13, 1)
local lr = 0xA98
local nm = G3.text("RIVAL", 8)
for i = 1, 8 do w.sb2[lr + i - 1] = nm[i] end
B.le(w.sb2, lr + 8, 4321, 2); B.le(w.sb2, lr + 10, 3, 2); B.le(w.sb2, lr + 12, 1, 2)
for i = 1, 4 do B.put(w.sb2, lr + i * 16, 0xFF) end
local tn = 0x3BA8
B.le(w.sb1, tn, 0x00011234, 4)
local nm2 = G3.text("FRIEND", 8)
for i = 1, 8 do w.sb1[tn + 3 + i] = nm2[i] end
end)
local s = import("firered", cart)
eq(s.roamer and s.roamer.species, 245, "FRLG roamer species imports")
eq(s.roamer.ivs.attack, 7, "roamer IVs use the engine's key names")
eq(s.roamer.active, true, "roamer active")
eq(s.linkBattleRecords[1].name, "RIVAL", "FRLG link battle record imports")
eq(s.linkBattleRecords[1].wins, 3, "link record wins")
eq(s.trainerNameRecords[1].name, "FRIEND", "FRLG trainer name record imports")
local out = assert(K.export(3, "firered", fresh("firered", cart), false))
local a, b = Compat.gen3Blocks(cart, "frlg"), Compat.gen3Blocks(out, "frlg")
eq(b.sb1:sub(0x30D1, 0x30DE), a.sb1:sub(0x30D1, 0x30DE), "templateless export keeps the roamer")
eq(b.sb1:byte(0x30D0 + 0x13 + 1), 1, "roamer stays active")
local again = import("firered", out)
eq(again.linkBattleRecords[1] and again.linkBattleRecords[1].name, "RIVAL", "templateless export keeps the link record")
eq(again.linkBattleRecords[1].wins + again.linkBattleRecords[1].losses * 100, 103, "link record counts survive")
eq(b.sb1:sub(0x3BA9, 0x3BAA) .. b.sb1:sub(0x3BAD, 0x3BB4), a.sb1:sub(0x3BA9, 0x3BAA) .. a.sb1:sub(0x3BAD, 0x3BB4),
"templateless export keeps the trainer name record")
local port = Gen3Save.forVersion("firered").port
check(port and type(port.finishImport) == "function", "FRLG has an import finisher")
local save = import("firered", fx("firered", "all_dex"))
local nat = {}
for i = 1, 386 do nat[i] = i end
port.finishImport(save, { national = { toSpecies = nat } })
check(save.dex.seen[1] and save.dex.owned[1], "FRLG Pokedex resolves at import")
eq(save.modData.cartImport.dexSeen, nil, "the deferred dex list is consumed")
end
for _, v in ipairs(VERSIONS) do
local cart = build(v, function(w) for b = 0, 13 do w.storage[0x83C2 + b] = (b * 5) % 16 end end)
local s = import(v, cart)
for b = 1, 14 do eq(s.storage.boxes[b].wallpaper, (b - 1) * 5 % 16 + 1, v .. " box " .. b .. " wallpaper w + 1") end
local out = Compat.gen3Blocks(assert(K.export(3, v, fresh(v, cart), false)), FAMILY[v])
for b = 0, 13 do eq(out.storage:byte(0x83C2 + b + 1), (b * 5) % 16, v .. " box " .. (b + 1) .. " wallpaper round trip") end
s = fresh(v, cart)
s.storage.boxes[3] = nil
s.storage.boxes[4].wallpaper = 18
local o2 = Compat.gen3Blocks(assert(K.export(3, v, s, false)), FAMILY[v])
eq(o2.storage:byte(0x83C2 + 2 + 1), 2, v .. " a sparse box takes the engine default wallpaper")
eq(o2.storage:byte(0x83C2 + 3 + 1), v == "emerald" and 16 or 15, v .. " an out-of-range wallpaper clamps to the last one")
end
for _, v in ipairs(VERSIONS) do
local s = fresh(v, fx(v, "full_party_statuses"))
s.party[1].cartExtra, s.party[1].nickname, s.party[1].otName = nil, "Ré", "Ädé"
local out = assert(K.export(3, v, s, false))
local blk = Compat.gen3Blocks(out, FAMILY[v])
local off = (FAMILY[v] == "emerald" and 0x238 or 0x38)
eq(blk.sb1:sub(off + 9, off + 18), string.char(0xCC, 0x1B, 0xFF) .. string.rep("\255", 7), v .. " nickname 0xFF padded with é")
eq(blk.sb1:sub(off + 0x15, off + 0x1B), string.char(0xF1, 0xD8, 0x1B, 0xFF, 0xFF, 0xFF, 0xFF), v .. " OT 0xFF padded with Ä")
local back = import(v, out)
eq(back.party[1].nickname, "Ré", v .. " Latin nickname decodes")
eq(back.party[1].otName, "Ädé", v .. " Latin OT decodes")
end
do
local codec = Gen3Save
local bad = {}
for c = 0, 0xF8 do
local s = string.char(c, 0xFF)
local again = codec.encodeString(codec.decodeString(s, 0, 2), 2, 0xFF)
if again ~= s then bad[#bad + 1] = ("%02X"):format(c) end
end
eq(table.concat(bad, " "), "", "every name byte 0x00-0xF8 survives decode then encode")
end
do
local cart = fx("emerald", "basic")
local s = import("emerald", cart)
local before = Gen3Save.forVersion("emerald").decode(cart)
if before.player then
s.facing = before.player.facing == 2 and "left" or "up"
local out = assert(K.export(3, "emerald", s, cart))
local after = Gen3Save.forVersion("emerald").decode(out)
eq(require("src.save_convert.gen3_layouts.common").FACING[after.player.facing], s.facing, "the player's facing reaches the cart")
end
local n = fresh("emerald", cart)
n.dex = n.dex or {}
n.dex.national = true
local d = Gen3Save.forVersion("emerald").decode(assert(K.export(3, "emerald", n, false)))
eq(d.dexMode, 1, "unlocking the national dex sets DEX_MODE_NATIONAL")
end
for _, c in ipairs(G3.cases()) do
if not c.refuse then
local out = K.export(3, c.version, import(c.version, c.bytes), c.bytes)
local mine = D.decode(out, FAMILY[c.version])
local src = D.decode(c.bytes, FAMILY[c.version])
local theirs = Gen3Save.forVersion(c.version).decode(out)
local diffs = D.diff(src, mine)
local allowed = { [".counter"] = true }
local real = {}
for _, p in ipairs(diffs) do if not allowed[p] then real[#real + 1] = p end end
eq(table.concat(real, " "), "", c.id .. " independent decode of the export equals the source")
eq(mine.money, theirs.money, c.id .. " codec and independent decoder agree on money")
eq(mine.key, theirs.encryptionKey, c.id .. " agree on key")
eq(#mine.party, #theirs.party, c.id .. " agree on party size")
end
end
local seed = tonumber(os.getenv("SAVE_COMPAT_SEED")) or 3
local N = tonumber(os.getenv("SAVE_COMPAT_FUZZ_N")) or 25
local function rng()
seed = (seed * 1103515245 + 12345) % 2147483648
return seed
end
for _, v in ipairs(VERSIONS) do
local base = fx(v, "full_party_statuses")
local raised = 0
for i = 1, N do
local bytes = base
for _ = 1, 1 + rng() % 64 do
local at = rng() % #bytes
bytes = bytes:sub(1, at) .. string.char(rng() % 256) .. bytes:sub(at + 2)
end
local cut = ({ #bytes, 0x1FFFF, 0x10000, 0x20040, 100, 0x20010 })[i % 6 + 1]
if cut < #bytes then bytes = bytes:sub(1, cut) else bytes = bytes .. string.rep("\0", cut - #bytes) end
local ok, save = pcall(SaveConvert.importSav, bytes, v, v)
if not ok then raised = raised + 1
elseif save then
local ok2 = pcall(SaveConvert.exportSav, save, v, bytes)
if not ok2 then raised = raised + 1 end
local ok3 = pcall(Gen3Save.forVersion(v).exportPort, save, K.gen3Opts(v, bytes))
if not ok3 then raised = raised + 1 end
end
end
eq(raised, 0, v .. " corruption and truncation never raise a Lua error")
local sealedRaised, sealedImported = 0, 0
for _ = 1, N do
local cart = build(v, function(w)
for _ = 1, 1 + rng() % 200 do
local blk = ({ w.sb2, w.sb1, w.storage })[rng() % 3 + 1]
blk[rng() % blk.size] = rng() % 256
end
end)
local ok, save = pcall(SaveConvert.importSav, cart, v, v)
if not ok then sealedRaised = sealedRaised + 1
elseif save then
sealedImported = sealedImported + 1
local ok2, out = pcall(Gen3Save.forVersion(v).exportPort, save, K.gen3Opts(v, cart))
if not ok2 then sealedRaised = sealedRaised + 1
elseif out then
local ok3 = pcall(SaveConvert.importSav, out, v, v)
if not ok3 then sealedRaised = sealedRaised + 1 end
end
end
end
eq(sealedRaised, 0, v .. " checksum-valid corrupted sections never raise a Lua error")
check(sealedImported > 0, v .. " the sealed fuzz reaches the decoder")
local fam = FAMILY[v]
local mism = 0
for _ = 1, N do
local money, coins = rng() % 1000000, rng() % 10000
local cart = build(v, function(w)
G3.setMoney(w, money)
B.le(w.sb1, w.F.coins, require("bit").bxor(coins, w.key % 65536) % 65536, 2)
end)
local d = D.decode(assert(K.export(3, v, import(v, cart), cart)), fam)
if d.money ~= money or d.coins ~= coins then mism = mism + 1 end
end
eq(mism, 0, v .. " mutated modeled fields reproduce through import/export")
local carried = 0
local region = fam == "frlg" and { "sb1", 0x3120, 0x300 } or { "sb1", 0x322C, 0x300 }
for _ = 1, N do
local at, val = region[2] + rng() % region[3], rng() % 256
local cart = build(v, function(w) w[region[1]][at] = val end)
local out = Compat.gen3Blocks(assert(K.export(3, v, import(v, cart), cart)), fam)
if out[region[1]]:byte(at + 1) == val then carried = carried + 1 end
end
eq(carried, N, v .. " mutated unmodeled bytes are carried through")
local wrong = 0
for _ = 1, N do
local s = fresh(v, fx(v, "basic"))
s.money = rng() % 1000000
for _, m in ipairs(s.party) do
m.personality = rng() * 2 % 4294967296
m.exp = rng() % 100000
end
local out = assert(K.export(3, v, s, false))
local d = D.decode(out, fam)
if d.money ~= s.money or errors(out, v) ~= 0 then wrong = wrong + 1 end
for i, m in ipairs(s.party) do
if not d.party[i] or d.party[i].pid ~= m.personality or d.party[i].exp ~= m.exp or not d.party[i].checksumOk then
wrong = wrong + 1
end
end
end
eq(wrong, 0, v .. " random models export to what an independent decoder reads back")
end
T.finish()