Import Gen 2 cart saves

Gold, Silver and Crystal battery saves import now. Export is still refused.

GenSave.lua is pokered's SRAM window and nothing else, which is why the
guard refusing Gen 2 was right to be there. This adds Gen2Save.lua beside
it, covering pokegold and pokecrystal.

Every offset is generated, not transcribed. tools/gen2_sram_offsets.py
reads pokegold.sym and pokecrystal.sym from a pret build and emits
Gen2Layout.lua, including the text table from constants/charmap.asm and
Crystal's backup-save layout. Gen 2 copies a contiguous WRAM block into
SRAM bank 1, so a field's file offset is sPlayerData + (wField -
wPlayerData); the generator asserts that relation against sPokemonData
rather than assuming it, and range-guards anything outside
sGameData..sGameDataEnd.

Gold and Crystal are separate tables because they disagree about nearly
every field. Reading a Crystal save with Gold's numbers gives a party
count of 133 and 13113 hours played, with a checksum that validates.

The cart stores numbers and the engine is keyed by name, so the codec
crosswalks species, moves and items through the generated tables the same
way GenSave.crosswalks does for Gen 1. Without that, an import looks
perfect and the engine cannot read a byte of it.

Shapes that have to match what the engine reads:
  * events is byte index -> packed byte, which Save.scrubEvents validates
    with tonumber. A set of booleans is silently emptied.
  * the bag is one flat save.inventory keyed by item id, which PackMenu
    buckets by each item's pocket. Nothing reads save.keyItems or
    save.balls, and the TM/HM pocket lands here too.
  * position carries the map id, or Save.summary falls through to
    save.spawn and the player resumes somewhere else at their old
    coordinates.
  * mon.status is an ItemEffects.STATUS_CLASS key with statusTurns beside
    it, nil when healthy. 0 is truthy in Lua.

A save the real cartridge would open is not refused: TryLoadSaveFile falls
back to VerifyBackupChecksum, so this does too. Crystal's backup is
contiguous and laid out like the primary; Gold and Silver split theirs
across three sections and have none to offer.

Three suites that pinned Gen 2 import being refused now pin what refuses
instead. Tests live in tests/engine so the ROM-free tier actually runs
them.

./scripts/test.sh passes end to end, and luacheck is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colson Rice
2026-08-27 10:19:57 -04:00
parent e9b815fcac
commit ebd315b01e
12 changed files with 1408 additions and 116 deletions
+14 -6
View File
@@ -78,14 +78,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 +96,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)
+335
View File
@@ -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
+374
View File
@@ -0,0 +1,374 @@
-- 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
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
return {
pokemon = byIndex(data.pokemon),
moves = byIndex(data.moves),
items = items,
maps = maps,
}
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 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),
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
return Gen2Save
+49 -5
View File
@@ -21,12 +21,24 @@
-- require alone cannot see them there (#420).
local GenSave = require("src.save_convert.GenSave")
local Gen2Save = require("src.save_convert.Gen2Save")
local GameVersion = require("src.core.GameVersion")
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 +137,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).
@@ -248,11 +278,16 @@ 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
@@ -278,6 +313,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
+307
View File
@@ -0,0 +1,307 @@
-- 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
-- ------------------------------------------------------------------
-- 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
local _, expErr = SaveConvert.exportSav({ meta = {} }, "gold")
check(type(expErr) == "string" and expErr:find("exporting", 1, true) ~= nil,
"export is still refused, and says so -- 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
end
end
end
T.finish()
+123
View File
@@ -0,0 +1,123 @@
-- 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 is still one-way, and still says so
-- ------------------------------------------------------------------
for _, version in ipairs({ "gold", "silver", "crystal" }) do
local ok, why = SaveConvert.exportSupported(version)
eq(ok, false, version .. ": export is still refused")
check(type(why) == "string" and why:find("exporting", 1, true) ~= nil,
version .. ": and the sentence is about exporting -- " .. tostring(why))
end
T.finish()
@@ -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"
+9 -3
View File
@@ -67,15 +67,21 @@ check(out:find("Gen 2 cart save", 1, true) ~= nil,
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.
+10 -5
View File
@@ -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
-96
View File
@@ -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()
-1
View File
@@ -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",
+185
View File
@@ -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<PK>" 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()