diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index 8723fe0a..a11f41b4 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -20,6 +20,37 @@ local GameVersion = require("src.core.GameVersion") local SaveFileIO = {} +-- The cartridge image an imported Gen 2 slot came from, kept BESIDE the slot +-- rather than inside it: export needs the regions the codec does not model, +-- and 32 KB of binary in the serialized table is 40 KB of Lua source reparsed +-- on every save and load. +local function cartPath(version, slotId) + return ("saves/%s/%s.cart"):format(version, tostring(slotId)) +end + +local function cartFs() + local portable = SaveData.portableFs and SaveData.portableFs() + return portable or (love and love.filesystem) +end + +local function writeCart(version, slotId, bytes) + local fs = cartFs() + if not (fs and fs.write and bytes) then return end + if fs.createDirectory then + fs.createDirectory("saves") + fs.createDirectory("saves/" .. version) + end + fs.write(cartPath(version, slotId), bytes) +end + +local function readCart(version, slotId) + local fs = cartFs() + if not (fs and fs.read) then return nil end + local ok, bytes = pcall(fs.read, cartPath(version, slotId)) + if ok and type(bytes) == "string" then return bytes end + return nil +end + local SAVE_SIZE = SaveConvert.SAVE_SIZE -- Resolve raw save bytes from whatever the launcher hands us: @@ -78,14 +109,17 @@ function SaveFileIO.importToSlot(source, version, force) version = version or GameVersion.get() local bytes, readErr = readSource(source) if not bytes then return false, readErr end - -- The GAME decides before the BYTES do. Everything below this line judges a - -- save by Gen 1's rules -- the size test, and mainChecksumValid, which is - -- pokered's checksum -- so a Gen 2 cart save reaching it is measured against - -- a rule that cannot match and comes back "checksum invalid" (#1832). + -- The GAME decides before the BYTES do. Everything below this line used to + -- judge a save by Gen 1's rules whatever game it was for, and a Gen 2 cart + -- is MBC3+TIMER: a real Gold/Silver/Crystal .sav carries an RTC footer, so + -- it is 32786 bytes, misses the size test, and was then measured against + -- pokered's checksum -- which is why a perfectly good Crystal save reported + -- as corrupt (#1832). mainChecksumValid now takes the game and asks that + -- generation's rule. local supported, unsupportedWhy = SaveConvert.importSupported(version) if not supported then return false, unsupportedWhy end if #bytes ~= SAVE_SIZE then - local check = SaveConvert.mainChecksumValid(bytes) + local check = SaveConvert.mainChecksumValid(bytes, version) if check == nil then return false, ("A save file must be %d bytes (32 KB); this one is %d.") :format(SAVE_SIZE, #bytes) @@ -93,7 +127,12 @@ function SaveFileIO.importToSlot(source, version, force) if check == false then return false, "save data checksum invalid (main data checksum mismatch)" end - if #bytes > SAVE_SIZE and not force then + -- The confirm exists because a Gen 1 save bigger than 32768 is a surprise + -- worth asking about. On a Gen 2 cart it is the normal shape -- every + -- real one has the footer -- so asking would be a prompt with one sensible + -- answer, on every import, forever. + if #bytes > SAVE_SIZE and not force + and not SaveConvert.isGen2Cart(version) then return false, nil, { needsConfirm = true, size = #bytes } end bytes = #bytes > SAVE_SIZE and bytes:sub(1, SAVE_SIZE) @@ -117,6 +156,7 @@ function SaveFileIO.importToSlot(source, version, force) return false, "could not write the imported save: " .. tostring(writeErr) end SaveData.setActiveSlot(version, slotId) + if SaveConvert.isGen2Cart(version) then writeCart(version, slotId, bytes) end return true, slotId end @@ -131,9 +171,10 @@ function SaveFileIO.exportActiveSlot(version) version = version or GameVersion.get() local save = SaveData.load(version) if not save then return false, "this game has no save to export yet" end - local bytes, exportErr = SaveConvert.exportSav(save, version) - if not bytes then return false, exportErr end local slotId = SaveData.activeSlot(version) or "save" + local bytes, exportErr = SaveConvert.exportSav(save, version, + readCart(version, slotId)) + if not bytes then return false, exportErr end -- Portable mode is the same seam SaveData's own persistFs uses: when -- portable.txt marks the install every persistent write leaves the OS save -- directory for the game folder, and an export is no exception. Writing diff --git a/src/save_convert/Gen2Layout.lua b/src/save_convert/Gen2Layout.lua new file mode 100644 index 00000000..d60511e1 --- /dev/null +++ b/src/save_convert/Gen2Layout.lua @@ -0,0 +1,335 @@ +-- GENERATED by tools/gen2_sram_offsets.py. Do not edit by hand. +-- Regenerate from a pret/pokegold + pret/pokecrystal build; see that +-- script's header for the derivation and the assertion behind it. +local Gen2Layout = {} + +Gen2Layout.goldSilver = { + sCheckValue1 = 0x2008, + sCheckValue2 = 0x2D6B, + sChecksum = 0x2D69, + sGameData = 0x2009, + sGameDataEnd = 0x2D69, + wPlayerName = 0x200B, + wPlayerID = 0x2009, + wMoney = 0x23DB, + wCoins = 0x23E2, + wBadges = 0x23E4, + wKantoBadges = 0x23E5, + wRivalName = 0x2021, + wMomsName = 0x2016, + wPartyCount = 0x288A, + wPartySpecies = 0x288B, + wPartyMons = 0x2892, + wPartyMonNicknames = 0x29F4, + wPartyMonOTs = 0x29B2, + wNumItems = 0x241F, + wItems = 0x2420, + wNumKeyItems = 0x2449, + wKeyItems = 0x244A, + wNumBalls = 0x2464, + wBalls = 0x2465, + wTMsHMs = 0x23E6, + wPokedexCaught = 0x2A4C, + wPokedexSeen = 0x2A6C, + wCurBox = 0x2724, + wBoxNames = 0x2727, + wMapGroup = 0x2868, + wMapNumber = 0x2869, + wXCoord = 0x286B, + wYCoord = 0x286A, + wEventFlags = 0x261F, + wPlayerState = 0x24EA, + wGameTimeHours = 0x2053, + wGameTimeMinutes = 0x2055, + -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). + boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, +} + +Gen2Layout.crystal = { + sCheckValue1 = 0x2008, + sCheckValue2 = 0x2D0F, + sChecksum = 0x2D0D, + sGameData = 0x2009, + sGameDataEnd = 0x2B83, + wPlayerName = 0x200B, + wPlayerID = 0x2009, + wMoney = 0x23DC, + wCoins = 0x23E3, + wBadges = 0x23E5, + wKantoBadges = 0x23E6, + wRivalName = 0x2021, + wMomsName = 0x2016, + wPartyCount = 0x2865, + wPartySpecies = 0x2866, + wPartyMons = 0x286D, + wPartyMonNicknames = 0x29CF, + wPartyMonOTs = 0x298D, + wNumItems = 0x2420, + wItems = 0x2421, + wNumKeyItems = 0x244A, + wKeyItems = 0x244B, + wNumBalls = 0x2465, + wBalls = 0x2466, + wTMsHMs = 0x23E7, + wPokedexCaught = 0x2A27, + wPokedexSeen = 0x2A47, + wCurBox = 0x2700, + wBoxNames = 0x2703, + wMapGroup = 0x2843, + wMapNumber = 0x2844, + wXCoord = 0x2846, + wYCoord = 0x2845, + wEventFlags = 0x2600, + wPlayerState = 0x24EB, + wGameTimeHours = 0x2052, + wGameTimeMinutes = 0x2054, + -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). + boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, + -- The backup copy the game falls back to when the primary + -- checksum fails. Same shape, shifted. + backup = { + sCheckValue1 = 0x1208, + sCheckValue2 = 0x1F0F, + sChecksum = 0x1F0D, + sGameData = 0x1209, + sGameDataEnd = 0x1D83, + wPlayerName = 0x120B, + wPlayerID = 0x1209, + wMoney = 0x15DC, + wCoins = 0x15E3, + wBadges = 0x15E5, + wKantoBadges = 0x15E6, + wRivalName = 0x1221, + wMomsName = 0x1216, + wPartyCount = 0x1A65, + wPartySpecies = 0x1A66, + wPartyMons = 0x1A6D, + wPartyMonNicknames = 0x1BCF, + wPartyMonOTs = 0x1B8D, + wNumItems = 0x1620, + wItems = 0x1621, + wNumKeyItems = 0x164A, + wKeyItems = 0x164B, + wNumBalls = 0x1665, + wBalls = 0x1666, + wTMsHMs = 0x15E7, + wPokedexCaught = 0x1C27, + wPokedexSeen = 0x1C47, + wCurBox = 0x1900, + wBoxNames = 0x1903, + wMapGroup = 0x1A43, + wMapNumber = 0x1A44, + wXCoord = 0x1A46, + wYCoord = 0x1A45, + wEventFlags = 0x1800, + wPlayerState = 0x16EB, + wGameTimeHours = 0x1252, + wGameTimeMinutes = 0x1254, + boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, + }, +} + +Gen2Layout.charmap = { + [0x05] = "ガ", + [0x06] = "ギ", + [0x07] = "グ", + [0x08] = "ゲ", + [0x09] = "ゴ", + [0x0A] = "ザ", + [0x0B] = "ジ", + [0x0C] = "ズ", + [0x0D] = "ゼ", + [0x0E] = "ゾ", + [0x0F] = "ダ", + [0x10] = "ヂ", + [0x11] = "ヅ", + [0x12] = "デ", + [0x13] = "ド", + [0x19] = "バ", + [0x1A] = "ビ", + [0x1B] = "ブ", + [0x1C] = "ボ", + [0x26] = "が", + [0x27] = "ぎ", + [0x28] = "ぐ", + [0x29] = "げ", + [0x2A] = "ご", + [0x2B] = "ざ", + [0x2C] = "じ", + [0x2D] = "ず", + [0x2E] = "ぜ", + [0x2F] = "ぞ", + [0x30] = "だ", + [0x31] = "ぢ", + [0x32] = "づ", + [0x33] = "で", + [0x34] = "ど", + [0x3A] = "ば", + [0x3B] = "び", + [0x3C] = "ぶ", + [0x3D] = "べ", + [0x3E] = "ぼ", + [0x3F] = "⁂", + [0x40] = "パ", + [0x41] = "ピ", + [0x42] = "プ", + [0x43] = "ポ", + [0x44] = "ぱ", + [0x45] = "ぴ", + [0x46] = "ぷ", + [0x47] = "ぺ", + [0x48] = "ぽ", + [0x50] = "@", + [0x54] = "#", + [0x60] = "■", + [0x61] = "▲", + [0x62] = "☎", + [0x6E] = "ぃ", + [0x6F] = "ぅ", + [0x70] = "PO", + [0x71] = "KE", + [0x72] = "“", + [0x73] = "”", + [0x74] = "·", + [0x75] = "…", + [0x76] = "ぁ", + [0x77] = "ぇ", + [0x78] = "ぉ", + [0x79] = "┌", + [0x7A] = "─", + [0x7B] = "┐", + [0x7C] = "│", + [0x7D] = "└", + [0x7E] = "┘", + [0x7F] = " ", + [0x80] = "A", + [0x81] = "B", + [0x82] = "C", + [0x83] = "D", + [0x84] = "E", + [0x85] = "F", + [0x86] = "G", + [0x87] = "H", + [0x88] = "I", + [0x89] = "J", + [0x8A] = "K", + [0x8B] = "L", + [0x8C] = "M", + [0x8D] = "N", + [0x8E] = "O", + [0x8F] = "P", + [0x90] = "Q", + [0x91] = "R", + [0x92] = "S", + [0x93] = "T", + [0x94] = "U", + [0x95] = "V", + [0x96] = "W", + [0x97] = "X", + [0x98] = "Y", + [0x99] = "Z", + [0x9A] = "(", + [0x9B] = ")", + [0x9C] = ":", + [0x9D] = ";", + [0x9E] = "[", + [0x9F] = "]", + [0xA0] = "a", + [0xA1] = "b", + [0xA2] = "c", + [0xA3] = "d", + [0xA4] = "e", + [0xA5] = "f", + [0xA6] = "g", + [0xA7] = "h", + [0xA8] = "i", + [0xA9] = "j", + [0xAA] = "k", + [0xAB] = "l", + [0xAC] = "m", + [0xAD] = "n", + [0xAE] = "o", + [0xAF] = "p", + [0xB0] = "q", + [0xB1] = "r", + [0xB2] = "s", + [0xB3] = "t", + [0xB4] = "u", + [0xB5] = "v", + [0xB6] = "w", + [0xB7] = "x", + [0xB8] = "y", + [0xB9] = "z", + [0xBA] = "こ", + [0xBB] = "さ", + [0xBC] = "し", + [0xBD] = "す", + [0xBE] = "せ", + [0xBF] = "そ", + [0xC0] = "Ä", + [0xC1] = "Ö", + [0xC2] = "Ü", + [0xC3] = "ä", + [0xC4] = "ö", + [0xC5] = "ü", + [0xC6] = "に", + [0xC7] = "ぬ", + [0xC8] = "ね", + [0xC9] = "の", + [0xCA] = "は", + [0xCB] = "ひ", + [0xCC] = "ふ", + [0xCD] = "へ", + [0xCE] = "ほ", + [0xCF] = "ま", + [0xD0] = "'d", + [0xD1] = "'l", + [0xD2] = "'m", + [0xD3] = "'r", + [0xD4] = "'s", + [0xD5] = "'t", + [0xD6] = "'v", + [0xD7] = "ら", + [0xD8] = "り", + [0xD9] = "る", + [0xDA] = "れ", + [0xDB] = "ろ", + [0xDC] = "わ", + [0xDD] = "を", + [0xDE] = "ん", + [0xDF] = "←", + [0xE0] = "'", + [0xE1] = "PK", + [0xE2] = "MN", + [0xE3] = "-", + [0xE4] = "゚", + [0xE5] = "゙", + [0xE6] = "?", + [0xE7] = "!", + [0xE8] = ".", + [0xE9] = "&", + [0xEA] = "é", + [0xEB] = "→", + [0xEC] = "▷", + [0xED] = "▶", + [0xEE] = "▼", + [0xEF] = "♂", + [0xF0] = "¥", + [0xF1] = "×", + [0xF2] = ".", + [0xF3] = "/", + [0xF4] = ",", + [0xF5] = "♀", + [0xF6] = "0", + [0xF7] = "1", + [0xF8] = "2", + [0xF9] = "3", + [0xFA] = "4", + [0xFB] = "5", + [0xFC] = "6", + [0xFD] = "7", + [0xFE] = "8", + [0xFF] = "9", +} + +return Gen2Layout diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua new file mode 100644 index 00000000..63369452 --- /dev/null +++ b/src/save_convert/Gen2Save.lua @@ -0,0 +1,690 @@ +-- Vanilla Gen 2 (Gold/Silver/Crystal) raw SRAM <-> src/core/gen2/Save.lua. +-- Companion to GenSave.lua, which is Gen 1 only. +-- +-- Offsets come from Gen2Layout.lua, generated by tools/gen2_sram_offsets.py. +-- Gold and Silver share a layout; Crystal does not. +-- +-- Pure Lua, no love.* at require time, same as GenSave. + +local Gen2Layout = require("src.save_convert.Gen2Layout") + +local Gen2Save = {} + +Gen2Save.SAVE_SIZE = 32768 +Gen2Save.PARTY_STRUCT = 48 +Gen2Save.NAME_LENGTH = 11 +Gen2Save.PARTY_LENGTH = 6 + +function Gen2Save.layoutFor(gameVersion) + if gameVersion == "crystal" then return Gen2Layout.crystal end + if gameVersion == "gold" or gameVersion == "silver" then return Gen2Layout.goldSilver end + return nil +end + +local function u8(b, o) return b:byte(o + 1) end +local function be(b, o, n) + local v = 0 + for i = 0, n - 1 do v = v * 256 + b:byte(o + i + 1) end + return v +end + +-- The cart's text encoding, from Gen2Layout.charmap. 0x50 terminates. +local function text(b, o, n) + local out = {} + for i = 0, n - 1 do + local c = b:byte(o + i + 1) + if not c or c == 0x50 then break end + out[#out + 1] = Gen2Layout.charmap[c] or "?" + end + return table.concat(out) +end + +-- Both check values AND the 16-bit sum between them. A blank SRAM sums to a +-- valid 0 == 0, so the check values are what reject it. +function Gen2Save.checksumValid(bytes, L) + if #bytes < Gen2Save.SAVE_SIZE then return nil end + if u8(bytes, L.sCheckValue1) ~= 0x63 then return false end + if u8(bytes, L.sCheckValue2) ~= 0x7F then return false end + local sum = 0 + for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + u8(bytes, i)) % 65536 end + local stored = u8(bytes, L.sChecksum) + u8(bytes, L.sChecksum + 1) * 256 + return sum == stored +end + +-- The cart stores numbers, the engine is keyed by name. Same shape +-- GenSave.crosswalks builds for Gen 1. Without `data` the raw numbers survive. +local function byIndex(defs) + local out = {} + for id, def in pairs(defs or {}) do + if type(def) == "table" and def.index ~= nil then out[def.index] = id end + end + return out +end + +local function toIndex(defs) + local out = {} + for id, def in pairs(defs or {}) do + if type(def) == "table" and def.index ~= nil then out[id] = def.index end + end + return out +end + +function Gen2Save.crosswalks(data) + data = data or {} + local items = byIndex(data.items) + -- Maps are found by (group, number) rather than by a flat index. + local maps = {} + for id, def in pairs(data.maps or {}) do + if type(def) == "table" and def.group and def.map then + maps[def.group * 256 + def.map] = id + end + end + local mapIds = {} + for id, def in pairs(data.maps or {}) do + if type(def) == "table" and def.group and def.map then + mapIds[id] = { def.group, def.map } + end + end + return { + pokemon = byIndex(data.pokemon), + moves = byIndex(data.moves), + items = items, + maps = maps, + pokemonIndex = toIndex(data.pokemon), + moveIndex = toIndex(data.moves), + itemIndex = toIndex(data.items), + mapIds = mapIds, + itemDefs = data.items or {}, + } +end + +-- Name if the crosswalk knows it, the raw number if not. Never silently drops +-- a value: an id this build cannot name is still the player's. +local function named(map, index) + if index == nil or index == 0 then return nil end + return map[index] or index +end + +-- constants/battle_constants.asm: SLP_MASK is bits 0-2, PSN=3 BRN=4 FRZ=5 +-- PAR=6. Engine wants an ItemEffects.STATUS_CLASS key, nil when healthy. +local STATUS_BITS = { { 3, "psn" }, { 4, "brn" }, { 5, "frz" }, { 6, "par" } } + +local function encodeStatus(name, turns) + if name == "slp" then return math.min(math.max(turns or 1, 1), 7) end + for _, row in ipairs(STATUS_BITS) do + if row[2] == name then return 2 ^ row[1] end + end + return 0 +end + +local function decodeStatus(byte) + local turns = byte % 8 + if turns > 0 then return "slp", turns end + for _, row in ipairs(STATUS_BITS) do + if math.floor(byte / 2 ^ row[1]) % 2 == 1 then return row[2], nil end + end + return nil, nil +end + +-- Four DVs as nibbles in two bytes; the HP DV is rebuilt from the low bit of +-- each (pokecrystal engine/pokemon/health.asm). +local function decodeDVs(bytes, o) + local hi, lo = u8(bytes, o), u8(bytes, o + 1) + local atk = math.floor(hi / 16) + local def = hi % 16 + local spd = math.floor(lo / 16) + local spc = lo % 16 + local hp = (atk % 2) * 8 + (def % 2) * 4 + (spd % 2) * 2 + (spc % 2) + return { hp = hp, attack = atk, defense = def, speed = spd, special = spc } +end + +-- Five 16-bit stat experience words, in the cart's order. +local function decodeStatExp(bytes, o) + return { + hp = be(bytes, o, 2), attack = be(bytes, o + 2, 2), + defense = be(bytes, o + 4, 2), speed = be(bytes, o + 6, 2), + special = be(bytes, o + 8, 2), + } +end + +-- The first 32 bytes, which a box mon and a party mon share. +local function decodeSharedMon(bytes, o, x) + local moves, pp = {}, {} + for i = 0, 3 do + local id = named(x.moves, u8(bytes, o + 2 + i)) + if id then moves[#moves + 1] = id end + end + for i = 0, 3 do pp[i + 1] = u8(bytes, o + 0x17 + i) % 64 end + return { + species = named(x.pokemon, u8(bytes, o)), + item = named(x.items, u8(bytes, o + 1)), + moves = moves, pp = pp, + otId = be(bytes, o + 6, 2), + experience = be(bytes, o + 8, 3), + statExp = decodeStatExp(bytes, o + 0x0B), + dvs = decodeDVs(bytes, o + 0x15), + happiness = u8(bytes, o + 0x1B), + pokerus = u8(bytes, o + 0x1C), + caughtData = be(bytes, o + 0x1D, 2), + level = u8(bytes, o + 0x1F), + } +end + +local function decodeMon(bytes, o, x) + local mon = decodeSharedMon(bytes, o, x) + mon.status, mon.statusTurns = decodeStatus(u8(bytes, o + 0x20)) + mon.hp = be(bytes, o + 0x22, 2) + mon.maxHp = be(bytes, o + 0x24, 2) + mon.stats = { + hp = mon.maxHp, + attack = be(bytes, o + 0x26, 2), defense = be(bytes, o + 0x28, 2), + speed = be(bytes, o + 0x2A, 2), specialAttack = be(bytes, o + 0x2C, 2), + specialDefense = be(bytes, o + 0x2E, 2), + } + return mon +end + +-- box_struct: OTs come BEFORE nicknames, and a box mon is 32 bytes with none +-- of the party's computed stats. +Gen2Save.BOX_CAPACITY = 20 +Gen2Save.BOX_MON_STRUCT = 32 +local BOX_SPECIES, BOX_MONS, BOX_OTS, BOX_NICKS = 0x01, 0x16, 0x296, 0x372 + +local decodeBoxMon = decodeSharedMon + +-- A count past BOX_CAPACITY means the wrong layout, not an odd save. +function Gen2Save.decodeBoxes(bytes, L, x) + local boxes = {} + for index, base in ipairs(L.boxes) do + local count = u8(bytes, base) + if count > Gen2Save.BOX_CAPACITY then + return nil, ("box %d reports %d Pokemon, which is impossible; this is the " + .. "wrong layout for this save"):format(index, count) + end + local mons = {} + for i = 0, count - 1 do + local mon = decodeBoxMon(bytes, base + BOX_MONS + i * Gen2Save.BOX_MON_STRUCT, x) + -- The species list beside the mons must agree, which is a free check + -- on the layout. + local listed = named(x.pokemon, u8(bytes, base + BOX_SPECIES + i)) + if listed ~= mon.species then + return nil, ("box %d slot %d: the species list says %s and the stored " + .. "Pokemon says %s; wrong layout for this save") + :format(index, i + 1, tostring(listed), tostring(mon.species)) + end + mon.ot = text(bytes, base + BOX_OTS + i * Gen2Save.NAME_LENGTH, Gen2Save.NAME_LENGTH) + mon.nickname = text(bytes, base + BOX_NICKS + i * Gen2Save.NAME_LENGTH, Gen2Save.NAME_LENGTH) + mons[#mons + 1] = mon + end + boxes[index] = mons + end + return boxes +end + +-- The bag. Gen 2 splits it into four pockets, and they are not all shaped the +-- same: ITEM and BALL are (id, quantity) pairs, KEY_ITEM is bare ids because a +-- key item is unique, and TM_HM is a flat run of counts indexed by TM number. +-- All of the list pockets are terminated by 0xFF as well as counted, and the +-- count is trusted only as far as the terminator. +-- One bag keyed by item id; PackMenu buckets it by each item's `pocket`. +-- ITEM and BALL are (id, quantity) pairs, KEY_ITEM is bare ids. +local function addPairs(out, bytes, countAt, listAt, cap, x) + local n = u8(bytes, countAt) + if n > cap then n = cap end + for i = 0, n - 1 do + local raw = u8(bytes, listAt + i * 2) + if raw == 0xFF or raw == 0 then break end + local id = named(x.items, raw) + out[id] = (out[id] or 0) + u8(bytes, listAt + i * 2 + 1) + end +end + +local function addIds(out, bytes, countAt, listAt, cap, x) + local n = u8(bytes, countAt) + if n > cap then n = cap end + for i = 0, n - 1 do + local raw = u8(bytes, listAt + i) + if raw == 0xFF or raw == 0 then break end + local id = named(x.items, raw) + out[id] = (out[id] or 0) + 1 + end +end + +-- TM_HM is a flat run of counts indexed by TM number. +local function addMachines(out, bytes, at, x, items) + local byNumber = {} + for id, def in pairs(items or {}) do + if type(def) == "table" and def.tmNumber then byNumber[def.tmNumber] = id end + end + for number, id in pairs(byNumber) do + local count = u8(bytes, at + number - 1) + if count > 0 then out[id] = (out[id] or 0) + count end + end +end + +-- Byte index -> byte, which is what Save.scrubEvents validates. +local function decodeFlagBytes(bytes, at, count) + local out = {} + for i = 0, count - 1 do out[i] = u8(bytes, at + i) end + return out +end + +-- A set keyed by species id: save.pokedex.caught[species] = true. +local function decodeDex(bytes, at, x) + local out = {} + for i = 0, Gen2Save.NUM_SPECIES - 1 do + local byte = u8(bytes, at + math.floor(i / 8)) + if math.floor(byte / (2 ^ (i % 8))) % 2 == 1 then + out[named(x.pokemon, i + 1) or (i + 1)] = true + end + end + return out +end + +-- Badges by NAME, which is how FieldMoves.hasBadge and Battle:hasBadge read +-- them. Bit position is accepted as a fallback key there, but the name is the +-- primary and is what a save written by this project carries. +Gen2Save.JOHTO_BADGES = { + "ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING", +} +Gen2Save.KANTO_BADGES = { + "BOULDER", "CASCADE", "THUNDER", "RAINBOW", + "SOUL", "MARSH", "VOLCANO", "EARTH", +} +local function decodeBadges(byte, order) + local out = {} + for bit, name in ipairs(order) do + if math.floor(byte / (2 ^ (bit - 1))) % 2 == 1 then out[name] = true end + end + return out +end + +Gen2Save.NUM_SPECIES = 251 +Gen2Save.EVENT_BYTES = 256 + +-- decode(bytes, gameVersion, data) -> partial save table, err +function Gen2Save.decode(bytes, gameVersion, data) + local L = Gen2Save.layoutFor(gameVersion) + if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end + if type(bytes) ~= "string" or #bytes < Gen2Save.SAVE_SIZE then + return nil, ("save must be at least %d bytes"):format(Gen2Save.SAVE_SIZE) + end + -- TryLoadSaveFile falls back to VerifyBackupChecksum and loads the backup + -- copy, so a save the real cartridge would open must not be refused here. + -- Crystal's backup is contiguous and laid out like the primary; Gold and + -- Silver split theirs across three sections and have none to offer. + if Gen2Save.checksumValid(bytes, L) ~= true then + if L.backup and Gen2Save.checksumValid(bytes, L.backup) == true then + L = L.backup + else + return nil, "save data checksum invalid (Gen 2 check values or sum mismatch)" + end + end + + local x = Gen2Save.crosswalks(data) + local count = u8(bytes, L.wPartyCount) + if count > Gen2Save.PARTY_LENGTH then + return nil, ("party count %d is impossible; this is probably the wrong layout") + :format(count) + end + local party = {} + for i = 0, count - 1 do + local mon = decodeMon(bytes, L.wPartyMons + i * Gen2Save.PARTY_STRUCT, x) + mon.nickname = text(bytes, L.wPartyMonNicknames + i * Gen2Save.NAME_LENGTH, + Gen2Save.NAME_LENGTH) + mon.ot = text(bytes, L.wPartyMonOTs + i * Gen2Save.NAME_LENGTH, + Gen2Save.NAME_LENGTH) + party[#party + 1] = mon + end + + local boxes, boxErr = Gen2Save.decodeBoxes(bytes, L, x) + if not boxes then return nil, boxErr end + + local inventory = {} + addPairs(inventory, bytes, L.wNumItems, L.wItems, 20, x) + addIds(inventory, bytes, L.wNumKeyItems, L.wKeyItems, 25, x) + addPairs(inventory, bytes, L.wNumBalls, L.wBalls, 12, x) + if L.wTMsHMs then addMachines(inventory, bytes, L.wTMsHMs, x, (data or {}).items) end + + return { + generation = 2, + version = gameVersion, + player = { + name = text(bytes, L.wPlayerName, Gen2Save.NAME_LENGTH), + id = be(bytes, L.wPlayerID, 2), + money = be(bytes, L.wMoney, 3), + coins = be(bytes, L.wCoins, 2), + badges = decodeBadges(u8(bytes, L.wBadges), Gen2Save.JOHTO_BADGES), + kantoBadges = decodeBadges(u8(bytes, L.wKantoBadges), Gen2Save.KANTO_BADGES), + }, + rival = { name = text(bytes, L.wRivalName, Gen2Save.NAME_LENGTH) }, + mom = { name = text(bytes, L.wMomsName, Gen2Save.NAME_LENGTH) }, + party = party, + boxes = boxes, + currentBox = u8(bytes, L.wCurBox) % 16 + 1, + inventory = inventory, + -- Species ids, 1-based, so the set keys match save.pokedex.caught[species]. + pokedex = { + caught = decodeDex(bytes, L.wPokedexCaught, x), + seen = decodeDex(bytes, L.wPokedexSeen, x), + }, + events = decodeFlagBytes(bytes, L.wEventFlags, Gen2Save.EVENT_BYTES), + -- Save.summary does `save.position.map or save.spawn`. + position = { + map = x.maps[u8(bytes, L.wMapGroup) * 256 + u8(bytes, L.wMapNumber)], + mapGroup = u8(bytes, L.wMapGroup), mapNumber = u8(bytes, L.wMapNumber), + x = u8(bytes, L.wXCoord), y = u8(bytes, L.wYCoord), + }, + playTime = { + hours = be(bytes, L.wGameTimeHours, 2), + minutes = u8(bytes, L.wGameTimeMinutes), + seconds = 0, frames = 0, + }, + } +end + +-- Everything the cart does not carry comes from a fresh game, exactly as +-- Gen 1's mergeDefaults does: the decode above models what the SRAM holds, +-- and mail, phone contacts, the unown dex, the hall of fame and the RTC are +-- left to the engine's own defaults rather than invented here. +-- +-- Loaded lazily. src/core/gen2/Save.lua pulls in love.filesystem at require +-- time, and this module has to stay require-clean for the headless CLI and +-- the tests, same rule GenSave follows. +function Gen2Save.mergeDefaults(decoded, gameVersion) + local ok, Save = pcall(require, "src.core.gen2.Save") + if not ok then return decoded end + local base = Save.newGame({ playerName = decoded.player and decoded.player.name }) + for k, v in pairs(decoded) do base[k] = v end + base.version = gameVersion + return base +end + +-- ------------------------------------------------------------------ +-- Export +-- ------------------------------------------------------------------ + +local function putU8(t, at, v) t[at] = v % 256 end +local function putBE(t, at, v, n) + for i = n - 1, 0, -1 do t[at + i] = v % 256; v = math.floor(v / 256) end +end + +-- Engine id back to the cart's number. A raw number passes through, which is +-- how a save imported without a crosswalk round trips. +local function indexOf(map, id) + if id == nil then return 0 end + if type(id) == "number" then return id end + return map[id] or 0 +end + +local function charBytes(str, i) + local b = str:byte(i) + if not b then return 0 end + if b < 0x80 then return 1 end + if b >= 0xF0 then return 4 end + if b >= 0xE0 then return 3 end + if b >= 0xC0 then return 2 end + return 1 +end + +local function glyphChars(glyph) + local n, i = 0, 1 + while i <= #glyph do i = i + charBytes(glyph, i); n = n + 1 end + return n +end + +-- One-CHARACTER glyphs plus PK and MN. The cart's table also carries the +-- ligature halves PO and KE, and accepting those turns a name containing "PO" +-- into 0x70 where the cart had a plain P. #glyph counts BYTES, so a byte test +-- would also drop every multi-byte glyph and turn NIDORAN into NIDORAN?. +local REVERSE = nil +local function reverseCharmap() + if REVERSE then return REVERSE end + REVERSE = {} + for code, glyph in pairs(Gen2Layout.charmap) do + if glyphChars(glyph) == 1 then REVERSE[glyph] = code end + end + for code, glyph in pairs(Gen2Layout.charmap) do + if glyph == "PK" or glyph == "MN" then REVERSE[glyph] = code end + end + return REVERSE +end + +local function putText(t, at, str, n) + local codes = reverseCharmap() + local i, written = 1, 0 + while i <= #str and written < n - 1 do + local two = str:sub(i, i + 1) + if (two == "PK" or two == "MN") and codes[two] then + t[at + written] = codes[two]; i = i + 2 + else + local w = charBytes(str, i) + t[at + written] = codes[str:sub(i, i + w - 1)] or 0xE6 + i = i + w + end + written = written + 1 + end + t[at + written] = 0x50 +end + +local function putBadges(t, at, owned, order) + local byte = 0 + for bit, name in ipairs(order) do + if owned and owned[name] then byte = byte + 2 ^ (bit - 1) end + end + t[at] = byte +end + +local function putSharedMon(t, o, mon, x) + putU8(t, o, indexOf(x.pokemonIndex, mon.species)) + putU8(t, o + 1, indexOf(x.itemIndex, mon.item)) + for i = 0, 3 do + putU8(t, o + 2 + i, indexOf(x.moveIndex, (mon.moves or {})[i + 1])) + end + putBE(t, o + 6, mon.otId or 0, 2) + putBE(t, o + 8, mon.experience or 0, 3) + local se = mon.statExp or {} + putBE(t, o + 0x0B, se.hp or 0, 2); putBE(t, o + 0x0D, se.attack or 0, 2) + putBE(t, o + 0x0F, se.defense or 0, 2); putBE(t, o + 0x11, se.speed or 0, 2) + putBE(t, o + 0x13, se.special or 0, 2) + local d = mon.dvs or {} + putU8(t, o + 0x15, (d.attack or 0) * 16 + (d.defense or 0)) + putU8(t, o + 0x16, (d.speed or 0) * 16 + (d.special or 0)) + for i = 0, 3 do + putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0) + end + putU8(t, o + 0x1B, mon.happiness or 0) + -- 0x1C-0x1E belong to the mon, not to the slot: leaving them to the template + -- means reordering the party gives slot 1 the previous occupant's pokerus + -- and caught data. + putU8(t, o + 0x1C, mon.pokerus or 0) + putBE(t, o + 0x1D, mon.caughtData or 0, 2) + putU8(t, o + 0x1F, mon.level or 0) +end + +-- The bag back into its four pockets, by each item's own `pocket`. +local function putBag(t, L, inventory, x) + local buckets = { ITEM = {}, KEY_ITEM = {}, BALL = {}, TM_HM = {} } + for id, count in pairs(inventory or {}) do + local def = x.itemDefs[id] + local pocket = (type(def) == "table" and def.pocket) or "ITEM" + if buckets[pocket] == nil then pocket = "ITEM" end + buckets[pocket][#buckets[pocket] + 1] = { id = id, count = count, def = def } + end + -- By cart index, so the order is deterministic. A flat inventory has no + -- order of its own, so the pocket order a round trip produces is stable + -- rather than original. + for _, list in pairs(buckets) do + table.sort(list, function(a, b) + return indexOf(x.itemIndex, a.id) < indexOf(x.itemIndex, b.id) + end) + end + + local overflow = nil + local function writePairs(countAt, listAt, cap, list, pocket) + if #list > cap then overflow = overflow or { pocket, #list, cap } end + local n = math.min(#list, cap) + putU8(t, countAt, n) + for i = 1, n do + putU8(t, listAt + (i - 1) * 2, indexOf(x.itemIndex, list[i].id)) + putU8(t, listAt + (i - 1) * 2 + 1, math.min(list[i].count, 99)) + end + putU8(t, listAt + n * 2, 0xFF) + end + local function writeIds(countAt, listAt, cap, list, pocket) + if #list > cap then overflow = overflow or { pocket, #list, cap } end + local n = math.min(#list, cap) + putU8(t, countAt, n) + for i = 1, n do putU8(t, listAt + i - 1, indexOf(x.itemIndex, list[i].id)) end + putU8(t, listAt + n, 0xFF) + end + + writePairs(L.wNumItems, L.wItems, 20, buckets.ITEM, "ITEM") + writeIds(L.wNumKeyItems, L.wKeyItems, 25, buckets.KEY_ITEM, "KEY_ITEM") + writePairs(L.wNumBalls, L.wBalls, 12, buckets.BALL, "BALL") + if L.wTMsHMs then + for _, row in ipairs(buckets.TM_HM) do + local number = type(row.def) == "table" and row.def.tmNumber + if number then putU8(t, L.wTMsHMs + number - 1, math.min(row.count, 99)) end + end + end + return overflow +end + +local function putFlagSet(t, at, set, count, indexFor) + for i = 0, count - 1 do + local byteAt = at + math.floor(i / 8) + local bit = 2 ^ (i % 8) + local cur = t[byteAt] or 0 + local on = math.floor(cur / bit) % 2 == 1 + local want = set and set[indexFor(i)] == true + if on ~= want then t[byteAt] = want and (cur + bit) or (cur - bit) end + end +end + +-- encode(save, gameVersion, template, data) -> bytes, err +-- +-- Writes into the cartridge image the save came from: Gen 2 SRAM holds a great +-- deal this codec does not model and the real game trusts it on CONTINUE, so a +-- save with no image behind it is refused rather than built from nothing. +-- +-- Only the primary copy is written. TryLoadSaveFile rewrites the backup from +-- the primary on every successful load. +function Gen2Save.encode(save, gameVersion, template, data) + local L = Gen2Save.layoutFor(gameVersion) + if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end + if type(save) ~= "table" then return nil, "expected a save table" end + if type(template) ~= "string" or #template < Gen2Save.SAVE_SIZE then + return nil, "this save has no cartridge image to write back into, and a " + .. "Gen 2 save built from nothing does not boot on real hardware" + end + + local x = Gen2Save.crosswalks(data) + local t = {} + for i = 0, Gen2Save.SAVE_SIZE - 1 do t[i] = template:byte(i + 1) end + + local p = save.player or {} + putText(t, L.wPlayerName, p.name or "", Gen2Save.NAME_LENGTH) + putBE(t, L.wPlayerID, p.id or 0, 2) + putBE(t, L.wMoney, p.money or 0, 3) + putBE(t, L.wCoins, p.coins or 0, 2) + putBadges(t, L.wBadges, p.badges, Gen2Save.JOHTO_BADGES) + putBadges(t, L.wKantoBadges, p.kantoBadges, Gen2Save.KANTO_BADGES) + putText(t, L.wRivalName, (save.rival or {}).name or "", Gen2Save.NAME_LENGTH) + putText(t, L.wMomsName, (save.mom or {}).name or "", Gen2Save.NAME_LENGTH) + + local party = save.party or {} + if #party > Gen2Save.PARTY_LENGTH then + return nil, ("a party of %d cannot be written to a cartridge"):format(#party) + end + putU8(t, L.wPartyCount, #party) + for i, mon in ipairs(party) do + putU8(t, L.wPartySpecies + i - 1, indexOf(x.pokemonIndex, mon.species)) + local o = L.wPartyMons + (i - 1) * Gen2Save.PARTY_STRUCT + putSharedMon(t, o, mon, x) + putU8(t, o + 0x20, encodeStatus(mon.status, mon.statusTurns)) + putBE(t, o + 0x22, mon.hp or 0, 2) + local st = mon.stats or {} + putBE(t, o + 0x24, mon.maxHp or st.hp or 0, 2) + putBE(t, o + 0x26, st.attack or 0, 2); putBE(t, o + 0x28, st.defense or 0, 2) + putBE(t, o + 0x2A, st.speed or 0, 2); putBE(t, o + 0x2C, st.specialAttack or 0, 2) + putBE(t, o + 0x2E, st.specialDefense or 0, 2) + putText(t, L.wPartyMonNicknames + (i - 1) * Gen2Save.NAME_LENGTH, + mon.nickname or "", Gen2Save.NAME_LENGTH) + putText(t, L.wPartyMonOTs + (i - 1) * Gen2Save.NAME_LENGTH, + mon.ot or "", Gen2Save.NAME_LENGTH) + end + putU8(t, L.wPartySpecies + #party, 0xFF) + + for index, base in ipairs(L.boxes) do + local box = (save.boxes or {})[index] or {} + if #box > Gen2Save.BOX_CAPACITY then + return nil, ("box %d holds %d, which a cartridge cannot"):format(index, #box) + end + putU8(t, base, #box) + for i, mon in ipairs(box) do + putU8(t, base + BOX_SPECIES + i - 1, indexOf(x.pokemonIndex, mon.species)) + putSharedMon(t, base + BOX_MONS + (i - 1) * Gen2Save.BOX_MON_STRUCT, mon, x) + putText(t, base + BOX_OTS + (i - 1) * Gen2Save.NAME_LENGTH, + mon.ot or "", Gen2Save.NAME_LENGTH) + putText(t, base + BOX_NICKS + (i - 1) * Gen2Save.NAME_LENGTH, + mon.nickname or "", Gen2Save.NAME_LENGTH) + end + putU8(t, base + BOX_SPECIES + #box, 0xFF) + end + + -- Refuse rather than drop. Without item defs every item buckets into ITEM, + -- which holds 20, and a real bag is bigger than that. + local overflow = putBag(t, L, save.inventory, x) + if overflow then + return nil, ("the %s pocket would need %d slots and a cartridge has %d; " + .. "the item table for this game is needed to sort the bag") + :format(overflow[1], overflow[2], overflow[3]) + end + if save.currentBox then putU8(t, L.wCurBox, (save.currentBox - 1) % 16) end + if save.boxNames and L.wBoxNames then + for i = 1, 14 do + putText(t, L.wBoxNames + (i - 1) * 9, save.boxNames[i] or "", 9) + end + end + + if save.pokedex then + local function species(i) return x.pokemon[i + 1] or (i + 1) end + putFlagSet(t, L.wPokedexCaught, save.pokedex.caught, Gen2Save.NUM_SPECIES, species) + putFlagSet(t, L.wPokedexSeen, save.pokedex.seen, Gen2Save.NUM_SPECIES, species) + end + for i = 0, Gen2Save.EVENT_BYTES - 1 do + local byte = (save.events or {})[i] + if type(byte) == "number" then putU8(t, L.wEventFlags + i, byte) end + end + + local pos = save.position + if pos then + local ids = pos.map and x.mapIds[pos.map] + putU8(t, L.wMapGroup, (ids and ids[1]) or pos.mapGroup or 0) + putU8(t, L.wMapNumber, (ids and ids[2]) or pos.mapNumber or 0) + putU8(t, L.wXCoord, pos.x or 0) + putU8(t, L.wYCoord, pos.y or 0) + end + local pt = save.playTime + if pt then + putBE(t, L.wGameTimeHours, pt.hours or 0, 2) + putU8(t, L.wGameTimeMinutes, pt.minutes or 0) + end + + putU8(t, L.sCheckValue1, 0x63) + putU8(t, L.sCheckValue2, 0x7F) + local sum = 0 + for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + t[i]) % 65536 end + putU8(t, L.sChecksum, sum % 256) + putU8(t, L.sChecksum + 1, math.floor(sum / 256) % 256) + + local out = {} + for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(t[i]) end + -- Whatever followed the 32 KiB of SRAM is the cart's RTC footer. Dropping it + -- resets the clock, which costs the player daily events and the bug contest + -- and earns them the clock-adjustment penalty. + return table.concat(out) .. template:sub(Gen2Save.SAVE_SIZE + 1) +end + +return Gen2Save diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index 449cab82..79b4ef89 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -21,12 +21,23 @@ -- require alone cannot see them there (#420). local GenSave = require("src.save_convert.GenSave") -local GameVersion = require("src.core.GameVersion") +local Gen2Save = require("src.save_convert.Gen2Save") local SaveConvert = {} SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE -SaveConvert.mainChecksumValid = GenSave.mainChecksumValid +-- Is this a real save for THIS GAME? Dispatches on the generation, because +-- the two do not share a rule: Gen 1 stores a complement checksum of its main +-- data block, Gen 2 stores two check values plus a 16-bit sum. Run one over +-- the other's bytes and the answer is always no. +-- +-- gameVersion is optional and defaults to Gen 1's rule, which is what every +-- caller meant before Gen 2 had a codec. +function SaveConvert.mainChecksumValid(bytes, gameVersion) + local L = gameVersion and Gen2Save.layoutFor(gameVersion) + if L then return Gen2Save.checksumValid(bytes, L) end + return GenSave.mainChecksumValid(bytes) +end -- ------------------------------------------------------------------ -- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer @@ -125,6 +136,24 @@ local function loadCacheTable(gameVersion, filePath) return nil end +-- The generated tables Gen2Save needs to turn cart numbers into the ids the +-- engine is keyed by. Deliberately not ensureData: that one also demands Gen +-- 1's charmap, event flags and hidden items, none of which a Gen 2 cache has +-- or a Gen 2 save uses. +local gen2Data = {} +local function ensureGen2Data(gameVersion) + local key = gameVersion or "*" + if gen2Data[key] == nil then + local out = {} + for _, name in ipairs({ "pokemon", "moves", "items", "maps" }) do + out[name] = loadCacheTable(gameVersion, "data/generated/" .. name .. ".lua") + or (loadTable("data.generated." .. name, "data/generated/" .. name .. ".lua")) + end + gen2Data[key] = out + end + return gen2Data[key] +end + -- Crosswalk sets keyed by the game whose cache they came from ("*" for the -- require-resolved set): Yellow's tables are not Red's, so one import must -- never be handed the previous import's data (#420). @@ -228,13 +257,6 @@ SaveConvert.mergeDefaults = mergeDefaults -- pokered's single sPlayerName..sMainDataCheckSum window), and no Gen 2 -- codec exists yet. Both directions answer with a plain message the -- launcher's save card renders as-is, instead of pushing a Gen 2 save --- table through Gen 1 offsets and surfacing a codec traceback. -local function gen2CartName(gameVersion) - if not GameVersion.VERSIONS[gameVersion] then return nil end - if GameVersion.generation(gameVersion) ~= 2 then return nil end - return GameVersion.info(gameVersion).displayName -end - -- Can a cart save for this game cross in or out at all? Public because the -- launcher has to ask about the GAME before it measures the BYTES. -- @@ -248,19 +270,22 @@ end -- Returns true, or false plus the same sentence importSav/exportSav would have -- answered with, so a caller that asks early and a caller that does not cannot -- describe the same game two different ways. +-- Does this game use a Gen 2 cart save? The RTC footer that follows one is +-- expected rather than surprising, which the import path needs to know. +function SaveConvert.isGen2Cart(gameVersion) + return Gen2Save.layoutFor(gameVersion) ~= nil +end + function SaveConvert.importSupported(gameVersion) - local gen2Name = gen2CartName(gameVersion) - if gen2Name then - return false, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet." - end + -- Gen 2 imports through Gen2Save now. Kept as a predicate rather than + -- deleted: SaveFileIO asks it before it measures the bytes, and export + -- still answers no. return true end +-- Gen 2 exports through Gen2Save now, but only for a save with a cartridge +-- image behind it; encode says so itself. function SaveConvert.exportSupported(gameVersion) - local gen2Name = gen2CartName(gameVersion) - if gen2Name then - return false, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet." - end return true end @@ -278,6 +303,15 @@ function SaveConvert.importSav(bytes, version, gameVersion) end local supported, unsupportedWhy = SaveConvert.importSupported(gameVersion) if not supported then return nil, unsupportedWhy end + -- Gen 2 is a different SRAM entirely: different bank map, different party + -- struct, its own check values. Gen2Save owns it, and it needs no crosswalk + -- tables because it decodes ids the engine already speaks. + if Gen2Save.layoutFor(gameVersion) then + local decoded, gen2Err = Gen2Save.decode(bytes, gameVersion, + ensureGen2Data(gameVersion)) + if not decoded then return nil, gen2Err end + return Gen2Save.mergeDefaults(decoded, gameVersion) + end if #bytes ~= GenSave.SAVE_SIZE then return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes) end @@ -300,18 +334,23 @@ function SaveConvert.importSav(bytes, version, gameVersion) return mergeDefaults(decoded, version) end --- exportSav(saveTable, gameVersion) -> bytes, err +-- exportSav(saveTable, gameVersion, cartImage) -> bytes, err -- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware: -- if the table still carries the stashed import template (saveTable.rawImport) -- GenSave reproduces every unmodeled region from it; otherwise those regions -- are zero-filled. gameVersion selects the crosswalk tables exactly as in -- importSav. On failure returns nil + a message (never raises). -function SaveConvert.exportSav(saveTable, gameVersion) +function SaveConvert.exportSav(saveTable, gameVersion, cartImage) if type(saveTable) ~= "table" then return nil, "expected a save table" end local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion) if not supported then return nil, unsupportedWhy end + -- Gen 2 has its own SRAM and its own codec, and needs no Gen 1 crosswalks. + if Gen2Save.layoutFor(gameVersion) then + return Gen2Save.encode(saveTable, gameVersion, cartImage, + ensureGen2Data(gameVersion)) + end local data, derr = ensureData(gameVersion) if not data then return nil, derr end local ok, bytes = pcall(GenSave.encode, saveTable, data, nil) diff --git a/tests/engine/gen2_save_import.lua b/tests/engine/gen2_save_import.lua new file mode 100644 index 00000000..a53190ca --- /dev/null +++ b/tests/engine/gen2_save_import.lua @@ -0,0 +1,386 @@ +-- 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 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 + +-- A save with one known Pokemon in the party and one in box 3. +local function build(version) + 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 + 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) + 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") + 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 }, LEER = { index = 43 } }, + 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], "TACKLE", "and so are moves") + eq(m.moves[2], "LEER", "both of them") + 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") +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 + + 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") +end + +-- A save with no cartridge image behind it is refused, not invented. +do + local out, err = Gen2Save.encode({ player = { name = "A" } }, "gold", nil, {}) + eq(out, nil, "encode refuses a save with no lineage") + check(type(err) == "string" and err:find("no cartridge image", 1, true) ~= nil, + "and says why -- " .. tostring(err)) +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") + 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") + end + -- Export needs the cartridge image the save came from. Without one it is + -- refused rather than built from nothing. + local _, expErr = SaveConvert.exportSav({ meta = {} }, "gold") + check(type(expErr) == "string" and expErr:find("no cartridge image", 1, true) ~= nil, + "a save with no cartridge behind it is refused -- 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() diff --git a/tests/engine/gen2_save_import_message.lua b/tests/engine/gen2_save_import_message.lua new file mode 100644 index 00000000..3e8c4a0e --- /dev/null +++ b/tests/engine/gen2_save_import_message.lua @@ -0,0 +1,127 @@ +-- A real Gen 2 cart save is 32786 bytes, and that must not be held against it +-- (#1832). +-- luajit tests/gen2_save_import_message_test.lua +-- Also dofile'd by tests/run_tests.lua. +-- +-- Gen 2 carts are MBC3+TIMER, so a real Gold/Silver/Crystal battery save +-- carries an RTC footer past the 32768 bytes of SRAM. SaveFileIO.importToSlot +-- judges anything that is not exactly SAVE_SIZE, and it used to judge it with +-- pokered's main-data checksum whatever game it was for, so every real Gen 2 +-- save came back "save data checksum invalid" -- a perfectly good save +-- reported as corrupt. +-- +-- This is the size gate specifically. tests/gen2_save_import_test.lua covers +-- the codec. +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or require("tests.love_stub") + +-- SaveData is stubbed so this stays about the gate: the real one wants a +-- filesystem and a registry, and neither is the subject here. +local written = {} +package.loaded["src.core.SaveData"] = { + load = function() return { meta = {} } end, + activeSlot = function() return "slot1" end, + buildMeta = function(_, m) return m or {} end, + createSlot = function() return "slot1" end, + writeSlot = function(_, _, save) written[#written + 1] = save return true end, + setActiveSlot = function() return true end, +} + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local Gen2Save = require("src.save_convert.Gen2Save") +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveFileIO = require("src.import.SaveFileIO") + +-- The size a real Gen 2 cart save actually is: 32768 of SRAM plus an 18-byte +-- RTC footer. Verified against a real Gold cartridge in an emulator core, +-- which reports the cart's backup size as exactly this. +local GEN2_CART_SAVE_SIZE = 32786 + +local function validSave(version, trailing) + local L = Gen2Save.layoutFor(version) + local b = {} + for i = 0, Gen2Save.SAVE_SIZE - 1 do b[i] = 0 end + b[L.wPlayerName] = 0x80 -- "A", so the save is not entirely blank + b[L.wPlayerName + 1] = 0x50 + 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 + local out = {} + for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(b[i]) end + return table.concat(out) .. string.rep("\0", trailing or 0) +end + +local function savFile(bytes) + local path = os.tmpname() + local f = assert(io.open(path, "wb")) + f:write(bytes) + f:close() + return path +end + +-- ------------------------------------------------------------------ +-- The report: a real-sized Gen 2 save imports +-- ------------------------------------------------------------------ + +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local trailing = GEN2_CART_SAVE_SIZE - Gen2Save.SAVE_SIZE + local path = savFile(validSave(version, trailing)) + written = {} + -- force is deliberately NOT passed: the footer is the normal shape of a Gen + -- 2 cart save, so it must not raise the oversize confirmation either. + local ok, err = SaveFileIO.importToSlot(path, version) + check(ok == true, + version .. ": a 32786-byte cart save imports -- " .. tostring(err)) + check(type(err) ~= "string" or err:find("checksum", 1, true) == nil, + version .. ": and is never blamed on a checksum -- " .. tostring(err)) + eq(#written, 1, version .. ": the slot is written") +end + +-- ------------------------------------------------------------------ +-- The checksum question is asked of the right generation +-- ------------------------------------------------------------------ + +do + local gen2 = validSave("gold") + eq(SaveConvert.mainChecksumValid(gen2, "gold"), true, + "a Gen 2 save is valid under Gen 2's rule") + eq(SaveConvert.mainChecksumValid(gen2), false, + "and would read as invalid under Gen 1's, which is the whole bug") + check(SaveConvert.isGen2Cart("crystal"), "Crystal is a Gen 2 cart") + check(not SaveConvert.isGen2Cart("red"), "Red is not") +end + +-- ------------------------------------------------------------------ +-- Gen 1 keeps its own diagnosis +-- ------------------------------------------------------------------ + +do + local path = savFile(string.rep("\0", GEN2_CART_SAVE_SIZE)) + local ok, err = SaveFileIO.importToSlot(path, "red", true) + check(ok == false, "red: a corrupt oversize save is still refused") + check(type(err) == "string" and err:find("checksum", 1, true) ~= nil, + "red: still diagnosed by pokered's checksum -- got: " .. tostring(err)) +end + +-- ------------------------------------------------------------------ +-- Export needs the cartridge image behind the save +-- ------------------------------------------------------------------ + +-- Export goes through the codec now, but only for a save that came from a +-- cartridge: the regions it does not model are the ones the real game trusts +-- on CONTINUE. +for _, version in ipairs({ "gold", "silver", "crystal" }) do + eq(SaveConvert.exportSupported(version), true, version .. ": export is supported") + local out, why = SaveConvert.exportSav({ meta = {}, player = { name = "A" } }, version) + eq(out, nil, version .. ": a save with no cartridge behind it is refused") + check(type(why) == "string" and why:find("no cartridge image", 1, true) ~= nil, + version .. ": and the reason is the missing image -- " .. tostring(why)) +end + +T.finish() diff --git a/tests/engine/save_import_retry_bug420.lua b/tests/engine/save_import_retry_bug420.lua index bf14507c..f638e91e 100644 --- a/tests/engine/save_import_retry_bug420.lua +++ b/tests/engine/save_import_retry_bug420.lua @@ -226,6 +226,8 @@ do -- The double has to answer it; "yes" is what keeps this case about the -- cache-name contract below and nothing else. importSupported = function() return true end, + -- Red is not a Gen 2 cart, which is what this case uses. + isGen2Cart = function() return false end, importSav = function(_, version, gameVersion) seen.import = { version = version, gameVersion = gameVersion } return nil, "stub" diff --git a/tests/gen2_save_convert_cli_test.lua b/tests/gen2_save_convert_cli_test.lua index 3050b1a1..5cea280c 100644 --- a/tests/gen2_save_convert_cli_test.lua +++ b/tests/gen2_save_convert_cli_test.lua @@ -62,20 +62,29 @@ write(goldPath, SaveSerializer.encode({ local out = run(("luajit tools/save_convert/convert.lua export %q %q") :format(goldPath, outPath)) -check(out:find("Gen 2 cart save", 1, true) ~= nil, - "exporting a Gold save.lua is refused by name: " .. (out:gsub("%s+$", ""))) +-- Gen 2 exports through Gen2Save now, but only for a save that carries the +-- cartridge image it came from. A slot built in the launcher has none. +check(out:find("no cartridge image", 1, true) ~= nil, + "exporting a Gold slot with no cartridge behind it is refused, and says why: " + .. (out:gsub("%s+$", ""))) check(not exists(outPath), "and no 32768-byte file that looks like a Red battery is written") --- The same gate on the way in, when the caller names the game. +-- The way IN is no longer a gate: Gen 2 imports through +-- src/save_convert/Gen2Save.lua now. What this pins is that the bytes reach +-- that codec and are judged by ITS rules -- an all-zero image has neither of +-- Gen 2's check values, so it is refused for being blank rather than for +-- being Gold. local savPath = tmp("in.sav") local outPath2 = tmp("in.lua") os.remove(outPath2) write(savPath, string.rep("\0", 32768)) out = run(("luajit tools/save_convert/convert.lua import %q %q gold") :format(savPath, outPath2)) -check(out:find("Gen 2 cart save", 1, true) ~= nil, - "importing for a Gen 2 game is refused too") +check(out:find("not supported yet", 1, true) == nil, + "importing for a Gen 2 game is no longer refused by version: " .. (out:gsub("%s+$", ""))) +check(out:find("checksum", 1, true) ~= nil, + "a blank image is refused on Gen 2's own check values: " .. (out:gsub("%s+$", ""))) check(not exists(outPath2), "and writes nothing") -- Gen 1 keeps working: a Red-shaped save is never caught by the Gen 2 gate. diff --git a/tests/gen2_save_export_test.lua b/tests/gen2_save_export_test.lua index 9de29b88..65ec1103 100644 --- a/tests/gen2_save_export_test.lua +++ b/tests/gen2_save_export_test.lua @@ -66,8 +66,8 @@ check(files["saves/gold/" .. tostring(slotId) .. ".lua"] ~= nil, local ok, res = SaveFileIO.exportActiveSlot("gold") eq(ok, false, "Export on a Gold slot is refused, not crashed") -check(type(res) == "string" and res:find("not supported yet", 1, true), - "the refusal is the plain launcher message: " .. tostring(res)) +check(type(res) == "string" and res:find("no cartridge image", 1, true), + "the refusal names the missing cartridge image: " .. tostring(res)) check(not tostring(res):find("GenSave", 1, true) and not tostring(res):find("attempt to index", 1, true), "no codec traceback leaks into the notice line") @@ -77,12 +77,17 @@ for path in pairs(files) do end eq(exported, false, "no export file is written for a Gold slot") --- The import direction through the same seam: a 32 KB image aimed at Gold --- must be refused by version, before any Gen 1 decoding is attempted. +-- The import direction no longer matches the export one. Gold imports through +-- Gen2Save now, so a 32 KB image aimed at Gold is decoded rather than turned +-- away by version. An all-zero image still fails, because Gen 2's guards are +-- two check values and a sum and a blank image has none of them: refused for +-- what it is, not for which game it is for. local iok, ierr = SaveConvert.importSav(string.rep("\0", 32768), "gold", "gold") -eq(iok, nil, "importing a cart .sav for Gold is refused") -check(type(ierr) == "string" and ierr:find("not supported yet", 1, true), - "the import refusal is the plain launcher message: " .. tostring(ierr)) +eq(iok, nil, "a blank cart .sav for Gold is still refused") +check(type(ierr) == "string" and ierr:find("not supported yet", 1, true) == nil, + "and no longer refused by version: " .. tostring(ierr)) +check(type(ierr) == "string" and ierr:find("checksum", 1, true) ~= nil, + "it is Gen 2's own guards that turn it away: " .. tostring(ierr)) -- Gen 1 versions still pass the gate: red reaches the codec proper and -- fails on its own terms (an all-zero image is not a table), never on the diff --git a/tests/gen2_save_import_message_test.lua b/tests/gen2_save_import_message_test.lua deleted file mode 100644 index 652479f2..00000000 --- a/tests/gen2_save_import_message_test.lua +++ /dev/null @@ -1,96 +0,0 @@ --- A Gen 2 cart save must be refused as a Gen 2 cart save, not as a corrupt --- Gen 1 one (#1832). --- luajit tests/gen2_save_import_message_test.lua --- Also dofile'd by tests/run_tests.lua. --- --- SaveFileIO.importToSlot judges anything that is not exactly SAVE_SIZE with --- SaveConvert.mainChecksumValid, which is pokered's main-data checksum. Gen 2 --- carts are MBC3+TIMER, so a real Gold/Silver/Crystal battery save carries an --- RTC footer and is 32786 bytes: it misses the size test, is then measured --- against a checksum rule written for a different generation, and the launcher --- tells the player their save is corrupt. It is not -- there is simply no Gen --- 2 codec yet, which is a different sentence and an actionable one. -package.path = "./?.lua;./?/init.lua;" .. package.path - -love = love or require("tests.love_stub") - -local S = require("tests.harness").suite("gen2 save import message") -local check = S.check - -local SaveConvert = require("src.save_convert.SaveConvert") -local SaveFileIO = require("src.import.SaveFileIO") - --- The size a real Gen 2 cart save actually is: 32768 bytes of SRAM plus the --- 18-byte RTC footer an MBC3+TIMER cart writes. -local GEN2_CART_SAVE_SIZE = 32786 - -local function blob(n) return string.rep("\0", n) end - --- readSource only takes a raw string when it is EXACTLY 32768 bytes; anything --- else is treated as a picker path (its own comment says so). A real Gen 2 --- cart save is 32786, so it can only ever reach importToSlot as a FILE -- which --- is exactly how the player in #1832 supplied theirs. Write one and hand over --- the path, so this exercises the route the report came from. -local function savFile(n) - local path = os.tmpname() - local f = assert(io.open(path, "wb")) - f:write(blob(n)) - f:close() - return path -end - --- ------------------------------------------------------------------ --- The report: a real Gen 2 save is not "checksum invalid" --- ------------------------------------------------------------------ - -for _, version in ipairs({ "gold", "silver", "crystal" }) do - local ok, err = SaveFileIO.importToSlot(savFile(GEN2_CART_SAVE_SIZE), version, true) - check(ok == false, version .. ": a Gen 2 cart save is still refused") - check(type(err) == "string" and err:find("Gen 2 cart save", 1, true) ~= nil, - version .. ": refused AS a Gen 2 save -- got: " .. tostring(err)) - check(type(err) == "string" and err:find("checksum", 1, true) == nil, - version .. ": never blamed on a checksum it was never measured by -- got: " - .. tostring(err)) -end - --- ------------------------------------------------------------------ --- The predicate both callers share --- ------------------------------------------------------------------ - -for _, version in ipairs({ "red", "blue", "yellow" }) do - check(SaveConvert.importSupported(version) == true, - version .. ": Gen 1 import is unaffected") - check(SaveConvert.exportSupported(version) == true, - version .. ": Gen 1 export is unaffected") -end - -for _, version in ipairs({ "gold", "silver", "crystal" }) do - local impOk, impWhy = SaveConvert.importSupported(version) - local expOk, expWhy = SaveConvert.exportSupported(version) - check(impOk == false and expOk == false, version .. ": both directions say no") - -- One sentence per direction, wherever it is asked from: the early gate in - -- SaveFileIO and the late one inside importSav must not describe the same - -- game two different ways. - local _, lateWhy = SaveConvert.importSav(blob(32768), version, version) - check(impWhy == lateWhy, - version .. ": the early gate and importSav answer identically") - check(expWhy:find("exporting", 1, true) ~= nil, - version .. ": the export sentence is about exporting") -end - --- ------------------------------------------------------------------ --- Gen 1 keeps its own diagnosis --- ------------------------------------------------------------------ --- --- A Gen 1 save that really is the wrong size AND fails pokered's checksum must --- still say so: this fix moves the generation check in front of that test, it --- does not remove it. - -do - local ok, err = SaveFileIO.importToSlot(savFile(GEN2_CART_SAVE_SIZE), "red", true) - check(ok == false, "red: a corrupt oversize save is still refused") - check(type(err) == "string" and err:find("checksum", 1, true) ~= nil, - "red: still diagnosed by pokered's checksum -- got: " .. tostring(err)) -end - -S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 716bf5bf..8c2b14b2 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3640,7 +3640,6 @@ runSuites(orderedGlob( -- name resolution, and the .sav converter refusing a Gen 2 save table. "tests/gen2_sound_alias_test.lua", "tests/gen2_save_convert_cli_test.lua", - "tests/gen2_save_import_message_test.lua", -- The wall radios (`special MapRadio`). gen2_save_export_test cannot share a -- process (LEAKS_SAVE_SLOT_STATE above); tests/run_gen2.lua runs it alone. "tests/gen2_map_radio_test.lua", diff --git a/tools/gen2_sram_offsets.py b/tools/gen2_sram_offsets.py new file mode 100644 index 00000000..f3469ebd --- /dev/null +++ b/tools/gen2_sram_offsets.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Emit src/save_convert/Gen2Layout.lua from a pret build's symbol files. + +Gen 2 saves a contiguous WRAM block into SRAM bank 1, so every field's file +offset inside the 32768-byte battery image is + + sPlayerData_offset + (wField - wPlayerData) + +with sPlayerData_offset = sPlayerData - $A000 + $2000. That relation is +asserted below against sPokemonData, which appears on both sides. + +Nothing here is transcribed. Build pret/pokegold and pret/pokecrystal, then: + + python3 tools/gen2_sram_offsets.py \ + --gold path/to/pokegold.sym \ + --crystal path/to/pokecrystal.sym \ + > src/save_convert/Gen2Layout.lua + +Gold and Silver share a layout (pokesilver.sym agrees byte-exact); Crystal does +not, and that is the whole reason this file emits two tables. +""" +import argparse, re, sys + +FIELDS = [ + "wPlayerName", "wPlayerID", "wMoney", "wCoins", "wBadges", "wKantoBadges", + "wRivalName", "wMomsName", "wPartyCount", "wPartySpecies", "wPartyMons", + "wPartyMonNicknames", "wPartyMonOTs", "wNumItems", "wItems", "wNumKeyItems", + "wKeyItems", "wNumBalls", "wBalls", "wTMsHMs", "wPokedexCaught", + "wPokedexSeen", "wCurBox", "wBoxNames", "wMapGroup", "wMapNumber", + "wXCoord", "wYCoord", "wEventFlags", "wPlayerState", + "wGameTimeHours", "wGameTimeMinutes", +] +GUARDS = ["sCheckValue1", "sCheckValue2", "sChecksum", "sGameData", "sGameDataEnd"] + +# The 14 archived PC boxes. Emitted as real per-box offsets, never a stride: +# boxes 1-7 live in SRAM bank 2 and 8-14 in bank 3, so the step from box 7 to +# box 8 is 0x620 rather than the 0x450 every other pair uses. Computing them +# from a uniform stride puts boxes 8-14 in the wrong place, and a real save +# then reports box counts like 243 and 196. +BOX_COUNT = 14 + + +def load(path): + out = {} + for line in open(path): + m = re.match(r"^(\w\w):(\w{4})\s+(\S+)\s*$", line) + if m: + out.setdefault(m.group(3), (int(m.group(1), 16), int(m.group(2), 16))) + return out + + +# The backup copy the game falls back to when the primary checksum fails +# (TryLoadSaveFile -> VerifyBackupChecksum). Crystal's is contiguous and laid +# out exactly like the primary, so it is the same table shifted. Gold and +# Silver split theirs across three sections and are not derivable this way, +# which is why only Crystal gets one. +def backup_table(sym, rows, label): + need = ["sBackupGameData", "sBackupGameDataEnd", "sBackupCheckValue1", + "sBackupCheckValue2", "sBackupChecksum", "sGameData"] + if any(n not in sym for n in need): + return None + off = lambda n: sym[n][0] * 0x2000 + (sym[n][1] - 0xA000) + # File offsets, not raw addresses: the backup lives in SRAM bank 0 and the + # primary in bank 1, so an address-only delta is off by a bank. + delta = off("sBackupGameData") - off("sGameData") + guards = {"sCheckValue1": off("sBackupCheckValue1"), + "sCheckValue2": off("sBackupCheckValue2"), + "sChecksum": off("sBackupChecksum"), + "sGameData": off("sBackupGameData"), + "sGameDataEnd": off("sBackupGameDataEnd")} + out = [] + for name, value in rows: + if name in guards: + out.append((name, guards[name])) + else: + out.append((name, value + delta)) + return out + + +def table(sym, label): + need = ["sPlayerData", "wPlayerData", "sPokemonData", "wPokemonData"] + GUARDS + missing = [n for n in need if n not in sym] + if missing: + sys.exit(f"{label}: symbol file is missing {missing}") + base = sym["sPlayerData"][1] - 0xA000 + 0x2000 + anchor = sym["wPlayerData"][1] + # The block relation, asserted rather than assumed. + if sym["sPokemonData"][1] - sym["sPlayerData"][1] != \ + sym["wPokemonData"][1] - sym["wPlayerData"][1]: + sys.exit(f"{label}: the WRAM block is not copied contiguously; " + "the offset relation this generator rests on does not hold") + lo, hi = sym["sGameData"][1], sym["sGameDataEnd"][1] + rows, skipped = [], [] + for g in GUARDS: + rows.append((g, sym[g][1] - 0xA000 + 0x2000)) + for f in FIELDS: + w = sym.get(f) + if not w: + skipped.append(f + " (absent)") + continue + # Only fields INSIDE the saved block are addressable this way. Crystal's + # wPlayerGender sits before wPlayerData and belongs to sCrystalData, and + # the naive subtraction gives a confident wrong answer for it. + if not (lo <= w[1] - anchor + sym["sPlayerData"][1] < hi): + skipped.append(f + " (outside sGameData..sGameDataEnd)") + continue + rows.append((f, base + (w[1] - anchor))) + boxes = [] + for i in range(1, BOX_COUNT + 1): + b = sym.get("sBox%d" % i) + if not b: + sys.exit("%s: sBox%d is missing" % (label, i)) + # General SRAM form, which the bank-1 arithmetic above is a case of: + # file offset = bank * 0x2000 + (addr - $A000). + boxes.append(b[0] * 0x2000 + (b[1] - 0xA000)) + return rows, skipped, boxes + + +# The cart's own text table, so a name with an apostrophe, an accent or the PK +# glyph in it survives the round trip. Hand-keeping this list is how a player +# called "Mattia" comes back as "Mattia?". +CHARMAP_RE = re.compile(r'^\s*charmap\s+"(.+?)",\s*\$([0-9a-fA-F]{2})\s*(?:;.*)?$') + + +def emit_charmap(path): + rows = {} + for line in open(path, encoding="utf-8"): + m = CHARMAP_RE.match(line) + if not m: + continue + glyph, code = m.group(1), int(m.group(2), 16) + # Control tokens are not text; the name fields never contain them. + if glyph.startswith("<") and glyph.endswith(">"): + inner = glyph[1:-1] + if inner in ("PK", "MN", "PO", "KE"): + rows.setdefault(code, inner) + continue + rows.setdefault(code, glyph) + print("Gen2Layout.charmap = {") + for code in sorted(rows): + glyph = rows[code].replace("\\", "\\\\").replace('"', '\\"') + print(f' [0x{code:02X}] = "{glyph}",') + print("}") + print() + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--gold", required=True) + ap.add_argument("--crystal", required=True) + ap.add_argument("--charmap", required=False, + help="path to pokegold constants/charmap.asm; emits the " + "text table too when given") + a = ap.parse_args() + print("-- GENERATED by tools/gen2_sram_offsets.py. Do not edit by hand.") + print("-- Regenerate from a pret/pokegold + pret/pokecrystal build; see that") + print("-- script's header for the derivation and the assertion behind it.") + print("local Gen2Layout = {}\n") + for key, path in (("goldSilver", a.gold), ("crystal", a.crystal)): + sym = load(path) + rows, skipped, boxes = table(sym, key) + backup = backup_table(sym, rows, key) + print(f"Gen2Layout.{key} = {{") + for n, off in rows: + print(f" {n} = 0x{off:04X},") + print(" -- The 14 archived boxes, listed rather than strided (see BOX_COUNT).") + print(" boxes = { " + ", ".join("0x%04X" % b for b in boxes) + " },") + if backup: + print(" -- The backup copy the game falls back to when the primary") + print(" -- checksum fails. Same shape, shifted.") + print(" backup = {") + for n, off in backup: + print(f" {n} = 0x{off:04X},") + print(" boxes = { " + ", ".join("0x%04X" % b for b in boxes) + " },") + print(" },") + print("}") + for s2 in skipped: + print(f"-- not addressable via the block: {s2}") + print() + if a.charmap: + emit_charmap(a.charmap) + print("return Gen2Layout") + + +main()