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

357 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 K = require("tests.save_compat._codec")
local G2 = require("tests.fixtures.save.gen2_build")
local B = require("tests.fixtures.save.bytes")
local R2 = require("tests.save_compat._r2")
local Ref = require("tests.save_compat._gen2_reference")
local Gen2Save = require("src.save_convert.Gen2Save")
local Compat = require("src.save_convert.Compat")
local N = tonumber(os.getenv("SAVE_COMPAT_FUZZ_N")) or 340
local SEED = tonumber(os.getenv("SAVE_COMPAT_FUZZ_SEED")) or 20261001
local VERSIONS = { "gold", "silver", "crystal" }
local ROOTS = { "player", "rival", "mom", "party", "boxes", "inventory", "bagOrder", "pcItems", "pcOrder",
"mail", "currentBox", "pokedex", "unownDex", "firstUnownSeen", "position", "playTime", "events",
"mapScenes", "engineFlags", "boxNames", "variableSprites", "playerState", "options" }
R2.ROOTS[2] = ROOTS
local state = SEED
local function rnd(a, b)
state = (state * 48271) % 2147483647
return a + state % (b - a + 1)
end
local function chance(p) return rnd(1, 1000) <= p * 1000 end
local function pick(list) return list[rnd(1, #list)] end
local LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
local function name(lo, hi)
local out = {}
for i = 1, rnd(lo, hi) do
local k = rnd(1, #LETTERS)
out[i] = LETTERS:sub(k, k)
end
return table.concat(out)
end
local function internalError(why)
return type(why) == "string" and why:find("codec failed", 1, true) ~= nil
end
-- data/events/engine_flags.asm:11-40, :65-101
local ENGINE_IDS = { 0, 1, 2, 3, 4, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 22, 42, 43, 44, 45, 46, 47, 48, 49 }
for i = 50, 76 do ENGINE_IDS[#ENGINE_IDS + 1] = i end
local SCENES = {}
for mapId in pairs(G2.layout("gold").sceneVars) do SCENES[#SCENES + 1] = mapId end
table.sort(SCENES)
local CRYSTAL_SCENES = {}
for mapId in pairs(G2.layout("crystal").sceneVars) do CRYSTAL_SCENES[#CRYSTAL_SCENES + 1] = mapId end
table.sort(CRYSTAL_SCENES)
local ITEMS, KEYS, BALLS = {}, {}, {}
for i = 1, 26 do ITEMS[#ITEMS + 1] = "ITEM_" .. i end
for i = 1, 25 do KEYS[#KEYS + 1] = "KEY_" .. i end
for i = 1, 12 do BALLS[#BALLS + 1] = "BALL_" .. i end
local function tmIndex(n)
if n <= 4 then return 0xBF + n - 1 end
if n <= 28 then return 0xC4 + n - 5 end
if n <= 50 then return 0xDD + n - 29 end
return 0xF3 + n - 51
end
local function randomMon(version, party)
local moves = {}
for i = 1, rnd(1, 4) do moves[i] = { id = rnd(1, 251), pp = rnd(0, 63), ppUps = rnd(0, 3) } end
local dvs = { attack = rnd(0, 15), defense = rnd(0, 15), speed = rnd(0, 15), special = rnd(0, 15) }
dvs.hp = (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 + (dvs.speed % 2) * 2 + dvs.special % 2
local m = {
species = rnd(1, 251), moves = moves, otId = rnd(0, 65535), experience = rnd(0, 0xFFFFFF),
statExp = { hp = rnd(0, 65535), attack = rnd(0, 65535), defense = rnd(0, 65535), speed = rnd(0, 65535),
special = rnd(0, 65535) },
dvs = dvs, happiness = rnd(0, 255), pokerus = rnd(0, 255), level = rnd(1, 100),
ot = name(1, 7), nickname = name(1, 10),
}
if chance(0.3) then m.item = pick(ITEMS) elseif chance(0.1) then m.item = rnd(0xFA, 0xFF) end
if chance(0.1) then
m.isEgg, m.eggSteps, m.happiness = true, rnd(0, 255), Gen2Save.HATCH_HAPPINESS
end
if version == "crystal" then
m.caughtTime, m.caughtLevel, m.caughtLocation = rnd(0, 3), rnd(0, 63), rnd(0, 127)
m.caughtByGender = chance(0.5) and "girl" or "boy"
else
m.caughtData = rnd(0, 65535)
end
if party then
local st = { attack = rnd(0, 999), defense = rnd(0, 999), speed = rnd(0, 999),
specialAttack = rnd(0, 999), specialDefense = rnd(0, 999) }
m.maxHp = rnd(1, 999)
st.hp = m.maxHp
m.stats, m.hp = st, rnd(0, m.maxHp)
local status = rnd(0, 5)
if status == 1 then m.status, m.statusTurns = "sleep", rnd(1, 7)
elseif status > 1 then m.status = ({ "poison", "burn", "freeze", "paralyze" })[status - 1] end
end
return m
end
local function randomSave(version)
local crystal = version == "crystal"
local s = {
player = { name = name(1, 7), id = rnd(0, 65535), money = rnd(0, 999999), coins = rnd(0, 9999),
badges = {}, kantoBadges = {} },
rival = { name = name(1, 7) },
mom = { name = name(1, 7), savedMoney = rnd(0, 999999), active = chance(0.5), savingMoney = chance(0.5),
whichItem = rnd(0, 255), triggerBalance = rnd(0, 999999) },
party = {}, boxes = {}, inventory = {}, bagOrder = {}, pcItems = {}, pcOrder = {},
mail = { party = {}, box = {} }, currentBox = rnd(1, 14),
pokedex = { caught = {}, seen = {} }, unownDex = {}, firstUnownSeen = rnd(0, 26),
position = { map = K.GEN2_MAP, mapGroup = 24, mapNumber = 7, x = rnd(0, 3), y = rnd(0, 3) },
playTime = { hours = rnd(0, 65535), minutes = rnd(0, 59), seconds = rnd(0, 59), frames = rnd(0, 59) },
events = {}, mapScenes = {}, engineFlags = {}, boxNames = {}, variableSprites = {},
playerState = pick({ "normal", "bike", "surf", "surf_pika" }),
options = { textSpeed = pick({ "FAST", "MID", "SLOW" }), battleScene = chance(0.5),
battleStyle = pick({ "SHIFT", "SET" }), sound = pick({ "MONO", "STEREO" }), frame = rnd(1, 8),
print = pick({ "LIGHTEST", "LIGHTER", "NORMAL", "DARKER", "DARKEST" }), menuAccount = chance(0.5) },
}
if crystal then s.player.gender = chance(0.5) and "female" or "male" end
for _, b in ipairs(Gen2Save.JOHTO_BADGES) do if chance(0.5) then s.player.badges[b] = true end end
for _, b in ipairs(Gen2Save.KANTO_BADGES) do if chance(0.5) then s.player.kantoBadges[b] = true end end
for i = 1, rnd(0, 6) do s.party[i] = randomMon(version, true) end
for b = 1, 14 do
s.boxes[b] = {}
if chance(0.4) then for i = 1, rnd(0, 20) do s.boxes[b][i] = randomMon(version, false) end end
s.boxNames[b] = chance(0.3) and ("BOX" .. b) or name(1, 9)
end
for i, mon in ipairs(s.party) do
if chance(0.3) then
mon.item = "FLOWER_MAIL"
local msg = name(0, 32)
s.mail.party[i] = { type = "FLOWER_MAIL", message = msg, author = name(1, crystal and 8 or 10),
authorId = rnd(0, 65535), species = rnd(1, 251) }
end
end
for k = 1, rnd(0, 10) do
s.mail.box[k] = { type = "FLOWER_MAIL", message = name(0, 32), author = name(1, crystal and 8 or 10),
authorId = rnd(0, 65535), species = rnd(1, 251) }
end
local function fill(pool, cap, perStack, list, map, order)
local used, slots = {}, 0
while slots < cap and chance(0.8) do
local id = pick(pool)
if not used[id] then
local n = rnd(1, perStack and 250 or 1)
local need = perStack and math.ceil(n / 99) or 1
if slots + need > cap then break end
used[id] = true
slots = slots + need
map[id] = n
order[#order + 1] = id
end
end
end
fill(ITEMS, 20, true, nil, s.inventory, s.bagOrder)
fill(KEYS, 25, false, nil, s.inventory, s.bagOrder)
fill(BALLS, 12, true, nil, s.inventory, s.bagOrder)
for n = 1, 57 do
if chance(0.3) then
local id = n == 1 and "TM_DYNAMICPUNCH" or tmIndex(n)
s.inventory[id] = rnd(1, 99)
s.bagOrder[#s.bagOrder + 1] = id
end
end
fill(ITEMS, 50, true, nil, s.pcItems, s.pcOrder)
for i = 1, 251 do
if chance(0.3) then s.pokedex.caught[i] = true end
if chance(0.5) then s.pokedex.seen[i] = true end
end
local letters = {}
for i = 1, 26 do letters[i] = i end
for i = 1, rnd(0, 26) do
local k = rnd(i, 26)
letters[i], letters[k] = letters[k], letters[i]
s.unownDex[i] = letters[i]
end
for i = 0, 255 do s.events[i] = rnd(0, 255) end
for _, mapId in ipairs(crystal and CRYSTAL_SCENES or SCENES) do
if chance(0.2) then s.mapScenes[mapId] = rnd(1, 255) end
end
for _, id in ipairs(ENGINE_IDS) do
if chance(0.5) then s.engineFlags[(crystal and id >= 16) and id + 1 or id] = true end
end
for i = 0, 15 do if chance(0.3) then s.variableSprites[i] = rnd(1, 255) end end
return s
end
for _, v in ipairs(VERSIONS) do
local bad = 0
for n = 1, N do
local s = randomSave(v)
local out, why = Gen2Save.encode(s, v, nil, K.gen2Data)
if not out then
bad = bad + 1
if bad <= 3 then check(false, ("%s model %d exports -- %s"):format(v, n, tostring(why))) end
else
local report = Compat.check(out, v)
if #report.errors > 0 then
bad = bad + 1
if bad <= 3 then check(false, ("%s model %d: %s"):format(v, n, Compat.describe(report))) end
end
local ref = Ref.decode(out, v)
if not (ref.checksum1 and ref.checksum2) then
bad = bad + 1
if bad <= 3 then check(false, ("%s model %d: the reference decoder rejects a checksum"):format(v, n)) end
end
local back = Gen2Save.decode(out, v, K.gen2Data)
local want, got = R2.project(2, s), back and R2.project(2, back) or {}
for path, value in pairs(want) do
if got[path] ~= value then
bad = bad + 1
if bad <= 3 then check(false, ("%s model %d: %s %s -> %s"):format(v, n, path, value, tostring(got[path]))) end
break
end
end
local again = back and Gen2Save.encode(back, v, nil, K.gen2Data)
if again ~= out then
bad = bad + 1
if bad <= 3 then check(false, ("%s model %d: export is not a fixed point"):format(v, n)) end
end
end
end
eq(bad, 0, ("%s: %d random models round trip through the cart"):format(v, N))
end
local function excludedFor(L)
local ranges = {}
local function ex(from, size) ranges[#ranges + 1] = { from, from + size } end
ex(L.wPartyCount, 8)
for i = 0, 5 do
local o = L.wPartyMons + i * 48
ex(o + 2, 4); ex(o + 0x17, 4); ex(o + 0x20, 1)
end
for _, base in ipairs(L.boxes) do
ex(base, 22)
for i = 0, 19 do
local o = base + 0x16 + i * 32
ex(o + 2, 4); ex(o + 0x17, 4)
end
end
ex(L.sBox, Gen2Save.BOX_BYTES)
for _, seg in ipairs(L.backupSave.segments) do ex(seg[2], seg[3]) end
ex(L.backupSave.options, 9)
ex(L.backupSave.checksum, 3)
ex(L.sCheckValue1, 1); ex(L.sChecksum, 3)
ex(L.wNumItems, 1); ex(L.wNumKeyItems, 1); ex(L.wNumBalls, 1); ex(L.wNumPCItems, 1)
ex(L.wItems, 41); ex(L.wKeyItems, 26); ex(L.wBalls, 25); ex(L.wPCItems, 101)
ex(L.sMailboxCount, 1); ex(L.sMailboxCountBackup, 1)
ex(L.wMapGroup, 4)
ex(L.wPlayerStruct - 9, 0x28 * 13 + 9)
ex(L.wMapObjects, 16 * 16 + 32)
ex(L.wScreenSave, 30)
return function(o)
for _, r in ipairs(ranges) do if o >= r[1] and o < r[2] then return true end end
return false
end
end
for _, v in ipairs(VERSIONS) do
local L = G2.layout(v)
local excluded = excludedFor(L)
local base = B.fromString(assert(G2.build({ version = v, boxes = { [1] = G2.boxOf(5, 3), [9] = G2.boxOf(20, 7) },
party = { G2.mon(), G2.mon({ species = 25, item = G2.ITEMS.FLOWER_MAIL.index }) }, mail = true,
hallOfFame = true, unownDex = true, currentBox = 8,
items = { { G2.ITEMS.POTION.index, 5 }, { G2.ITEMS.REPEL.index, 9 }, { G2.ITEMS.POTION.index, 99 } },
pcItems = { { G2.ITEMS.MAX_POTION.index, 3 } } })))
local bad = 0
for n = 1, N do
local b = {}
for i = 0, 0x7FFF do b[i] = base[i] end
b.size = 0x8000
local touched = {}
for _ = 1, rnd(1, 8) do
local o
repeat o = rnd(0, 0x7FFF) until not excluded(o)
b[o] = rnd(0, 255)
touched[#touched + 1] = ("%04X"):format(o)
end
local cur = b[L.wCurBox] < 14 and b[L.wCurBox] + 1 or 1
B.copy(b, L.boxes[cur], L.sBox, Gen2Save.BOX_BYTES)
B.copy(b, L.sPartyMailBackup, L.sPartyMail, 6 * Gen2Save.MAIL_STRUCT)
B.copy(b, L.sMailboxCountBackup, L.sMailboxCount, 1 + 10 * Gen2Save.MAIL_STRUCT)
G2.seal(b, v)
local src = B.pack(b)
local save, why = Gen2Save.decode(src, v, K.gen2Data)
local out, xwhy
if save then out, xwhy = Gen2Save.encode(save, v, src, K.gen2Data) end
if out ~= src then
bad = bad + 1
if bad <= 3 then
local first
if out then for i = 1, #src do if src:byte(i) ~= out:byte(i) then first = i - 1 break end end end
check(false, ("%s mutation %d at %s: %s"):format(v, n, table.concat(touched, ","),
out and ("first difference 0x%04X"):format(first or -1) or tostring(why or xwhy)))
end
end
end
eq(bad, 0, ("%s: %d mutated carts reproduce byte for byte"):format(v, N))
end
for _, v in ipairs(VERSIONS) do
local src = G2.build({ version = v, boxes = { [2] = G2.boxOf(20, 1) }, mail = true })
local bad = 0
for n = 1, N do
local b = B.fromString(src)
for _ = 1, rnd(1, 64) do b[rnd(0, 0x7FFF)] = rnd(0, 255) end
if chance(0.5) then G2.seal(b, v) end
local bytes = B.pack(b)
local ok, save, why = pcall(Gen2Save.decode, bytes, v, K.gen2Data)
if not ok or internalError(why) or (save == nil and type(why) ~= "string") then
bad = bad + 1
if bad <= 3 then check(false, ("%s corruption %d decode: %s"):format(v, n, tostring(ok and why or save))) end
elseif save then
local ok2, out, xwhy = pcall(Gen2Save.encode, save, v, bytes, K.gen2Data)
if not ok2 or internalError(xwhy) or (out == nil and type(xwhy) ~= "string") then
bad = bad + 1
if bad <= 3 then check(false, ("%s corruption %d encode: %s"):format(v, n, tostring(ok2 and xwhy or out))) end
end
end
end
eq(bad, 0, ("%s: %d corrupted carts never raise"):format(v, N))
local junk = { 7, "x", {}, { party = "no" }, { party = { 5 } }, { boxes = { { "a" } } },
{ inventory = { [true] = 3 } }, { mail = { party = { "x" } } }, { position = { x = "a" } },
{ playTime = "1" }, { options = 3 }, { mom = { savedMoney = "lots" } } }
for i, s in ipairs(junk) do
local ok, out, why = pcall(Gen2Save.encode, s, v, src, K.gen2Data)
check(ok and not internalError(why) and (out ~= nil or type(why) == "string"),
("%s: junk save %d is answered, not raised -- %s"):format(v, i, tostring(why)))
end
end
for _, v in ipairs(VERSIONS) do
local src = G2.build({ version = v })
for _, size in ipairs({ 0, 1, 100, 0x7FFF, 0x8000, 0x8007, 0x800C, 0x8030, 0x8040, 0x10000 }) do
local bytes = size <= #src and src:sub(1, size) or (src .. string.rep("\170", size - #src))
local ok, save, why = pcall(Gen2Save.decode, bytes, v, K.gen2Data)
check(ok and not internalError(why), ("%s size %d: decode answers -- %s"):format(v, size, tostring(why)))
if size < 0x8000 then
eq(save, nil, ("%s size %d: a short image is refused"):format(v, size))
else
check(save ~= nil, ("%s size %d: a full image with any trailer imports"):format(v, size))
local out = save and Gen2Save.encode(save, v, bytes, K.gen2Data)
eq(out, bytes, ("%s size %d: and exports back byte for byte, trailer included"):format(v, size))
end
local ok2, out, xwhy = pcall(Gen2Save.encode, assert(Gen2Save.decode(src, v, K.gen2Data)), v, bytes, K.gen2Data)
check(ok2 and not internalError(xwhy), ("%s size %d: as a template it is answered -- %s"):format(v, size, tostring(xwhy)))
if size < 0x8000 then eq(out, nil, ("%s size %d: a short template is refused"):format(v, size)) end
end
end
T.finish()