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

279 lines
15 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Compat = require("src.save_convert.Compat")
local B = require("tests.fixtures.save.bytes")
local G1 = require("tests.fixtures.save.gen1_build")
local G2 = require("tests.fixtures.save.gen2_build")
local G3 = require("tests.fixtures.save.gen3_build")
local fired = {}
local function rules(report, list)
local out = {}
for _, e in ipairs(report[list]) do
out[e.rule] = (out[e.rule] or 0) + 1
fired[e.rule] = true
end
return out
end
local function expectError(bytes, version, rule, label)
local r = Compat.check(bytes, version)
check(rules(r, "errors")[rule] ~= nil, ("%s: flags %s -- got: %s"):format(label, rule, Compat.describe(r)))
eq(r.ok, false, label .. ": the report is not ok")
return r
end
local function expectWarn(bytes, version, rule, label)
local r = Compat.check(bytes, version)
check(rules(r, "warnings")[rule] ~= nil, ("%s: warns %s -- got: %s"):format(label, rule, Compat.describe(r)))
eq(r.ok, true, label .. ": a warning alone keeps the report ok -- " .. Compat.describe(r))
return r
end
local function expectClean(bytes, version, label)
local r = Compat.check(bytes, version)
eq(#r.errors, 0, label .. ": no errors -- " .. Compat.describe(r))
return r
end
local function edit(bytes, fn)
local b = B.fromString(bytes)
fn(b)
return B.pack(b)
end
for rule, sev in pairs(Compat.RULES) do
check(sev == "error" or sev == "warn", "rule " .. rule .. " has a known severity")
end
local function g1(spec)
spec = spec or {}
spec.version = spec.version or "red"
return G1.build(spec)
end
local function g1Reseal(b)
b[0x3523] = B.complement8(b, 0x2598, 0x3523)
end
expectClean(g1(), "red", "gen1 basic")
expectClean(g1({ version = "yellow" }), "yellow", "gen1 yellow")
expectError(string.rep("\0", 100), "red", "size", "gen1 short file")
expectError(edit(g1(), function(b) b[0x3523] = (b[0x3523] + 1) % 256 end), "red", "gen1.mainChecksum",
"gen1 bad main checksum")
expectError(edit(g1(), function(b)
b[0x2D0D] = B.sum8(b, 0x2009, 0x2B83)
b[0x1F0D] = B.sum8(b, 0x1209, 0x1D83)
g1Reseal(b)
end), "red", "gen1.openhomeMisdetect", "gen1 image whose Crystal 8-bit sums validate")
expectError(edit(g1(), function(b)
B.le(b, 0x2D0D, B.sum16(b, 0x2009, 0x2C8C), 2)
B.le(b, 0x7F0D, B.sum16(b, 0x2009, 0x2C8C), 2)
g1Reseal(b)
end), "red", "gen1.openhomeMisdetect", "gen1 image whose Japanese Gen 2 sums validate")
expectWarn(g1({ patch = function(b) b[0x271C] = 9 end }), "red", "gen1.versionMarker",
"gen1 Red save whose byte 0x271C OpenHome reads as Pikachu friendship")
expectError(edit(g1(), function(b) b[0x2F2C + 2] = 0x99; g1Reseal(b) end), "red", "gen1.partyList",
"gen1 party list without terminator")
expectError(edit(g1(), function(b) b[0x30C0] = 21; g1Reseal(b) end), "red", "gen1.curBoxList",
"gen1 current box count past 20")
expectError(g1({ party = { G1.mon(), G1.mon() }, patch = function(b) b[0x2F2C + 2] = 0 end }), "red",
"gen1.listSpecies0", "gen1 species 0 inside the party list")
expectError(g1({ currentBox = 12, genuineCurrentSlot = false }), "red", "gen1.currentBoxIndex", "gen1 box index 12")
expectError(g1({ patch = function(b) B.put(b, 0x2ED5, 0, 0xFF); B.put(b, 0x302D, 0, 0xFF) end }), "red",
"gen1.japaneseCollision", "gen1 lists that also pass PKHeX's Japanese test")
expectError(edit(g1(), function(b)
b[0x2D69] = B.sum8(b, 0x2009, 0x2D69)
b[0x7E6D] = (B.sum8(b, 0x15C7, 0x17ED) + B.sum8(b, 0x3D96, 0x3F40) + B.sum8(b, 0x0C6B, 0x10E8)
+ B.sum8(b, 0x7E39, 0x7E6D) + B.sum8(b, 0x10E8, 0x15C7)) % 256
g1Reseal(b)
end), "red", "gen1.openhomeMisdetect", "gen1 image whose Gold/Silver 8-bit sums validate (G1-25)")
expectError(edit(g1({ boxes = { G1.boxOf(1, 3) }, currentBox = 1 }), function(b)
b[0x4000] = 25
end), "red", "gen1.bankList", "gen1 stored box count past 20")
expectWarn(g1({ party = { G1.mon({ species = 0x1F }) } }), "red", "gen1.unknownSpecies", "gen1 MissingNo index")
expectWarn(g1({ staleBankChecksums = true }), "red", "gen1.bankChecksum", "gen1 stale bank checksums")
expectWarn(g1({ starter = 0x54 }), "red", "gen1.versionMarker", "gen1 Red save with Pikachu as starter")
expectWarn(g1({ version = "yellow", patch = function(b) b[0x29C3] = 0xB0 end }), "yellow", "gen1.versionMarker",
"gen1 Yellow save without the Pikachu starter")
expectWarn(g1({ boxes = { G1.boxOf(1, 3), G1.boxOf(1, 4) }, currentBox = 0,
patch = function(b) b[0x4462 + 22 + 2] = 0xFF end }), "red", "gen1.openhomeHp255", "gen1 boxed mon with HP 255")
expectWarn(g1({ patch = function(b) b[0x29C5] = 0x30 end }), "red", "gen1.blackoutMap", "gen1 blackout map outside the fly table")
expectWarn(g1({ boxesInitialized = false, bankFill = 0,
after = function(b) B.put(b, 0x4462, 1, 0x99, 0xFF) end }), "red", "gen1.boxesUninitialized",
"gen1 bank list with bit 7 clear")
expectClean(g1({ boxesInitialized = false }), "red", "gen1 never changed boxes, banks still 0xFF")
local function g2(version, spec)
spec = spec or {}
spec.version = version
return G2.build(spec)
end
for _, v in ipairs({ "gold", "silver", "crystal" }) do
local L = G2.layout(v)
expectClean(g2(v), v, v .. " basic")
expectClean(g2(v, { footer = string.rep("\0", 48) }), v, v .. " with a 48-byte RTC footer")
expectError(g2(v, { footer = string.rep("\0", 13) }), v, "size", v .. " with an odd 13-byte tail")
expectError(edit(g2(v), function(b) b[L.sCheckValue1] = 0 end), v, "gen2.checkValues", v .. " check value 1")
expectError(edit(g2(v), function(b) b[L.sChecksum] = (b[L.sChecksum] + 1) % 256 end), v, "gen2.checksum",
v .. " bad primary sum")
expectError(g2(v, { party = { G2.mon(), G2.mon() }, patch = function(b) b[L.wPartySpecies + 1] = 0 end }), v,
"gen2.listSpecies0", v .. " species 0 inside the party list")
expectError(g2(v, { patch = function(b) b[L.boxes[4]] = 30 end }), v, "gen2.boxList", v .. " box count past 20")
expectError(g2(v, { patch = function(b) b[L.wPartyCount] = 30 end }), v, "gen2.partyList", v .. " party count past 6")
expectError(edit(g2(v), function(b) b[L.sChecksum] = (b[L.sChecksum] + 1) % 256 end), v, "gen2.openhomeChecksum",
v .. " whose 8-bit sums do not validate for OpenHome")
local egg = expectClean(g2(v, { party = { G2.mon(), G2.mon({ listed = G2.EGG, nick = "EGG" }) } }), v,
v .. " party egg is legal")
eq(rules(egg, "warnings")["gen2.listStructMismatch"], nil, v .. ": an 0xFD egg marker is not a list mismatch")
expectWarn(g2(v, { party = { G2.mon({ species = 252 }) } }), v, "gen2.unknownSpecies", v .. " species past Celebi")
expectWarn(g2(v, { party = { G2.mon({ listed = 7 }) } }), v, "gen2.listStructMismatch", v .. " list/struct disagree")
expectWarn(g2(v, { party = { G2.mon({ nick = "" }) } }), v, "gen2.blankNickname", v .. " blank nickname")
expectWarn(g2(v, { patch = function(b)
local caught = v == "crystal" and 0x2A27 or 0x2A4C
b[caught + 25] = 1
end }), v, "gen2.unownDex", v .. " Unown caught with an empty Unown dex")
end
for _, v in ipairs({ "gold", "silver" }) do
expectWarn(g2(v, { after = function(b) B.fill(b, 0x3D69, 0x2D, 0x5A) end }),
v, "gen2.pkhexHallOfFame", v .. " Hall of Fame tail overwritten by PKHeX")
local clean = Compat.check(g2(v), v)
eq(rules(clean, "warnings")["gen2.pkhexHallOfFame"], nil, v .. " empty Hall of Fame tail has no loss warning")
end
local crystalHall = Compat.check(g2("crystal", { after = function(b) B.fill(b, 0x3D69, 0x2D, 0x5A) end }), "crystal")
eq(rules(crystalHall, "warnings")["gen2.pkhexHallOfFame"], nil, "Crystal writer does not overlap Hall of Fame")
expectError(edit(g2("gold"), function(b) b[0x7E6D] = 0; b[0x7E6E] = 0 end), "gold", "gen2.backupChecksum",
"gold whose PKHeX checksum 2 was never written (G2-08)")
expectError(edit(g2("crystal"), function(b) b[0x1F0D] = (b[0x1F0D] + 1) % 256 end), "crystal", "gen2.backupChecksum",
"crystal backup sum wrong")
local gsBothZero = edit(g2("gold"), function(b)
for _, seg in ipairs(G2.GS_BACKUP) do B.fill(b, seg.to, seg.size, 0) end
B.fill(b, 0x7E30, 0x40, 0)
local pad = 0x2D68
b[pad] = (b[pad] - B.sum8(b, 0x2009, 0x2D69)) % 256
B.le(b, 0x2D69, B.sum16(b, 0x2009, 0x2D69), 2)
end)
eq(gsBothZero:byte(0x2D69 + 1), 0, "the crafted Gold image sums to a 0 low byte")
expectError(gsBothZero, "gold", "gen2.openhomeChecksum", "gold with low byte 0 and an empty backup (G2-22)")
expectError(g2("gold", { after = function(b)
B.le(b, 0x2D0D, B.sum16(b, 0x2009, 0x2C8C), 2)
B.le(b, 0x7F0D, B.sum16(b, 0x2009, 0x2C8C), 2)
end }), "gold", "gen2.openhomeJapanCollision", "gold whose bytes also pass OpenHome's Japanese Gen 2 checksums")
expectError(g2("crystal", { patch = function(b) B.put(b, 0x288A, 0, 0xFF); B.put(b, 0x2D6C, 0, 0xFF) end }),
"crystal", "gen2.gsCollision", "crystal whose bytes also pass PKHeX's Gold/Silver list test")
expectError(g2("gold", { patch = function(b) B.put(b, 0x2F2C, 0, 0xFF); B.put(b, 0x30C0, 0, 0xFF) end }),
"gold", "gen2.gen1Collision", "gold whose bytes also pass PKHeX's Gen 1 list test")
local function g3(version, fn)
local w = G3.base(version)
if fn then fn(w) end
return G3.emit(w), w
end
local function sectorOf(w, id) return w.at[id] end
local function reseal(b, off, size)
local s = {}
for k = 0, 0xF7F do s[k + 1] = string.char(b[off + k]) end
B.le(b, off + 0xFF6, Compat.gen3SectorChecksum(table.concat(s), 0, size), 2)
end
for _, v in ipairs({ "firered", "leafgreen", "emerald" }) do
local fam = v == "emerald" and "emerald" or "frlg"
local chunks = G3.CHUNKS[fam]
local good, w = g3(v)
expectClean(good, v, v .. " basic")
expectError(good:sub(1, 0x10000), v, "size", v .. " 64K file")
expectWarn(good .. string.rep("\0", 16), v, "gen3.trailer", v .. " with a 16-byte trailer")
expectWarn(good .. string.rep("\255", 7), v, "gen3.trailer", v .. " with a 7-byte footer")
expectWarn(good .. string.rep("\255", 0x80), v, "gen3.trailer", v .. " with a uniform 0x80 pad that only PKForge trims")
expectError(good .. string.rep("\1", 13), v, "gen3.trailerUnreadable", v .. " with a 13-byte non-uniform tail")
expectError(good .. string.rep("\1", 0x80), v, "gen3.trailerUnreadable", v .. " with a 0x80-byte non-uniform tail")
expectError(good .. string.rep("\0", 0x101), v, "size", v .. " past OpenHome's 0x20100 window")
expectWarn(edit(good, function(b) B.le(b, sectorOf(w, 3) + 0xFFC, 0x01020304, 4) end), v, "gen3.slotCounter",
v .. " section whose counter differs from section 0")
expectWarn(edit(good, function(b)
local other = w.at[0] < 0xE000 and 0xE000 or -0xE000
for i = 0, 0xDFFF do b[w.at[0] - w.at[0] % 0xE000 + other + i] = b[w.at[0] - w.at[0] % 0xE000 + i] end
end), v, "gen3.slotCounter", v .. " both slots with the same counter")
expectWarn(g3(v, function(x) G3.setBoxMon(x, 5, 1, G3.mon({ pid = 81, lang = 12 })) end), v, "gen3.language",
v .. " mon with language byte 12")
expectError(edit(good, function(b) b[sectorOf(w, 3) + 0xFF4] = 2 end), v, "gen3.sectionIds",
v .. " slot with section 2 twice")
expectError(edit(good, function(b) b[sectorOf(w, 5) + 0x10] = (b[sectorOf(w, 5) + 0x10] + 1) % 256 end), v,
"gen3.sectorChecksum", v .. " stale sector checksum")
expectError(edit(good, function(b) B.le(b, sectorOf(w, 7) + 0xFF8, 0x01121999, 4) end), v, "gen3.signature",
v .. " Unbound sector signature")
expectError(edit(good, function(b)
local off = sectorOf(w, 0)
b[off + chunks[0] + 2] = 1
reseal(b, off, chunks[0])
end), v, "gen3.checksumWindow", v .. " nonzero byte past the section size")
expectError(g3(v, function(x) B.le(x.sb2, 6, 0, 2) end), v, "gen3.japaneseOt", v .. " OT bytes 6-7 zero")
expectError(g3(v, function(x) x.sb1[x.F.partyCount] = 7 end), v, "gen3.partyCount", v .. " party of 7")
expectError(g3(v, function(x)
G3.setBoxMon(x, 1, 1, G3.mon({ species = 0 }))
end), v, "gen3.species0", v .. " has-species with species 0 (G3-08)")
expectError(g3(v, function(x)
for s = 1, 12 do G3.setBoxMon(x, 4, s, G3.mon({ pid = s * 101, breakChecksum = true })) end
end), v, "gen3.monChecksumMajority", v .. " most boxed mons fail their checksum")
expectWarn(g3(v, function(x) G3.setBoxMon(x, 4, 1, G3.mon({ pid = 77, breakChecksum = true })) end), v,
"gen3.monChecksum", v .. " one bad mon checksum")
expectWarn(g3(v, function(x) G3.setBoxMon(x, 4, 2, G3.mon({ pid = 78, species = 500 })) end), v,
"gen3.foreignSpecies", v .. " species id 500")
expectWarn(g3(v, function(x) G3.setBoxMon(x, 4, 3, G3.mon({ pid = 79, species = 260 })) end), v,
"gen3.openhomeSpecies", v .. " filler species 260")
expectWarn(g3(v, function(x) G3.setBoxMon(x, 4, 4, G3.mon({ pid = 80, badEgg = true })) end), v,
"gen3.badEgg", v .. " bad egg flag")
expectWarn(edit(good, function(b) B.fill(b, 0x1E000, 0x100, 0x5A) end), v, "gen3.extraSector",
v .. " sector 30 with a stale checksum (G3-16)")
expectClean(g3(v, function(x)
G3.setParty(x, { G3.mon(), G3.mon({ pid = 0x2222, egg = true, nick = "EGG" }) })
end), v, v .. " party egg is legal")
end
local function frlgKey(fn)
return g3("firered", function(w)
w.key = 0
fn(w)
end)
end
expectError(frlgKey(function(w) B.le(w.sb2, 0xF20, 0, 4); B.le(w.sb2, 0xAF8, 0, 4) end), "firered",
"gen3.securityKey", "FireRed with key 0 at 0xF20 and 0xAF8 (G3-01)")
expectError(frlgKey(function(w) B.le(w.sb2, 0xAF8, 0, 4) end), "firered", "gen3.securityKey",
"FireRed with 0 at 0xAF8 only, which OpenHome reads as the key")
expectError(g3("firered", function(w) B.le(w.sb2, 0xAC, 0, 4) end), "firered", "gen3.gameCode",
"FireRed without the 1 marker at 0xAC")
expectError(g3("emerald", function(w) B.le(w.sb2, 0xAC, 0, 4) end), "emerald", "gen3.gameCode",
"Emerald with key 0 (G3-02, typed Ruby/Sapphire)")
expectError(g3("emerald", function(w) B.le(w.sb2, 0xAC, 1, 4) end), "emerald", "gen3.gameCode",
"Emerald with key 1 (G3-02, typed FireRed/LeafGreen)")
expectError(g3("emerald", function(w) B.fill(w.sb2, 0x890, 0xF2C - 0x890, 0) end), "emerald", "gen3.emeraldData",
"Emerald with SB2 0x890-0xF2B empty (G3-17)")
expectError(string.rep("\255", 0x20000), "firered", "gen3.sectionIds", "an erased flash")
for rule in pairs(Compat.RULES) do
check(fired[rule], "rule " .. rule .. " is exercised by a crafted bad image")
end
local ok, msg, report = Compat.gate(edit(g1(), function(b) b[0x3523] = (b[0x3523] + 1) % 256 end), "red")
eq(ok, nil, "gate refuses a hard failure")
check(type(msg) == "string" and msg:find("gen1.mainChecksum", 1, true) ~= nil, "and names the rule -- " .. tostring(msg))
eq(report.ok, false, "and hands back the report")
local ok2, warns = Compat.gate(g1({ staleBankChecksums = true }), "red")
eq(ok2, true, "gate passes a file with warnings only")
eq(#warns, 2, "and returns the warnings")
local ok3, msg3 = Compat.gate("x", "nonsense")
eq(ok3, nil, "an unknown version is refused")
check(tostring(msg3):find("unknown game version", 1, true) ~= nil, "with a reason -- " .. tostring(msg3))
T.finish()