mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
606 lines
27 KiB
Lua
606 lines
27 KiB
Lua
-- Importing a Gen 2 cart save (Gold, Silver, Crystal).
|
|
-- luajit tests/gen2_save_import_test.lua
|
|
-- Also dofile'd by tests/run_tests.lua.
|
|
--
|
|
-- The save this builds is synthesized rather than checked in, the same rule
|
|
-- tests/save_convert_tests.lua follows for Gen 1: a real .sav is personal
|
|
-- data. Point POKEPORT_GEN2_SAV_FIXTURE at one to run the audit at the
|
|
-- bottom against your own Gold/Silver/Crystal save.
|
|
--
|
|
-- Every offset under test comes from src/save_convert/Gen2Layout.lua, which
|
|
-- tools/gen2_sram_offsets.py generates from a pret build. The reason that
|
|
-- matters is here in miniature: Gold and Crystal disagree on almost every
|
|
-- field, so reading one with the other's table is not a near miss, it is a
|
|
-- party count of 133.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
love = love or require("tests.love_stub")
|
|
|
|
local T = require("tests.harness")
|
|
local check, eq = T.check, T.eq
|
|
|
|
local Gen2Save = require("src.save_convert.Gen2Save")
|
|
local Gen2Layout = require("src.save_convert.Gen2Layout")
|
|
local SaveConvert = require("src.save_convert.SaveConvert")
|
|
local FieldMoves = require("src.world.gen2.FieldMoves")
|
|
|
|
local SIZE = Gen2Save.SAVE_SIZE
|
|
|
|
-- A byte-addressable save under construction.
|
|
local function blank()
|
|
local b = {}
|
|
for i = 0, SIZE - 1 do b[i] = 0 end
|
|
return b
|
|
end
|
|
local function put(b, at, ...)
|
|
local vals = { ... }
|
|
for i, v in ipairs(vals) do b[at + i - 1] = v % 256 end
|
|
end
|
|
local function putName(b, at, name)
|
|
for i = 1, #name do b[at + i - 1] = 0x80 + (name:byte(i) - 65) end
|
|
b[at + #name] = 0x50
|
|
end
|
|
local function sealOne(b, L)
|
|
b[L.sCheckValue1] = 0x63
|
|
b[L.sCheckValue2] = 0x7F
|
|
local sum = 0
|
|
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + b[i]) % 65536 end
|
|
b[L.sChecksum] = sum % 256
|
|
b[L.sChecksum + 1] = math.floor(sum / 256) % 256
|
|
end
|
|
|
|
-- A real cart seals both copies, and the game rewrites the backup from the
|
|
-- primary on every successful load.
|
|
local function seal(b, L)
|
|
if L.backup then
|
|
for i = 0, (L.sGameDataEnd - L.sGameData) - 1 do
|
|
b[L.backup.sGameData + i] = b[L.sGameData + i]
|
|
end
|
|
sealOne(b, L.backup)
|
|
end
|
|
sealOne(b, L)
|
|
end
|
|
local function pack(b)
|
|
local out = {}
|
|
for i = 0, SIZE - 1 do out[i + 1] = string.char(b[i]) end
|
|
return table.concat(out)
|
|
end
|
|
|
|
-- SPRITE_TWIN is $26 (constants/sprite_constants.asm:42,149,154).
|
|
local WEIRD_TREE_SLOT, SPRITE_TWIN = 4, 0x26
|
|
|
|
local function build(version, bare, female)
|
|
local L = Gen2Save.layoutFor(version)
|
|
local b = blank()
|
|
putName(b, L.wPlayerName, "ASH")
|
|
putName(b, L.wRivalName, "GARY")
|
|
putName(b, L.wMomsName, "MOM")
|
|
put(b, L.wPlayerID, 0x12, 0x34) -- big-endian 0x1234
|
|
put(b, L.wMoney, 0x01, 0xE2, 0x40) -- 123456
|
|
put(b, L.wBadges, 0x05) -- ZEPHYR + PLAIN
|
|
put(b, L.wPartyCount, 1)
|
|
put(b, L.wPartySpecies, 155)
|
|
local mon = L.wPartyMons
|
|
put(b, mon, 155) -- species
|
|
put(b, mon + 1, 0) -- no held item
|
|
put(b, mon + 2, 33, 43, 0, 0) -- two moves
|
|
put(b, mon + 0x17, 0x63, 40, 0, 0) -- 35 PP with one PP Up, then 40
|
|
put(b, mon + 6, 0x12, 0x34) -- OT id
|
|
put(b, mon + 0x15, 0x9F, 0x6A) -- DVs: a=9 d=15 s=6 sp=10
|
|
put(b, mon + 0x1B, 200) -- happiness
|
|
put(b, mon + 0x1F, 42) -- level
|
|
put(b, mon + 0x20, 0x10) -- BRN (bit 4)
|
|
put(b, mon + 0x22, 0x00, 0x64) -- hp 100
|
|
put(b, mon + 0x24, 0x00, 0x64) -- maxHp 100
|
|
put(b, mon + 0x26, 0x00, 0x37) -- attack 55
|
|
putName(b, L.wPartyMonNicknames, "FLAME")
|
|
putName(b, L.wPartyMonOTs, "ASH")
|
|
put(b, L.wMapGroup, 21); put(b, L.wMapNumber, 14)
|
|
if not bare then
|
|
put(b, L.wStatusFlags, 0x01) -- STATUSFLAGS_POKEDEX_F
|
|
put(b, L.wPokegearFlags, 0x81) -- POKEGEAR_OBTAINED_F + map card
|
|
put(b, L.wVisitedSpawns + 1, 0x40)
|
|
put(b, L.wVisitedSpawns + 2, 0x05)
|
|
put(b, L.wVariableSprites + WEIRD_TREE_SLOT, SPRITE_TWIN)
|
|
put(b, L.sceneVars.ELMS_LAB, 2)
|
|
put(b, L.sceneVars.NEW_BARK_TOWN, 1)
|
|
put(b, L.sceneVars.ROUTE_27, 1)
|
|
put(b, L.sceneVars.VICTORY_ROAD, 1)
|
|
end
|
|
if L.wPlayerGender then put(b, L.wPlayerGender, female and 1 or 0) end
|
|
put(b, L.wNumItems, 1); put(b, L.wItems, 20, 3); put(b, L.wItems + 2, 0xFF)
|
|
put(b, L.wNumKeyItems, 1); put(b, L.wKeyItems, 7); put(b, L.wKeyItems + 1, 0xFF)
|
|
put(b, L.wNumBalls, 1); put(b, L.wBalls, 5, 9); put(b, L.wBalls + 2, 0xFF)
|
|
-- box 3, one Pokemon
|
|
local box = L.boxes[3]
|
|
put(b, box, 1)
|
|
put(b, box + 0x01, 7)
|
|
put(b, box + 0x16, 7)
|
|
put(b, box + 0x16 + 0x1F, 15)
|
|
putName(b, box + 0x296, "ASH")
|
|
putName(b, box + 0x372, "SQUIRT")
|
|
seal(b, L)
|
|
return pack(b)
|
|
end
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- What the cart holds comes back out
|
|
-- ------------------------------------------------------------------
|
|
|
|
for _, version in ipairs({ "gold", "silver", "crystal" }) do
|
|
local save, err = Gen2Save.decode(build(version), version)
|
|
check(save ~= nil, version .. ": a valid save decodes -- " .. tostring(err))
|
|
if save then
|
|
eq(save.player.name, "ASH", version .. ": player name")
|
|
eq(save.rival.name, "GARY", version .. ": rival name")
|
|
eq(save.player.id, 0x1234, version .. ": trainer id is big-endian")
|
|
eq(save.player.money, 123456, version .. ": money is a 3-byte big-endian")
|
|
eq(save.player.badges.ZEPHYR, true, version .. ": badges are keyed by name")
|
|
eq(save.player.badges.PLAIN, true, version .. ": and the second bit too")
|
|
eq(save.player.badges.HIVE, nil, version .. ": an unearned badge is absent")
|
|
eq(#save.party, 1, version .. ": party size")
|
|
local m = save.party[1]
|
|
eq(m.species, 155, version .. ": species, uncrosswalked")
|
|
eq(m.level, 42, version .. ": level")
|
|
eq(m.maxHp, 100, version .. ": max hp")
|
|
eq(m.stats.attack, 55, version .. ": computed stats come off the party tail")
|
|
eq(#m.moves, 2, version .. ": empty move slots are dropped")
|
|
eq(m.nickname, "FLAME", version .. ": nickname")
|
|
eq(m.ot, "ASH", version .. ": OT name")
|
|
eq(m.happiness, 200, version .. ": happiness")
|
|
-- DVs are nibbles, and the HP DV is rebuilt from the other four's low bits
|
|
eq(m.dvs.attack, 9, version .. ": attack DV is the high nibble")
|
|
eq(m.dvs.defense, 15, version .. ": defense DV is the low nibble")
|
|
eq(m.dvs.speed, 6, version .. ": speed DV")
|
|
eq(m.dvs.special, 10, version .. ": special DV")
|
|
eq(m.dvs.hp, 12, version .. ": HP DV is rebuilt, not stored")
|
|
eq(#save.boxes, 14, version .. ": every box is present")
|
|
eq(#save.boxes[3], 1, version .. ": box 3 holds one Pokemon")
|
|
eq(save.boxes[3][1].species, 7, version .. ": the stored species, uncrosswalked")
|
|
eq(save.boxes[3][1].nickname, "SQUIRT", version .. ": box nicknames follow the OTs")
|
|
eq(save.boxes[3][1].ot, "ASH", version .. ": box OT")
|
|
eq(save.boxes[3][1].hp, nil, version .. ": a box mon carries no computed stats")
|
|
-- data/events/engine_flags.asm (#1900)
|
|
eq(save.engineFlags[11], true, version .. ": ENGINE_POKEDEX out of wStatusFlags")
|
|
eq(save.engineFlags[4], true, version .. ": ENGINE_POKEGEAR out of wPokegearFlags")
|
|
eq(save.engineFlags[1], true, version .. ": the map card is engine flag 1")
|
|
eq(save.engineFlags[0], nil, version .. ": the radio card bit is clear")
|
|
local bare = assert(Gen2Save.decode(build(version, true), version))
|
|
eq(bare.engineFlags[11], nil,
|
|
version .. ": a save from before Mr. Pokemon's house has no dex flag")
|
|
eq(bare.engineFlags[4], nil, version .. ": nor a POKEGEAR")
|
|
|
|
local shift = version == "crystal" and 1 or 0
|
|
eq(save.engineFlags[64 + shift], true, version .. ": ENGINE_FLYPOINT_NEW_BARK")
|
|
eq(save.engineFlags[66 + shift], true, version .. ": ENGINE_FLYPOINT_VIOLET")
|
|
eq(save.engineFlags[67 + shift], true,
|
|
version .. ": AZALEA is bit 18, not 17 -- SPAWN_UNION_CAVE has no flag")
|
|
eq(save.engineFlags[65 + shift], nil,
|
|
version .. ": a town the cart never reached stays unflyable")
|
|
eq(bare.engineFlags[64 + shift], nil,
|
|
version .. ": and a pre-Mr.-Pokemon save has no flypoint at all")
|
|
if shift == 0 then
|
|
eq(FieldMoves.hasVisitedSpawn(save, "SPAWN_AZALEA"), true,
|
|
version .. ": FLY offers the town the cart unlocked")
|
|
eq(FieldMoves.hasVisitedSpawn(save, "SPAWN_CHERRYGROVE"), false,
|
|
version .. ": and not the one it did not")
|
|
eq(FieldMoves.hasVisitedSpawn(bare, "SPAWN_NEW_BARK"), false,
|
|
version .. ": a fresh cart offers nothing")
|
|
end
|
|
|
|
eq(save.variableSprites[WEIRD_TREE_SLOT], SPRITE_TWIN,
|
|
version .. ": the SPRITE_WEIRD_TREE slot comes off the cart")
|
|
eq(save.variableSprites[0], nil,
|
|
version .. ": a slot the cart never filled stays absent for World's seed")
|
|
|
|
-- data/maps/scenes.asm:7
|
|
eq(save.mapScenes.ELMS_LAB, 2, version .. ": Elm's lab is past the starter")
|
|
eq(save.mapScenes.NEW_BARK_TOWN, 1, version .. ": the teacher no longer stops you")
|
|
eq(save.mapScenes.ROUTE_27, 1, version .. ": the Route 27 fisher stays put")
|
|
eq(save.mapScenes.VICTORY_ROAD, 1, version .. ": the Victory Road rival is done")
|
|
eq(save.mapScenes.POKECENTER_2F, nil, version .. ": a zero scene byte is no entry")
|
|
eq(next(bare.mapScenes), nil, version .. ": a fresh cart has every map on scene 0")
|
|
|
|
-- constants/ram_constants.asm:177 (#1907)
|
|
eq(save.player.gender, version == "crystal" and "male" or nil,
|
|
version .. ": Gold and Silver have no gender byte to read")
|
|
local kris = assert(Gen2Save.decode(build(version, false, true), version))
|
|
eq(kris.player.gender, version == "crystal" and "female" or nil,
|
|
version .. ": sCrystalData bit 0 is the imported gender")
|
|
end
|
|
end
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- The engine is keyed by name, so the codec has to translate
|
|
-- ------------------------------------------------------------------
|
|
--
|
|
-- The cart stores numbers. save.inventory holds POTION, mon.species is
|
|
-- "TYPHLOSION", data.pokemon is indexed by that name. Without this the import
|
|
-- looks perfect and the engine cannot read a byte of it.
|
|
|
|
local CROSSWALK = {
|
|
pokemon = { CYNDAQUIL = { index = 155 }, SQUIRTLE = { index = 7 } },
|
|
moves = { TACKLE = { index = 33, pp = 35, name = "TACKLE" },
|
|
LEER = { index = 43, pp = 30, name = "LEER" } },
|
|
items = { POTION = { index = 20 }, BICYCLE = { index = 7 },
|
|
POKE_BALL = { index = 5 } },
|
|
maps = { GOLDENROD_CITY = { group = 21, map = 14 } },
|
|
}
|
|
|
|
do
|
|
local save = assert(Gen2Save.decode(build("gold"), "gold", CROSSWALK))
|
|
local m = save.party[1]
|
|
eq(m.species, "CYNDAQUIL", "species is the engine's id, not the cart's number")
|
|
eq(m.moves[1].id, "TACKLE", "and so are moves")
|
|
eq(m.moves[2].id, "LEER", "both of them")
|
|
-- engine/pokemon/mon_stats.asm (#1899)
|
|
eq(m.moves[1].pp, 35, "a move slot carries the PP the cart stored")
|
|
eq(m.moves[1].ppUps, 1, "and its PP Ups, out of the top two bits")
|
|
eq(m.moves[1].maxPp, 42, "max PP is the base plus one PP Up's bonus")
|
|
eq(m.pp, nil, "and there is no parallel PP array beside it")
|
|
eq(save.boxes[3][1].species, "SQUIRTLE", "boxes translate too")
|
|
|
|
-- One flat bag. Nothing in src reads save.keyItems or save.balls; PackMenu
|
|
-- buckets save.inventory by each item's own pocket.
|
|
eq(save.keyItems, nil, "there is no separate key item table")
|
|
eq(save.balls, nil, "nor a separate ball table")
|
|
eq(save.inventory.POTION, 3, "the ITEM pocket lands in the bag")
|
|
eq(save.inventory.BICYCLE, 1, "so does KEY_ITEM, which is why you can cycle")
|
|
eq(save.inventory.POKE_BALL, 9, "and BALL, which is why you can throw one")
|
|
|
|
-- Save.summary does `save.position.map or save.spawn`, so a save with no
|
|
-- map key resumes at the spawn point with the old coordinates.
|
|
eq(save.position.map, "GOLDENROD_CITY", "position names the map it is on")
|
|
|
|
-- save.pokedex.caught[species] = true, keyed the same way.
|
|
local dexKey = next(save.pokedex.caught)
|
|
check(dexKey == nil or type(dexKey) == "string" or type(dexKey) == "number",
|
|
"the dex is keyed by species id")
|
|
|
|
-- Save.scrubEvents runs tonumber over the VALUE against Save.EVENT_BYTES,
|
|
-- and tonumber(true) is nil, so a set of booleans is silently emptied.
|
|
eq(type(save.events[0]), "number", "events are packed bytes, not booleans")
|
|
local evCount = 0
|
|
for _ in pairs(save.events) do evCount = evCount + 1 end
|
|
eq(evCount, Gen2Save.EVENT_BYTES, "one entry per event byte")
|
|
|
|
-- 0 is truthy in Lua, so a raw status byte makes healthy mons look ill.
|
|
eq(m.status, "brn", "status is the engine's class string")
|
|
eq(save.boxes[3][1].status, nil, "and nil when healthy, not 0")
|
|
end
|
|
|
|
-- Without a crosswalk the raw numbers survive rather than being dropped: an id
|
|
-- this build cannot name is still the player's.
|
|
do
|
|
local save = assert(Gen2Save.decode(build("gold"), "gold"))
|
|
eq(save.party[1].species, 155, "an unknown species keeps its cart number")
|
|
eq(save.party[1].moves[1].id, 33, "and an unnamed move keeps its own")
|
|
eq(save.party[1].moves[1].maxPp, nil,
|
|
"with no move table there is no base PP to grow a max out of")
|
|
end
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- Export: what goes in comes back out, including what changed
|
|
-- ------------------------------------------------------------------
|
|
--
|
|
-- Exporting a save onto the buffer it was decoded from proves nothing: every
|
|
-- region encode does not write matches because it was copied. So this CHANGES
|
|
-- things first, in each of the places export has to reach, and reads them back
|
|
-- through a fresh decode.
|
|
|
|
do
|
|
local ITEMS = {
|
|
POTION = { index = 20, pocket = "ITEM" },
|
|
BICYCLE = { index = 7, pocket = "KEY_ITEM" },
|
|
POKE_BALL = { index = 5, pocket = "BALL" },
|
|
TM_HEADBUTT = { index = 191, pocket = "TM_HM", tmNumber = 2 },
|
|
}
|
|
local data = {
|
|
pokemon = CROSSWALK.pokemon, moves = CROSSWALK.moves,
|
|
items = ITEMS, maps = CROSSWALK.maps,
|
|
}
|
|
local cart = build("gold")
|
|
local save = assert(Gen2Save.decode(cart, "gold", data))
|
|
|
|
save.inventory = { POTION = 7, BICYCLE = 1, POKE_BALL = 12, TM_HEADBUTT = 1 }
|
|
save.currentBox = 5
|
|
save.party[1].status, save.party[1].statusTurns = "slp", 3
|
|
save.party[1].pokerus = 0x34
|
|
save.party[1].caughtData = 0x1234
|
|
save.events[9] = 0xA5
|
|
save.party[1].moves[1].pp = 12
|
|
save.engineFlags[11] = nil
|
|
save.engineFlags[66] = nil
|
|
save.engineFlags[69] = true
|
|
save.variableSprites[6] = 0x35
|
|
save.mapScenes.ELMS_LAB = 6
|
|
save.mapScenes.NEW_BARK_TOWN = 0
|
|
save.mapScenes.MAHOGANY_TOWN = 1
|
|
|
|
local out = assert(Gen2Save.encode(save, "gold", cart, data))
|
|
eq(#out, #cart, "the image keeps its size")
|
|
local back = assert(Gen2Save.decode(out, "gold", data))
|
|
|
|
-- The bag: encode used to leave it at whatever the template carried, so a
|
|
-- potion bought in a session never reached the cartridge.
|
|
eq(back.inventory.POTION, 7, "the ITEM pocket is written")
|
|
eq(back.inventory.BICYCLE, 1, "and KEY_ITEM")
|
|
eq(back.inventory.POKE_BALL, 12, "and BALL")
|
|
eq(back.inventory.TM_HEADBUTT, 1, "and TM_HM, by its tmNumber")
|
|
eq(back.currentBox, 5, "the open box is written")
|
|
|
|
-- 0x1C-0x1E are the mon's, not the slot's: left to the template, reordering
|
|
-- the party gives slot 1 the previous occupant's pokerus and caught data.
|
|
eq(back.party[1].pokerus, 0x34, "pokerus rides the mon")
|
|
eq(back.party[1].caughtData, 0x1234, "so does caught data")
|
|
|
|
eq(back.party[1].status, "slp", "status survives as a class")
|
|
eq(back.party[1].statusTurns, 3, "with its turn count")
|
|
eq(back.events[9], 0xA5, "event bytes are written")
|
|
|
|
eq(back.party[1].moves[1].id, "TACKLE", "a move slot survives the round trip")
|
|
eq(back.party[1].moves[1].pp, 12, "with the PP it was spent down to")
|
|
eq(back.party[1].moves[1].ppUps, 1, "and its PP Ups still in the top bits")
|
|
-- engine/menus/save.asm
|
|
eq(back.engineFlags[11], nil, "a cleared engine flag clears its SRAM bit")
|
|
eq(back.engineFlags[4], true, "and the ones left alone stay set")
|
|
|
|
-- engine/pokegear/pokegear.asm:2189 (#1906)
|
|
eq(back.engineFlags[66], nil, "a cleared flypoint clears its wVisitedSpawns bit")
|
|
eq(back.engineFlags[69], true, "and a newly earned one is written")
|
|
eq(back.engineFlags[64], true, "with the neighbours on either side untouched")
|
|
eq(back.engineFlags[67], true, "AZALEA included, across the UNION_CAVE hole")
|
|
|
|
-- engine/overworld/scripting.asm:869 (#1905)
|
|
eq(back.variableSprites[WEIRD_TREE_SLOT], SPRITE_TWIN,
|
|
"the twins' slot survives the round trip")
|
|
eq(back.variableSprites[6], 0x35, "and a slot filled inside the port is written")
|
|
|
|
-- data/maps/scenes.asm:7
|
|
eq(back.mapScenes.ELMS_LAB, 6, "a scene advanced in the port is written")
|
|
eq(back.mapScenes.NEW_BARK_TOWN, nil, "a cleared scene clears the cart byte")
|
|
eq(back.mapScenes.MAHOGANY_TOWN, 1, "and a newly set one reaches the cart")
|
|
eq(back.mapScenes.ROUTE_27, 1, "with the untouched ones left as they were")
|
|
|
|
local again = assert(Gen2Save.encode(back, "gold", out, data))
|
|
local L = Gen2Save.layoutFor("gold")
|
|
local startAt, stopAt = L.sceneVars.POKECENTER_2F, L.sceneVars.MOUNT_MOON_SQUARE
|
|
eq(again:sub(startAt + 1, stopAt + 1), out:sub(startAt + 1, stopAt + 1),
|
|
"import, export, import gives the same scene bytes")
|
|
|
|
back.mapScenes = {}
|
|
local untouched = assert(Gen2Save.encode(back, "gold", out, data))
|
|
eq(untouched:byte(L.sceneVars.ELMS_LAB + 1), 6,
|
|
"a save with no scene entries leaves the cart's scene bytes alone")
|
|
end
|
|
|
|
-- ram/sram.asm:138-144
|
|
do
|
|
eq(Gen2Layout.goldSilver.wPlayerGender, nil,
|
|
"Gold and Silver have no gender byte")
|
|
eq(Gen2Layout.crystal.wPlayerGender, Gen2Layout.crystal.backup.wPlayerGender,
|
|
"the Crystal backup shares the primary's gender byte")
|
|
|
|
local cart = build("crystal", false, true)
|
|
local save = assert(Gen2Save.decode(cart, "crystal"))
|
|
eq(save.player.gender, "female", "Kris imports as Kris")
|
|
local out = assert(Gen2Save.encode(save, "crystal", cart))
|
|
eq(out:byte(Gen2Layout.crystal.wPlayerGender + 1) % 2, 1,
|
|
"and exports back into sCrystalData bit 0")
|
|
|
|
save.player.gender = "male"
|
|
local male = assert(Gen2Save.encode(save, "crystal", cart))
|
|
eq(male:byte(Gen2Layout.crystal.wPlayerGender + 1) % 2, 0,
|
|
"a gender changed in the port reaches the cart image")
|
|
eq(assert(Gen2Save.decode(male, "crystal")).player.gender, "male",
|
|
"and reads back as Chris")
|
|
end
|
|
|
|
-- data/maps/scenes.asm:7
|
|
do
|
|
local function shape(L, want, label)
|
|
local n = 0
|
|
for mapId, at in pairs(L.sceneVars) do
|
|
n = n + 1
|
|
check(at >= L.sGameData and at < L.sGameDataEnd,
|
|
("%s: %s scene byte 0x%04X is inside the checksummed block"):format(label, mapId, at))
|
|
end
|
|
eq(n, want, label .. ": one scene byte per scene_var row")
|
|
end
|
|
shape(Gen2Layout.goldSilver, 59, "gold/silver")
|
|
shape(Gen2Layout.crystal, 79, "crystal")
|
|
shape(Gen2Layout.crystal.backup, 79, "crystal backup")
|
|
eq(Gen2Layout.goldSilver.sceneVars.ELMS_LAB, 0x2534, "gold wElmsLabSceneID")
|
|
eq(Gen2Layout.crystal.sceneVars.ELMS_LAB, 0x2515, "crystal wElmsLabSceneID")
|
|
eq(Gen2Layout.crystal.backup.sceneVars.ELMS_LAB,
|
|
Gen2Layout.crystal.sceneVars.ELMS_LAB - 0xE00, "the backup copy is the same byte shifted")
|
|
end
|
|
|
|
-- engine/pokemon/stats_screen.asm (#1899)
|
|
|
|
do
|
|
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
|
|
local save = assert(Gen2Save.decode(build("gold"), "gold", CROSSWALK))
|
|
local view = setmetatable(
|
|
{ mon = save.party[1], moves = CROSSWALK.moves, items = CROSSWALK.items,
|
|
party = {}, page = SummaryMenu.GREEN_PAGE },
|
|
{ __index = SummaryMenu })
|
|
local rows = {}
|
|
for _, row in ipairs(view:greenPlacements()) do
|
|
rows[#rows + 1] = row.text
|
|
end
|
|
local text = table.concat(rows, "|")
|
|
check(text:find("TACKLE", 1, true) ~= nil,
|
|
"the green page names the imported move -- got: " .. text)
|
|
check(text:find("35", 1, true) ~= nil and text:find("42", 1, true) ~= nil,
|
|
"and prints its PP over its max rather than 0/ 0 -- got: " .. text)
|
|
end
|
|
|
|
-- engine/menus/start_menu.asm (#1900)
|
|
do
|
|
local StartMenu = require("src.ui.gen2.StartMenu")
|
|
local imported = assert(SaveConvert.importSav(build("gold"), "gold", "gold"))
|
|
local menu = setmetatable({ save = imported }, { __index = StartMenu })
|
|
local rows = {}
|
|
for _, item in ipairs(menu:visibleItems()) do rows[#rows + 1] = item.value end
|
|
local list = table.concat(rows, ",")
|
|
check(list:find("pokedex", 1, true) ~= nil,
|
|
"an imported save shows #DEX on the START menu -- got: " .. list)
|
|
check(list:find("pokegear", 1, true) ~= nil,
|
|
"and the POKEGEAR row too -- got: " .. list)
|
|
|
|
local before = assert(SaveConvert.importSav(build("gold", true), "gold", "gold"))
|
|
local early = setmetatable({ save = before }, { __index = StartMenu })
|
|
local earlyRows = {}
|
|
for _, item in ipairs(early:visibleItems()) do earlyRows[#earlyRows + 1] = item.value end
|
|
check(table.concat(earlyRows, ","):find("pokedex", 1, true) == nil,
|
|
"and a save from before the dex was given still hides it")
|
|
end
|
|
|
|
-- A save with no cartridge image behind it gets one synthesized, but only
|
|
do
|
|
local out, err = Gen2Save.encode({ player = { name = "A" } }, "gold", nil, {})
|
|
eq(out, nil, "encode refuses a save with no lineage AND no position")
|
|
check(type(err) == "string" and err:find("does not name one", 1, true) ~= nil,
|
|
"and says which half is missing -- " .. tostring(err))
|
|
|
|
local data = {
|
|
maps = { A_HOUSE = {
|
|
group = 21, map = 14, objectEventsAddr = 0x5A17,
|
|
width = 2, height = 2, blocks = { 1, 2, 3, 4 }, objects = {},
|
|
} },
|
|
}
|
|
local built, why = Gen2Save.encode({
|
|
player = { name = "A" },
|
|
position = { mapGroup = 21, mapNumber = 14, x = 1, y = 1 },
|
|
}, "gold", nil, data)
|
|
check(built ~= nil, "a positioned save builds its own image -- " .. tostring(why))
|
|
eq(#built, Gen2Save.SAVE_SIZE, "and it is a full battery image")
|
|
eq(Gen2Save.checksumValid(built, Gen2Save.layoutFor("gold")), true,
|
|
"sealed with the check values and sum the cartridge verifies")
|
|
local back = Gen2Save.decode(built, "gold")
|
|
check(back ~= nil and back.player.name == "A", "and decodes back to itself")
|
|
end
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- A save the real cartridge would open must not be refused
|
|
-- ------------------------------------------------------------------
|
|
--
|
|
-- TryLoadSaveFile checks the primary, and on failure VerifyBackupChecksum and
|
|
-- LoadBackupPlayerData. Refusing on the primary alone reports a save the game
|
|
-- itself would load as corrupt, which is what #1832 was about.
|
|
|
|
do
|
|
local L = Gen2Save.layoutFor("crystal")
|
|
check(L.backup ~= nil, "Crystal carries a backup layout")
|
|
eq(Gen2Save.layoutFor("gold").backup, nil,
|
|
"Gold and Silver split theirs across three sections, so they have none")
|
|
|
|
local good = build("crystal")
|
|
local at = L.sChecksum + 1
|
|
local broken = good:sub(1, at - 1)
|
|
.. string.char((good:byte(at) + 1) % 256) .. good:sub(at + 1)
|
|
|
|
eq(Gen2Save.checksumValid(broken, L), false, "the primary is now corrupt")
|
|
eq(Gen2Save.checksumValid(broken, L.backup), true, "the backup is not")
|
|
local save, err = Gen2Save.decode(broken, "crystal")
|
|
check(save ~= nil, "so the save still opens -- " .. tostring(err))
|
|
if save then
|
|
eq(save.player.name, "ASH", "and reads the same player out of the backup")
|
|
eq(save.mapScenes.ELMS_LAB, 2, "and the same map scenes out of the backup")
|
|
end
|
|
end
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- Gold's table is not Crystal's
|
|
-- ------------------------------------------------------------------
|
|
|
|
do
|
|
local goldBytes = build("gold")
|
|
local wrong, err = Gen2Save.decode(goldBytes, "crystal")
|
|
check(wrong == nil, "a Gold save read with Crystal's table is refused")
|
|
check(type(err) == "string" and err:find("checksum", 1, true) ~= nil,
|
|
"and refused by the guard, not by luck -- got: " .. tostring(err))
|
|
check(Gen2Layout.goldSilver.wPartyMons ~= Gen2Layout.crystal.wPartyMons,
|
|
"the two layouts really do disagree about where the party is")
|
|
end
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- Through the launcher's own entry point
|
|
-- ------------------------------------------------------------------
|
|
|
|
do
|
|
local save, err = SaveConvert.importSav(build("gold"), "gold", "gold")
|
|
check(save ~= nil, "importSav accepts a Gen 2 save now -- " .. tostring(err))
|
|
if save then
|
|
eq(save.player.name, "ASH", "and returns the decoded player")
|
|
eq(save.generation, 2, "tagged as Gen 2")
|
|
-- Everything the cart does not carry still has to be there.
|
|
check(type(save.mail) == "table", "mail falls back to the new-game default")
|
|
check(type(save.hallOfFame) == "table", "so does the hall of fame")
|
|
check(type(save.phoneContacts) == "table", "and the phone book")
|
|
eq(save.mapScenes.ELMS_LAB, 2, "and the cart's map scenes survive the merge")
|
|
end
|
|
local out, expErr = SaveConvert.exportSav({ meta = {} }, "gold")
|
|
eq(out, nil, "a save that names no map is still refused")
|
|
check(type(expErr) == "string" and expErr:find("does not name one", 1, true) ~= nil,
|
|
"and the reason is the missing map, not a missing cartridge -- got: "
|
|
.. tostring(expErr))
|
|
check(type(expErr) == "string" and expErr:find("no cartridge image to write", 1, true) == nil,
|
|
"the old refusal is gone -- got: " .. tostring(expErr))
|
|
end
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- Real-save audit (fixture-gated)
|
|
-- ------------------------------------------------------------------
|
|
|
|
local fixture = os.getenv("POKEPORT_GEN2_SAV_FIXTURE")
|
|
local fixtureVersion = os.getenv("POKEPORT_GEN2_SAV_VERSION") or "crystal"
|
|
if not fixture then
|
|
print("real-save audit skipped (set POKEPORT_GEN2_SAV_FIXTURE to a Gen 2 .sav, "
|
|
.. "and POKEPORT_GEN2_SAV_VERSION to gold/silver/crystal)")
|
|
else
|
|
local f = io.open(fixture, "rb")
|
|
local bytes = f and f:read("*a")
|
|
if f then f:close() end
|
|
check(bytes ~= nil, "the fixture is readable")
|
|
if bytes then
|
|
local save, err = Gen2Save.decode(bytes, fixtureVersion)
|
|
check(save ~= nil, "a real cart save decodes -- " .. tostring(err))
|
|
if save then
|
|
check(#save.party >= 1 and #save.party <= 6, "party size is possible")
|
|
check(#save.player.name > 0, "the player has a name")
|
|
for i, mon in ipairs(save.party) do
|
|
check(mon.species >= 1 and mon.species <= Gen2Save.NUM_SPECIES,
|
|
("party %d species is a real species (%d)"):format(i, mon.species))
|
|
check(mon.level >= 1 and mon.level <= 100,
|
|
("party %d level is possible (%d)"):format(i, mon.level))
|
|
check(mon.dvs.attack <= 15 and mon.dvs.special <= 15,
|
|
("party %d DVs are nibbles"):format(i))
|
|
end
|
|
for b, box in ipairs(save.boxes) do
|
|
check(#box <= Gen2Save.BOX_CAPACITY, ("box %d holds at most 20"):format(b))
|
|
end
|
|
|
|
-- And back out. Without the item table the bag cannot be bucketed, so
|
|
-- this asserts the refusal rather than a lossy write.
|
|
local out, err = Gen2Save.encode(save, fixtureVersion, bytes, {})
|
|
if out then
|
|
eq(#out, #bytes, "the exported image keeps the cart's size, RTC and all")
|
|
local back = assert(Gen2Save.decode(out, fixtureVersion))
|
|
eq(back.player.name, save.player.name, "the player survives the round trip")
|
|
eq(#back.party, #save.party, "and the party")
|
|
local a, b2 = 0, 0
|
|
for _, box in ipairs(save.boxes) do a = a + #box end
|
|
for _, box in ipairs(back.boxes) do b2 = b2 + #box end
|
|
eq(b2, a, "and every stored Pokemon")
|
|
else
|
|
check(err:find("pocket", 1, true) ~= nil,
|
|
"or it refuses because the bag cannot be sorted -- " .. tostring(err))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
T.finish()
|