-- 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()