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

222 lines
11 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 H = require("tests.save_compat._gen3_sections")
local Layout = require("src.save_convert.Gen3Layout")
local VERSIONS = { "firered", "leafgreen", "emerald" }
for _, v in ipairs(VERSIONS) do
local fam = H.family(v)
local cart = H.cart(v, function(w)
G3.setParty(w, {
G3.mon({ moves = { 10, 0, 45, 35 }, pp = { 35, 7, 40, 25 }, nick = "GAP" }),
G3.mon({ moves = { 0, 0, 0, 0 }, pp = { 0, 0, 0, 0 }, pid = 0x5151, nick = "NONE" }),
G3.mon({ moves = { 10, 45, 0, 0 }, pp = { 35, 40, 9, 0 }, pid = 0x6161, nick = "JUNKPP" }),
G3.mon({ moves = { 10, 45, 0, 0 }, pp = { 35, 40, 0, 0 }, pid = 0x7171, nick = "PLAIN", status = 0x80 + 5 * 256 }),
})
end)
local save = H.import(v, cart)
eq(#K.r1Diff(3, v, cart, H.withTemplate(v, save, cart)), 0, v .. ": move slot gaps and stray PP survive the template round trip")
local fresh = D.deep(H.fresh(v, H.import(v, cart)), fam)
eq(table.concat(fresh.party[1].moves, ","), "10,0,45,35", v .. ": a gap mon keeps its slot layout without a template")
eq(table.concat(fresh.party[1].pp, ","), "35,7,40,25", v .. ": the PP of every slot is kept")
eq(table.concat(fresh.party[3].pp, ","), "35,40,9,0", v .. ": PP stored for an empty slot is kept")
eq(fresh.party[4].status, 0x80 + 5 * 256, v .. ": the toxic counter is kept")
local edited = H.import(v, cart)
edited.party[1].moves[#edited.party[1].moves + 1] = 33
edited.party[1].pp[#edited.party[1].pp + 1] = 35
local after = D.deep(H.fresh(v, edited), fam)
eq(table.concat(after.party[1].moves, ","), "10,45,35,33", v .. ": a changed move list compacts instead of resurrecting the old layout")
eq(table.concat(after.party[1].pp, ","), "35,40,25,35", v .. ": a changed move list carries its own PP")
end
for _, v in ipairs(VERSIONS) do
local L = Layout.forVersion(v)
local bytes = H.cart(v)
local out = H.fresh(v, H.import(v, bytes))
local blocks = H.blocks(out, v)
local party = L.PARTY_OFFSET
local emptyMail = true
for i = 0, L.PARTY_SIZE - 1 do
local count = blocks.sb1:byte(L.PARTY_COUNT_OFFSET and L.PARTY_COUNT_OFFSET + 1 or (v == "emerald" and 0x234 or 0x34) + 1)
if i >= count and blocks.sb1:byte(party + i * 100 + 85 + 1) ~= 0xFF then emptyMail = false end
end
check(emptyMail, v .. ": unused party slots carry MAIL_NONE like ZeroMonData (src/pokemon.c)")
local M = L.MAIL
local clear = true
local save = H.import(v, bytes)
save.mail = {}
local cleared = H.blocks(H.fresh(v, save), v)
for i = 0, M.count - 1 do
local o = M.off + i * M.size
for k = 0, M.playerNameLength - 1 do
if cleared.sb1:byte(o + M.playerName + k + 1) ~= 0xFF then clear = false end
end
for k = 0, M.wordCount - 1 do
local w = cleared.sb1:byte(o + k * 2 + 1) + cleared.sb1:byte(o + k * 2 + 2) * 256
if w ~= L.EC_WORD_UNDEFINED then clear = false end
end
if cleared.sb1:byte(o + M.itemId + 1) ~= 0 or cleared.sb1:byte(o + M.itemId + 2) ~= 0 then clear = false end
end
check(clear, v .. ": an empty mail record is exactly what ClearMail writes (src/mail_data.c:19)")
end
for _, v in ipairs(VERSIONS) do
local fam = H.family(v)
local cart = H.cart(v, function(w)
local key = w.key
local base = w.F.gameStats
local B = require("tests.fixtures.save.bytes")
for id, val in pairs({ [23] = 7, [24] = 3, [25] = 1, [5] = 9 }) do
B.le(w.sb1, base + id * 4, require("bit").bxor(val, key) % 4294967296, 4)
end
end)
local save = H.import(v, cart)
eq(save.gameStats[23], 7, v .. ": the cart's link wins import as game stat 23")
eq(save.gameStats.linkBattleWins, 7, v .. ": the engine's named link win counter starts from the cart value")
eq(save.gameStats.linkBattleLosses, 3, v .. ": the engine's named link loss counter starts from the cart value")
eq(save.gameStats.linkBattleDraws, 1, v .. ": the engine's named link draw counter starts from the cart value")
save.gameStats.linkBattleWins = 8
save.gameStats.linkBattleDraws = nil
local out = D.deep(H.withTemplate(v, save, cart), fam)
eq(out.stats[23], 8, v .. ": a named link win counter bumped by the engine reaches stat 23")
eq(out.stats[24], 3, v .. ": an untouched link loss counter keeps its value")
eq(out.stats[25], 1, v .. ": a missing named counter falls back to the numeric stat")
eq(out.stats[5], 9, v .. ": other game stats are unaffected")
local named = H.import(v, cart)
named.gameStats = { linkBattleWins = 4 }
local only = D.deep(H.fresh(v, named), fam)
eq(only.stats[23], 4, v .. ": a save table with only the named counter exports it")
end
for _, v in ipairs({ "firered", "leafgreen" }) do
local B = require("tests.fixtures.save.bytes")
local rng = H.rng(77)
local expect = {}
local cart = H.cart(v, function(w)
for i = 0, 15 do
local pick, flavor, unk = rng(3), rng(4096), rng(4)
expect[i + 1] = { pickState = pick, flavorTextFlags = flavor, unk = unk }
B.le(w.sb1, 0x3A54 + i * 4, pick + flavor * 4 + unk * 16384, 2)
B.le(w.sb1, 0x3A54 + i * 4 + 2, 0xA000 + i, 2)
end
end)
local save = H.import(v, cart)
local fc = save.modData.fameChecker
local same = type(fc) == "table"
for i = 1, 16 do
same = same and fc[i] and fc[i].pickState == expect[i].pickState and fc[i].flavorTextFlags == expect[i].flavorTextFlags
end
check(same, v .. ": the Fame Checker is read at the 4 byte stride of struct FameCheckerSaveData (include/global.h:631)")
eq(#K.r1Diff(3, v, cart, H.withTemplate(v, save, cart)), 0, v .. ": the Fame Checker survives the template round trip")
save.modData.fameChecker[3].pickState = 3
save.modData.fameChecker[3].flavorTextFlags = 0x155
local out = H.blocks(H.withTemplate(v, save, cart), v).sb1
local w3 = out:byte(0x3A54 + 8 + 1) + out:byte(0x3A54 + 8 + 2) * 256
eq(w3 % 4, 3, v .. ": an edited pick state lands in entry 3")
eq(math.floor(w3 / 4) % 4096, 0x155, v .. ": an edited flavor mask lands in entry 3")
eq(out:byte(0x3A54 + 8 + 3) + out:byte(0x3A54 + 8 + 4) * 256, 0xA002, v .. ": the upper half of the entry is untouched")
eq(out:byte(0x3A54 + 4 + 3) + out:byte(0x3A54 + 4 + 4) * 256, 0xA001, v .. ": neighbouring entries are untouched")
local fresh = H.blocks(H.fresh(v, H.import(v, cart)), v).sb1
eq(fresh:byte(0x3A54 + 4 + 3) + fresh:byte(0x3A54 + 4 + 4) * 256, 0, v .. ": a templateless export leaves the upper halves zero")
end
for _, v in ipairs(VERSIONS) do
local fam = H.family(v)
local F = G3.F[fam]
local cart = H.cart(v, function(w)
w.sb2[0x28 + 51] = 0xFF
w.sb2[0x5C + 51] = 0xFF
w.sb1[F.dexSeen[1] + 51] = 0xFF
w.sb1[F.dexSeen[2] + 51] = 0xFF
end)
local save = H.import(v, cart)
local out = H.blocks(H.withTemplate(v, save, cart), v)
eq(out.sb2:byte(0x28 + 51 + 1), 0xFF, v .. ": dex owned bits past the National Dex survive an untouched export")
eq(out.sb2:byte(0x5C + 51 + 1), 0xFF, v .. ": dex seen bits past the National Dex survive in SB2")
eq(out.sb1:byte(F.dexSeen[1] + 51 + 1), 0xFF, v .. ": the first SB1 seen copy keeps its high bits")
eq(out.sb1:byte(F.dexSeen[2] + 51 + 1), 0xFF, v .. ": the second SB1 seen copy keeps its high bits")
eq(#K.r1Diff(3, v, cart, H.withTemplate(v, save, cart)), 0, v .. ": R1 holds with edited dex bytes")
end
for _, v in ipairs(VERSIONS) do
local fam = H.family(v)
local cart = H.cart(v, function(w)
G3.setParty(w, { G3.mon({ nick = "RIB", contest = { 1, 2, 3, 4, 5, 6 }, ribbons = 0x00001234 }) })
end)
local save = H.import(v, cart)
local mon = save.party[1]
eq(mon.contest.cool, 1, v .. ": contest stats import onto the mon")
eq(mon.contest.sheen, 6, v .. ": sheen imports onto the mon")
eq(mon.ribbons, 0x1234, v .. ": the ribbon word imports onto the mon")
mon.contest.cool, mon.contest.tough = 200, 77
mon.ribbons = 0x1234 + 2 ^ 20
mon.championRibbon = true
mon.modernFatefulEncounter = true
local out = D.deep(H.withTemplate(v, save, cart), fam).party[1]
eq(table.concat(out.contest, ","), "200,2,3,4,77,6", v .. ": engine contest stats reach the cart")
eq(out.ribbons, 0x1234 + 2 ^ 20 + 2 ^ 15 + 2 ^ 31, v .. ": engine ribbons, champion and fateful flags reach the cart")
mon.championRibbon, mon.modernFatefulEncounter = false, false
local cleared = D.deep(H.withTemplate(v, save, cart), fam).party[1]
eq(cleared.ribbons, 0x1234 + 2 ^ 20 - (math.floor((0x1234 + 2 ^ 20) / 2 ^ 15) % 2) * 2 ^ 15, v .. ": clearing the flags clears their bits")
local legacy = H.import(v, cart)
legacy.party[1].contest, legacy.party[1].ribbons, legacy.party[1].championRibbon = nil, nil, nil
legacy.party[1].cartExtra.contest, legacy.party[1].cartExtra.ribbons = { cool = 9, beauty = 8, cute = 7, smart = 6, tough = 5, sheen = 4 }, 0x77
local old = D.deep(H.withTemplate(v, legacy, cart), fam).party[1]
eq(table.concat(old.contest, ","), "9,8,7,6,5,4", v .. ": a slot imported by an older build still exports its carried contest stats")
eq(old.ribbons, 0x77, v .. ": and its carried ribbons")
end
for _, v in ipairs(VERSIONS) do
local fam = H.family(v)
local F = G3.F[fam]
local B = require("tests.fixtures.save.bytes")
local mailOff = fam == "emerald" and 0x2BE0 or 0x2CD0
local cart = H.cart(v, function(w)
local o = mailOff + 6 * 36
for k = 0, 8 do B.le(w.sb1, o + k * 2, 0xFFFF, 2) end
B.put(w.sb1, o + 18, 0xBB, 0xBC, 0xBD, 0, 0, 0, 0xFF, 0xFF)
B.le(w.sb1, o + 26, 0x59650100, 4)
B.le(w.sb1, o + 30, 1, 2)
B.le(w.sb1, o + 32, 121, 2)
local o2 = mailOff + 7 * 36
for k = 0, 8 do B.le(w.sb1, o2 + k * 2, 0xFFFF, 2) end
B.put(w.sb1, o2 + 18, 0xBB, 0xBC, 0xBD, 0, 0, 0, 0xFF, 0xFF)
B.le(w.sb1, o2 + 26, 0x00000200, 4)
B.le(w.sb1, o2 + 30, 1, 2)
B.le(w.sb1, o2 + 32, 122, 2)
end)
local save = H.import(v, cart)
save.mail[1] = save.mail[1] or { words = {}, playerName = "", trainerId = 0, species = 1, itemId = 0 }
save.mail[2] = { words = { 5, 6, 7, 8, 9, 10, 11, 12, 13 }, playerName = "EDIT", trainerId = 0, species = 1, itemId = 121 }
local out = D.deep(H.withTemplate(v, save, cart), fam)
eq(out.mail[7].trainerId, 0x59650100, v .. ": foreign mail keeps the sender's secret id when other mail is edited")
eq(out.mail[8].trainerId, 0x00000200, v .. ": foreign mail with no secret id is unchanged")
eq(#K.r1Diff(3, v, cart, H.withTemplate(v, H.import(v, cart), cart)), 0, v .. ": R1 holds with foreign mail")
end
for _, v in ipairs(VERSIONS) do
local fam = H.family(v)
local cap = fam == "emerald" and 50 or 30
local save = H.import(v, H.cart(v))
save.modData.cartImage = nil
save.storage.items = {}
for i = 1, cap + 5 do save.storage.items[i] = { id = 13 + i, qty = 1 } end
local out, note = K.export(3, v, save, false)
check(out ~= nil, v .. ": an oversized PC item list still exports")
check(type(note) == "string" and note:find("5 item slot", 1, true) ~= nil, v .. ": the export says how many PC items were left out (" .. tostring(note) .. ")")
eq(#D.deep(out, fam).pcItems, cap, v .. ": exactly the cartridge's capacity is written")
local fit = H.import(v, H.cart(v))
fit.modData.cartImage = nil
local _, noNote = K.export(3, v, fit, false)
eq(noNote, nil, v .. ": no note when everything fits")
end
T.finish()