mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
828 lines
43 KiB
Lua
828 lines
43 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 Gen3Save = require("src.save_convert.Gen3Save")
|
|
local L = require("src.save_convert.Gen3Layout")
|
|
local SaveConvert = require("src.save_convert.SaveConvert")
|
|
local F = require("tests.fixture_data.gen3_saves")
|
|
|
|
local function unrle(s)
|
|
local out = {}
|
|
for tok in s:gmatch("%S+") do
|
|
local k, n = tok:match("^([ZF])(%x+)$")
|
|
if k then
|
|
out[#out + 1] = string.rep(k == "Z" and "\0" or "\255", tonumber(n, 16))
|
|
else
|
|
out[#out + 1] = (tok:gsub("%x%x", function(h) return string.char(tonumber(h, 16)) end))
|
|
end
|
|
end
|
|
return table.concat(out)
|
|
end
|
|
|
|
local IMAGES = {}
|
|
for name, r in pairs(F.images) do IMAGES[name] = unrle(r) end
|
|
|
|
local function same(a, b)
|
|
if type(a) ~= type(b) then return false end
|
|
if type(a) ~= "table" then return a == b end
|
|
for k, v in pairs(a) do if not same(v, b[k]) then return false end end
|
|
for k in pairs(b) do if a[k] == nil then return false end end
|
|
return true
|
|
end
|
|
|
|
local function show(v)
|
|
if type(v) ~= "table" then return tostring(v) end
|
|
local parts = {}
|
|
for k, x in pairs(v) do parts[#parts + 1] = tostring(k) .. "=" .. show(x) end
|
|
table.sort(parts)
|
|
return "{" .. table.concat(parts, ",") .. "}"
|
|
end
|
|
|
|
local function same_eq(a, b, label)
|
|
check(same(a, b), label .. " (" .. show(a) .. " vs " .. show(b) .. ")")
|
|
end
|
|
|
|
local function items(list)
|
|
local out = {}
|
|
for _, it in ipairs(list) do out[#out + 1] = { it.id, it.qty } end
|
|
return out
|
|
end
|
|
|
|
local function plusOne(list)
|
|
local out = {}
|
|
for _, i in ipairs(list) do out[#out + 1] = i + 1 end
|
|
return out
|
|
end
|
|
|
|
local function checkMon(label, m, o, party)
|
|
eq(m.species, o.speciesInternal, label .. " species")
|
|
eq(m.personality, o.pid, label .. " personality")
|
|
eq(m.otId, o.tid, label .. " TID")
|
|
eq(m.otSecretId, o.sid, label .. " SID")
|
|
if o.nickname then eq(m.nickname, o.nickname, label .. " nickname") end
|
|
if o.ot then eq(m.otName, o.ot, label .. " OT name") end
|
|
eq(m.otGender, o.otGender, label .. " OT gender")
|
|
eq(m.language, o.lang, label .. " language")
|
|
eq(m.isEgg, o.isEgg, label .. " egg")
|
|
eq(m.isBadEgg, o.badEgg, label .. " bad egg")
|
|
eq(m.markings, o.markingsRaw, label .. " markings")
|
|
eq(m.heldItem, o.heldItem, label .. " held item")
|
|
eq(m.exp, o.exp, label .. " exp")
|
|
eq(m.friendship, o.friendship, label .. " friendship")
|
|
eq(m.ppBonuses, o.ppUps, label .. " PP ups")
|
|
same_eq(m.moves, o.moves, label .. " moves")
|
|
same_eq(m.pp, o.pp, label .. " PP")
|
|
same_eq({ m.evs.hp, m.evs.atk, m.evs.def, m.evs.spe, m.evs.spa, m.evs.spd }, o.evs, label .. " EVs")
|
|
local c = m.contest
|
|
same_eq({ c.cool, c.beauty, c.cute, c.smart, c.tough, c.sheen }, o.contest, label .. " contest")
|
|
eq(m.pokerus, o.pokerus, label .. " pokerus")
|
|
eq(m.metLocation, o.metLocation, label .. " met location")
|
|
eq(m.metLevel, o.metLevel, label .. " met level")
|
|
eq(m.metGame, o.version, label .. " met game")
|
|
eq(m.pokeball, o.ball, label .. " ball")
|
|
local iv = m.ivs
|
|
same_eq({ iv.hp, iv.atk, iv.def, iv.spe, iv.spa, iv.spd }, o.ivs, label .. " IVs")
|
|
eq(m.abilityNum == 1, o.abilityBit, label .. " ability bit")
|
|
eq(m.ribbons, o.ribbons, label .. " ribbons")
|
|
eq(m.modernFatefulEncounter, o.fateful, label .. " fateful")
|
|
eq(m.checksumOk, o.checksumValid, label .. " checksum")
|
|
if party then
|
|
same_eq({ m.status, m.level, m.hp, m.maxHp, m.attack, m.defense, m.speed, m.spAtk, m.spDef },
|
|
{ o.status, o.statLevel, o.hp, o.maxHp, o.stats[1], o.stats[2], o.stats[3], o.stats[4], o.stats[5] },
|
|
label .. " party stats")
|
|
end
|
|
end
|
|
|
|
local function checkOracle(name, c, o)
|
|
eq(c.name, o.ot, name .. " player name")
|
|
eq(c.gender, o.gender, name .. " gender")
|
|
eq(c.trainerId, o.tid, name .. " TID")
|
|
eq(c.secretId, o.sid, name .. " SID")
|
|
same_eq({ c.playHours, c.playMinutes, c.playSeconds }, o.playTime, name .. " play time")
|
|
eq(c.money, o.money, name .. " money")
|
|
eq(c.coins, o.coins, name .. " coins")
|
|
eq(c.encryptionKey, o.securityKey, name .. " encryption key")
|
|
eq(c.rivalName, o.rival, name .. " rival")
|
|
same_eq({ c.posX, c.posY }, o.pos, name .. " position")
|
|
local w = c.location
|
|
same_eq({ w.group, w.num, w.warpId, w.x, w.y }, o.location, name .. " location")
|
|
w = c.lastHealLocation
|
|
same_eq({ w.group, w.num, w.warpId, w.x, w.y }, o.heal, name .. " last heal location")
|
|
same_eq(items(c.pockets.ITEMS), o.pouches.Items, name .. " items pocket")
|
|
same_eq(items(c.pockets.KEY_ITEMS), o.pouches.KeyItems, name .. " key items")
|
|
same_eq(items(c.pockets.POKE_BALLS), o.pouches.Balls, name .. " balls")
|
|
same_eq(items(c.pockets.TM_CASE), o.pouches.TMHMs, name .. " TM case")
|
|
same_eq(items(c.pockets.BERRY_POUCH), o.pouches.Berries, name .. " berry pouch")
|
|
same_eq(items(c.pcItems), o.pouches.PCItems, name .. " PC items")
|
|
same_eq(plusOne(c.dexSeen), o.seen, name .. " dex seen")
|
|
same_eq(plusOne(c.dexOwned), o.caught, name .. " dex caught")
|
|
same_eq(c.flags, o.flags, name .. " flags")
|
|
local vars = {}
|
|
for id, v in pairs(c.vars) do vars[tostring(id)] = v end
|
|
local ovars = {}
|
|
for id, v in pairs(o.vars) do ovars[tostring(id)] = v end
|
|
same_eq(vars, ovars, name .. " vars")
|
|
local stats = {}
|
|
for id, v in pairs(c.gameStats) do stats[tostring(id)] = v end
|
|
local ostats = {}
|
|
for id, v in pairs(o.gameStats) do ostats[tostring(id)] = v end
|
|
same_eq(stats, ostats, name .. " game stats")
|
|
eq(c.storage.currentBox, o.currentBox, name .. " current box")
|
|
local names, walls = {}, {}
|
|
for b, box in ipairs(c.storage.boxes) do names[b], walls[b] = box.name, box.wallpaper end
|
|
same_eq(names, o.boxNames, name .. " box names")
|
|
same_eq(walls, o.walls, name .. " wallpapers")
|
|
eq(#c.party, #o.party, name .. " party size")
|
|
for i, om in ipairs(o.party) do checkMon(name .. " party " .. i, c.party[i], om, true) end
|
|
local n = 0
|
|
for bx, box in ipairs(c.storage.boxes) do
|
|
for sl, mon in pairs(box.mons) do
|
|
n = n + 1
|
|
local found
|
|
for _, ob in ipairs(o.boxes) do
|
|
if ob.box == bx - 1 and ob.slot == sl - 1 then found = ob end
|
|
end
|
|
check(found ~= nil, name .. " box " .. bx .. " slot " .. sl .. " is in the oracle")
|
|
if found then checkMon(name .. " box " .. bx .. "/" .. sl, mon, found.mon, false) end
|
|
end
|
|
end
|
|
eq(n, #o.boxes, name .. " box mon count")
|
|
end
|
|
|
|
for _, name in ipairs({ "fr_rich_game", "lg_rich_game", "fr_bedroom" }) do
|
|
local c, why = Gen3Save.decode(IMAGES[name])
|
|
check(c ~= nil, name .. " decodes (" .. tostring(why) .. ")")
|
|
if c then
|
|
checkOracle(name, c, F.oracle[name])
|
|
eq(c.olderSlot, false, name .. " reads the newest slot")
|
|
end
|
|
end
|
|
|
|
local rich = IMAGES.fr_rich_game
|
|
|
|
do
|
|
local c = assert(Gen3Save.decode(rich .. string.rep("\0", 16)))
|
|
checkOracle("128K+16 trailer", c, F.oracle.fr_rich_game)
|
|
local c64 = assert(Gen3Save.decode(rich:sub(1, 0x10000)))
|
|
checkOracle("64K flash", c64, F.oracle.fr_rich_game)
|
|
local c63 = Gen3Save.decode(rich .. string.rep("\0", 0x40))
|
|
eq(c63, nil, "a 128K image with 64 extra bytes is refused")
|
|
end
|
|
|
|
local function poke(bytes, off, v)
|
|
return bytes:sub(1, off) .. string.char(v) .. bytes:sub(off + 2)
|
|
end
|
|
|
|
do
|
|
local corrupt = poke(rich, 24677, (rich:byte(24678) + 1) % 256)
|
|
local c = assert(Gen3Save.decode(corrupt))
|
|
eq(c.olderSlot, true, "a damaged newest slot falls back to the older one")
|
|
checkOracle("older slot", c, F.oracle.fr_rich_pkhex)
|
|
local save, err, note = SaveConvert.importSav(corrupt, "firered", "firered")
|
|
check(save ~= nil, "the older slot imports (" .. tostring(err) .. ")")
|
|
eq(note, Gen3Save.MSG.olderSlot, "the import says the previous save was used")
|
|
local both = poke(corrupt, 14 * 0x1000 + 100, (corrupt:byte(14 * 0x1000 + 101) + 1) % 256)
|
|
local _, bad = SaveConvert.importSav(both, "firered", "firered")
|
|
eq(bad, Gen3Save.MSG.corrupt, "two damaged slots are refused")
|
|
local _, empty = SaveConvert.importSav(string.rep("\255", 0x20000), "firered", "firered")
|
|
eq(empty, Gen3Save.MSG.empty, "an erased flash is refused as empty")
|
|
local _, small = SaveConvert.importSav(string.rep("\0", 32768), "firered", "firered")
|
|
eq(small, Gen3Save.MSG.size:format(32768), "a 32 KB file gets the size sentence")
|
|
end
|
|
|
|
local function rebuild(bytes, patch)
|
|
local blocks = assert(Gen3Save.readBlocks(bytes))
|
|
patch(blocks)
|
|
return Gen3Save.buildFlash(blocks, { counter = blocks.counter + 1 })
|
|
end
|
|
|
|
local function setByte(block, off, v) return block:sub(1, off) .. string.char(v) .. block:sub(off + 2) end
|
|
|
|
do
|
|
local rs = rebuild(rich, function(b) b.sb2 = setByte(b.sb2, 0xAC, 0) end)
|
|
local _, err = SaveConvert.importSav(rs, "firered", "firered")
|
|
eq(err, Gen3Save.MSG.notFrlg, "a Ruby/Sapphire marker is refused")
|
|
local jp = rebuild(rich, function(b) b.sb2 = setByte(setByte(b.sb2, 6, 0), 7, 0) end)
|
|
local _, jerr = SaveConvert.importSav(jp, "firered", "firered")
|
|
eq(jerr, Gen3Save.MSG.japanese, "a Japanese save is refused")
|
|
local ok = assert(Gen3Save.decode(rebuild(rich, function() end)))
|
|
checkOracle("rebuilt flash", ok, F.oracle.fr_rich_game)
|
|
end
|
|
|
|
math.randomseed(2444)
|
|
local function rnd(n) return math.random(0, n - 1) end
|
|
for order = 0, 23 do
|
|
for trial = 1, 3 do
|
|
local personality = (rnd(65536) * 65536 + rnd(65536)) - ((rnd(65536) * 65536 + rnd(65536)) % 24) + order
|
|
personality = personality % 4294967296
|
|
if personality % 24 ~= order then personality = personality - personality % 24 + order end
|
|
local mon = {
|
|
personality = personality,
|
|
otIdRaw = rnd(65536) * 65536 + rnd(65536),
|
|
nickname = "MON" .. order, language = 2, otName = "OT", markings = rnd(16),
|
|
species = 1 + rnd(411), heldItem = rnd(376), exp = rnd(1000000), ppBonuses = rnd(256), friendship = rnd(256),
|
|
growthFiller = 0,
|
|
moves = { 1 + rnd(354), rnd(355), rnd(355), rnd(355) }, pp = { rnd(64), rnd(64), rnd(64), rnd(64) },
|
|
evs = { hp = rnd(256), atk = rnd(256), def = rnd(256), spe = rnd(256), spa = rnd(256), spd = rnd(256) },
|
|
contest = { cool = rnd(256), beauty = rnd(256), cute = rnd(256), smart = rnd(256), tough = rnd(256), sheen = rnd(256) },
|
|
pokerus = rnd(256), metLocation = rnd(256), metLevel = rnd(101), metGame = rnd(16), pokeball = rnd(16), otGender = rnd(2),
|
|
ivs = { hp = rnd(32), atk = rnd(32), def = rnd(32), spe = rnd(32), spa = rnd(32), spd = rnd(32) },
|
|
isEgg = trial == 2, abilityNum = rnd(2), ribbons = rnd(65536) * 65536 + rnd(65536),
|
|
isBadEgg = trial == 3, unknown = 0,
|
|
}
|
|
local raw = Gen3Save.encodeBoxMon(mon)
|
|
eq(#raw, L.BOX_MON_SIZE, "order " .. order .. " encodes 80 bytes")
|
|
local back = Gen3Save.decodeBoxMon(raw)
|
|
eq(back.checksumOk, true, "order " .. order .. " trial " .. trial .. " checksum")
|
|
for _, k in ipairs({ "personality", "otIdRaw", "nickname", "otName", "species", "heldItem", "exp", "ppBonuses",
|
|
"friendship", "pokerus", "metLocation", "metLevel", "metGame", "pokeball", "otGender", "isEgg",
|
|
"abilityNum", "ribbons", "markings", "isBadEgg" }) do
|
|
eq(back[k], mon[k], "order " .. order .. " trial " .. trial .. " " .. k)
|
|
end
|
|
same_eq(back.moves, mon.moves, "order " .. order .. " moves")
|
|
same_eq(back.ivs, mon.ivs, "order " .. order .. " IVs")
|
|
same_eq(back.evs, mon.evs, "order " .. order .. " EVs")
|
|
same_eq(back.contest, mon.contest, "order " .. order .. " contest")
|
|
eq(back.isEggFlag, mon.isEgg, "order " .. order .. " egg flag byte")
|
|
local tampered = poke(raw, 0x20 + rnd(48), (raw:byte(0x21) + 1) % 256)
|
|
local bad = Gen3Save.decodeBoxMon(tampered)
|
|
if not bad.checksumOk then
|
|
eq(bad.isBadEgg, true, "order " .. order .. " a bad checksum reads as a bad egg")
|
|
end
|
|
end
|
|
end
|
|
|
|
local function strip(c)
|
|
c.counter, c.slot, c.olderSlot = nil, nil, nil
|
|
return c
|
|
end
|
|
|
|
for _, name in ipairs({ "fr_rich_game", "lg_rich_game", "fr_bedroom" }) do
|
|
local c, blocks = Gen3Save.decode(IMAGES[name])
|
|
local again = assert(Gen3Save.decode(Gen3Save.encode(c, { template = blocks, counter = c.counter + 1 })))
|
|
same_eq(strip(again), strip(Gen3Save.decode(IMAGES[name])), name .. " encode(decode) with its template decodes identically")
|
|
local c2 = Gen3Save.decode(IMAGES[name])
|
|
local bare = assert(Gen3Save.decode(Gen3Save.encode(c2, { counter = 1 })))
|
|
bare.player, c2.player = nil, nil
|
|
same_eq(strip(bare), strip(c2), name .. " encode(decode) without a template decodes identically")
|
|
end
|
|
|
|
do
|
|
local save, err, note = SaveConvert.importSav(rich, "firered", "firered")
|
|
check(save ~= nil, "fr_rich_game converts to a port save (" .. tostring(err) .. ")")
|
|
eq(note, nil, "a clean import has no fallback note")
|
|
eq(save.engine, "game3", "port save engine")
|
|
eq(save.version, "firered", "port save version")
|
|
eq(save.name, "AAA", "port player name")
|
|
eq(save.rivalName, "GREEN", "port rival name")
|
|
eq(save.trainerId, 56718, "port TID")
|
|
eq(save.secretId, 43871, "port SID")
|
|
eq(save.money, 123456, "port money")
|
|
eq(save.coins, 777, "port coins")
|
|
eq(save.map, "FR_PLAYERS_HOUSE_2F", "port map")
|
|
eq(save.x, 6, "port x")
|
|
eq(save.y, 6, "port y")
|
|
same_eq(save.playTime, { hours = 12, minutes = 35, seconds = 24, vblanks = 12 }, "port play time")
|
|
eq(save.modData.cartImport.lastHealLocation.map, "FR_PALLET_TOWN", "the heal spot waits for the first load")
|
|
local p = save.party
|
|
eq(#p, 4, "port party size")
|
|
eq(p[1].species, 6, "party 1 species")
|
|
eq(p[1].nickname, "BLAZE", "party 1 nickname")
|
|
eq(p[1].level, 36, "party 1 level")
|
|
eq(p[1].hp, 50, "party 1 hp")
|
|
eq(p[1].status, "PSN", "party 1 poisoned")
|
|
eq(p[1].ppBonusesPacked, 3, "party 1 PP ups")
|
|
eq(p[1].item, 200, "party 1 held item")
|
|
eq(p[1].cartExtra.championRibbon, true, "party 1 champion ribbon kept")
|
|
eq(p[1].cartImport, true, "party mons are finished on first load")
|
|
eq(p[2].species, 277, "Treecko keeps its internal species")
|
|
eq(p[2].otName, "PKHEX", "Treecko keeps its foreign OT")
|
|
eq(p[3].isEgg, true, "the Pichu egg is an egg")
|
|
eq(p[3].nickname, "EGG", "the egg is named EGG")
|
|
eq(p[3].eggCycles, 10, "the egg keeps its cycles")
|
|
eq(p[4].species, 201, "Unown")
|
|
eq(p[4].cartExtra.modernFatefulEncounter, true, "Unown fateful bit kept")
|
|
eq(save.storage.currentBox, 2, "current box is 1-based")
|
|
eq(save.storage.boxes[2].mons[20].species, 150, "box 2 slot 20 straddles storage chunks")
|
|
eq(save.storage.boxes[2].mons[20].level, nil, "box levels wait for the first load")
|
|
eq(save.storage.boxes[14].mons[30].nickname, "SPARKY", "box 14 slot 30")
|
|
eq(save.storage.boxes[8].mons[15].species, 410, "Deoxys in box 8")
|
|
eq(save.storage.boxes[3].name, "CUSTOM", "a renamed box keeps its name")
|
|
eq(save.storage.boxes[3].wallpaper, 6, "a changed wallpaper is 1-based")
|
|
eq(save.storage.boxes[1].name, "BOX 1", "a default box name takes the port default")
|
|
eq(save.storage.boxes[5].wallpaper, 5, "a default wallpaper takes the port default")
|
|
same_eq(save.storage.items, { { id = 13, qty = 1 }, { id = 20, qty = 3 } }, "PC items")
|
|
same_eq(save.bag.pockets.ITEMS, { { id = 13, qty = 10 }, { id = 68, qty = 5 }, { id = 23, qty = 99 } }, "bag items")
|
|
same_eq(save.bag.pockets.KEY_ITEMS, { { id = 360, qty = 1 }, { id = 364, qty = 1 } }, "key items")
|
|
for _, f in ipairs({ 0x820, 0x821, 0x828, 0x829 }) do
|
|
eq(save.flags[f], true, ("flag 0x%X set"):format(f))
|
|
eq(save.flags[tostring(f)], true, ("flag 0x%X set by string key"):format(f))
|
|
end
|
|
eq(save.flags[0x822], nil, "flag 0x822 clear")
|
|
eq(save.vars[0x4050], 7, "var 0x4050")
|
|
local nat = {}
|
|
for _, n in ipairs(save.modData.cartImport.dexOwned) do nat[n] = true end
|
|
eq(nat[252], true, "Treecko is caught under national 252")
|
|
eq(nat[386], true, "Deoxys is caught under national 386")
|
|
eq(save.dex.national, false, "national dex stays off")
|
|
eq(save.game_cleared, false, "game not cleared")
|
|
eq(save.options.textSpeed, 1, "text speed")
|
|
end
|
|
|
|
do
|
|
local lg, err = SaveConvert.importSav(IMAGES.lg_rich_game, "firered", "firered")
|
|
check(lg ~= nil, "a LeafGreen cart imports into FireRed (" .. tostring(err) .. ")")
|
|
local fr, err2 = SaveConvert.importSav(rich, "leafgreen", "leafgreen")
|
|
check(fr ~= nil, "a FireRed cart imports into LeafGreen (" .. tostring(err2) .. ")")
|
|
eq(fr and fr.version, "leafgreen", "the import is stamped with the target game")
|
|
end
|
|
|
|
local STUB_NATIONAL = { [277] = 252, [410] = 386 }
|
|
local STUB_LAYOUT = { ["4:1"] = 2, ["3:0"] = 1, ["3:1"] = 79 }
|
|
local function stubOpts(extra)
|
|
local o = {
|
|
version = "firered",
|
|
metGame = L.VERSION_FIRE_RED,
|
|
toNational = function(sp) return STUB_NATIONAL[sp] or (sp >= 1 and sp <= 251 and sp or nil) end,
|
|
speciesFromNational = function(n) for sp, nat in pairs(STUB_NATIONAL) do if nat == n then return sp end end return n end,
|
|
speciesName = function(sp) return ({ [4] = "CHARMANDER", [16] = "PIDGEY", [172] = "PICHU" })[sp] end,
|
|
mapLayoutId = function(g, n) return STUB_LAYOUT[g .. ":" .. n] end,
|
|
healWarp = function() return { group = 3, num = 0, warpId = -1, x = 6, y = 8 } end,
|
|
}
|
|
for k, v in pairs(extra or {}) do o[k] = v end
|
|
return o
|
|
end
|
|
|
|
local SaveSerializer = require("src.core.SaveSerializer")
|
|
local function reload(t) return SaveSerializer.decode(SaveSerializer.encode(t)) end
|
|
|
|
local function byteRuns(a, b)
|
|
local runs, start = {}, nil
|
|
for i = 1, #a + 1 do
|
|
local d = i <= #a and a:byte(i) ~= b:byte(i)
|
|
if d and not start then start = i - 1 end
|
|
if not d and start then runs[#runs + 1] = { start, i - 2 }; start = nil end
|
|
end
|
|
return runs
|
|
end
|
|
|
|
local function onlyIn(runs, allowed)
|
|
for _, r in ipairs(runs) do
|
|
local ok = false
|
|
for _, w in ipairs(allowed) do
|
|
if r[1] >= w[1] and r[2] <= w[2] then ok = true end
|
|
end
|
|
if not ok then return false, string.format("0x%X-0x%X", r[1], r[2]) end
|
|
end
|
|
return true
|
|
end
|
|
|
|
do
|
|
eq(SaveConvert.exportSupported("firered"), true, "FireRed cart export is supported")
|
|
eq(SaveConvert.exportSupported("leafgreen"), true, "LeafGreen cart export is supported")
|
|
|
|
local save = reload(assert(SaveConvert.importSav(rich, "firered", "firered")))
|
|
local out = assert(Gen3Save.exportPort(save, stubOpts({ template = rich })))
|
|
eq(#out, L.FLASH_SIZE, "a template export is a 128K flash image")
|
|
local a, b = assert(Gen3Save.readBlocks(rich)), assert(Gen3Save.readBlocks(out))
|
|
eq(b.counter, a.counter + 1, "the export is the next save after the cart's")
|
|
local ok, where = onlyIn(byteRuns(a.sb2, b.sb2), { { 0x09, 0x09 } })
|
|
check(ok, "SaveBlock2 only changes the continue flag (" .. tostring(where) .. ")")
|
|
ok, where = onlyIn(byteRuns(a.sb1, b.sb1), { { 0x0C, 0x13 } })
|
|
check(ok, "SaveBlock1 only changes the continue warp (" .. tostring(where) .. ")")
|
|
eq(#byteRuns(a.storage, b.storage), 0, "PC storage is byte-identical")
|
|
for s = 28, 31 do
|
|
eq(out:sub(s * 0x1000 + 1, (s + 1) * 0x1000), rich:sub(s * 0x1000 + 1, (s + 1) * 0x1000),
|
|
"sector " .. s .. " (Hall of Fame / Trainer Tower) is kept from the cart")
|
|
end
|
|
local c = assert(Gen3Save.decode(out))
|
|
eq(c.specialSaveWarpFlags, L.CONTINUE_GAME_WARP, "the export continues through the continue-game warp")
|
|
same_eq(c.continueGameWarp, { group = 4, num = 1, warpId = -1, x = 6, y = 6 }, "continue warp is the bedroom")
|
|
eq(c.encryptionKey, a.sb2:byte(0xF21) + a.sb2:byte(0xF22) * 256 + a.sb2:byte(0xF23) * 65536 + a.sb2:byte(0xF24) * 16777216,
|
|
"the cart's encryption key is kept for the unmodeled key-XORed fields")
|
|
|
|
local again = reload(assert(SaveConvert.importSav(out, "firered", "firered")))
|
|
local cw = again.continueGameWarp
|
|
again.continueGameWarp, again.specialSaveWarpFlags = nil, nil
|
|
local base = reload(save)
|
|
base.continueGameWarp, base.specialSaveWarpFlags = nil, nil
|
|
same_eq(again, base, "import(export(cart slot)) equals the slot")
|
|
same_eq(cw, { map = "FR_PLAYERS_HOUSE_2F", warpId = -1, x = 6, y = 6 }, "the re-import sees the continue warp")
|
|
|
|
local moved = reload(save)
|
|
moved.map, moved.x, moved.y = "FR_VIRIDIAN_CITY", 26, 28
|
|
moved.money = 777
|
|
table.remove(moved.party, 4)
|
|
moved.storage.boxes[2].mons[20] = nil
|
|
moved.flags["FLAG_BADGE01_GET"] = nil
|
|
moved.flags[0x820], moved.flags["2080"] = nil, nil
|
|
moved.flags["FLAG_SYS_POKEDEX_GET"] = true
|
|
moved.vars.VAR_STARTER_MON = 2
|
|
local mb = assert(Gen3Save.exportPort(moved, stubOpts({ template = rich })))
|
|
local m = assert(Gen3Save.decode(mb))
|
|
same_eq(m.location, { group = 3, num = 1, warpId = -1, x = 26, y = 28 }, "a moved slot saves its new map")
|
|
eq(m.mapLayoutId, 79, "the new map's layout id rides with it")
|
|
eq(m.money, 777, "money change")
|
|
eq(#m.party, 3, "a released party mon is gone")
|
|
eq(m.storage.boxes[2].mons[20], nil, "a released box mon is gone")
|
|
local fl = {}
|
|
for _, id in ipairs(m.flags) do fl[id] = true end
|
|
eq(fl[0x820], nil, "a cleared flag is cleared")
|
|
eq(fl[0x829], true, "a flag set by name is set")
|
|
eq(m.vars[0x4031], 2, "a var set by name is set")
|
|
local mblk = assert(Gen3Save.readBlocks(mb))
|
|
-- include/global.h:793
|
|
eq(mblk.sb1:sub(0x1301, 0x2CA0), a.sb1:sub(0x1301, 0x2CA0), "the quest log is kept from the cart")
|
|
-- include/global.h:352
|
|
eq(mblk.sb2:sub(0x899, 0xAF0), a.sb2:sub(0x899, 0xAF0), "map view and link battle records are kept")
|
|
-- include/global.h:807
|
|
eq(mblk.sb1:sub(0x3121, 0x3A18), a.sb1:sub(0x3121, 0x3A18), "mystery gift data is kept")
|
|
end
|
|
|
|
local function portMon(o)
|
|
local m = {
|
|
species = 4, speciesNumbering = "internal", name = "CHARMANDER", nickname = "", level = 5, exp = 135,
|
|
moves = { 10, 45 }, pp = { 35, 40 }, personality = 0x11223344,
|
|
ivs = { hp = 1, atk = 2, def = 3, spe = 4, spa = 5, spd = 6 },
|
|
evs = { hp = 7, atk = 8, def = 9, spe = 10, spa = 11, spd = 12 },
|
|
friendship = 70, metLocation = 88, metLevel = 5, metGame = 4, pokeball = 4,
|
|
otName = "PORT", otId = 12345, otSecretId = 54321, otGender = 1,
|
|
hp = 20, maxHp = 20, attack = 11, defense = 10, speed = 12, spAtk = 11, spDef = 10,
|
|
}
|
|
for k, v in pairs(o or {}) do m[k] = v end
|
|
return m
|
|
end
|
|
|
|
do
|
|
local save = {
|
|
engine = "game3", version = "firered", generation = 3,
|
|
name = "PORT", rivalName = "RIVAL", gender = 1, trainerId = 12345, secretId = 54321,
|
|
money = 4321, coins = 12, map = "FR_PALLET_TOWN", x = 12, y = 16, facing = "down",
|
|
healMap = "FR_PLAYERS_HOUSE_1F", healX = 8, healY = 5,
|
|
party = {
|
|
portMon({ hp = 7, status = "PAR" }),
|
|
portMon({ species = 16, name = "PIDGEY", nickname = "BIRDY", personality = 0x55667788, item = 13, heldItem = 13 }),
|
|
portMon({ species = 172, name = "EGG", nickname = "EGG", isEgg = true, eggCycles = 10, personality = 0x0BADF00D }),
|
|
},
|
|
storage = {
|
|
currentBox = 2,
|
|
boxes = {
|
|
[1] = { name = "BOX 1", wallpaper = 1, mons = { [5] = portMon({ species = 277, name = nil, nickname = "", personality = 0x99 }) } },
|
|
[3] = { name = "MINE", wallpaper = 7, mons = {} },
|
|
},
|
|
items = { { id = 13, qty = 1 } },
|
|
},
|
|
bag = { pockets = { ITEMS = { { id = 13, qty = 3 } }, KEY_ITEMS = { { id = 360, qty = 1 } },
|
|
POKE_BALLS = { { id = 4, qty = 5 } }, TM_CASE = {}, BERRY_POUCH = {} } },
|
|
flags = { ["2088"] = true, [0x820] = true, FLAG_SYS_POKEDEX_GET = true, ["5"] = true },
|
|
vars = { [0x4050] = 3, VAR_STARTER_MON = 1 },
|
|
dex = { seen = { [4] = true, [16] = true, [277] = true }, owned = { [4] = true }, caught = { [4] = true }, national = false },
|
|
playTime = { hours = 1, minutes = 2, seconds = 3, vblanks = 4 },
|
|
options = { firered = { textSpeed = 2, battleScene = 1, battleStyle = 1, sound = 1, buttonMode = 1, frameType = 3 } },
|
|
gameStats = { [2] = 5 },
|
|
easyChatProfile = { 1, 2, 3, 4 },
|
|
specialSaveWarpFlags = 0,
|
|
hallOfFameTeams = { { { species = 6, level = 50, nickname = "CHAR", trainerId = 12345, otSecretId = 54321, personality = 99 } } },
|
|
roamer = { active = true, species = 243, level = 50, hp = 100, statusNum = 0, pid = 7,
|
|
ivs = { hp = 31, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 } },
|
|
modData = { firered_daycare = { daycare = { [1] = portMon({ personality = 0x1234 }), steps = { 300, 0 } },
|
|
route5Daycare = { steps = 0 } } },
|
|
}
|
|
local bytes, err = Gen3Save.exportPort(reload(save), stubOpts())
|
|
check(bytes ~= nil, "a port-born slot exports without a template (" .. tostring(err) .. ")")
|
|
eq(#bytes, L.FLASH_SIZE, "a fresh export is a 128K flash image")
|
|
local c = assert(Gen3Save.decode(bytes))
|
|
eq(c.counter, 1, "a fresh export is a first save")
|
|
eq(c.encryptionKey, 0, "a fresh export starts with key 0")
|
|
eq(c.specialSaveWarpFlags, L.CONTINUE_GAME_WARP, "continue-game warp flag set")
|
|
same_eq(c.continueGameWarp, { group = 3, num = 0, warpId = -1, x = 12, y = 16 }, "continue warp is Pallet 12,16")
|
|
same_eq(c.location, { group = 3, num = 0, warpId = -1, x = 12, y = 16 }, "location")
|
|
eq(c.mapLayoutId, 1, "map layout id comes from the map header")
|
|
eq(c.posX == 12 and c.posY == 16, true, "position")
|
|
same_eq(c.lastHealLocation, { group = 3, num = 0, warpId = -1, x = 6, y = 8 }, "heal location")
|
|
eq(c.name, "PORT", "player name")
|
|
eq(c.rivalName, "RIVAL", "rival name")
|
|
eq(c.gender, 1, "gender")
|
|
eq(c.trainerId == 12345 and c.secretId == 54321, true, "TID/SID")
|
|
eq(c.money, 4321, "money")
|
|
eq(c.coins, 12, "coins")
|
|
eq(c.playHours == 1 and c.playMinutes == 2 and c.playSeconds == 3, true, "play time")
|
|
eq(c.options.textSpeed == 2 and c.options.battleScene == 1 and c.options.battleStyle == 1
|
|
and c.options.sound == 1 and c.options.frameType == 3 and c.buttonMode == 1, true, "options from the game's options block")
|
|
eq(#c.party, 3, "party size")
|
|
local p1 = c.party[1]
|
|
eq(p1.checksumOk, true, "party 1 checksum")
|
|
eq(p1.species, 4, "party 1 species")
|
|
eq(p1.nickname, "CHARMANDER", "an unnamed mon carries its species name like the cart")
|
|
eq(p1.level, 5, "party 1 level")
|
|
eq(p1.hp, 7, "party 1 hp")
|
|
eq(p1.maxHp, 20, "party 1 max hp")
|
|
eq(p1.status, L.STATUS.PAR, "party 1 paralysis")
|
|
eq(p1.mail, 0xFF, "no mail")
|
|
same_eq(p1.moves, { 10, 45, 0, 0 }, "party 1 moves")
|
|
same_eq(p1.pp, { 35, 40, 0, 0 }, "party 1 pp")
|
|
same_eq(p1.ivs, { hp = 1, atk = 2, def = 3, spe = 4, spa = 5, spd = 6 }, "party 1 IVs")
|
|
eq(p1.otId == 12345 and p1.otSecretId == 54321 and p1.otName == "PORT", true, "party 1 OT")
|
|
eq(p1.abilityNum, 0x11223344 % 2, "ability bit from personality")
|
|
eq(p1.language, L.LANGUAGE_ENGLISH, "English")
|
|
eq(c.party[2].nickname, "BIRDY", "nickname")
|
|
eq(c.party[2].heldItem, 13, "held item")
|
|
local egg = c.party[3]
|
|
eq(egg.isEgg, true, "egg bit")
|
|
eq(egg.language, L.LANGUAGE_JAPANESE, "a port egg is Japanese like CreateEgg")
|
|
eq(egg.nicknameBytes:sub(1, 3), L.EGG_NICKNAME, "a port egg carries the egg nickname")
|
|
eq(egg.friendship, 10, "egg cycles")
|
|
eq(c.storage.currentBox, 1, "current box is 0-based")
|
|
eq(c.storage.boxes[1].name, "BOX1", "default box name is the cart's")
|
|
eq(c.storage.boxes[1].wallpaper, 0, "default wallpaper is the cart's")
|
|
eq(c.storage.boxes[3].name, "MINE", "renamed box")
|
|
eq(c.storage.boxes[3].wallpaper, 6, "wallpaper is 0-based")
|
|
eq(c.storage.boxes[1].mons[5].species, 277, "box mon position")
|
|
eq(c.storage.boxes[1].mons[5].exp, 135, "box mon exp")
|
|
same_eq(c.pcItems, { { id = 13, qty = 1 } }, "PC items")
|
|
same_eq(c.pockets.ITEMS, { { id = 13, qty = 3 } }, "bag items")
|
|
same_eq(c.pockets.KEY_ITEMS, { { id = 360, qty = 1 } }, "key items")
|
|
same_eq(c.pockets.POKE_BALLS, { { id = 4, qty = 5 } }, "balls")
|
|
local fl = {}
|
|
for _, id in ipairs(c.flags) do fl[id] = true end
|
|
eq(fl[0x828] and fl[0x820] and fl[0x829], true, "flags by number, string and name")
|
|
eq(fl[5], nil, "temp flags are not saved")
|
|
eq(c.vars[0x4050], 3, "var by number")
|
|
eq(c.vars[0x4031], 1, "var by name")
|
|
same_eq(c.dexOwned, { 3 }, "owned bit is national order")
|
|
same_eq(c.dexSeen, { 3, 15, 251 }, "seen bits use the national number (Treecko 252)")
|
|
same_eq(c.dexSeen1, c.dexSeen, "seen1 matches")
|
|
same_eq(c.dexSeen2, c.dexSeen, "seen2 matches")
|
|
eq(c.dexNationalMagic, 0, "national dex off")
|
|
eq(c.gameStats[2], 5, "game stat")
|
|
same_eq({ c.easyChatProfile[1], c.easyChatProfile[2], c.easyChatProfile[3], c.easyChatProfile[4] }, { 1, 2, 3, 4 }, "profile words")
|
|
eq(#c.hallOfFame, 1, "Hall of Fame team")
|
|
eq(c.hallOfFame[1][1].species == 6 and c.hallOfFame[1][1].level == 50 and c.hallOfFame[1][1].nickname == "CHAR", true, "Hall of Fame mon")
|
|
eq(c.roamer.species == 243 and c.roamer.active == 1 and c.roamer.ivs == 31 and c.roamer.personality == 7, true, "roamer")
|
|
eq(c.daycare.mons[1] and c.daycare.mons[1].species, 4, "daycare mon")
|
|
eq(c.daycare.steps[1], 300, "daycare steps")
|
|
eq(c.daycare.mons[2], nil, "empty daycare slot")
|
|
|
|
local back = assert(SaveConvert.importSav(bytes, "firered", "firered"))
|
|
eq(back.name == "PORT" and back.rivalName == "RIVAL" and back.money == 4321 and back.coins == 12, true, "re-import: trainer")
|
|
eq(back.map == "FR_PALLET_TOWN" and back.x == 12 and back.y == 16, true, "re-import: map")
|
|
for i, m in ipairs(save.party) do
|
|
local r = back.party[i]
|
|
for _, k in ipairs({ "species", "level", "hp", "personality", "exp", "otId", "otSecretId", "metLevel", "pokeball", "otGender" }) do
|
|
eq(r[k], m[k], ("re-import: party %d %s"):format(i, k))
|
|
end
|
|
same_eq(r.moves, m.moves, ("re-import: party %d moves"):format(i))
|
|
same_eq(r.ivs, m.ivs, ("re-import: party %d IVs"):format(i))
|
|
same_eq(r.evs, m.evs, ("re-import: party %d EVs"):format(i))
|
|
end
|
|
eq(back.party[1].status, "PAR", "re-import: status")
|
|
eq(back.party[3].isEgg, true, "re-import: egg")
|
|
eq(back.party[3].eggCycles, 10, "re-import: egg cycles")
|
|
eq(back.storage.currentBox, 2, "re-import: current box")
|
|
eq(back.storage.boxes[1].name, "BOX 1", "re-import: default box name")
|
|
eq(back.storage.boxes[1].wallpaper, 1, "re-import: default wallpaper")
|
|
eq(back.storage.boxes[3].name == "MINE" and back.storage.boxes[3].wallpaper == 7, true, "re-import: custom box")
|
|
eq(back.storage.boxes[1].mons[5].species, 277, "re-import: box mon")
|
|
same_eq(back.bag.pockets.ITEMS, save.bag.pockets.ITEMS, "re-import: bag")
|
|
same_eq(back.storage.items, save.storage.items, "re-import: PC items")
|
|
eq(back.flags[0x828] and back.flags[0x820] and back.flags[0x829], true, "re-import: flags")
|
|
eq(back.vars[0x4050] == 3 and back.vars[0x4031] == 1, true, "re-import: vars")
|
|
same_eq(back.modData.cartImport.dexOwned, { 4 }, "re-import: owned (national)")
|
|
same_eq(back.modData.cartImport.dexSeen, { 4, 16, 252 }, "re-import: seen (national)")
|
|
same_eq(back.playTime, save.playTime, "re-import: play time")
|
|
eq(back.options.textSpeed == 2 and back.options.buttonMode == 1 and back.options.frameType == 3, true, "re-import: options")
|
|
eq(back.gameStats[2], 5, "re-import: game stat")
|
|
eq(back.hallOfFameTeams[1][1].species == 6 and back.hallOfFameTeams[1][1].trainerId == 12345
|
|
and back.hallOfFameTeams[1][1].otSecretId == 54321, true, "re-import: Hall of Fame")
|
|
eq(back.modData.firered_daycare.daycare[1].species, 4, "re-import: daycare")
|
|
|
|
local _, noData = Gen3Save.exportPort(reload(save), stubOpts({ toNational = false }))
|
|
eq(noData, Gen3Save.MSG.noData, "no species table refuses with the re-import sentence")
|
|
local _, noLayout = Gen3Save.exportPort(reload(save), stubOpts({ mapLayoutId = function() return nil end }))
|
|
eq(noLayout, Gen3Save.MSG.noData, "no map header refuses a fresh export")
|
|
local lost = reload(save)
|
|
lost.map = "FR_NOWHERE"
|
|
local _, noMap = Gen3Save.exportPort(lost, stubOpts())
|
|
eq(noMap, Gen3Save.MSG.noMap, "an unknown map is refused")
|
|
end
|
|
|
|
local function portSave(extra)
|
|
local s = {
|
|
engine = "game3", version = "firered", generation = 3,
|
|
name = "PORT", rivalName = "RIVAL", gender = 0, trainerId = 12345, secretId = 54321,
|
|
money = 3000, coins = 0, map = "FR_PALLET_TOWN", x = 12, y = 16, facing = "down",
|
|
healMap = "FR_PLAYERS_HOUSE_1F", healX = 8, healY = 5,
|
|
party = { portMon() },
|
|
storage = { currentBox = 1, boxes = {}, items = {} },
|
|
bag = { pockets = {} }, flags = {}, vars = {},
|
|
dex = { seen = { [4] = true }, owned = { [4] = true }, caught = { [4] = true }, national = false },
|
|
playTime = { hours = 1, minutes = 0, seconds = 0, vblanks = 0 }, options = {}, specialSaveWarpFlags = 0,
|
|
}
|
|
for k, v in pairs(extra or {}) do s[k] = v end
|
|
return s
|
|
end
|
|
|
|
local function flagSet(c)
|
|
local fl = {}
|
|
for _, id in ipairs(c.flags) do fl[id] = true end
|
|
return fl
|
|
end
|
|
|
|
do
|
|
local N = L.NATIONAL_DEX
|
|
local cases = {
|
|
{ "dex.nationalUnlocked", { dex = { seen = {}, owned = {}, caught = {}, national = false, nationalUnlocked = true } } },
|
|
{ "FLAG_SYS_NATIONAL_DEX alone", { flags = { ["2112"] = true } } },
|
|
{ "VAR_NATIONAL_DEX alone", { vars = { [0x404E] = 0x6258 } } },
|
|
}
|
|
for _, case in ipairs(cases) do
|
|
local bytes = assert(Gen3Save.exportPort(reload(portSave(case[2])), stubOpts()))
|
|
local c = assert(Gen3Save.decode(bytes))
|
|
eq(c.dexNationalMagic, N.magic, case[1] .. ": the export carries the National Dex magic")
|
|
eq(c.vars[N.var], N.varValue, case[1] .. ": VAR_NATIONAL_DEX")
|
|
eq(flagSet(c)[N.flag], true, case[1] .. ": FLAG_SYS_NATIONAL_DEX")
|
|
local back = assert(SaveConvert.importSav(bytes, "firered", "firered"))
|
|
eq(back.dex.national == true and back.dex.nationalUnlocked == true, true, case[1] .. ": the re-import unlocks the port's National Dex")
|
|
end
|
|
local off = assert(Gen3Save.decode(assert(Gen3Save.exportPort(reload(portSave()), stubOpts()))))
|
|
eq(off.dexNationalMagic, 0, "no unlock, no magic")
|
|
eq(off.vars[N.var], nil, "no unlock, no var")
|
|
local back = assert(SaveConvert.importSav(Gen3Save.exportPort(reload(portSave()), stubOpts()), "firered", "firered"))
|
|
eq(back.dex.nationalUnlocked, nil, "no unlock stays locked on import")
|
|
end
|
|
|
|
do
|
|
local ORANGE, HARBOR = 121, 122
|
|
local words = { 1, 2, 3, 4, 5, 6, 7, 8, 9 }
|
|
local save = portSave({
|
|
party = {
|
|
portMon({ item = ORANGE, heldItem = ORANGE, mail = 0 }),
|
|
portMon({ species = 16, item = HARBOR, heldItem = HARBOR }),
|
|
portMon({ species = 16, item = 13, heldItem = 13, mail = 3 }),
|
|
portMon({ species = 16, item = HARBOR, heldItem = HARBOR, mail = 20 }),
|
|
},
|
|
mail = { { words = words, playerName = "PORT", trainerId = 12345, species = 4, itemId = ORANGE } },
|
|
modData = { firered_daycare = {
|
|
daycare = { [1] = portMon({ personality = 0x1234 }), steps = { 5, 0 },
|
|
mail = { [1] = { otName = "PORT", monName = "CHARMANDER",
|
|
message = { words = { 9, 8, 7, 6, 5, 4, 3, 2, 1 }, playerName = "PORT", trainerId = 12345, species = 4, itemId = HARBOR } } } },
|
|
route5Daycare = { steps = 0 } } },
|
|
})
|
|
local bytes = assert(Gen3Save.exportPort(reload(save), stubOpts()))
|
|
local c = assert(Gen3Save.decode(bytes))
|
|
eq(c.party[1].mail, 0, "a port mon's mail index is exported")
|
|
same_eq(c.mail[1].words, words, "the letter's words are in SaveBlock1.mail")
|
|
eq(c.mail[1].itemId, ORANGE, "the letter's mail item")
|
|
eq(c.mail[1].species, 4, "the letter's species")
|
|
eq(c.mail[1].playerName, "PORT", "the letter's author")
|
|
eq(c.mail[1].trainerIdRaw, 12345 + 54321 * 65536, "the player's own letter carries TID and SID")
|
|
local blk = assert(Gen3Save.readBlocks(bytes))
|
|
-- src/mail_data.c:58
|
|
eq(blk.sb1:sub(L.MAIL.off + L.MAIL.playerName + 1, L.MAIL.off + L.MAIL.playerName + 8), "\xCA\xC9\xCC\xCE\x00\x00\xFF\xFF",
|
|
"the author is space padded to 6 like GiveMailToMon")
|
|
eq(c.party[2].mail, 1, "mail held without a letter gets a fresh letter slot")
|
|
eq(c.mail[2].itemId, HARBOR, "the fresh letter is that mail")
|
|
eq(c.party[3].mail, L.MAIL_NONE, "a non-mail item has no mail index")
|
|
eq(c.party[4].mail, 2, "an index past the mail table is never exported")
|
|
for i = 1, #c.party do check(c.party[i].mail == L.MAIL_NONE or c.party[i].mail < L.MAIL.count, "party " .. i .. " mail index in range") end
|
|
eq(c.mail[4].itemId, 0, "unused letters are cleared")
|
|
same_eq(c.mail[4].words, { 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF }, "cleared letters use EC_WORD_UNDEFINED")
|
|
eq(c.mail[4].species, L.MAIL_CLEAR_SPECIES, "cleared letters use ClearMailStruct's species")
|
|
eq(c.daycare.mail[1] and c.daycare.mail[1].message.itemId, HARBOR, "daycare mail is exported")
|
|
eq(c.daycare.mail[1] and c.daycare.mail[1].monName, "CHARMANDER", "daycare mail mon name")
|
|
|
|
local back = assert(SaveConvert.importSav(bytes, "firered", "firered"))
|
|
eq(back.party[1].mail, 0, "import: mon.mail is the cart's index")
|
|
same_eq(back.mail[1].words, words, "import: the letter lands in save.mail")
|
|
eq(back.mail[1].itemId == ORANGE and back.mail[1].playerName == "PORT" and back.mail[1].trainerId == 12345, true, "import: letter header")
|
|
eq(back.mail[1].design, 0, "import: letter design from the mail item")
|
|
eq(back.party[3].mail, nil, "import: no mail index without a letter")
|
|
eq(#back.mail, 16, "import: the whole mail table")
|
|
eq(back.modData.firered_daycare.daycare.mail[1].message.itemId, HARBOR, "import: daycare mail")
|
|
eq(back.party[1].cartExtra.mail, nil, "import: the index is not parked in cartExtra")
|
|
local Mail = require("src.core.game3.mail")
|
|
local restored = Mail.restore(back.mail)
|
|
eq(Mail.monHasMail(back.party[1]) and not Mail.isEmpty(restored[back.party[1].mail + 1]), true, "import: the port sees the mon's letter")
|
|
end
|
|
|
|
do
|
|
local bytes = assert(Gen3Save.exportPort(reload(portSave()), stubOpts({
|
|
registeredTextDefaults = function() return { "HELLO", "POKéMON", "TRADE", "BATTLE", "LET'S", "OK!", "SORRY", "YAY{EMOJI_BIGSMILE}", "THANK YOU", "BYE-BYE!" } end,
|
|
})))
|
|
local c = assert(Gen3Save.decode(bytes))
|
|
local blk = assert(Gen3Save.readBlocks(bytes))
|
|
-- src/new_game.c:151
|
|
same_eq(c.trainerTowerBest, { L.TRAINER_TOWER_MAX_TIME, L.TRAINER_TOWER_MAX_TIME, L.TRAINER_TOWER_MAX_TIME, L.TRAINER_TOWER_MAX_TIME },
|
|
"a fresh export has no Trainer Tower times")
|
|
-- src/easy_chat.c:440
|
|
same_eq(c.easyChatBattle.start, { 4111, 2562, 3621, 3075, 2051, 3072 }, "default battle start words")
|
|
same_eq(c.easyChatBattle.won, { 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF }, "battle won words undefined")
|
|
same_eq(c.easyChatBattle.lost, { 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF }, "battle lost words undefined")
|
|
-- src/fame_checker.c:1140
|
|
eq(c.fameChecker[1].pickState, L.FCPICKSTATE_COLORED, "Fame Checker shows Oak")
|
|
local others = 0
|
|
for i = 2, 16 do others = others + c.fameChecker[i].pickState + c.fameChecker[i].flavorTextFlags end
|
|
eq(others, 0, "Fame Checker has nobody else")
|
|
eq(c.registeredTexts[1], "HELLO", "registered text defaults")
|
|
eq(c.registeredTexts[8], "YAY{EMOJI_BIGSMILE}", "registered texts keep the emoji")
|
|
eq(blk.sb1:sub(L.REGISTERED_TEXTS.off + 7 * 21 + 1, L.REGISTERED_TEXTS.off + 7 * 21 + 6), "\xD3\xBB\xD3\xF9\xF9\xFF",
|
|
"the emoji is the cart's F9 F9 symbol")
|
|
-- src/battle_records.c:274
|
|
eq(blk.sb2:byte(L.LINK_BATTLE_RECORDS.off + 1), 0xFF, "link battle record names start empty")
|
|
-- src/event_data.c:71
|
|
eq(c.vars[L.RSE_NATIONAL_VAR], L.RSE_NATIONAL_VALUE, "EnableNationalPokedex_RSE var")
|
|
eq(flagSet(c)[L.RSE_NATIONAL_FLAG], true, "EnableNationalPokedex_RSE flag")
|
|
|
|
local fame = {}
|
|
for i = 1, 16 do fame[i] = { pickState = 0, flavorTextFlags = 0 } end
|
|
fame[1].pickState, fame[4] = 2, { pickState = 1, flavorTextFlags = 0x15 }
|
|
local save = portSave({
|
|
registeredTexts = { "HI", "", "", "", "", "", "", "YAY{EMOJI_BIGSMILE}", "", "" },
|
|
modData = { fameChecker = fame, trainerTower = { challengeId = 0, records = { { bestTime = 3600 }, {}, {}, {} } } },
|
|
})
|
|
local b2 = assert(Gen3Save.exportPort(reload(save), stubOpts()))
|
|
local c2 = assert(Gen3Save.decode(b2))
|
|
eq(c2.fameChecker[4].pickState == 1 and c2.fameChecker[4].flavorTextFlags == 0x15, true, "port Fame Checker progress is exported")
|
|
eq(c2.registeredTexts[1], "HI", "port registered texts are exported")
|
|
eq(c2.trainerTowerBest[1], 3600, "port Trainer Tower best time is exported")
|
|
eq(c2.trainerTowerBest[2], L.TRAINER_TOWER_MAX_TIME, "an unplayed Trainer Tower mode has no time")
|
|
local back = assert(SaveConvert.importSav(b2, "firered", "firered"))
|
|
eq(back.modData.fameChecker[4].pickState == 1 and back.modData.fameChecker[4].flavorTextFlags == 0x15, true, "import: Fame Checker progress")
|
|
eq(back.modData.fameChecker[1].pickState, 2, "import: Oak")
|
|
eq(back.registeredTexts[8], "YAY{EMOJI_BIGSMILE}", "import: registered texts")
|
|
eq(back.modData.trainerTower.records[1].bestTime, 3600, "import: Trainer Tower best time")
|
|
end
|
|
|
|
do
|
|
local stranger = reload(assert(SaveConvert.importSav(rich, "firered", "firered")))
|
|
stranger.trainerId, stranger.secretId, stranger.name = 1111, 2222, "NEWBIE"
|
|
stranger.hallOfFameTeams = {}
|
|
local out = assert(Gen3Save.exportPort(stranger, stubOpts({ template = rich })))
|
|
local c = assert(Gen3Save.decode(out))
|
|
eq(c.counter, 1, "another player's cart is not used as the template (first save)")
|
|
eq(c.encryptionKey, 0, "another player's key is not reused")
|
|
eq(c.name, "NEWBIE", "the export is the slot's player")
|
|
local other = out:sub(14 * 0x1000 * (1 - c.slot) + 1, 14 * 0x1000 * (2 - c.slot))
|
|
eq(other, string.rep("\255", 14 * 0x1000), "no copy of the other player's save rides in the older slot")
|
|
eq(out:sub(28 * 0x1000 + 1, 32 * 0x1000), string.rep("\255", 4 * 0x1000), "no Hall of Fame or Trainer Tower sectors from the other player")
|
|
local cart = assert(Gen3Save.decode(rich))
|
|
eq(Gen3Save.templateBelongs(Gen3Save.ownerOf(rich), stranger), false, "ownerOf tells the carts apart")
|
|
eq(Gen3Save.templateBelongs(Gen3Save.ownerOf(rich), { trainerId = cart.trainerId, secretId = cart.secretId, name = cart.name }), true,
|
|
"ownerOf matches the cart's own player")
|
|
end
|
|
|
|
do
|
|
local c = assert(Gen3Save.decode(rich))
|
|
local nat = c.dexOwned[1]
|
|
local patched = rebuild(rich, function(b)
|
|
local o = 0x5F8 + math.floor(nat / 8)
|
|
b.sb1 = setByte(b.sb1, o, b.sb1:byte(o + 1) - (math.floor(b.sb1:byte(o + 1) / 2 ^ (nat % 8)) % 2) * 2 ^ (nat % 8))
|
|
b.storage = setByte(b.storage, 0, 200)
|
|
end)
|
|
local save = assert(SaveConvert.importSav(patched, "firered", "firered"))
|
|
local seen, owned = {}, {}
|
|
for _, n in ipairs(save.modData.cartImport.dexSeen) do seen[n] = true end
|
|
for _, n in ipairs(save.modData.cartImport.dexOwned) do owned[n] = true end
|
|
-- src/pokedex_screen.c:2243
|
|
eq(seen[nat + 1], nil, "a species missing from one seen copy is not seen")
|
|
eq(owned[nat + 1], nil, "and not owned either")
|
|
eq(save.storage.currentBox, L.STORAGE.totalBoxes, "an out-of-range current box is clamped")
|
|
|
|
local bytes = assert(Gen3Save.exportPort(reload(portSave({ dex = { seen = {}, owned = { [4] = true }, caught = {} } })), stubOpts()))
|
|
local e = assert(Gen3Save.decode(bytes))
|
|
same_eq(e.dexSeen, { 3 }, "an owned species is written as seen")
|
|
same_eq(e.dexSeen1, { 3 }, "seen1 too")
|
|
same_eq(e.dexSeen2, { 3 }, "seen2 too")
|
|
end
|
|
|
|
do
|
|
local function withCounter(img, slot, counter)
|
|
local parts = {}
|
|
for i = 0, 31 do
|
|
local sec = img:sub(i * 0x1000 + 1, (i + 1) * 0x1000)
|
|
if math.floor(i / 14) == slot and i < 28 then
|
|
local v = counter
|
|
sec = sec:sub(1, 0xFFC) .. string.char(v % 256, math.floor(v / 256) % 256, math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
|
|
end
|
|
parts[#parts + 1] = sec
|
|
end
|
|
return table.concat(parts)
|
|
end
|
|
local c, blocks = Gen3Save.decode(rich)
|
|
c.money = 111
|
|
local a = Gen3Save.buildFlash(Gen3Save.encodeBlocks(c, blocks), { counter = 4 })
|
|
c.money = 222
|
|
local ab = Gen3Save.buildFlash(Gen3Save.encodeBlocks(c, blocks), { counter = 5, image = a })
|
|
local odd = withCounter(ab, 0, 5)
|
|
-- src/save.c:444
|
|
eq(assert(Gen3Save.decode(odd)).money, 222, "equal odd counters load slot B like the cart")
|
|
local even = withCounter(withCounter(ab, 1, 6), 0, 6)
|
|
eq(assert(Gen3Save.decode(even)).money, 111, "equal even counters load slot A like the cart")
|
|
eq(assert(Gen3Save.decode(ab)).money, 222, "the newer slot wins")
|
|
end
|
|
|
|
T.finish("gen3_save_codec")
|