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

1590 lines
64 KiB
Lua

#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local bit = require("bit")
local GameVersion = require("src.core.GameVersion")
local LABEL = "game3_cart_random_sessions_test"
local GAME
if os.getenv("POKEPORT_RANDOM_GAME") == "emerald" then
GameVersion.set("emerald")
require("src.import.gba.versions").select("emerald")
local Dataset = require("src.core.game3.dataset")
local cache = Dataset.cache()
if not cache:read("data/generated/gba/native/manifest.lua") or not cache:read("data/generated/gba/pokemon/national.lua") then
print("[skip] " .. LABEL .. ": no Emerald cache for identity "
.. tostring(os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d"))
os.exit(0)
end
Dataset.mountExtractRoots()
Dataset.hydrate({ data = {} })
GAME = "emerald"
else
GAME = require("tests.game3_cart_cache").mountOrSkip(LABEL)
GameVersion.set(GAME)
require("src.import.gba.versions").select(GAME)
end
local N = tonumber(os.getenv("POKEPORT_RANDOM_SESSIONS")) or 200
local SEED = tonumber(os.getenv("POKEPORT_RANDOM_SEED")) or 1
local ONLY = tonumber(os.getenv("POKEPORT_RANDOM_ONLY"))
local DUMP = os.getenv("POKEPORT_RANDOM_DUMP_DIR")
local VERBOSE = os.getenv("POKEPORT_RANDOM_VERBOSE") == "1"
local SaveConvert = require("src.save_convert.SaveConvert")
local Compat = require("src.save_convert.Compat")
local Gen3Save = require("src.save_convert.Gen3Save")
local Rse = require("src.save_convert.gen3_port.rse")
local Frlg = require("src.save_convert.gen3_port.frlg")
local Schema = require("src.core.game3.save_schema_firered")
local Party = require("src.core.game3.party")
local Bag = require("src.core.game3.bag")
local Storage = require("src.core.game3.storage")
local Mail = require("src.core.game3.mail")
local Dex = require("src.core.game3.dex")
local Flags = require("src.core.game3.scripting.flags")
local Pokemon = require("src.core.game3.pokemon")
local Daycare = require("src.core.game3.daycare")
local Roamer = require("src.core.game3.roamer")
local Options = require("src.core.game3.options")
local ItemsData = require("src.core.game3.items_data")
local SummaryData = require("src.core.game3.summary_data")
local Profile = require("src.core.game3.profile")
local Ribbons = require("src.core.game3.rse.ribbons")
local D = require("tests.save_compat._gen3_decode")
local Dataset = require("src.core.game3.dataset")
local CODEC = Gen3Save.forVersion(GAME)
local L = CODEC.L
local FAMILY = GAME == "emerald" and "emerald" or "frlg"
local SECTIONS = GAME == "emerald" and Rse.SECTIONS or Frlg.SECTIONS
local EMERALD = GAME == "emerald"
local U32 = 4294967296
local NATIONAL = assert(SaveConvert.gen3CacheTable(GAME, "pokemon/national.lua"), "pokemon/national.lua")
local SPECIES_NAMES = SaveConvert.gen3CacheTable(GAME, "pokemon/names.lua") or {}
local ITEM_PACK = assert(SaveConvert.gen3CacheTable(GAME, "items/pack.lua"), "items/pack.lua")
local MOVES = assert((SaveConvert.gen3CacheTable(GAME, "pokemon/battle_moves.lua") or {}).moves, "pokemon/battle_moves.lua")
local ABILITIES = SaveConvert.gen3CacheTable(GAME, "pokemon/abilities.lua")
local cache = Dataset.cache()
local function H_rng(seed)
local state = seed % 2147483647
if state <= 0 then state = state + 2147483646 end
return function(n)
state = (state * 48271) % 2147483647
if n then return state % n end
return state
end
end
local function deepcopy(v)
if type(v) ~= "table" then return v end
local out = {}
for k, x in pairs(v) do out[k] = deepcopy(x) end
return out
end
local function merge(dst, src)
for k, v in pairs(src) do
if type(v) == "table" and type(dst[k]) == "table" then merge(dst[k], v) else dst[k] = deepcopy(v) end
end
end
local VALID_SPECIES = {}
for sp = 1, 411 do
if (sp <= 251 or sp >= 277) and NATIONAL.toNational[sp] then VALID_SPECIES[#VALID_SPECIES + 1] = sp end
end
local VALID_MOVES = {}
for id, row in pairs(MOVES) do
if type(id) == "number" and id >= 1 and id <= 354 and type(row) == "table" then VALID_MOVES[#VALID_MOVES + 1] = id end
end
table.sort(VALID_MOVES)
local ITEMS_BY_POCKET, ALL_ITEMS, MAIL_ITEMS, KEY_ITEMS = {}, {}, {}, {}
for id, e in pairs(ITEM_PACK.items or {}) do
if type(id) == "number" and id > 0 and type(e) == "table" and e.name and e.name ~= "????????" then
local pocket = ItemsData.pocketOf(id)
ITEMS_BY_POCKET[pocket] = ITEMS_BY_POCKET[pocket] or {}
table.insert(ITEMS_BY_POCKET[pocket], id)
ALL_ITEMS[#ALL_ITEMS + 1] = id
if Mail.isMailItem(id) then MAIL_ITEMS[#MAIL_ITEMS + 1] = id end
if pocket == "KEY_ITEMS" then KEY_ITEMS[#KEY_ITEMS + 1] = id end
end
end
table.sort(ALL_ITEMS)
for _, list in pairs(ITEMS_BY_POCKET) do table.sort(list) end
table.sort(MAIL_ITEMS)
table.sort(KEY_ITEMS)
local HOLDABLE = {}
for _, id in ipairs(ALL_ITEMS) do
if not Mail.isMailItem(id) and ItemsData.pocketOf(id) ~= "KEY_ITEMS" then HOLDABLE[#HOLDABLE + 1] = id end
end
local PREFIX = Profile.of(GAME).map.enginePrefix
local function engineMapId(pretName)
local s = pretName:gsub("(%l)(%u)", "%1_%2"):gsub("(%u)(%u%l)", "%1_%2")
return PREFIX .. s:upper()
end
local MAPS = {}
for g = 0, 60 do
for n = 0, 200 do
local raw = cache:read(("data/generated/gba/map_tree/maps/%d_%d/header.json"):format(g, n))
if raw then
local id = raw:match('"id"%s*:%s*"([^"]+)"')
local width, height = tonumber(raw:match('"width"%s*:%s*(%d+)')), tonumber(raw:match('"height"%s*:%s*(%d+)'))
local layout = tonumber(raw:match('"layoutId"%s*:%s*(%d+)'))
if id and width and height and layout and width > 0 and height > 0 then
local eid, named = engineMapId(id), true
if not CODEC.cartWarp({ map = eid, x = 0, y = 0 }) then eid, named = CODEC.mapFor(g, n), false end
if eid then
MAPS[#MAPS + 1] = { id = eid, group = g, num = n, width = width, height = height, layout = layout }
if named then MAPS.byName = (MAPS.byName or 0) + 1 else MAPS.byCodec = (MAPS.byCodec or 0) + 1 end
end
end
end
end
end
table.sort(MAPS, function(a, b) return a.group * 1000 + a.num < b.group * 1000 + b.num end)
assert(#MAPS > 50, "map list from the cache headers: " .. #MAPS)
print(("[info] %d cached map headers: %d engine ids derived from the pret name, %d from the codec table")
:format(#MAPS, MAPS.byName or 0, MAPS.byCodec or 0))
local FLAG_MAX = L.FLAGS_COUNT - 1
local NAT = Gen3Save.forVersion(GAME).resolved().national
local CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 "
local stats = {
sessions = 0, exported = 0, compatErrors = 0, decodeFail = 0, fixedPoint = 0, r2 = 0, edit = 0,
eggsParty = 0, eggsBox = 0, mailMons = 0, pcMail = 0, fullBoxes = 0, emptyParty = 0, fullParty = 0,
boxMons = 0, partyMons = 0, shiny = 0, traded = 0, statusMons = 0, daycareMons = 0, daycareMail = 0,
route5 = 0, roamer = 0, hofTeams = 0, nationalDex = 0, pcItems = 0, flagsSet = 0, varsSet = 0,
bagItems = 0, sectionsMerged = 0, contest = 0, ribbons = 0, champion = 0, fateful = 0, trailingSpaceNick = 0,
dynamicWarp = 0, escapeWarp = 0, registeredTexts = 0, fameTower = 0, sectionReads = 0, registeredItem = 0, gameStats = 0, linkRoomMaps = 0,
}
local failures, failureKinds, kindOrder = 0, {}, {}
local KNOWN = {
{ "contest", "B1 engine mon.contest is not exported or imported (Gen3Save reads cartExtra.contest only)" },
{ "ribbons", "B2 engine mon.ribbons/championRibbon/modernFatefulEncounter are not exported or imported" },
{ "championRibbon", "B2" },
{ "modernFatefulEncounter", "B2" },
{ "abilit", "B3 abilityNum = personality & 1 for single-ability species (pokefirered/src/pokemon.c:1855)" },
{ "SetContinueGameWarpToDynamicWarp", "E1 engine saveWarpFields drops dynamicWarp.warpId" },
{ "fixture withTemplate", "B4 dex bits above the last species are dropped even with an untouched template" },
}
local function knownBug(kind)
for _, k in ipairs(KNOWN) do if kind:find(k[1], 1, true) then return k[2] end end
return nil
end
local sessionFails, sessionUnknown = {}, {}
local CURRENT = { idx = 0 }
local function kindOf(path)
return (path:gsub("%[%d+%]", "[]"):gsub("%.%d+", ".#"))
end
local function fail(stage, path, engine, cart)
if CURRENT.dry then CURRENT.dry = CURRENT.dry + 1 return end
failures = failures + 1
local kind = stage .. " " .. kindOf(path)
sessionFails[CURRENT.idx] = true
if not knownBug(kind) then sessionUnknown[CURRENT.idx] = true end
local rec = failureKinds[kind]
if not rec then
rec = { count = 0, sessions = {}, first = ("seed=%d session=%d %s %s engine=%s cart=%s"):format(SEED, CURRENT.idx,
stage, path, tostring(engine), tostring(cart)) }
failureKinds[kind] = rec
kindOrder[#kindOrder + 1] = kind
end
rec.count = rec.count + 1
if not rec.sessions[CURRENT.idx] then
rec.sessions[CURRENT.idx] = true
rec.sessionCount = (rec.sessionCount or 0) + 1
end
if VERBOSE then
print(("[FAIL] seed=%d session=%d %s %s engine=%s cart=%s"):format(SEED, CURRENT.idx, stage, path,
tostring(engine), tostring(cart)))
end
end
local function same(stage, path, engine, cart)
if engine ~= cart then fail(stage, path, engine, cart) return false end
return true
end
local function randName(r, maxLen, allowEmpty, allowTrailing)
local len = r(maxLen + (allowEmpty and 1 or 0)) + (allowEmpty and 0 or 1)
local out = {}
for i = 1, len do
local c = r(#CHARS) + 1
if (i == 1 or (i == len and not allowTrailing)) and CHARS:sub(c, c) == " " then c = 1 + r(26) end
out[i] = CHARS:sub(c, c)
end
local s = table.concat(out)
if s:match("^BOX%d+$") then s = "X" .. s:sub(2) end
return s
end
local function pick(r, list) return list[r(#list) + 1] end
local function expForLevel(mon, level)
local growth = tonumber(mon.growthRate) or Pokemon.growthRate(mon.species) or 0
return SummaryData.expForLevel(growth, level)
end
local function speciesName(sp)
return Pokemon.name(sp)
end
local function maxPpOf(move, bonus)
local base = tonumber(MOVES[move] and MOVES[move].pp) or 5
return math.floor(base * (5 + bonus) / 5)
end
local STATUSES = { "PSN", "BRN", "FRZ", "PAR", "TOX", "SLP" }
local function randomizeMon(s, mon, r, opts)
opts = opts or {}
local own = r(5) ~= 0
if not own then
mon.otId = r(65536)
mon.otSecretId = r(65536)
mon.otName = randName(r, 7)
mon.ot = mon.otName
mon.otGender = r(2)
stats.traded = stats.traded + 1
else
mon.otId = s.trainerId
mon.otSecretId = s.secretId
mon.otName, mon.ot = s.name, s.name
mon.otGender = Party.otGender(s)
end
local tid, sid = mon.otId % 65536, mon.otSecretId % 65536
local pid
if r(10) == 0 then
local hi = r(65536)
local lo = bit.bxor(bit.bxor(tid, sid), bit.bxor(hi, r(8))) % 65536
pid = hi * 65536 + lo
stats.shiny = stats.shiny + 1
else
pid = r(65536) * 65536 + r(65536)
end
mon.personality = pid
mon.nature = Pokemon.natureId(pid)
mon.ability = Pokemon.abilityId(mon.species, pid)
mon.abilityId = mon.ability
mon.gender = Pokemon.gender(mon.species, pid)
local ivs = {}
for _, k in ipairs({ "hp", "atk", "def", "spe", "spa", "spd" }) do ivs[k] = r(32) end
mon.ivs = ivs
local evs, left = {}, 510
for _, k in ipairs({ "hp", "atk", "def", "spe", "spa", "spd" }) do
local v = math.min(r(256), left)
evs[k] = v
left = left - v
end
mon.evs = evs
if not mon.isEgg then
local lvl = 1 + r(100)
mon.level = lvl
local lo = expForLevel(mon, lvl)
local hi = lvl < 100 and expForLevel(mon, lvl + 1) - 1 or lo
mon.exp = lo + (hi > lo and r(hi - lo + 1) or 0)
mon.friendship = r(256)
mon.happiness = mon.friendship
else
mon.eggCycles = r(256)
end
local moves, pp, maxPp = {}, {}, {}
local bonuses = r(256)
local count = 1 + r(4)
local used = {}
while #moves < count do
local m = pick(r, VALID_MOVES)
if not used[m] then
used[m] = true
moves[#moves + 1] = m
local mx = maxPpOf(m, math.floor(bonuses / 4 ^ (#moves - 1)) % 4)
maxPp[#moves] = mx
pp[#moves] = r(mx + 1)
end
end
mon.moves, mon.pp, mon.maxPp, mon.ppBonusesPacked = moves, pp, maxPp, bonuses
if r(4) == 0 then
local strain = 1 + r(15)
mon.pokerus = strain * 16 + r(strain % 4 + 2)
else
mon.pokerus = 0
end
mon.metLocation = r(0xD5)
mon.metLevel = mon.isEgg and 0 or r(101)
mon.metGame = 1 + r(5)
mon.pokeball = 1 + r(12)
mon.markings = r(16)
if not mon.isEgg then mon.language = pick(r, { 1, 2, 3, 4, 5, 7 }) end
if not mon.isEgg and r(3) == 0 then
mon.nickname = randName(r, 10, false, r(6) == 0)
if mon.nickname:sub(-1) == " " then stats.trailingSpaceNick = stats.trailingSpaceNick + 1 end
elseif not mon.isEgg then
mon.nickname = ""
end
if r(2) == 0 then
local it = pick(r, HOLDABLE)
mon.item, mon.heldItem = it, it
else
mon.item, mon.heldItem = nil, nil
end
if EMERALD and r(3) == 0 then
local c = require("src.core.game3.rse.pokeblock").contest(mon)
for _, k in ipairs({ "cool", "beauty", "cute", "smart", "tough", "sheen" }) do c[k] = r(256) end
stats.contest = stats.contest + 1
end
if EMERALD and r(3) == 0 then
for _, f in ipairs(Ribbons.FIELDS) do
if f.name ~= "unused" and f.name ~= "fateful" and r(3) == 0 then Ribbons.set(mon, f.name, r(2 ^ f.bits)) end
end
stats.ribbons = stats.ribbons + 1
end
if not mon.isEgg and r(8) == 0 then
if EMERALD then Ribbons.set(mon, "champion", 1) else mon.championRibbon = true end
stats.champion = stats.champion + 1
end
if r(12) == 0 then
mon.modernFatefulEncounter = true
stats.fateful = stats.fateful + 1
end
Pokemon.applyStats(mon)
if opts.party then
mon.hp = r(mon.maxHp + 1)
if r(3) == 0 then
local st = pick(r, STATUSES)
mon.status = st
if st == "SLP" then mon.sleep = 1 + r(7) end
stats.statusMons = stats.statusMons + 1
else
mon.status, mon.sleep = nil, nil
end
else
Storage.fullHealMon(mon)
end
return mon
end
local function scratchFor(s)
return { party = {}, name = s.name, trainerId = s.trainerId, secretId = s.secretId, gender = s.gender,
version = s.version, dex = { seen = {}, owned = {}, caught = {} } }
end
local function newMon(s, r, opts)
local tmp = scratchFor(s)
local sp = pick(r, VALID_SPECIES)
local ok, code, mon
if r(10) == 0 then
ok, code, mon = Party.giveEgg(tmp, sp)
else
ok, code, mon = Party.giveMon(tmp, sp, 5, nil)
end
assert(ok and mon, "Party.giveMon")
return randomizeMon(s, mon, r, opts)
end
local function randWarp(r)
local m = pick(r, MAPS)
return { map = m.id, warpId = r(10) == 0 and -1 or r(8), x = r(m.width), y = r(m.height) }
end
local LINK_ROOMS = {
[PREFIX .. "BATTLE_COLOSSEUM_2P"] = true, [PREFIX .. "TRADE_CENTER"] = true, [PREFIX .. "RECORD_CORNER"] = true,
[PREFIX .. "BATTLE_COLOSSEUM_4P"] = true, [PREFIX .. "UNION_ROOM"] = true, [PREFIX .. "UNION_ROOM_PLAZA"] = true,
}
local function sectionState(r)
local sb1, sb2 = {}, {}
for i = 1, L.BLOCKS[2].size do sb1[i] = string.char(r(256)) end
for i = 1, L.BLOCKS[1].size do sb2[i] = string.char(r(256)) end
local blocks = { sb1 = table.concat(sb1), sb2 = table.concat(sb2) }
local vals = {}
Rse.readSections(CODEC, blocks, vals, SECTIONS)
return vals, blocks
end
local function buildSession(idx)
local r = H_rng(SEED * 1000003 + idx * 7919)
local gender = r(2)
local s = Schema.newGame({ version = GAME, name = randName(r, 7), gender = gender,
rivalName = not EMERALD and randName(r, 7) or nil, rngSeed = SEED * 100000 + idx })
s.trainerId = r(65536)
s.secretId = r(65536)
s.id, s.playerId = s.trainerId, s.trainerId
local m = pick(r, MAPS)
s.map, s.x, s.y = m.id, r(m.width), r(m.height)
s.facing = pick(r, { "down", "up", "left", "right" })
s.money = r(10) == 0 and 999999 or r(1000000)
s.coins = r(10) == 0 and 9999 or r(10000)
s.berryPowder = r(10) == 0 and 99999 or r(100000)
s.playtime = { hours = r(10) == 0 and 999 or r(1000), minutes = r(60), seconds = r(60) }
local o = Options.ensure(s)
o.textSpeed, o.frameType, o.sound = r(3), r(10), r(2)
o.battleStyle, o.battleScene, o.buttonMode = r(2), r(2), r(3)
o.text_speed, o.l_equals_a = o.textSpeed, o.buttonMode == 2
s.flashLevel = r(8)
s.gcnLinkFlags = r(4) == 0 and r(65536) or 0
local partySize = r(7)
if partySize == 0 then stats.emptyParty = stats.emptyParty + 1 end
if partySize == 6 then stats.fullParty = stats.fullParty + 1 end
for _ = 1, partySize do
local sp = pick(r, VALID_SPECIES)
local ok, code, mon
if r(8) == 0 then
ok, code, mon = Party.giveEgg(s, sp)
stats.eggsParty = stats.eggsParty + 1
else
ok, code, mon = Party.giveMon(s, sp, 5, nil)
end
assert(ok, "Party.giveMon party")
randomizeMon(s, mon, r, { party = true })
stats.partyMons = stats.partyMons + 1
end
for _, mon in ipairs(s.party) do
if not mon.isEgg and r(4) == 0 then
local id = Mail.giveMailToMon(s, mon, pick(r, MAIL_ITEMS))
if id ~= Mail.MAIL_NONE then
local rec = Mail.slot(s, id)
for i = 1, 9 do rec.words[i] = r(5) == 0 and 0xFFFF or (r(22) * 512 + r(40)) end
stats.mailMons = stats.mailMons + 1
end
end
end
if r(3) == 0 then
local pool = Mail.pool(s)
for i = 7, 16 do
if r(3) == 0 then
local rec = pool[i]
Mail.clear(rec)
rec.playerName = randName(r, 7)
rec.trainerId = r(65536)
rec.species = pick(r, VALID_SPECIES)
rec.itemId = pick(r, MAIL_ITEMS)
rec.design = Mail.designOf(rec.itemId)
for k = 1, 9 do rec.words[k] = r(22) * 512 + r(40) end
stats.pcMail = stats.pcMail + 1
end
end
end
local Sto = s.storage
for b = 1, 14 do
local box = Sto.boxes[b]
if r(4) == 0 then box.name = randName(r, 8, true) end
if r(4) == 0 then box.wallpaper = 1 + r(L.WALLPAPER_MAX + 1) end
local count = r(12) == 0 and 30 or (r(3) == 0 and r(31) or 0)
if count == 30 then stats.fullBoxes = stats.fullBoxes + 1 end
local slots = {}
for sl = 1, 30 do slots[sl] = sl end
for i = 30, 2, -1 do local j = r(i) + 1; slots[i], slots[j] = slots[j], slots[i] end
for i = 1, count do
local mon = newMon(s, r, { party = false })
if mon.isEgg then stats.eggsBox = stats.eggsBox + 1 end
box.mons[slots[i]] = mon
stats.boxMons = stats.boxMons + 1
end
end
Sto.currentBox = 1 + r(14)
Sto.items = {}
for _ = 1, r(4) == 0 and 60 or r(12) do
local id = pick(r, ALL_ITEMS)
local stacked = false
for _, it in ipairs(Sto.items) do if it.id == id then stacked = true end end
if (stacked or #Sto.items < L.PC_ITEMS.count) and Storage.addPcItem(s, id, 1 + r(999)) then
stats.pcItems = stats.pcItems + 1
end
end
for _ = 1, r(5) == 0 and 120 or r(25) do
if Bag.add(s.bag, pick(r, ALL_ITEMS), 1 + r(r(3) == 0 and 999 or 20)) then stats.bagItems = stats.bagItems + 1 end
end
local keys = s.bag.pockets.KEY_ITEMS or {}
if #keys > 0 and r(2) == 0 then
s.registeredItem = keys[r(#keys) + 1].id
stats.registeredItem = stats.registeredItem + 1
end
for _ = 1, r(60) do
local sp = pick(r, VALID_SPECIES)
local roll = r(3)
if roll == 0 then Dex.setSeen(s.dex, sp)
elseif roll == 1 then Dex.setCaught(s.dex, sp)
else Dex.handleSetPokedexFlag(s.dex, sp, r(2) == 0, r(65536) * 65536 + r(65536)) end
end
if r(3) == 0 then
Dex.handleSetPokedexFlag(s.dex, 201, true, r(65536) * 65536 + r(65536))
Dex.handleSetPokedexFlag(s.dex, 308, false, r(65536) * 65536 + r(65536))
end
for _ = 1, r(200) do
local id = 0x20 + r(FLAG_MAX - 0x1F)
if id ~= NAT.flag then Flags.setFlag(s, nil, id, true) stats.flagsSet = stats.flagsSet + 1 end
end
for _ = 1, r(60) do
local id = 0x4000 + r(256)
if id ~= NAT.var and id ~= L.RSE_NATIONAL_VAR then
Flags.setVar(s, nil, id, r(65536))
stats.varsSet = stats.varsSet + 1
end
end
if r(3) == 0 then
stats.nationalDex = stats.nationalDex + 1
if EMERALD then
Dex.enableNational(s)
else
s.dex.nationalUnlocked = true
s.national_dex_unlocked = true
end
Flags.setVar(s, nil, NAT.var, NAT.varValue)
Flags.setFlag(s, nil, NAT.flag, true)
end
s.gameStats = {}
for _ = 1, r(20) do
local id = r(64)
if id == 1 then
s.gameStats[1] = r(1000) * 65536 + r(60) * 256 + r(60)
else
s.gameStats[id] = r(4) == 0 and r(U32) or r(100000)
end
stats.gameStats = stats.gameStats + 1
end
if r(2) == 0 then
s.registeredTexts = {}
for i = 1, 10 do s.registeredTexts[i] = randName(r, 10, true, true) end
stats.registeredTexts = stats.registeredTexts + 1
end
s.easyChatProfile = {}
for i = 1, 4 do s.easyChatProfile[i] = r(22) * 512 + r(40) end
if r(4) == 0 then
s.dynamicWarp = randWarp(r)
stats.dynamicWarp = stats.dynamicWarp + 1
end
if r(15) == 0 then
local rooms = {}
for _, mm in ipairs(MAPS) do if LINK_ROOMS[mm.id] then rooms[#rooms + 1] = mm end end
if #rooms > 0 then
local lr = pick(r, rooms)
s.map, s.x, s.y = lr.id, r(lr.width), r(lr.height)
s.dynamicWarp = randWarp(r)
stats.linkRoomMaps = stats.linkRoomMaps + 1
end
end
if EMERALD then
local Player = require("src.core.game3.rse.easy_chat_player")
for _, f in ipairs(Player.BATTLE_FIELDS) do
local words = Player.battleWords(s, f)
for i = 1, #words do if r(2) == 0 then words[i] = r(5) == 0 and 0xFFFF or r(22) * 512 + r(40) end end
end
end
local clear = Gen3Save.forVersion(GAME).resolved().gameClear
if Flags.getFlag(s, nil, clear) then s.game_cleared = true end
if r(4) == 0 then
s.escapeWarp = randWarp(r)
stats.escapeWarp = stats.escapeWarp + 1
end
if r(3) == 0 then
local ok
if EMERALD then ok = Roamer.initRse(s, r(2) == 0) else ok = Roamer.init(s, pick(r, { 1, 4, 7 })) end
if ok and s.roamer then
local ro = s.roamer
ro.active = r(4) ~= 0
ro.level = 1 + r(100)
ro.hp = r(400)
local st = r(8)
if EMERALD then ro.status = st else ro.status, ro.statusNum = st, st end
stats.roamer = stats.roamer + 1
end
end
local nDay = #s.party > 1 and r(3) or 0
for _ = 1, nDay do
if #s.party > 1 then
local slot = 1 + r(#s.party)
local mon = s.party[slot]
if mon and not mon.isEgg then
local hadMail = Mail.monHasMail(mon)
if Daycare.deposit(s, slot) then
stats.daycareMons = stats.daycareMons + 1
if hadMail then stats.daycareMail = stats.daycareMail + 1 end
end
end
end
end
if nDay > 0 then
local dc = Daycare.stateOf(s)
for i = 1, 2 do if Daycare.mon(dc, i) then dc.steps[i] = r(100000) end end
dc.offspringPersonality = r(4) == 0 and (EMERALD and r(65536) * 65536 + r(65536) or r(65536)) or 0
dc.stepCounter = r(256)
end
if not EMERALD and #s.party > 1 and r(4) == 0 then
local slot = 1 + r(#s.party)
local mon = s.party[slot]
if mon and not mon.isEgg and Daycare.depositRoute5(s, slot) then
Daycare.route5Of(s).steps = r(100000)
stats.route5 = stats.route5 + 1
end
end
if r(4) == 0 then
s.hallOfFameTeams = {}
for _ = 1, 1 + r(r(4) == 0 and 50 or 5) do
local team = {}
for _ = 1, 1 + r(6) do
local sp = pick(r, VALID_SPECIES)
team[#team + 1] = { species = sp, level = 1 + r(100), nickname = randName(r, 10),
trainerId = r(65536), otSecretId = r(65536), personality = r(65536) * 65536 + r(65536) }
end
s.hallOfFameTeams[#s.hallOfFameTeams + 1] = team
stats.hofTeams = stats.hofTeams + 1
end
s.hasHallOfFameRecords = true
end
if not EMERALD then
local FameChecker = require("src.core.game3.fame_checker")
FameChecker.records(s)
for p = 0, FameChecker.NUM_PERSONS - 1 do
for slot = 0, FameChecker.NUM_FLAVOR_TEXTS - 1 do
if r(4) == 0 then FameChecker.setFlavorText(p, slot, s) end
end
if r(3) == 0 then FameChecker.updatePickState(p, 1 + r(2), s) end
end
local Tower = require("src.core.game3.trainer_tower")
local state = Tower.state(s)
for _, rec in ipairs(state.records) do rec.bestTime = r(4) == 0 and Tower.MAX_TIME or r(Tower.MAX_TIME + 1) end
stats.fameTower = stats.fameTower + 1
end
local save = Schema.toSaveTable(s)
local vals, blocks = sectionState(r)
for k, v in pairs(vals) do
if k == "modData" then merge(save.modData, v) else save[k] = deepcopy(v) end
end
stats.sectionsMerged = stats.sectionsMerged + 1
save.modData.cartImage = nil
return s, save, r, vals, blocks
end
local function bitSet(list)
local out = {}
for _, v in ipairs(list) do out[v] = true end
return out
end
local function cmpSet(stage, path, want, got, limit)
for k in pairs(want) do if not got[k] then fail(stage, path .. "[" .. k .. "]", "set", "unset") end end
for k in pairs(got) do
if not want[k] and (not limit or k >= limit) then fail(stage, path .. "[" .. k .. "]", "unset", "set") end
end
end
-- pokeemerald/charmap.txt:80
local ESCAPES = { ["."] = "{AD}", ["-"] = "{AE}", ["'"] = "{B4}", ["\226\153\130"] = "{B5}", ["\226\153\128"] = "{B6}" }
local function textOf(s, n)
s = tostring(s or "")
local out, count, i = {}, 0, 1
while i <= #s and count < n do
local c = s:byte(i)
local len = c < 0x80 and 1 or c < 0xE0 and 2 or c < 0xF0 and 3 or 4
local ch = s:sub(i, i + len - 1)
out[#out + 1] = ESCAPES[ch] or ch
count, i = count + 1, i + len
end
return table.concat(out)
end
-- pokeemerald/src/mail_data.c:65
local function mailName(s)
s = tostring(s or "")
while #s < 6 do s = s .. " " end
return textOf(s, 7)
end
local function cartStatusOf(mon)
local st = mon.status
if st == "SLP" then return math.max(1, math.min(7, tonumber(mon.sleep) or 1)) end
local map = { PSN = 0x8, BRN = 0x10, FRZ = 0x20, PAR = 0x40, TOX = 0x80 }
return map[st] or 0
end
local function abilityOf(species, bitv)
local a = ABILITIES and ABILITIES[species]
if type(a) ~= "table" then return nil end
return bitv == 1 and (a[2] or 0) or (a[1] or 0)
end
local function compareMon(path, m, c, party, s)
local stage = "cart"
if not c then fail(stage, path, "mon", "empty") return end
same(stage, path .. ".checksum", true, c.checksumOk)
same(stage, path .. ".species", m.species, c.species)
same(stage, path .. ".personality", m.personality % U32, c.pid)
local sid = m.otSecretId
same(stage, path .. ".otId", (m.otId % 65536) + (sid % 65536) * 65536, c.otid)
same(stage, path .. ".heldItem", tonumber(m.item or m.heldItem) or 0, c.item)
same(stage, path .. ".isEgg", m.isEgg == true, c.isEgg)
local eggBit = math.floor(c.flags / 4) % 2 == 1
same(stage, path .. ".flags.isEggBit", m.isEgg == true, eggBit)
same(stage, path .. ".exp", m.exp, c.exp)
for i = 1, 4 do
same(stage, path .. ".moves[" .. i .. "]", m.moves[i] or 0, c.moves[i])
same(stage, path .. ".pp[" .. i .. "]", m.moves[i] and (m.pp[i] or 0) or 0, c.pp[i])
end
same(stage, path .. ".ppBonuses", m.ppBonusesPacked or 0, c.ppBonuses)
for i, k in ipairs({ "hp", "atk", "def", "spe", "spa", "spd" }) do
same(stage, path .. ".evs." .. k, m.evs[k] or 0, c.evs[i])
same(stage, path .. ".ivs." .. k, m.ivs[k] or 0, c.ivs[i])
end
local contest = type(m.contest) == "table" and m.contest or {}
for i, k in ipairs({ "cool", "beauty", "cute", "smart", "tough", "sheen" }) do
same(stage, path .. ".contest." .. k, tonumber(contest[k]) or 0, c.contest[i])
end
same(stage, path .. ".friendship", m.isEgg and (m.eggCycles or m.friendship) or m.friendship, c.friendship)
same(stage, path .. ".pokerus", m.pokerus or 0, c.pokerus)
same(stage, path .. ".metLocation", m.metLocation or 0, c.metLocation)
same(stage, path .. ".metLevel", m.metLevel or 0, c.metLevel)
same(stage, path .. ".metGame", m.metGame, c.metGame)
same(stage, path .. ".ball", m.pokeball or 4, c.ball)
same(stage, path .. ".otGender", m.otGender or 0, c.otGender)
local engineAbility = tonumber(m.ability or m.abilityId)
local cartAbility = abilityOf(m.species, c.ability)
if engineAbility and cartAbility then
same(stage, path .. ".abilityBit(GetAbilityBySpecies)", engineAbility, cartAbility)
end
local ribbons = EMERALD and Ribbons.word(m) or ((m.championRibbon and 2 ^ 15) or 0)
if m.modernFatefulEncounter then ribbons = ribbons + 2 ^ 31 end
same(stage, path .. ".ribbons", ribbons, c.ribbons)
same(stage, path .. ".markings", m.markings or 0, c.markings)
if m.isEgg then
same(stage, path .. ".nickname(egg)", "{60}{6F}{8B}", c.nickText)
same(stage, path .. ".language(egg)", m.language or 1, c.language)
else
local nick = (m.nickname and m.nickname ~= "") and m.nickname or speciesName(m.species)
same(stage, path .. ".nickname", textOf(nick, 10), c.nickText)
same(stage, path .. ".language", m.language or 2, c.language)
end
same(stage, path .. ".otName", textOf(m.otName or m.ot, 7), c.otText)
if party then
same(stage, path .. ".status", cartStatusOf(m), c.status)
same(stage, path .. ".level", m.level, c.level)
same(stage, path .. ".hp", m.hp, c.hp)
same(stage, path .. ".maxHp", m.maxHp, c.maxHp)
local st = { m.attack, m.defense, m.speed, m.spAtk, m.spDef }
local names = { "attack", "defense", "speed", "spAtk", "spDef" }
for i = 1, 5 do same(stage, path .. "." .. names[i], st[i], c.stats[i]) end
local wantMail = (Mail.monHasMail(m) and tonumber(m.mail)) or 0xFF
same(stage, path .. ".mail", wantMail, c.mail)
end
end
local function sortedPocket(list)
local out = {}
for _, it in ipairs(list or {}) do out[#out + 1] = { tonumber(it.id) or ItemsData.toNumericId(it.id), it.qty } end
return out
end
local function compareCart(s, save, bytes)
local c = D.deep(bytes, FAMILY)
local x = D.extra(bytes, FAMILY)
if not c or not x then fail("cart", "decode", "decodable", "nil") stats.decodeFail = stats.decodeFail + 1 return end
local st = "cart"
same(st, "name", textOf(s.name, 7), c.name)
same(st, "gender", s.gender, c.gender)
same(st, "trainerId", s.trainerId % 65536, c.tid)
same(st, "secretId", s.secretId % 65536, c.sid)
same(st, "playTime.hours", s.playtime.hours, c.playHours)
same(st, "playTime.minutes", s.playtime.minutes, c.playMinutes)
same(st, "playTime.seconds", s.playtime.seconds, c.playSeconds)
same(st, "playTime.vblanks", 0, c.playVBlanks)
local o = s.options
local ow = c.optionsWord
same(st, "options.textSpeed", o.textSpeed, ow % 8)
same(st, "options.frameType", o.frameType, math.floor(ow / 8) % 32)
same(st, "options.sound", o.sound, math.floor(ow / 256) % 2)
same(st, "options.battleStyle", o.battleStyle, math.floor(ow / 512) % 2)
same(st, "options.battleScene", o.battleScene, math.floor(ow / 1024) % 2)
if save.regionMapZoom ~= nil then
same(st, "regionMapZoom", save.regionMapZoom == true and 1 or 0, math.floor(ow / 2048) % 2)
end
same(st, "options.buttonMode", o.buttonMode, c.buttonMode)
local wantOwned, wantSeen = {}, {}
for sp, v in pairs(s.dex.owned or {}) do if v then wantOwned[NATIONAL.toNational[sp] - 1] = true end end
for sp, v in pairs(s.dex.caught or {}) do if v then wantOwned[NATIONAL.toNational[sp] - 1] = true end end
for sp, v in pairs(s.dex.seen or {}) do if v then wantSeen[NATIONAL.toNational[sp] - 1] = true end end
for k in pairs(wantOwned) do wantSeen[k] = true end
cmpSet(st, "dex.owned", wantOwned, bitSet(c.dexOwned))
cmpSet(st, "dex.seen(sb2)", wantSeen, bitSet(c.dexSeen))
cmpSet(st, "dex.seen(sb1 seen1)", wantSeen, bitSet(c.dexSeen1))
cmpSet(st, "dex.seen(sb1 seen2)", wantSeen, bitSet(c.dexSeen2))
same(st, "dex.unownPersonality", tonumber(s.dex.unownPersonality) or 0, x.unownPersonality)
same(st, "dex.spindaPersonality", tonumber(s.dex.spindaPersonality) or 0, x.spindaPersonality)
local national = s.dex.nationalUnlocked == true or s.dex.national == true
same(st, "dex.nationalMagic", national and NAT.magic or 0, c.dexMagic)
if national and EMERALD then
same(st, "dex.mode(national)", 1, c.dexMode)
same(st, "dex.order", 0, c.dexOrder)
end
same(st, "money", s.money, c.money)
same(st, "coins", s.coins, c.coins)
same(st, "berryPowder", s.berryPowder, c.powder)
if EMERALD then
local k = tonumber(save.encryptionKey)
if k and k ~= 0 and k ~= 1 then same(st, "encryptionKey", k, c.key) end
end
same(st, "gcnLinkFlags", s.gcnLinkFlags or 0, x.gcnLinkFlags)
same(st, "flashLevel", s.flashLevel or 0, x.flashLevel)
local here
for _, m in ipairs(MAPS) do if m.id == s.map then here = m break end end
same(st, "location.group", here.group, c.location.group)
same(st, "location.num", here.num, c.location.num)
same(st, "location.warpId", -1, c.location.warpId)
same(st, "location.x", s.x, c.location.x)
same(st, "location.y", s.y, c.location.y)
same(st, "pos.x", s.x, c.posX)
same(st, "pos.y", s.y, c.posY)
same(st, "mapLayoutId", here.layout, c.mapLayoutId)
same(st, "specialSaveWarpFlags.continue", 1, c.specialSaveWarpFlags % 2)
local cont = here
local cx, cy = s.x, s.y
if save.continueGameWarp and type(save.continueGameWarp.map) == "string" and bit.band(save.specialSaveWarpFlags or 0, 1) ~= 0 then
for _, m in ipairs(MAPS) do if m.id == save.continueGameWarp.map then cont = m end end
cx, cy = save.continueGameWarp.x, save.continueGameWarp.y
end
same(st, "continueGameWarp.group", cont.group, c.continueGameWarp.group)
same(st, "continueGameWarp.num", cont.num, c.continueGameWarp.num)
same(st, "continueGameWarp.x", cx, c.continueGameWarp.x)
same(st, "continueGameWarp.y", cy, c.continueGameWarp.y)
if LINK_ROOMS[s.map] and s.dynamicWarp then
same(st, "continueGameWarp.warpId(link room: SetContinueGameWarpToDynamicWarp copies dynamicWarp)",
s.dynamicWarp.warpId, c.continueGameWarp.warpId)
end
if EMERALD then
for k, f in ipairs({ "easyChatBattleStart", "easyChatBattleWon", "easyChatBattleLost" }) do
for i = 1, 6 do
same(st, ("%s[%d]"):format(f, i), (save[f] or {})[i], x.battleWords[(k - 1) * 6 + i])
end
end
end
for _, key in ipairs({ "dynamicWarp", "escapeWarp" }) do
local w = s[key]
if w then
local m
for _, mm in ipairs(MAPS) do if mm.id == w.map then m = mm end end
same(st, key .. ".group", m.group, c[key].group)
same(st, key .. ".num", m.num, c[key].num)
same(st, key .. ".warpId", w.warpId, c[key].warpId)
same(st, key .. ".x", w.x, c[key].x)
same(st, key .. ".y", w.y, c[key].y)
else
same(st, key .. ".group", -1, c[key].group >= 128 and c[key].group - 256 or c[key].group)
end
end
local lh = c.lastHealLocation
local healOk = false
for _, m in ipairs(MAPS) do if m.group == lh.group and m.num == lh.num then healOk = true end end
same(st, "lastHealLocation.isCachedMap", true, healOk)
local wantFlags = {}
for k, v in pairs(s.flags or {}) do
local id = tonumber(k)
if v and id and id > 0x1F and id <= FLAG_MAX then wantFlags[id] = true end
end
if not EMERALD and save.vars[L.RSE_NATIONAL_VAR] == nil then wantFlags[L.RSE_NATIONAL_FLAG] = true end
cmpSet(st, "flags", wantFlags, bitSet(c.flagsSet), 0)
for id = 0x4000, 0x40FF do
local want = (tonumber(s.vars[id]) or 0) % 65536
if not EMERALD and id == L.RSE_NATIONAL_VAR and s.vars[id] == nil then want = L.RSE_NATIONAL_VALUE end
same(st, ("vars[0x%X]"):format(id), want, c.vars[id])
end
for i = 0, 63 do same(st, "gameStats[" .. i .. "]", (tonumber(s.gameStats[i]) or 0) % U32, c.stats[i]) end
for i = 1, 4 do same(st, "easyChatProfile[" .. i .. "]", s.easyChatProfile[i], c.profile[i]) end
same(st, "partyCount", #s.party, #c.party)
for i, m in ipairs(s.party) do compareMon("party[" .. i .. "]", m, c.party[i], true, s) end
for b = 1, 14 do
local box = s.storage.boxes[b]
for sl = 1, 30 do
local m = box.mons[sl]
if m then compareMon(("box[%d][%d]"):format(b, sl), m, c.boxes[b][sl], false, s)
elseif c.boxes[b][sl] then fail(st, ("box[%d][%d]"):format(b, sl), "empty", "mon") end
end
local wantName = box.name == ("BOX %d"):format(b) and ("BOX%d"):format(b) or textOf(box.name, 8)
same(st, ("boxName[%d]"):format(b), wantName, c.boxNames[b])
same(st, ("wallpaper[%d]"):format(b), box.wallpaper - 1, c.wallpapers[b])
end
same(st, "currentBox", s.storage.currentBox - 1, c.currentBox)
for p, pk in ipairs(L.POCKETS) do
local want = sortedPocket(s.bag.pockets[pk.key])
local got = c.pockets[p]
same(st, "bag." .. pk.key .. ".count", #want, #got)
for i = 1, math.max(#want, #got) do
local a, b2 = want[i] or {}, got[i] or {}
same(st, ("bag.%s[%d].id"):format(pk.key, i), a[1], b2[1])
same(st, ("bag.%s[%d].qty"):format(pk.key, i), a[2], b2[2])
end
end
local wantPc = sortedPocket(s.storage.items)
same(st, "pcItems.count", #wantPc, #c.pcItems)
for i = 1, math.max(#wantPc, #c.pcItems) do
local a, b2 = wantPc[i] or {}, c.pcItems[i] or {}
same(st, ("pcItems[%d].id"):format(i), a[1], b2[1])
same(st, ("pcItems[%d].qty"):format(i), a[2], b2[2])
end
same(st, "registeredItem", tonumber(s.registeredItem) or 0, c.registeredItem)
local pool = s.mail or {}
for i = 1, 16 do
local want = pool[i]
local got = c.mail[i]
if want and (tonumber(want.itemId) or 0) ~= 0 then
same(st, ("mail[%d].itemId"):format(i), want.itemId, got.itemId)
same(st, ("mail[%d].species"):format(i), want.species, got.species)
same(st, ("mail[%d].trainerId16"):format(i), want.trainerId % 65536, got.trainerId % 65536)
same(st, ("mail[%d].name"):format(i), mailName(want.playerName), got.name)
for k = 1, 9 do same(st, ("mail[%d].words[%d]"):format(i, k), want.words[k], got.words[k]) end
else
same(st, ("mail[%d].itemId"):format(i), 0, got.itemId)
end
end
if s.roamer then
local ro = s.roamer
local raw = x.roamerRaw
local function u16(o) return raw[o + 1] + raw[o + 2] * 256 end
local function u32(o) return u16(o) + u16(o + 2) * 65536 end
local ivs = ro.ivs
local ivw = 0
if EMERALD then
for i, k in ipairs({ "hp", "atk", "def", "spe", "spa", "spd" }) do ivw = ivw + (ivs[k] or 0) * 2 ^ ((i - 1) * 5) end
else
for i, k in ipairs({ "hp", "attack", "defense", "speed", "spAtk", "spDef" }) do ivw = ivw + (ivs[k] or 0) * 2 ^ ((i - 1) * 5) end
end
same(st, "roamer.ivs", ivw, u32(0) % 2 ^ 30)
same(st, "roamer.personality", (ro.personality or ro.pid) % U32, u32(4))
same(st, "roamer.species", ro.species, u16(8))
same(st, "roamer.hp", ro.hp, u16(10))
same(st, "roamer.level", ro.level, raw[13])
same(st, "roamer.status", (EMERALD and ro.status or ro.statusNum) % 256, raw[14])
same(st, "roamer.active", ro.active and 1 or 0, raw[20])
end
local root = save.modData and save.modData[L.DAYCARE_SAVE_KEY] or {}
local dc = root.daycare or {}
for i = 1, 2 do
local m = dc[i]
local got = x.daycare[i]
if m then
compareMon("daycare[" .. i .. "]", m, got.mon, false, s)
same(st, "daycare[" .. i .. "].steps", (dc.steps or {})[i] or 0, got.steps)
local ml = (dc.mail or {})[i]
if ml and ml.message and (ml.message.itemId or 0) ~= 0 then
same(st, "daycare[" .. i .. "].mail.itemId", ml.message.itemId, got.mail.itemId)
same(st, "daycare[" .. i .. "].mail.otName", textOf(ml.otName, 7), got.otName)
same(st, "daycare[" .. i .. "].mail.monName", textOf(ml.monName, 10), got.monName)
for k = 1, 9 do same(st, ("daycare[%d].mail.words[%d]"):format(i, k), ml.message.words[k], got.mail.words[k]) end
else
same(st, "daycare[" .. i .. "].mail.itemId", 0, got.mail.itemId)
end
else
same(st, "daycare[" .. i .. "]", nil, got.mon and got.mon.species)
end
end
same(st, "daycare.offspringPersonality", tonumber(dc.offspringPersonality) or 0, x.offspringPersonality)
same(st, "daycare.stepCounter", tonumber(dc.stepCounter) or 0, x.daycareStepCounter)
if not EMERALD then
local r5 = root.route5Daycare or {}
if r5.mon then
compareMon("route5", r5.mon, x.route5.mon, false, s)
same(st, "route5.steps", r5.steps or 0, x.route5.steps)
if r5.mail and r5.mail.message and (r5.mail.message.itemId or 0) ~= 0 then
same(st, "route5.mail.itemId", r5.mail.message.itemId, x.route5.mail.itemId)
end
else
same(st, "route5", nil, x.route5.mon and x.route5.mon.species)
end
same(st, "rivalName", textOf(s.rivalName, 7), x.rivalName)
end
if s.registeredTexts then
for i = 1, 10 do same(st, ("registeredTexts[%d]"):format(i), textOf(s.registeredTexts[i], 20), x.registeredTexts[i]) end
end
local teams = s.hallOfFameTeams or {}
same(st, "hof.teamCount", #teams, #x.hof)
if #teams > 0 then same(st, "hof.sectorChecksums", true, x.hofChecksumOk) end
for t, team in ipairs(teams) do
local got = x.hof[t] or {}
same(st, ("hof[%d].size"):format(t), #team, #got)
for i, m in ipairs(team) do
local g = got[i] or {}
same(st, ("hof[%d][%d].species"):format(t, i), m.species, g.species)
same(st, ("hof[%d][%d].level"):format(t, i), m.level, g.level)
same(st, ("hof[%d][%d].tid"):format(t, i), m.trainerId + m.otSecretId * 65536, g.tid)
same(st, ("hof[%d][%d].personality"):format(t, i), m.personality, g.personality)
same(st, ("hof[%d][%d].nick"):format(t, i), textOf(m.nickname, 10), g.nick)
end
end
return c, x
end
local EXCLUDED_ROOTS = {
rng = "engine RNG state; pret gRngValue is not in the save blocks (src/random.c)",
specialVars = "VAR_0x8000+ special vars are EWRAM (src/event_data.c gSpecialVar_*), never saved",
stringVars = "gStringVar1-3 are EWRAM text buffers (src/string_util.c), never saved",
move_overlay = "engine-only Gen 1 host move quarantine",
meta = "engine slot metadata (playthrough id, mods)",
biking = "engine runtime flag; the cart keeps bike state in flags/avatar",
bikeType = "engine runtime field",
healMap = "engine respawn map; cart lastHealLocation is a heal-location warp (Gen3Save fromPortSave healWarp)",
healX = "see healMap",
healY = "see healMap",
rtcSkew = "engine wall-clock skew anchored at import (src/core/game3/rtc.lua anchorToLastUpdate)",
questLog = "engine quest log runtime; FRLG cart quest log is template-carried (gen3_layouts/frlg.lua QUEST_LOG)",
trainerCard = "engine trainer card UI cache",
inventory = "SaveData alias of bag",
schemaVersion = "engine schema version",
engine = "engine id",
generation = "engine id",
facing = "templateless export writes no player object event (Gen3Save.encodeBlocks only rewrites facing over a template)",
monBoxId = "engine PC cursor (VAR_PC_BOX_TO_SEND_MON is the cart copy)",
monBoxPos = "engine PC cursor",
}
local EXCLUDED_MODDATA = {
cartImage = "the import template itself",
cartGame = "import provenance stamp",
cartKey = "import key stamp (FRLG key lives in modData so the next export reuses it)",
cartImport = "import-only scratch (dex lists consumed by finishImport, lastHealLocation, mapLayoutId)",
}
local function effMon(m, party)
if type(m) ~= "table" then return m end
local isEgg = m.isEgg == true
local nick = m.nickname
if isEgg then nick = "EGG" elseif type(nick) ~= "string" or nick == "" then nick = speciesName(tonumber(m.species)) end
local contest = type(m.contest) == "table" and m.contest or {}
local out = {
species = tonumber(m.species), personality = tonumber(m.personality),
otId = (tonumber(m.otId) or 0) % 65536, otSecretId = tonumber(m.otSecretId), otName = m.otName or m.ot,
otGender = tonumber(m.otGender) or 0, nickname = nick, language = tonumber(m.language) or (isEgg and 1 or 2),
isEgg = isEgg, markings = tonumber(m.markings) or 0, item = tonumber(m.item or m.heldItem) or 0,
exp = tonumber(m.exp), friendship = isEgg and tonumber(m.eggCycles or m.friendship) or tonumber(m.friendship),
ppBonusesPacked = tonumber(m.ppBonusesPacked) or 0, moves = deepcopy(m.moves), pp = deepcopy(m.pp),
evs = deepcopy(m.evs), ivs = deepcopy(m.ivs), pokerus = tonumber(m.pokerus) or 0,
metLocation = tonumber(m.metLocation) or 0, metLevel = tonumber(m.metLevel), metGame = tonumber(m.metGame),
pokeball = tonumber(m.pokeball) or 4,
ability = Pokemon.abilityId(tonumber(m.species), tonumber(m.personality)),
contest = { tonumber(contest.cool) or 0, tonumber(contest.beauty) or 0, tonumber(contest.cute) or 0,
tonumber(contest.smart) or 0, tonumber(contest.tough) or 0, tonumber(contest.sheen) or 0 },
ribbons = EMERALD and Ribbons.word(m) or (m.championRibbon == true and 2 ^ 15 or 0),
championRibbon = m.championRibbon == true, modernFatefulEncounter = m.modernFatefulEncounter == true,
}
if tonumber(m.abilityNum) then
out.ability = abilityOf(out.species, tonumber(m.abilityNum)) or out.ability
end
if party then
out.level, out.hp, out.maxHp = tonumber(m.level), tonumber(m.hp), tonumber(m.maxHp)
out.attack, out.defense, out.speed = tonumber(m.attack), tonumber(m.defense), tonumber(m.speed)
out.spAtk, out.spDef = tonumber(m.spAtk), tonumber(m.spDef)
out.status = m.status
out.sleep = m.status == "SLP" and tonumber(m.sleep) or nil
out.mail = Mail.monHasMail(m) and tonumber(m.mail) or nil
end
return out
end
local function effStorage(stg)
local out = { currentBox = tonumber(stg and stg.currentBox) or 1, items = {}, boxes = {} }
for _, it in ipairs(stg and stg.items or {}) do
out.items[#out.items + 1] = { id = tonumber(it.id) or ItemsData.toNumericId(it.id), qty = tonumber(it.qty) }
end
for b = 1, 14 do
local box = stg and stg.boxes and stg.boxes[b] or {}
local eb = { name = box.name or ("BOX %d"):format(b), wallpaper = tonumber(box.wallpaper) or Storage.defaultWallpaper(b), mons = {} }
for sl, m in pairs(box.mons or {}) do eb.mons[tonumber(sl)] = effMon(m, false) end
out.boxes[b] = eb
end
return out
end
local function effBag(bag)
local out = {}
for _, pk in ipairs(L.POCKETS) do
local list = {}
for _, it in ipairs(bag and bag.pockets and bag.pockets[pk.key] or {}) do
list[#list + 1] = { id = tonumber(it.id) or ItemsData.toNumericId(it.id), qty = tonumber(it.qty) }
end
out[pk.key] = list
end
return out
end
local function effFlags(flags)
local out = {}
for k, v in pairs(flags or {}) do
local id = tonumber(k)
if v and id and id > 0x1F and id <= FLAG_MAX then out[id] = true end
end
return out
end
local function effVars(vars)
local out = {}
for k, v in pairs(vars or {}) do
local id = tonumber(k)
local n = (tonumber(v) or 0) % 65536
if id and id >= 0x4000 and id <= 0x40FF and n ~= 0 then out[id] = n end
end
return out
end
local function effDex(dex)
dex = dex or {}
local out = { seen = {}, owned = {}, national = dex.national == true or dex.nationalUnlocked == true,
unownPersonality = tonumber(dex.unownPersonality) or 0, spindaPersonality = tonumber(dex.spindaPersonality) or 0 }
for sp, v in pairs(dex.seen or {}) do if v then out.seen[tonumber(sp)] = true end end
for _, key in ipairs({ "owned", "caught" }) do
for sp, v in pairs(dex[key] or {}) do if v then out.owned[tonumber(sp)] = true; out.seen[tonumber(sp)] = true end end
end
return out
end
local function effMail(pool)
local out = {}
for i = 1, 16 do
local r = pool and pool[i]
if r and (tonumber(r.itemId) or 0) ~= 0 then
local words = {}
for k = 1, 9 do words[k] = tonumber(r.words and r.words[k]) or 0xFFFF end
out[i] = { itemId = tonumber(r.itemId), species = tonumber(r.species), trainerId = (tonumber(r.trainerId) or 0) % 65536,
playerName = r.playerName, words = words }
end
end
return out
end
local function effDaycare(md)
local root = type(md) == "table" and md[L.DAYCARE_SAVE_KEY] or {}
local dc = type(root.daycare) == "table" and root.daycare or {}
local out = { offspringPersonality = tonumber(dc.offspringPersonality) or 0, stepCounter = tonumber(dc.stepCounter) or 0,
mons = {}, steps = {}, mail = {} }
for i = 1, 2 do
out.mons[i] = effMon(dc[i] or (dc.mons and dc.mons[i]), false)
out.steps[i] = out.mons[i] and (tonumber((dc.steps or {})[i]) or 0) or 0
local ml = (dc.mail or {})[i]
if out.mons[i] and type(ml) == "table" and ml.message and (tonumber(ml.message.itemId) or 0) ~= 0 then
out.mail[i] = { otName = ml.otName, monName = ml.monName, message = effMail({ ml.message })[1] }
end
end
if not EMERALD then
local r5 = type(root.route5Daycare) == "table" and root.route5Daycare or {}
out.route5 = { mon = effMon(r5.mon, false), steps = r5.mon and (tonumber(r5.steps) or 0) or 0 }
if r5.mon and type(r5.mail) == "table" and r5.mail.message and (tonumber(r5.mail.message.itemId) or 0) ~= 0 then
out.route5.mail = { otName = r5.mail.otName, monName = r5.mail.monName, message = effMail({ r5.mail.message })[1] }
end
end
return out
end
local function effRoamer(ro)
if type(ro) ~= "table" or (tonumber(ro.species) or 0) == 0 then return nil end
local ivs = type(ro.ivs) == "table" and ro.ivs or {}
local keys = EMERALD and { "hp", "atk", "def", "spe", "spa", "spd" } or { "hp", "attack", "defense", "speed", "spAtk", "spDef" }
local iv = {}
for i, k in ipairs(keys) do iv[i] = tonumber(ivs[k]) or 0 end
return { active = ro.active == true, species = tonumber(ro.species), level = tonumber(ro.level), hp = tonumber(ro.hp),
status = tonumber(EMERALD and ro.status or (ro.statusNum or ro.status)) or 0,
personality = tonumber(ro.personality or ro.pid), ivs = iv }
end
local function effHof(teams)
local out = {}
for t, team in ipairs(teams or {}) do
out[t] = {}
for i, m in ipairs(team) do
out[t][i] = { species = m.species, level = m.level, nickname = m.nickname, trainerId = m.trainerId,
otSecretId = m.otSecretId, personality = m.personality }
end
end
return out
end
local function effOptions(o)
o = type(o) == "table" and o or {}
local blk = Profile.of(GAME).optionsBlock
if blk and type(o[blk]) == "table" then o = o[blk] end
return { textSpeed = o.textSpeed, frameType = o.frameType, sound = o.sound, battleStyle = o.battleStyle,
battleScene = o.battleScene, buttonMode = o.buttonMode }
end
local function effWarp(w)
if type(w) ~= "table" or type(w.map) ~= "string" then return nil end
return { map = w.map, warpId = tonumber(w.warpId) or -1, x = tonumber(w.x), y = tonumber(w.y) }
end
local MODELED = {
party = function(v) local out = {} for i, m in ipairs(v or {}) do out[i] = effMon(m, true) end return out end,
storage = effStorage, bag = effBag, flags = effFlags, vars = effVars, dex = effDex, mail = effMail,
options = effOptions, roamer = effRoamer, hallOfFameTeams = effHof, dynamicWarp = effWarp, escapeWarp = effWarp,
continueGameWarp = effWarp,
playTime = function(p) p = p or {} return { hours = p.hours, minutes = p.minutes, seconds = p.seconds, vblanks = tonumber(p.vblanks) or 0 } end,
gameStats = function(g)
local out = {}
for k, v in pairs(g or {}) do
local id = tonumber(k) or (Gen3Save.STAT_ALIASES or {})[k]
if (tonumber(v) or 0) ~= 0 then out[id or k] = math.max(out[id or k] or 0, tonumber(v) % U32) end
end
return out
end,
registeredItem = function(v) return tonumber(v) or 0 end,
hasHallOfFameRecords = function(v) return v == true end,
game_cleared = function(v) return v == true end,
gcnLinkFlags = function(v) return tonumber(v) or 0 end,
flashLevel = function(v) return tonumber(v) or 0 end,
}
local NORMALISED = {
continueGameWarp = "every export sets CONTINUE_GAME_WARP to the player position so the cart warps in on continue (pokefirered/src/overworld.c:1706); orig without the flag compares as that stamp",
specialSaveWarpFlags = "see continueGameWarp",
hofDebutHours = "derived on import from GAME_STAT_FIRST_HOF_PLAY_TIME (include/constants/game_stat.h:5); compared against gameStats[1]",
hofDebutMinutes = "see hofDebutHours",
hofDebutSeconds = "see hofDebutHours",
hofDebutTime = "see hofDebutHours",
registeredTexts = "nil in the engine means the new-game defaults (src/union_room_chat.c InitUnionChatRegisteredTexts)",
["vars[0x403C]/flags[0x838]"] = "FRLG templateless export runs EnableNationalPokedex_RSE like NewGameInitData (pokefirered/src/new_game.c:133)",
["modData.*"] = "compared on the paths the reimport holds; engine-only sub-keys are listed as [engine-only]",
}
local engineOnly = {}
local function backDiff(a, b2, path, out)
if type(b2) ~= "table" then
if a ~= b2 then out[#out + 1] = { path, a, b2 } end
return out
end
if type(a) ~= "table" then out[#out + 1] = { path, a, b2 } return out end
for k, v in pairs(b2) do backDiff(a[k], v, path .. "." .. tostring(k), out) end
for k in pairs(a) do
if b2[k] == nil then
local key = kindOf(path .. "." .. tostring(k))
engineOnly[key] = (engineOnly[key] or 0) + 1
end
end
return out
end
local excludedDiffs = {}
local function normaliseOrig(orig, back)
local o = {}
for k, v in pairs(orig) do o[k] = v end
if bit.band(tonumber(o.specialSaveWarpFlags) or 0, 1) == 0 or type(o.continueGameWarp) ~= "table" then
o.specialSaveWarpFlags = bit.bor(tonumber(o.specialSaveWarpFlags) or 0, 1)
o.continueGameWarp = { map = o.map, warpId = -1, x = o.x, y = o.y }
end
local t = tonumber((o.gameStats or {})[1]) or 0
if t ~= 0 and o.hofDebutHours == nil then
o.hofDebutHours, o.hofDebutMinutes, o.hofDebutSeconds = math.floor(t / 65536), math.floor(t / 256) % 256, t % 256
o.hofDebutTime = ("%d:%02d:%02d"):format(o.hofDebutHours, o.hofDebutMinutes, o.hofDebutSeconds)
end
if o.registeredTexts == nil then o.registeredTexts = back.registeredTexts end
if L.RSE_NATIONAL_VAR and type(o.vars) == "table" and o.vars[L.RSE_NATIONAL_VAR] == nil then
o.vars = deepcopy(o.vars)
o.vars[L.RSE_NATIONAL_VAR] = L.RSE_NATIONAL_VALUE
o.flags = deepcopy(o.flags or {})
o.flags[L.RSE_NATIONAL_FLAG] = true
end
return o
end
local function r2(orig, back)
orig = normaliseOrig(orig, back)
for k in pairs(EXCLUDED_ROOTS) do
if #D.diff(orig[k], back[k]) > 0 then excludedDiffs[k] = (excludedDiffs[k] or 0) + 1 end
end
local roots = {}
for k in pairs(orig) do roots[k] = true end
for k in pairs(back) do roots[k] = true end
for k in pairs(roots) do
if not EXCLUDED_ROOTS[k] then
if k == "modData" then
local a, b2 = orig.modData or {}, back.modData or {}
local keys = {}
for kk in pairs(a) do keys[kk] = true end
for kk in pairs(b2) do keys[kk] = true end
for kk in pairs(keys) do
if not EXCLUDED_MODDATA[kk] then
if kk == L.DAYCARE_SAVE_KEY then
for _, d in ipairs(D.diff(effDaycare(a), effDaycare(b2))) do fail("r2", "modData." .. kk .. d, "orig", "reimport") end
else
for _, d in ipairs(backDiff(a[kk], b2[kk], "." .. kk, {})) do
fail("r2", "modData" .. d[1], tostring(d[2]), tostring(d[3]))
end
end
end
end
else
local f = MODELED[k] or function(v) return v end
local ea, eb = f(orig[k]), f(back[k])
if type(ea) == "table" or type(eb) == "table" then
for _, d in ipairs(D.diff(ea, eb)) do
local function at(t)
for seg in d:gmatch("[^.]+") do
if type(t) ~= "table" then return t end
local nk = tonumber(seg)
if t[seg] ~= nil then t = t[seg] elseif nk and t[nk] ~= nil then t = t[nk] else return nil end
end
return t
end
fail("r2", k .. d, tostring(at(ea)), tostring(at(eb)))
end
elseif ea ~= eb then
fail("r2", k, ea, eb)
end
end
end
end
end
local function editCheck(s, back, bytes, r)
local save = deepcopy(back)
local edits = {}
save.money = (tonumber(save.money) or 0) == 777777 and 777776 or 777777
edits.money = true
local slot
for i, m in ipairs(save.party or {}) do
if not Mail.monHasMail(m) and not m.isEgg then slot = i end
end
local moved
if slot and #save.party > 1 then
local before = #save.party
local ok, b, sl = Storage.deposit(save, slot, nil, nil)
if ok then
moved = { box = b, slot = sl, from = slot }
same("edit", "deposit.partyCount", before - 1, #save.party)
end
end
local before = deepcopy(save.bag)
local added
for _ = 1, 40 do
local id = pick(r, ALL_ITEMS)
if Bag.add(save.bag, id, 1) then added = id break end
end
local out, err = SaveConvert.exportSav(save, GAME, bytes)
if not out then fail("edit", "export", "bytes", tostring(err)) return end
local a, b2 = D.deep(bytes, FAMILY), D.deep(out, FAMILY)
local xa, xb = D.extra(bytes, FAMILY), D.extra(out, FAMILY)
same("edit", "money", save.money, b2.money)
local allowed = { "%.counter$", "%.slot$", "^%.money$" }
if moved then
allowed[#allowed + 1] = "^%.party"
allowed[#allowed + 1] = ("^%%.boxes%%.%d%%.%d"):format(moved.box, moved.slot)
same("edit", "deposit.cartPartyCount", #save.party, #b2.party)
local m = b2.boxes[moved.box][moved.slot]
same("edit", "deposit.boxSpecies", back.party[moved.from].species, m and m.species)
end
if added then
local pk = ItemsData.pocketOf(added)
for p, pocket in ipairs(L.POCKETS) do
if pocket.key == pk then allowed[#allowed + 1] = "^%.pockets%." .. p end
end
if pk == "TM_CASE" or pk == "BERRY_POUCH" then allowed[#allowed + 1] = "^%.pockets%.2" end
end
for _, d in ipairs(D.diff(a, b2)) do
local ok = false
for _, pat in ipairs(allowed) do if d:match(pat) then ok = true end end
if not ok then fail("edit", "unintended" .. d, "unchanged", "changed") end
end
for _, d in ipairs(D.diff(xa, xb)) do fail("edit", "unintended.extra" .. d, "unchanged", "changed") end
local ba, bb = D.blocks(bytes, FAMILY), D.blocks(out, FAMILY)
if ba.sb2 ~= bb.sb2 then
local diffs = 0
for i = 1, #ba.sb2 do if ba.sb2:byte(i) ~= bb.sb2:byte(i) then diffs = diffs + 1 if diffs < 4 then fail("edit", ("sb2byte[0x%X]"):format(i - 1), ba.sb2:byte(i), bb.sb2:byte(i)) end end end
end
stats.edit = stats.edit + 1
end
local function writeDump(idx, bytes)
if not DUMP or not bytes then return end
local f = io.open(("%s/%s_%d_%d.sav"):format(DUMP, GAME, SEED, idx), "wb")
if f then f:write(bytes) f:close() end
end
local t0 = os.clock()
for idx = 1, N do
if not ONLY or ONLY == idx then
CURRENT.idx = idx
local ok, e = xpcall(function()
local s, save, r, vals, blocks = buildSession(idx)
stats.sessions = stats.sessions + 1
local bytes, err = SaveConvert.exportSav(save, GAME, nil)
if not bytes then fail("export", "exportSav", "bytes", tostring(err)) return end
stats.exported = stats.exported + 1
writeDump(idx, bytes)
local report = Compat.check(bytes, GAME)
if #report.errors > 0 then
stats.compatErrors = stats.compatErrors + 1
for _, ce in ipairs(report.errors) do fail("compat", ce.rule, "no error", ce.msg) end
end
if idx == (ONLY or 1) then
CURRENT.dry = 0
compareCart(s, save, bytes)
local baseline = CURRENT.dry
local keep = { s.money, s.party[1] and s.party[1].exp, s.storage.currentBox }
s.money = (s.money + 1) % 1000000
if s.party[1] then s.party[1].exp = s.party[1].exp + 1 end
s.storage.currentBox = s.storage.currentBox % 14 + 1
CURRENT.dry = 0
compareCart(s, save, bytes)
local caught = CURRENT.dry - baseline
CURRENT.dry = nil
s.money, s.storage.currentBox = keep[1], keep[3]
if s.party[1] then s.party[1].exp = keep[2] end
same("selftest", "decoder catches 2-3 planted engine edits", s.party[1] and 3 or 2, caught)
end
compareCart(s, save, bytes)
local back, ierr = SaveConvert.importSav(bytes, GAME, GAME)
if not back then fail("import", "importSav", "save", tostring(ierr)) return end
local again = deepcopy(back)
again.modData.cartImage = nil
local bytes2, err2 = SaveConvert.exportSav(again, GAME, nil)
if bytes2 ~= bytes then
local first
if bytes2 then for i = 1, #bytes do if bytes:byte(i) ~= bytes2:byte(i) then first = i - 1 break end end end
fail("fixedpoint", "export(import(bytes))", "identical",
bytes2 and ("first diff at flash 0x%X"):format(first or -1) or tostring(err2))
else
stats.fixedPoint = stats.fixedPoint + 1
end
local sb = D.blocks(bytes, FAMILY)
local outCtx = { L = L, codec = CODEC, sb1 = sb.sb1, sb2 = sb.sb2 }
local srcCtx = { L = L, codec = CODEC, sb1 = blocks.sb1, sb2 = blocks.sb2 }
for _, sec in ipairs(SECTIONS) do
if sec.read then
stats.sectionReads = stats.sectionReads + 1
for _, d in ipairs(D.diff(sec.read(srcCtx), sec.read(outCtx))) do
fail("section", sec.name .. d, "random-cart read", "exported read")
end
end
end
r2(save, back)
stats.r2 = stats.r2 + 1
editCheck(s, back, bytes, r)
end, debug.traceback)
if not ok then fail("crash", "lua error", "no error", tostring(e):gsub("\n", " | ")) end
end
end
local FIXTURE = os.getenv(EMERALD and "POKEPORT_EMERALD_SAV_FIXTURE" or "POKEPORT_FIRERED_SAV_FIXTURE")
if FIXTURE and FIXTURE ~= "" then
CURRENT.idx = 0
local f = io.open(FIXTURE, "rb")
local src = f and f:read("*a")
if f then f:close() end
if not src then
fail("fixture", "read " .. FIXTURE, "bytes", "missing")
else
local save, err = SaveConvert.importSav(src, GAME, GAME)
if not save then
fail("fixture", "importSav", "save", tostring(err))
else
local function decodeAll(bytes)
local blk = D.blocks(bytes, FAMILY)
local out = { deep = D.deep(bytes, FAMILY), extra = D.extra(bytes, FAMILY), sections = {} }
out.deep.counter, out.deep.slot = nil, nil
local ctx = { L = L, codec = CODEC, sb1 = blk.sb1, sb2 = blk.sb2 }
for _, sec in ipairs(SECTIONS) do
if sec.read then out.sections[sec.name] = sec.read(ctx) end
end
return out, blk
end
local orig, oblk = decodeAll(src)
local withTpl, werr = SaveConvert.exportSav(deepcopy(save), GAME, src)
if not withTpl then
fail("fixture", "export with template", "bytes", tostring(werr))
else
local got, gblk = decodeAll(withTpl)
for _, d in ipairs(D.diff(orig, got)) do fail("fixture", "withTemplate" .. d, "original", "changed") end
for _, key in ipairs({ "sb1", "sb2", "storage" }) do
if oblk[key] ~= gblk[key] then
local first
for i = 1, #oblk[key] do if oblk[key]:byte(i) ~= gblk[key]:byte(i) then first = i - 1 break end end
fail("fixture", "withTemplate.bytes." .. key, "identical", ("first diff at 0x%X"):format(first or -1))
end
end
stats.fixtureWithTemplate = 1
end
local bare = deepcopy(save)
bare.modData.cartImage = nil
local noTpl, nerr = SaveConvert.exportSav(bare, GAME, nil)
if not noTpl then
fail("fixture", "export without template", "bytes", tostring(nerr))
else
local got = decodeAll(noTpl)
local gaps = {}
for _, d in ipairs(D.diff(orig, got)) do
local k = kindOf(d)
gaps[k] = (gaps[k] or 0) + 1
end
local list = {}
for k, n in pairs(gaps) do list[#list + 1] = ("%s x%d"):format(k, n) end
table.sort(list)
for _, line in ipairs(list) do print("[fixture-templateless-diff] " .. line) end
stats.fixtureTemplatelessDiffKinds = #list
end
end
end
end
print(("[info] %s seed=%d sessions=%d (%.1fs)"):format(GAME, SEED, stats.sessions, os.clock() - t0))
local keys = {}
for k in pairs(stats) do keys[#keys + 1] = k end
table.sort(keys)
local parts = {}
for _, k in ipairs(keys) do parts[#parts + 1] = k .. "=" .. stats[k] end
print("[coverage] " .. table.concat(parts, " "))
for k, n in pairs(excludedDiffs) do
print(("[excluded] %s differed in %d sessions: %s"):format(k, n, EXCLUDED_ROOTS[k]))
end
for k, why in pairs(NORMALISED) do print(("[normalised] %s: %s"):format(k, why)) end
for k, n in pairs(engineOnly) do print(("[engine-only] modData%s in %d records"):format(k, n)) end
for _, kind in ipairs(kindOrder) do
local rec = failureKinds[kind]
local bug = knownBug(kind)
print(("[FAIL] %s x%d in %d sessions%s; first: %s"):format(kind, rec.count, rec.sessionCount or 0,
bug and (" [" .. bug:match("^%S+") .. "]") or " [UNCLASSIFIED]", rec.first))
end
local clean, cleanButKnown = 0, 0
for idx = 1, N do
if not ONLY or ONLY == idx then
if not sessionFails[idx] then clean = clean + 1 end
if not sessionUnknown[idx] then cleanButKnown = cleanButKnown + 1 end
end
end
print(("[summary] %s: %d/%d sessions fully clean, %d/%d clean apart from known bugs; %d unclassified failure kinds")
:format(GAME, clean, stats.sessions, cleanButKnown, stats.sessions, (function()
local n = 0
for _, kind in ipairs(kindOrder) do if not knownBug(kind) then n = n + 1 end end
return n
end)()))
local bad = 0
for _ in pairs(failureKinds) do bad = bad + 1 end
print(failures == 0 and (LABEL .. ": all passed (" .. GAME .. ")")
or (("%s: %d failed checks in %d kinds (%s)"):format(LABEL, failures, bad, GAME)))
os.exit(failures == 0 and 0 or 1)