From 27b2e2548bc5ebcc2e16e5519d57b842c04481f1 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Mon, 28 Sep 2026 12:07:48 -0500 Subject: [PATCH] fix(save_convert): Gen 2 cartridge SRAM export parity and PKHeX compatibility --- src/save_convert/Gen2Layout.lua | 12 + src/save_convert/Gen2Save.lua | 132 ++++++++- tests/engine/gen2_sram_parity_test.lua | 363 +++++++++++++++++++++++++ tools/gen2_sram_offsets.py | 1 + 4 files changed, 496 insertions(+), 12 deletions(-) create mode 100644 tests/engine/gen2_sram_parity_test.lua diff --git a/src/save_convert/Gen2Layout.lua b/src/save_convert/Gen2Layout.lua index 711aaff5..7048107a 100644 --- a/src/save_convert/Gen2Layout.lua +++ b/src/save_convert/Gen2Layout.lua @@ -46,6 +46,8 @@ Gen2Layout.goldSilver = { wVariableSprites = 0x23BD, wGameTimeHours = 0x2053, wGameTimeMinutes = 0x2055, + wGameTimeSeconds = 0x2056, + wGameTimeFrames = 0x2057, wRedsName = 0x202C, wGreensName = 0x2037, wSavedAtLeastOnce = 0x2042, @@ -64,6 +66,8 @@ Gen2Layout.goldSilver = { wDecoBed = 0x27C1, wDecoPoster = 0x27C4, wScreenSave = 0x286C, + sBox = 0x2D6C, + sPartyMail = 0x0000, sOptions = 0x2000, sMysteryGiftUnlocked = 0x0BE3, -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). @@ -174,6 +178,8 @@ Gen2Layout.crystal = { wVariableSprites = 0x23BC, wGameTimeHours = 0x2052, wGameTimeMinutes = 0x2054, + wGameTimeSeconds = 0x2055, + wGameTimeFrames = 0x2056, wRedsName = 0x202C, wGreensName = 0x2037, wSavedAtLeastOnce = 0x2042, @@ -193,6 +199,8 @@ Gen2Layout.crystal = { wDecoPoster = 0x27A0, wScreenSave = 0x2847, wPlayerGender = 0x3E3D, + sBox = 0x2D10, + sPartyMail = 0x0000, sOptions = 0x2000, sMysteryGiftUnlocked = 0x0BE3, -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). @@ -323,6 +331,8 @@ Gen2Layout.crystal = { wVariableSprites = 0x15BC, wGameTimeHours = 0x1252, wGameTimeMinutes = 0x1254, + wGameTimeSeconds = 0x1255, + wGameTimeFrames = 0x1256, wRedsName = 0x122C, wGreensName = 0x1237, wSavedAtLeastOnce = 0x1242, @@ -342,6 +352,8 @@ Gen2Layout.crystal = { wDecoPoster = 0x19A0, wScreenSave = 0x1A47, wPlayerGender = 0x3E3D, + sBox = 0x2D10, + sPartyMail = 0x0000, sOptions = 0x2000, sMysteryGiftUnlocked = 0x0BE3, boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 0e33818b..255d6c1b 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -461,6 +461,14 @@ function Gen2Save.decode(bytes, gameVersion, data) party = party, boxes = boxes, currentBox = u8(bytes, L.wCurBox) % 16 + 1, + boxNames = (function() + if not L.wBoxNames then return nil end + local names = {} + for i = 1, 14 do + names[i] = text(bytes, L.wBoxNames + (i - 1) * 9, 9) + end + return names + end)(), inventory = inventory, -- Species ids, 1-based, so the set keys match save.pokedex.caught[species]. pokedex = { @@ -481,7 +489,8 @@ function Gen2Save.decode(bytes, gameVersion, data) playTime = { hours = be(bytes, L.wGameTimeHours, 2), minutes = u8(bytes, L.wGameTimeMinutes), - seconds = 0, frames = 0, + seconds = L.wGameTimeSeconds and u8(bytes, L.wGameTimeSeconds) or 0, + frames = L.wGameTimeFrames and u8(bytes, L.wGameTimeFrames) or 0, }, } end @@ -567,7 +576,9 @@ local function putText(t, at, str, n) end written = written + 1 end - t[at + written] = 0x50 + for k = written, n - 1 do + t[at + k] = 0x50 + end end local function putBadges(t, at, owned, order) @@ -603,7 +614,7 @@ local function putSharedMon(t, o, mon, x) putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0) end end - putU8(t, o + 0x1B, mon.happiness or 0) + putU8(t, o + 0x1B, mon.happiness or mon.friendship or 70) -- 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. @@ -612,6 +623,22 @@ local function putSharedMon(t, o, mon, x) putU8(t, o + 0x1F, mon.level or 0) end +local function putBoxStruct(t, base, box, x) + box = box or {} + local count = math.min(#box, Gen2Save.BOX_CAPACITY) + putU8(t, base, count) + for i = 1, count do + local mon = box[i] + 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 + count, 0xFF) +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 = {} } @@ -768,6 +795,18 @@ function Gen2Save.blankImage(gameVersion, save, data) end end + -- Initialize active box in Bank 1 and archived boxes in Banks 2 & 3 + if L.sBox then + putU8(t, L.sBox, 0) + putU8(t, L.sBox + BOX_SPECIES, 0xFF) + end + if L.boxes then + for _, base in ipairs(L.boxes) do + putU8(t, base, 0) + putU8(t, base + BOX_SPECIES, 0xFF) + end + end + for i = 1, #PLAYER_MAP_OBJECT do t[O.mapObjects + i - 1] = PLAYER_MAP_OBJECT[i] end @@ -786,6 +825,28 @@ function Gen2Save.blankImage(gameVersion, save, data) putU8(t, L.wMapGroup, group) putU8(t, L.wMapNumber, number) + -- Crystal backup initialization + if L.backup then + if L.backup.sOptions then + for i = 1, #DEFAULT_OPTIONS do t[L.backup.sOptions + i - 1] = DEFAULT_OPTIONS[i] end + end + if L.backup.wSavedAtLeastOnce then putU8(t, L.backup.wSavedAtLeastOnce, 1) end + if L.backup.wRedsName then putText(t, L.backup.wRedsName, "RED", Gen2Save.NAME_LENGTH) end + if L.backup.wGreensName then putText(t, L.backup.wGreensName, "GREEN", Gen2Save.NAME_LENGTH) end + if L.backup.wMomItemTriggerBalance then putBE(t, L.backup.wMomItemTriggerBalance, 2300, 3) end + if L.backup.wNumPCItems then + putU8(t, L.backup.wNumPCItems, 0) + putU8(t, L.backup.wNumPCItems + 1, 0xFF) + end + if L.backup.wBoxNames then + for i = 1, 14 do + putText(t, L.backup.wBoxNames + (i - 1) * 9, "BOX" .. i, 9) + end + end + if L.backup.sCheckValue1 then putU8(t, L.backup.sCheckValue1, 0x63) end + if L.backup.sCheckValue2 then putU8(t, L.backup.sCheckValue2, 0x7F) end + end + local out = {} for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(t[i]) end return table.concat(out) @@ -860,16 +921,38 @@ function Gen2Save.encode(save, gameVersion, template, data) 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) + putBoxStruct(t, base, box, x) + end + + -- Write active box in Bank 1 + local curBoxIdx = math.max(1, math.min(save.currentBox or 1, 14)) + local activeBox = (save.boxes or {})[curBoxIdx] or {} + if L.sBox then + putBoxStruct(t, L.sBox, activeBox, x) + end + + -- Bank 0 Party Mail + local MAIL_STRUCT_LEN = 47 + for i = 1, Gen2Save.PARTY_LENGTH do + local mon = party[i] + local slotBase = (L.sPartyMail or 0) + (i - 1) * MAIL_STRUCT_LEN + if mon and (mon.item and (mon.item:find("_MAIL$") or mon.item == "LITEBLUEMAIL" or mon.item == "PORTRAITMAIL")) then + local entry = (save.mail and save.mail.party and save.mail.party[i]) or {} + local msg = entry.message or "" + local author = entry.author or mon.ot or p.name or "" + local authorId = entry.authorId or mon.otId or p.id or 0 + local speciesId = indexOf(x.pokemonIndex, mon.species) + local itemId = indexOf(x.itemIndex, mon.item) + putText(t, slotBase, msg, 32) + putText(t, slotBase + 32, author, 11) + putBE(t, slotBase + 43, authorId, 2) + putU8(t, slotBase + 45, speciesId) + putU8(t, slotBase + 46, itemId) + else + for k = 0, MAIL_STRUCT_LEN - 1 do + t[slotBase + k] = 0 + end end - putU8(t, base + BOX_SPECIES + #box, 0xFF) end -- Refuse rather than drop. Without item defs every item buckets into ITEM, @@ -953,6 +1036,8 @@ function Gen2Save.encode(save, gameVersion, template, data) if pt then putBE(t, L.wGameTimeHours, pt.hours or 0, 2) putU8(t, L.wGameTimeMinutes, pt.minutes or 0) + if L.wGameTimeSeconds then putU8(t, L.wGameTimeSeconds, pt.seconds or 0) end + if L.wGameTimeFrames then putU8(t, L.wGameTimeFrames, pt.frames or 0) end end putU8(t, L.sCheckValue1, 0x63) @@ -962,6 +1047,29 @@ function Gen2Save.encode(save, gameVersion, template, data) putU8(t, L.sChecksum, sum % 256) putU8(t, L.sChecksum + 1, math.floor(sum / 256) % 256) + -- Crystal Backup mirroring & checksum in Bank 0 + if L.backup and L.backup.sGameData and L.backup.sGameDataEnd then + local delta = L.backup.sGameData - L.sGameData + for i = L.sGameData, L.sGameDataEnd - 1 do + t[i + delta] = t[i] + end + if L.backup.sOptions and L.sOptions then + for i = 1, #DEFAULT_OPTIONS do + t[L.backup.sOptions + i - 1] = t[L.sOptions + i - 1] + end + end + if L.backup.sCheckValue1 then putU8(t, L.backup.sCheckValue1, 0x63) end + if L.backup.sCheckValue2 then putU8(t, L.backup.sCheckValue2, 0x7F) end + local bsum = 0 + for i = L.backup.sGameData, L.backup.sGameDataEnd - 1 do + bsum = (bsum + t[i]) % 65536 + end + if L.backup.sChecksum then + putU8(t, L.backup.sChecksum, bsum % 256) + putU8(t, L.backup.sChecksum + 1, math.floor(bsum / 256) % 256) + end + end + 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 diff --git a/tests/engine/gen2_sram_parity_test.lua b/tests/engine/gen2_sram_parity_test.lua new file mode 100644 index 00000000..0437561a --- /dev/null +++ b/tests/engine/gen2_sram_parity_test.lua @@ -0,0 +1,363 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Gen2Save = require("src.save_convert.Gen2Save") +local Gen2Layout = require("src.save_convert.Gen2Layout") +local Gen2MapContext = require("src.save_convert.Gen2MapContext") + +local function fixture() + return { + pokemon = { + PIKACHU = { index = 25 }, + CYNDAQUIL = { index = 155 }, + TOTODILE = { index = 158 }, + CHIKORITA = { index = 152 }, + }, + moves = { + TACKLE = { index = 33, pp = 35 }, + GROWL = { index = 45, pp = 40 }, + }, + items = { + POKE_BALL = { index = 4, pocket = "BALL" }, + POTION = { index = 20, pocket = "ITEM" }, + FLOWER_MAIL = { index = 158, pocket = "ITEM" }, + }, + maps = { PLAYERS_HOUSE_2F = { + group = 24, map = 7, objectEventsAddr = 0x5CF0, + width = 4, height = 3, + blocks = { 4, 1, 3, 2, 5, 6, 5, 5, 5, 5, 7, 5 }, + objects = {}, + } }, + } +end + +local function u8(bytes, at) return bytes:byte(at + 1) end +local function be(bytes, at, n) + local v = 0 + for i = 0, n - 1 do v = v * 256 + bytes:byte(at + i + 1) end + return v +end + +print("[test] 1. Active Box (sBox) in Bank 1 serialization & parity") +do + for _, version in ipairs({ "gold", "crystal" }) do + local L = Gen2Save.layoutFor(version) + local save = { + generation = 2, + player = { name = "ASH", id = 12345, money = 5000 }, + rival = { name = "GARY" }, mom = { name = "MOM" }, + position = { map = "PLAYERS_HOUSE_2F", x = 3, y = 3 }, + party = {}, + currentBox = 2, + boxes = { + [1] = { + { species = "CYNDAQUIL", level = 5, moves = { { id = "TACKLE" } } } + }, + [2] = { + { species = "TOTODILE", level = 10, nickname = "TOTO", ot = "ASH", otId = 12345, + moves = { { id = "TACKLE", pp = 35, ppUps = 0 } } }, + { species = "PIKACHU", level = 12, nickname = "PIKA", ot = "ASH", otId = 12345, + moves = { { id = "GROWL", pp = 40, ppUps = 1 } } } + } + }, + playTime = { hours = 10, minutes = 25, seconds = 42, frames = 15 } + } + + local bytes, err = Gen2Save.encode(save, version, nil, fixture()) + check(bytes ~= nil, version .. ": save encodes with active box -- " .. tostring(err)) + eq(#bytes, Gen2Save.SAVE_SIZE, version .. ": 32 KB save size") + + -- Check sBox in Bank 1 + eq(u8(bytes, L.sBox), 2, version .. ": sBox count matches active box (box 2)") + eq(u8(bytes, L.sBox + 1), 158, version .. ": sBox species 1 is TOTODILE (158)") + eq(u8(bytes, L.sBox + 2), 25, version .. ": sBox species 2 is PIKACHU (25)") + eq(u8(bytes, L.sBox + 3), 0xFF, version .. ": sBox species list terminated with 0xFF") + + -- sBox in Bank 1 matches sBox2 in Bank 2 byte-for-byte + local box2Base = L.boxes[2] + local sBoxSlice = bytes:sub(L.sBox + 1, L.sBox + 1102) + local box2Slice = bytes:sub(box2Base + 1, box2Base + 1102) + eq(sBoxSlice, box2Slice, version .. ": sBox in Bank 1 is byte-identical to active sBox2 in Bank 2") + + -- Check primary checksum validity + eq(Gen2Save.checksumValid(bytes, L), true, version .. ": primary checksum is valid") + end +end + +print("[test] 2. Crystal Bank 0 Backup Save & Checksums") +do + local L = Gen2Save.layoutFor("crystal") + local save = { + generation = 2, + player = { name = "KRIS", id = 54321, money = 9999, gender = "female" }, + rival = { name = "SILVER" }, mom = { name = "MOM" }, + position = { map = "PLAYERS_HOUSE_2F", x = 3, y = 3 }, + party = { + { species = "CHIKORITA", level = 5, moves = { { id = "TACKLE" } } } + }, + boxes = {}, + playTime = { hours = 1, minutes = 2, seconds = 3, frames = 4 } + } + + local bytes, err = Gen2Save.encode(save, "crystal", nil, fixture()) + check(bytes ~= nil, "crystal: encode succeeds") + + -- Verify Backup Check Values in Bank 0 + eq(u8(bytes, L.backup.sCheckValue1), 0x63, "sBackupCheckValue1 at 0x1208 is 0x63") + eq(u8(bytes, L.backup.sCheckValue2), 0x7F, "sBackupCheckValue2 at 0x1F0F is 0x7F") + + -- Verify backup checksum passes Gen2Save.checksumValid on backup layout + eq(Gen2Save.checksumValid(bytes, L.backup), true, "backup save checksum validates cleanly in Bank 0") + + -- Verify player name in backup + local backName = {} + for i = 0, 10 do + local c = u8(bytes, L.backup.wPlayerName + i) + if c == 0x50 then break end + backName[#backName + 1] = Gen2Layout.charmap[c] + end + eq(table.concat(backName), "KRIS", "backup wPlayerName matches primary") +end + +print("[test] 3. String Padding & 0x50 Fill (No Buffer Overrun)") +do + local L = Gen2Save.layoutFor("gold") + local save = { + generation = 2, + player = { name = "RED", id = 100 }, + rival = { name = "BLUE" }, mom = { name = "MOMMY" }, + position = { map = "PLAYERS_HOUSE_2F", x = 3, y = 3 }, + party = { + { species = "PIKACHU", nickname = "SPARK", ot = "RED", otId = 100 } + }, + boxNames = { [1] = "MAIN" }, + boxes = {}, + } + + local bytes, err = Gen2Save.encode(save, "gold", nil, fixture()) + check(bytes ~= nil, "encode succeeds") + + -- Verify 11-byte player name: 'R', 'E', 'D', followed by eight 0x50 bytes + eq(u8(bytes, L.wPlayerName + 0), 0x91, "P1 'R'") + eq(u8(bytes, L.wPlayerName + 1), 0x84, "P2 'E'") + eq(u8(bytes, L.wPlayerName + 2), 0x83, "P3 'D'") + for i = 3, 10 do + eq(u8(bytes, L.wPlayerName + i), 0x50, "Player name padded with 0x50 at byte " .. i) + end + + -- Verify 9-byte box name: 'M', 'A', 'I', 'N', followed by five 0x50 bytes + eq(u8(bytes, L.wBoxNames + 0), 0x8C, "B1 'M'") + eq(u8(bytes, L.wBoxNames + 1), 0x80, "B2 'A'") + eq(u8(bytes, L.wBoxNames + 2), 0x88, "B3 'I'") + eq(u8(bytes, L.wBoxNames + 3), 0x8D, "B4 'N'") + for i = 4, 8 do + eq(u8(bytes, L.wBoxNames + i), 0x50, "Box name padded with 0x50 at byte " .. i) + end + + -- Verify party mon nickname padding + local nickBase = L.wPartyMonNicknames + eq(u8(bytes, nickBase + 0), 0x92, "N1 'S'") + eq(u8(bytes, nickBase + 1), 0x8F, "N2 'P'") + eq(u8(bytes, nickBase + 2), 0x80, "N3 'A'") + eq(u8(bytes, nickBase + 3), 0x91, "N4 'R'") + eq(u8(bytes, nickBase + 4), 0x8A, "N5 'K'") + for i = 5, 10 do + eq(u8(bytes, nickBase + i), 0x50, "Nickname padded with 0x50 at byte " .. i) + end +end + +print("[test] 4. Play Time Seconds & Frames Roundtrip") +do + local save = { + generation = 2, + player = { name = "TIME", id = 999 }, + position = { map = "PLAYERS_HOUSE_2F", x = 3, y = 3 }, + party = {}, boxes = {}, + playTime = { hours = 123, minutes = 45, seconds = 56, frames = 29 } + } + + local bytes = Gen2Save.encode(save, "gold", nil, fixture()) + local decoded = Gen2Save.decode(bytes, "gold") + eq(decoded.playTime.hours, 123, "hours roundtrip") + eq(decoded.playTime.minutes, 45, "minutes roundtrip") + eq(decoded.playTime.seconds, 56, "seconds roundtrip") + eq(decoded.playTime.frames, 29, "frames roundtrip") +end + +print("[test] 5. Default Base Happiness to 70") +do + local save = { + generation = 2, + player = { name = "HAP", id = 111 }, + position = { map = "PLAYERS_HOUSE_2F", x = 3, y = 3 }, + party = { + { species = "CYNDAQUIL", level = 5 } -- happiness is nil + }, + boxes = { + [1] = { + { species = "TOTODILE", level = 5 } -- happiness is nil + } + } + } + + local bytes = Gen2Save.encode(save, "gold", nil, fixture()) + local decoded = Gen2Save.decode(bytes, "gold") + eq(decoded.party[1].happiness, 70, "party mon unset happiness defaults to 70") + eq(decoded.boxes[1][1].happiness, 70, "box mon unset happiness defaults to 70") +end + +print("[test] 6. Bank 0 Party Mail Struct Synchronization") +do + local save = { + generation = 2, + player = { name = "MAILER", id = 7777 }, + position = { map = "PLAYERS_HOUSE_2F", x = 3, y = 3 }, + party = { + { species = "PIKACHU", item = "FLOWER_MAIL", ot = "MAILER", otId = 7777 }, + { species = "TOTODILE", item = "POTION" } -- not holding mail + }, + mail = { + party = { + [1] = { + type = "FLOWER_MAIL", + message = "HELLO WORLD", + author = "MAILER", + authorId = 7777, + species = "PIKACHU", + } + } + }, + boxes = {}, + } + + local bytes = Gen2Save.encode(save, "crystal", nil, fixture()) + -- Slot 1 Mail in Bank 0 (0x0000) + eq(be(bytes, 0x0000 + 43, 2), 7777, "Slot 1 mail authorId matches") + eq(u8(bytes, 0x0000 + 45), 25, "Slot 1 mail species matches PIKACHU (25)") + eq(u8(bytes, 0x0000 + 46), 158, "Slot 1 mail type matches FLOWER_MAIL (158)") + + -- Slot 2 (no mail) is zeroed in Bank 0 (0x002F) + eq(u8(bytes, 47), 0, "Slot 2 (no mail) is 0x00") +end + +print("[test] 7. Full Save Roundtrip & Parity Verification") +do + local fullFixture = { + pokemon = { + CHIKORITA = { index = 152 }, + BAYLEEF = { index = 153 }, + CYNDAQUIL = { index = 155 }, + TYPHLOSION = { index = 157 }, + TOTODILE = { index = 158 }, + PIKACHU = { index = 25 }, + TOGEPI = { index = 175 }, + MAREEP = { index = 179 }, + AMPHAROS = { index = 181 }, + }, + moves = { + TACKLE = { index = 33, pp = 35 }, + GROWL = { index = 45, pp = 40 }, + EMBER = { index = 52, pp = 25 }, + THUNDERSHOCK = { index = 84, pp = 30 }, + }, + items = { + MASTER_BALL = { index = 1, pocket = "BALL" }, + POKE_BALL = { index = 4, pocket = "BALL" }, + POTION = { index = 20, pocket = "ITEM" }, + BERRY = { index = 77, pocket = "ITEM" }, + FLOWER_MAIL = { index = 158, pocket = "ITEM" }, + BICYCLE = { index = 7, pocket = "KEY_ITEM" }, + TM01 = { index = 191, pocket = "TM_HM", tmNumber = 1 }, + HM01 = { index = 241, pocket = "TM_HM", tmNumber = 51 }, + }, + maps = { PLAYERS_HOUSE_2F = { + group = 24, map = 7, objectEventsAddr = 0x5CF0, + width = 4, height = 3, + blocks = { 4, 1, 3, 2, 5, 6, 5, 5, 5, 5, 7, 5 }, + objects = {}, + } }, + } + + for _, version in ipairs({ "gold", "crystal" }) do + local richSave = { + generation = 2, + player = { + name = "GOLD", id = 42105, gender = "female", money = 87654, coins = 1250, + badges = { ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true }, + kantoBadges = { BOULDER = true, CASCADE = true }, + }, + rival = { name = "SILVER" }, mom = { name = "MOM" }, + position = { map = "PLAYERS_HOUSE_2F", mapGroup = 24, mapNumber = 7, x = 3, y = 3 }, + playTime = { hours = 48, minutes = 32, seconds = 17, frames = 45 }, + currentBox = 2, + boxNames = { [1] = "STARTERS", [2] = "ELECTRIC" }, + party = { + { + species = "TYPHLOSION", level = 42, nickname = "CINDER", ot = "GOLD", otId = 42105, + item = "BERRY", experience = 84500, hp = 135, maxHp = 135, + stats = { hp = 135, attack = 95, defense = 88, speed = 110, specialAttack = 119, specialDefense = 95 }, + statExp = { hp = 1200, attack = 1500, defense = 900, speed = 2100, special = 1800 }, + dvs = { attack = 14, defense = 12, speed = 15, special = 13 }, + moves = { { id = "EMBER", pp = 25, ppUps = 0 } }, + happiness = 220, pokerus = 0, caughtData = 0x4815, + }, + }, + boxes = { + [1] = { + { species = "CHIKORITA", level = 5, nickname = "CHIQUI", ot = "GOLD", otId = 42105, + moves = { { id = "TACKLE", pp = 35, ppUps = 0 } }, dvs = { attack = 12, defense = 11, speed = 10, special = 14 } }, + }, + [2] = { + { species = "PIKACHU", level = 20, nickname = "SPARK", ot = "ASH", otId = 10001, + moves = { { id = "THUNDERSHOCK", pp = 30, ppUps = 0 } }, dvs = { attack = 15, defense = 15, speed = 15, special = 15 } }, + }, + }, + inventory = { + POTION = 5, BERRY = 10, MASTER_BALL = 1, POKE_BALL = 20, + BICYCLE = 1, TM01 = 1, HM01 = 1, + }, + pokedex = { + caught = { CYNDAQUIL = true, TYPHLOSION = true, PIKACHU = true }, + seen = { CHIKORITA = true, BAYLEEF = true, CYNDAQUIL = true, TYPHLOSION = true, PIKACHU = true }, + }, + events = { [0] = 0x55, [1] = 0xAA }, + } + + local savBytes, encErr = Gen2Save.encode(richSave, version, nil, fullFixture) + check(savBytes ~= nil, version .. ": full save encode succeeds -- " .. tostring(encErr)) + local decoded, decErr = Gen2Save.decode(savBytes, version, fullFixture) + check(decoded ~= nil, version .. ": full save decode succeeds -- " .. tostring(decErr)) + + eq(decoded.player.name, richSave.player.name, version .. ": player name matches") + eq(decoded.player.id, richSave.player.id, version .. ": player ID matches") + eq(decoded.player.money, richSave.player.money, version .. ": player money matches") + eq(decoded.player.coins, richSave.player.coins, version .. ": player coins matches") + if version == "crystal" then + eq(decoded.player.gender, richSave.player.gender, version .. ": player gender matches") + end + eq(decoded.playTime.hours, richSave.playTime.hours, version .. ": play hours match") + eq(decoded.playTime.minutes, richSave.playTime.minutes, version .. ": play minutes match") + eq(decoded.playTime.seconds, richSave.playTime.seconds, version .. ": play seconds match") + eq(decoded.playTime.frames, richSave.playTime.frames, version .. ": play frames match") + eq(decoded.currentBox, richSave.currentBox, version .. ": current box matches") + eq(decoded.boxNames[1], "STARTERS", version .. ": box 1 name matches") + eq(decoded.boxNames[2], "ELECTRIC", version .. ": box 2 name matches") + eq(#decoded.party, 1, version .. ": party length matches") + eq(decoded.party[1].species, "TYPHLOSION", version .. ": party 1 species matches") + eq(decoded.party[1].nickname, "CINDER", version .. ": party 1 nickname matches") + eq(decoded.party[1].happiness, 220, version .. ": party 1 happiness matches") + eq(decoded.boxes[1][1].species, "CHIKORITA", version .. ": box 1 mon matches") + eq(decoded.boxes[2][1].species, "PIKACHU", version .. ": active box mon matches") + for item, count in pairs(richSave.inventory) do + eq(decoded.inventory[item], count, version .. ": inventory " .. item .. " count matches") + end + for mon in pairs(richSave.pokedex.caught) do + eq(decoded.pokedex.caught[mon], true, version .. ": dex caught " .. mon .. " matches") + end + end +end + +T.finish() diff --git a/tools/gen2_sram_offsets.py b/tools/gen2_sram_offsets.py index c953db06..318c64fb 100644 --- a/tools/gen2_sram_offsets.py +++ b/tools/gen2_sram_offsets.py @@ -33,6 +33,7 @@ FIELDS = [ "wXCoord", "wYCoord", "wEventFlags", "wPlayerState", "wStatusFlags", "wStatusFlags2", "wPokegearFlags", "wVisitedSpawns", "wVariableSprites", "wGameTimeHours", "wGameTimeMinutes", + "wGameTimeSeconds", "wGameTimeFrames", # engine/menus/intro_menu.asm:28 _ResetWRAM, engine/overworld/player_object.asm:19 "wRedsName", "wGreensName", "wSavedAtLeastOnce", "wSpawnAfterChampion", "wCenteredObject", "wPlayerStruct", "wMapObjects", "wNumPCItems",