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

468 lines
20 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 SaveConvert = require("src.save_convert.SaveConvert")
local Gen2Save = require("src.save_convert.Gen2Save")
local Compat = require("src.save_convert.Compat")
local Ref = require("tests.save_compat._gen2_reference")
local Diff = require("tests.save_compat._diff")
local N = tonumber(os.getenv("SAVE_COMPAT_ENGINE_N")) or 200
local SEED = tonumber(os.getenv("SAVE_COMPAT_ENGINE_SEED")) or 20261001
local HOME = os.getenv("HOME") or ""
local ROOTS = {
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 DATA = {}
for version, dir in pairs(ROOTS) do
DATA[version] = SaveConvert.gen2DataFromDir(dir)
end
if not (DATA.gold and DATA.silver and DATA.crystal) then
print("SKIP engine fuzz: no extracted Gen 2 caches on this machine")
T.finish()
return
end
local EngineSave = require("src.core.gen2.Save")
local Mon = require("src.battle.gen2.Mon")
local Boxes = require("src.core.gen2.Boxes")
local Mail = require("src.core.gen2.Mail")
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 function lists(version)
local d = DATA[version]
local out = { species = {}, items = { ITEM = {}, KEY_ITEM = {}, BALL = {}, TM_HM = {} }, maps = {}, moves = {}, sprites = {} }
for id, def in pairs(d.pokemon) do
if type(def) == "table" and def.index and def.index >= 1 and def.index <= 251 and def.baseStats then
out.species[#out.species + 1] = id
end
end
for id, def in pairs(d.items) do
if type(def) == "table" and def.index and out.items[def.pocket] and not Mail.isMail(id)
and not tostring(id):find("BADGE", 1, true) then
table.insert(out.items[def.pocket], id)
end
end
for id, def in pairs(d.maps) do
if type(def) == "table" and def.group and def.map and def.width and def.width >= 2 and def.height and def.height >= 2
and def.blocks and def.objectEventsAddr then
out.maps[#out.maps + 1] = id
end
end
for id, name in ipairs(d.constants.spriteOrder) do
if d.constants.spriteContext.rows[id] or id >= 0x80 and id < 0xF0 and name ~= "UNUSED" and d.sprites[name] then
out.sprites[#out.sprites + 1] = id
end
end
for id, def in pairs(d.moves) do
if type(def) == "table" and def.index and def.index >= 1 and def.index <= 251 then out.moves[#out.moves + 1] = id end
end
for _, l in pairs({ out.species, out.maps, out.moves, out.sprites, out.items.ITEM, out.items.KEY_ITEM, out.items.BALL, out.items.TM_HM }) do
table.sort(l)
end
return out
end
local POOL = { gold = lists("gold"), silver = lists("silver"), crystal = lists("crystal") }
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 MAIL_ITEMS = { "FLOWER_MAIL", "SURF_MAIL", "LITEBLUEMAIL", "PORTRAITMAIL", "LOVELY_MAIL", "EON_MAIL",
"MORPH_MAIL", "BLUESKY_MAIL", "MUSIC_MAIL", "MIRAGE_MAIL" }
local STATUSES = { "poison", "burn", "freeze", "paralyze", "toxic" }
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 stats = { saves = 0, mons = 0, eggs = 0, forced = 0 }
local function newMon(version, data, pool, party)
local species = pick(pool.species)
local level = rnd(2, 100)
local mon = Mon.new(data, species, level, {
nickname = chance(0.3) and name(1, 10) or nil,
item = chance(0.3) and pick(pool.items.ITEM) or nil,
pokerus = chance(0.1) and rnd(1, 255) or 0,
happiness = chance(0.5) and rnd(0, 255) or nil,
})
mon.statExp = { hp = rnd(0, 65535), attack = rnd(0, 65535), defense = rnd(0, 65535), speed = rnd(0, 65535),
special = rnd(0, 65535) }
mon.experience = math.min(mon.experience + rnd(0, 20), 0xFFFFFF)
mon.ot = name(1, 7)
mon.otId = rnd(0, 65535)
if version == "crystal" then
mon.caughtTime, mon.caughtLevel, mon.caughtLocation = rnd(0, 3), rnd(0, 63), rnd(0, 127)
mon.caughtByGender = chance(0.5) and "girl" or "boy"
end
for _ = 1, rnd(0, 3) do
local id = pick(pool.moves)
Mon.learnMove(mon, id, data)
end
for _, mv in ipairs(mon.moves) do
mv.ppUps = rnd(0, 3)
mv.maxPp = Mon.maxPpOf(mv, data)
mv.pp = rnd(0, mv.maxPp)
end
if party then
Mon.refreshStats(mon, data)
mon.hp = rnd(0, mon.maxHp)
if chance(0.35) then
local kind = rnd(1, 6)
if kind == 1 then mon.status, mon.statusTurns = "sleep", rnd(1, 7)
else mon.status = STATUSES[kind - 1] end
end
else
Boxes.enterBox(mon, data)
end
if chance(0.06) and species ~= "UNOWN" then
mon.shiny = true
stats.forced = stats.forced + 1
end
if chance(0.08) then
mon.isEgg, mon.eggSteps, mon.happiness, mon.nickname = true, rnd(0, 255), 120, nil
mon.hp = 0
end
return mon
end
local function buildSave(version)
local data = DATA[version]
local pool = POOL[version]
local save = EngineSave.newGame({ playerName = name(1, 7), rivalName = name(1, 7), gender = chance(0.5) and "female" or "male",
trainerId = rnd(0, 65535) })
save.version = version
if version ~= "crystal" then save.player.gender = "male" end
save.player.money = rnd(0, 999999)
save.player.coins = rnd(0, 9999)
for _, b in ipairs(Gen2Save.JOHTO_BADGES) do if chance(0.5) then save.player.badges[b] = true end end
for _, b in ipairs(Gen2Save.KANTO_BADGES) do if chance(0.5) then save.player.kantoBadges[b] = true end end
save.mom = { name = name(1, 7), active = chance(0.5), savingMoney = chance(0.5), savedMoney = rnd(0, 999999),
whichItem = rnd(0, 20), triggerBalance = rnd(0, 999999) }
for i = 1, rnd(1, 6) do
local mon = newMon(version, data, pool, true)
save.party[i] = mon
Mon.stampOT(save, mon)
end
for i, mon in ipairs(save.party) do
if chance(0.25) and not mon.isEgg then
local id = pick(MAIL_ITEMS)
mon.item = id
Mail.set(save, i, Mail.entry(id, name(0, 30), name(1, version == "crystal" and 8 or 10), rnd(0, 65535), mon.species))
end
end
for b = 1, 14 do
save.boxes[b] = {}
if chance(0.4) then
for i = 1, rnd(0, 20) do save.boxes[b][i] = newMon(version, data, pool, false) end
end
save.boxNames[b] = chance(0.4) and ("BOX" .. b) or name(1, 9)
end
Boxes.setCurrent(save, rnd(1, 14))
for k = 1, rnd(0, 10) do
local id = pick(MAIL_ITEMS)
save.mail.box[k] = Mail.entry(id, name(0, 30), name(1, version == "crystal" and 8 or 10), rnd(0, 65535),
pick(pool.species))
end
local function stock(poolList, cap, perStack, target, order)
local used, slots = {}, 0
while slots < cap and chance(0.8) do
local id = pick(poolList)
if not used[id] then
local n = perStack and rnd(1, 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
target[id] = n
order[#order + 1] = id
end
end
end
save.bagOrder = {}
stock(pool.items.ITEM, 20, true, save.inventory, save.bagOrder)
stock(pool.items.KEY_ITEM, 25, false, save.inventory, save.bagOrder)
stock(pool.items.BALL, 12, true, save.inventory, save.bagOrder)
for _, id in ipairs(pool.items.TM_HM) do
if chance(0.3) then
save.inventory[id] = rnd(1, 99)
save.bagOrder[#save.bagOrder + 1] = id
end
end
save.pcOrder = {}
stock(pool.items.ITEM, 50, true, save.pcItems, save.pcOrder)
for i = 1, 251 do
local species
for id, def in pairs(DATA[version].pokemon) do
if type(def) == "table" and def.index == i then species = id break end
end
if species then
if chance(0.3) then save.pokedex.caught[species] = true end
if chance(0.5) then save.pokedex.seen[species] = true end
end
end
local letters = {}
for i = 1, 26 do letters[i] = i end
save.unownDex = {}
for i = 1, rnd(0, 26) do
local k = rnd(i, 26)
letters[i], letters[k] = letters[k], letters[i]
save.unownDex[i] = letters[i]
end
save.firstUnownSeen = #save.unownDex > 0 and save.unownDex[1] or 0
for i = 0, 255 do save.events[i] = rnd(0, 255) end
local L = Gen2Save.layoutFor(version)
for mapId in pairs(L.sceneVars) do
if chance(0.2) then save.mapScenes[mapId] = rnd(1, 255) end
end
save.engineFlags = {}
for _, id in ipairs(ENGINE_IDS) do
if chance(0.5) then save.engineFlags[(version == "crystal" and id >= 16) and id + 1 or id] = true end
end
save.variableSprites = {}
for i = 0, 15 do if chance(0.3) then save.variableSprites[i] = pick(pool.sprites) end end
save.playerState = pick({ "normal", "bike", "surf", "surf_pika" })
save.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) }
local mapId = pick(pool.maps)
local def = DATA[version].maps[mapId]
save.position = { map = mapId, x = rnd(0, def.width * 2 - 1), y = rnd(0, def.height * 2 - 1) }
if mapId == "PLAYERS_HOUSE_2F" then
local Decorations = require("src.core.gen2.Decorations")
for _, row in ipairs(Decorations.visibility(Decorations.state(save))) do
local index, mask = math.floor(row.flag / 8), 2 ^ (row.flag % 8)
local byte = save.events[index] or 0
save.events[index] = byte - math.floor(byte / mask) % 2 * mask + (row.hidden and mask or 0)
if not row.hidden then save.variableSprites[row.sprite] = row.byte end
end
end
save.playTime = { hours = rnd(0, 999), minutes = rnd(0, 59), seconds = rnd(0, 59), frames = rnd(0, 59) }
return save
end
local function speciesIndex(version, id) return DATA[version].pokemon[id].index end
local function moveIndex(version, id) return DATA[version].moves[id].index end
local function itemIndex(version, id) return DATA[version].items[id].index end
local function statusByte(mon)
if not mon.status then return 0 end
if mon.status == "sleep" then return mon.statusTurns end
return ({ poison = 8, toxic = 8, burn = 16, freeze = 32, paralyze = 64 })[mon.status]
end
local function compareMon(label, version, mon, ref, party, fails)
local function chk(what, want, got)
if want ~= got then fails[#fails + 1] = ("%s %s: %s != %s"):format(label, what, tostring(want), tostring(got)) end
end
chk("species", speciesIndex(version, mon.species), ref.species)
chk("listed", mon.isEgg and 0xFD or ref.species, ref.listed)
chk("item", mon.item and itemIndex(version, mon.item) or 0, ref.item)
for i = 1, 4 do
local mv = mon.moves[i]
chk("move" .. i, mv and moveIndex(version, mv.id) or 0, ref.moves[i])
chk("pp" .. i, mv and ((mv.ppUps or 0) * 64 + mv.pp) or 0, ref.pp[i])
end
chk("otId", mon.otId, ref.otId)
chk("exp", mon.experience, ref.exp)
local se = mon.statExp
chk("statExp", table.concat({ se.hp, se.attack, se.defense, se.speed, se.special }, ","), table.concat(ref.statExp, ","))
local d = mon.dvs
if mon.shiny then
chk("shiny dvs", true, ref.dvDef == 10 and ref.dvSpe == 10 and ref.dvSpc == 10 and (ref.dvAtk % 4 >= 2))
else
chk("dvs", table.concat({ d.attack, d.defense, d.speed, d.special }, ","),
table.concat({ ref.dvAtk, ref.dvDef, ref.dvSpe, ref.dvSpc }, ","))
end
chk("friendship", mon.isEgg and mon.eggSteps or mon.happiness, ref.friendship)
chk("pokerus", mon.pokerus or 0, ref.pokerus)
chk("level", mon.level, ref.level)
chk("ot", mon.ot, ref.ot)
local nick = mon.nickname
if not nick or nick == "" then nick = mon.isEgg and "EGG" or mon.name end
chk("nick", nick, ref.nick)
if version == "crystal" then
local b0 = (mon.caughtTime % 4) * 64 + mon.caughtLevel % 64
local b1 = (mon.caughtByGender == "girl" and 128 or 0) + mon.caughtLocation % 128
chk("caught", b0 * 256 + b1, ref.caught)
end
if party then
chk("status", statusByte(mon), ref.status)
chk("hp", mon.hp, ref.hp)
chk("maxHp", mon.maxHp, ref.maxHp)
local st = mon.stats
chk("stats", table.concat({ st.attack, st.defense, st.speed, st.specialAttack, st.specialDefense }, ","),
table.concat(ref.stats, ","))
end
end
local PROJECT = { "rival", "mom", "currentBox", "pokedex", "unownDex", "firstUnownSeen", "events",
"mapScenes", "engineFlags", "boxNames", "variableSprites", "playerState", "options", "playTime" }
local function flat(v, path, out)
if type(v) ~= "table" then out[path] = tostring(v) return end
local any = false
for k, x in pairs(v) do
any = true
flat(x, path .. (type(k) == "number" and ("[" .. k .. "]") or ("." .. tostring(k))), out)
end
if not any then out[path] = "{}" end
end
local function project(save)
local out = {}
for _, root in ipairs(PROJECT) do if save[root] ~= nil then flat(save[root], root, out) end end
return out
end
for _, version in ipairs({ "gold", "silver", "crystal" }) do
local bad = 0
stats.saves, stats.mons, stats.eggs, stats.forced = 0, 0, 0, 0
for n = 1, N do
local save = buildSave(version)
local tag = ("%s seed %d"):format(version, n)
local out, err = Gen2Save.encode(save, version, nil, DATA[version])
local fails = {}
if not out then
fails[#fails + 1] = "export refused: " .. tostring(err)
else
stats.saves = stats.saves + 1
local dumpDir = os.getenv("SAVE_COMPAT_ENGINE_DUMP")
if dumpDir and n <= (tonumber(os.getenv("SAVE_COMPAT_ENGINE_DUMP_N")) or 8) then
local f = io.open(("%s/gen2.%s.engine%03d.sav"):format(dumpDir, version, n), "wb")
if f then f:write(out) f:close() end
local function arr(list) return "[" .. table.concat(list, ",") .. "]" end
local function str(v) return '"' .. tostring(v) .. '"' end
local mdef = DATA[version].maps[save.position.map]
local parts = {}
for i, mon in ipairs(save.party) do
local mv, pp = {}, {}
for k = 1, 4 do
local m = mon.moves[k]
mv[k] = m and moveIndex(version, m.id) or 0
pp[k] = m and ((m.ppUps or 0) * 64 + m.pp) or 0
end
parts[i] = ("{\"species\":%d,\"item\":%d,\"moves\":%s,\"pp\":%s,\"level\":%d,\"status\":%d,\"hp\":%d,\"max_hp\":%d,\"listed\":%d}")
:format(speciesIndex(version, mon.species), mon.item and itemIndex(version, mon.item) or 0, arr(mv), arr(pp),
mon.level, statusByte(mon), mon.hp, mon.maxHp, mon.isEgg and 0xFD or speciesIndex(version, mon.species))
end
local counts, names = {}, {}
for b = 1, 14 do counts[b] = #save.boxes[b]; names[b] = str(save.boxNames[b]) end
local f2 = io.open(("%s/gen2.%s.engine%03d.expect.json"):format(dumpDir, version, n), "wb")
if f2 then
f2:write(("{\"player_name\":%s,\"player_id\":%d,\"money\":%d,\"party_count\":%d,\"party\":[%s],\"cur_box\":%d,"
.. "\"box_names\":%s,\"map_group\":%d,\"map_number\":%d,\"x\":%d,\"y\":%d,\"sram_box_counts\":%s,"
.. "\"sram_cur_box_count\":%d}"):format(str(save.player.name), save.player.id, save.player.money, #save.party,
table.concat(parts, ","), save.currentBox - 1, "[" .. table.concat(names, ",") .. "]", mdef.group, mdef.map,
save.position.x, save.position.y, arr(counts), #save.boxes[save.currentBox]))
f2:close()
end
end
local report = Compat.check(out, version)
if #report.errors > 0 then fails[#fails + 1] = "compat: " .. Compat.describe(report) end
local ref = Ref.decode(out, version)
if not (ref.checksum1 and ref.checksum2) then fails[#fails + 1] = "reference checksum" end
for i, mon in ipairs(save.party) do
stats.mons = stats.mons + 1
if mon.isEgg then stats.eggs = stats.eggs + 1 end
compareMon(("party%d"):format(i), version, mon, ref.party.mons[i] or {}, true, fails)
end
eq(ref.party.count, #save.party, tag .. ": party count")
eq(ref.id, save.player.id, tag .. ": trainer id")
eq(ref.name, save.player.name, tag .. ": player name")
eq(ref.rival, save.rival.name, tag .. ": rival name")
eq(ref.money, save.player.money, tag .. ": money")
eq(ref.coins, save.player.coins, tag .. ": coins")
eq(ref.hours, save.playTime.hours, tag .. ": play hours")
eq(ref.minutes, save.playTime.minutes, tag .. ": play minutes")
eq(ref.curBox, save.currentBox, tag .. ": current box")
for b = 1, 14 do
if ref.boxes[b].count ~= #save.boxes[b] then fails[#fails + 1] = ("box%d count"):format(b) end
for i, mon in ipairs(save.boxes[b]) do
stats.mons = stats.mons + 1
compareMon(("box%d.%d"):format(b, i), version, mon, ref.boxes[b].mons[i] or {}, false, fails)
end
if ref.boxNames[b] ~= save.boxNames[b] then fails[#fails + 1] = ("boxName%d"):format(b) end
end
for i = 0, 255 do
if ref.events[i] ~= save.events[i] then fails[#fails + 1] = ("event byte %d"):format(i) break end
end
local back, derr = Gen2Save.decode(out, version, DATA[version])
if not back then
fails[#fails + 1] = "reimport: " .. tostring(derr)
else
eq(back.player.name, save.player.name, tag .. ": player name reimports")
eq(back.player.money, save.player.money, tag .. ": money reimports")
local want, got = project(save), project(back)
for path, value in pairs(want) do
if got[path] ~= value then
fails[#fails + 1] = ("field %s: %s -> %s"):format(path, value, tostring(got[path]))
break
end
end
eq(back.position.map, save.position.map, tag .. ": the map comes back by name")
eq(back.position.x, save.position.x, tag .. ": x")
eq(back.position.y, save.position.y, tag .. ": y")
for id, qty in pairs(save.inventory) do
if back.inventory[id] ~= qty then fails[#fails + 1] = ("bag %s: %s -> %s"):format(id, qty, tostring(back.inventory[id])) break end
end
for id, qty in pairs(save.pcItems) do
if back.pcItems[id] ~= qty then fails[#fails + 1] = ("pc %s"):format(id) break end
end
for i, mon in ipairs(save.party) do
local bm = back.party[i]
if (bm.status or "") ~= ((mon.status == "toxic" and "poison") or mon.status or "") then
fails[#fails + 1] = ("party%d status came back %s"):format(i, tostring(bm.status))
end
end
for i, entry in pairs(save.mail.party) do
local be = back.mail.party[i]
if not be or be.message ~= entry.message or be.author ~= entry.author or be.authorId ~= entry.authorId
or be.species ~= entry.species then
fails[#fails + 1] = ("party mail %d"):format(i)
end
end
for i, entry in ipairs(save.mail.box) do
local be = back.mail.box[i]
if not be or be.message ~= entry.message or be.author ~= entry.author then fails[#fails + 1] = ("box mail %d"):format(i) end
end
local again, aerr = Gen2Save.encode(back, version, nil, DATA[version])
if again ~= out then
fails[#fails + 1] = "not a fixed point: " .. (again and Diff.format(Diff.diff(out, again, Diff.regionsFor(2, version)), 4) or tostring(aerr))
end
local templated, terr = Gen2Save.encode(back, version, out, DATA[version])
if templated ~= out then
fails[#fails + 1] = "templated export differs: " .. (templated and Diff.format(Diff.diff(out, templated, Diff.regionsFor(2, version)), 4) or tostring(terr))
end
end
end
if #fails > 0 then
bad = bad + 1
if bad <= 4 then check(false, tag .. ": " .. table.concat(fails, "; ", 1, math.min(#fails, 6))) end
end
end
eq(bad, 0, ("%s: %d engine-built saves (%d mons, %d eggs, %d forced shiny) export, decode independently, reimport and reach a fixed point")
:format(version, N, stats.mons, stats.eggs, stats.forced))
end
T.finish()