From 78df1a32e85fce56b4494a077aca88e1e224dd16 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 10 Sep 2026 09:44:38 -0400 Subject: [PATCH] Save import: add Gen 1 and Gen 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit savedex.js now sniffs the file and parses whichever generation it is: - Gen 1 (R/B/Y): 32 KB SRAM, fixed offsets — owned 0x25A3, seen 0x25B6 (151 species), name 0x2598, one-byte complement checksum. - Gen 2 (G/S/C): tries Gold/Silver offsets (0x2A4C/0x2A6C, checksum 0x2D69) and Crystal (0x2A27/0x2A47, checksum 0x2D0D); the 16-bit checksum picks which, with a caught⊆seen / no-overflow sanity fallback. - Gen 3: unchanged (parseGen3Dex kept as an alias). parseSaveDex(buffer) is the new entry point; the Settings picker relabels to "Import from a game save". Verified against synthetic Gen 1 / GS / Crystal saves and end-to-end through the UI (a Gen 1 save merged 11 caught / 84 seen). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- README.md | 9 +- src/lib/savedex.js | 252 +++++++++++++++++++++++++++----------- src/views/SettingsView.js | 8 +- 3 files changed, 188 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 3ba53af..fb8ed6e 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,11 @@ framework. Export / import of all local state (settings, tracking, team, form tracking, shiny hunts) as JSON lives in **Settings**, which also imports -the Pokédex straight out of a **Generation III save file** (`.sav` / -`.srm`, Ruby/Sapphire/Emerald/FireRed/LeafGreen) — it reads the game's -own seen/owned bitfields (newer save slot, section rotation and all) and -merges them in. +the Pokédex straight out of a **game save file** (`.sav` / `.srm`): +Gen 1 (R/B/Y), Gen 2 (G/S/C) and Gen 3 (R/S/E, FR/LG). It reads the +game's own seen/owned bitfields — checksum-picking Gold/Silver vs +Crystal, resolving Gen 3's newer slot and rotated sections — and merges +them in without removing anything. ## Scripts diff --git a/src/lib/savedex.js b/src/lib/savedex.js index f31d323..bde319b 100644 --- a/src/lib/savedex.js +++ b/src/lib/savedex.js @@ -1,38 +1,42 @@ /** - * Read the Pokédex (seen / owned) out of a Generation III save file — - * Ruby, Sapphire, Emerald, FireRed, LeafGreen — as dumped by emulators - * (`.sav`) or RetroArch (`.srm`). Wild-only importers this is not: it - * pulls the actual dex bitfields the game keeps. + * Read the Pokédex (seen / owned) out of a Game Boy / GBA save file — + * Generations I, II and III, as dumped by emulators (`.sav`) or RetroArch + * (`.srm`). These read the game's own dex bitfields, not an approximation. * - * Layout (Bulbapedia "Save data structure (Generation III)", pret): - * - 128 KB file = two 57 344-byte save slots (A @ 0, B @ 0xE000). - * - Each slot = 14 x 4096-byte sections, stored rotated; a section's real - * id and the slot's save counter live in its last 128 bytes. - * - Use the slot with the higher save counter. Section 0 (trainer info) - * holds `struct Pokedex` at offset 0x18: nat-dex magic at +2, then - * owned[52] at +16, seen[52] at +68. Bit (dexNo-1) per species. + * Refs: Bulbapedia "Save data structure (Generation I / II / III)", and + * the pret disassemblies (pokered / pokecrystal / pokeemerald). */ -const SECTION_SIZE = 4096; -const SECTIONS_PER_SLOT = 14; -const SLOT_SIZE = SECTION_SIZE * SECTIONS_PER_SLOT; // 57344 -const SIGNATURE = 0x08012025; -const SECTION_DATA_SIZE = [ - 3884, 3968, 3968, 3968, 3848, 3968, 3968, 3968, 3968, 3968, 3968, 3968, 3968, 2000, -]; +/* ------------------------------------------------------------------ * + * Shared helpers + * ------------------------------------------------------------------ */ -// Section 0 -> struct Pokedex -const DEX_OFFSET = 0x18; -const DEX_MAGIC = DEX_OFFSET + 2; // 0xDA = RSE national, 0xB9 = FRLG national -const OWNED = DEX_OFFSET + 16; -const SEEN = DEX_OFFSET + 68; -const MAX_DEX = 386; +const bitsToList = (bytes, start, count) => { + const list = []; + for (let n = 1; n <= count; n++) { + if (bytes[start + ((n - 1) >> 3)] & (1 << ((n - 1) & 7))) list.push(n); + } + return list; +}; -// Gen 3 in-game text -> ASCII, enough for trainer names. -const CHARS = (() => { - const t = {}; - t[0x00] = ' '; - for (let i = 0; i < 10; i++) t[0xa1 + i] = String(i); // 0-9 +// GB (Gen 1 & 2) in-game text -> ASCII, enough for trainer names. +const GB_CHARS = (() => { + const t = { 0x7f: ' ', 0x50: '\0' }; + const up = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const lo = 'abcdefghijklmnopqrstuvwxyz'; + for (let i = 0; i < 26; i++) { + t[0x80 + i] = up[i]; + t[0xa0 + i] = lo[i]; + } + for (let i = 0; i < 10; i++) t[0xf6 + i] = String(i); + Object.assign(t, { 0xe8: '.', 0xe3: '-', 0xf3: '/', 0xf4: ',', 0x9a: '(', 0x9b: ')', 0xe6: '?', 0xe7: '!' }); + return t; +})(); + +// GBA (Gen 3) in-game text -> ASCII. +const GBA_CHARS = (() => { + const t = { 0x00: ' ' }; + for (let i = 0; i < 10; i++) t[0xa1 + i] = String(i); const up = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; const lo = 'abcdefghijklmnopqrstuvwxyz'; for (let i = 0; i < 26; i++) { @@ -40,23 +44,110 @@ const CHARS = (() => { t[0xd5 + i] = lo[i]; } Object.assign(t, { - 0xad: '.', 0xae: '-', 0xb0: '…', 0xb1: '“', 0xb2: '”', 0xb3: '‘', 0xb4: '’', - 0xba: '/', 0xac: ',', 0xb8: '&', 0x2d: '=', + 0xad: '.', 0xae: '-', 0xba: '/', 0xac: ',', 0xb8: '&', }); return t; })(); -function decodeName(bytes) { +function decodeName(bytes, table, terminator) { let out = ''; for (const b of bytes) { - if (b === 0xff) break; - out += CHARS[b] ?? ''; + if (b === terminator) break; + const c = table[b]; + if (c === '\0' || c === undefined) { + if (c === '\0') break; + continue; + } + out += c; } return out.trim(); } +const sumBytes = (bytes, from, to) => { + let s = 0; + for (let i = from; i <= to; i++) s = (s + bytes[i]) & 0xffffffff; + return s >>> 0; +}; + +/* ------------------------------------------------------------------ * + * Generation I — Red / Blue / Yellow (International, 32 KB SRAM) + * ------------------------------------------------------------------ */ + +const G1 = { name: 0x2598, owned: 0x25a3, seen: 0x25b6, species: 151, ckByte: 0x3523, ckFrom: 0x2598, ckTo: 0x3522 }; + +function tryGen1(bytes) { + if (bytes.length < 0x8000) return null; + const caught = bitsToList(bytes, G1.owned, G1.species); + const seen = bitsToList(bytes, G1.seen, G1.species); + const checksumOk = ((~sumBytes(bytes, G1.ckFrom, G1.ckTo)) & 0xff) === bytes[G1.ckByte]; + if (!seen.length && !caught.length) return null; + return { + gen: 1, + game: 'Red / Blue / Yellow', + trainer: decodeName(bytes.subarray(G1.name, G1.name + 11), GB_CHARS, 0x50), + seen, + caught, + nationalUnlocked: true, + checksumOk, + }; +} + +/* ------------------------------------------------------------------ * + * Generation II — Gold / Silver / Crystal (International, 32 KB SRAM) + * ------------------------------------------------------------------ */ + +const G2 = { + gs: { name: 0x200b, owned: 0x2a4c, seen: 0x2a6c, ck: 0x2d69, ckFrom: 0x2009, ckTo: 0x2d68, label: 'Gold / Silver' }, + cr: { name: 0x2009, owned: 0x2a27, seen: 0x2a47, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82, label: 'Crystal' }, +}; +const G2_SPECIES = 251; + +function tryGen2Variant(bytes, v) { + const caught = bitsToList(bytes, v.owned, G2_SPECIES); + const seen = bitsToList(bytes, v.seen, G2_SPECIES); + if (!seen.length && !caught.length) return null; + // No species may be "caught" but not "seen", and the last used byte must + // not spill past species 251 — cheap sanity checks for the right offsets. + if (caught.some((n) => !seen.includes(n))) return null; + if (bytes[v.owned + 31] & 0xf8 || bytes[v.seen + 31] & 0xf8) return null; + const stored = bytes[v.ck] | (bytes[v.ck + 1] << 8); + const checksumOk = (sumBytes(bytes, v.ckFrom, v.ckTo) & 0xffff) === stored; + return { + gen: 2, + game: v.label, + trainer: decodeName(bytes.subarray(v.name, v.name + 11), GB_CHARS, 0x50), + seen, + caught, + nationalUnlocked: true, + checksumOk, + }; +} + +function tryGen2(bytes) { + if (bytes.length < 0x8000) return null; + const gs = tryGen2Variant(bytes, G2.gs); + const cr = tryGen2Variant(bytes, G2.cr); + if (gs?.checksumOk) return gs; + if (cr?.checksumOk) return cr; + return gs || cr || null; +} + +/* ------------------------------------------------------------------ * + * Generation III — RSE / FRLG (128 KB, two slots, rotated sections) + * ------------------------------------------------------------------ */ + +const SECTION_SIZE = 4096; +const SECTIONS_PER_SLOT = 14; +const SLOT_SIZE = SECTION_SIZE * SECTIONS_PER_SLOT; // 57344 +const SIGNATURE = 0x08012025; +const SECTION_DATA_SIZE = [3884, 3968, 3968, 3968, 3848, 3968, 3968, 3968, 3968, 3968, 3968, 3968, 3968, 2000]; +const DEX_OFFSET = 0x18; +const DEX_MAGIC = DEX_OFFSET + 2; // 0xDA = RSE national, 0xB9 = FRLG national +const G3_OWNED = DEX_OFFSET + 16; +const G3_SEEN = DEX_OFFSET + 68; +const G3_SPECIES = 386; + function readSlot(view, base) { - // Map real section id -> byte offset, and read the slot's save counter. const sections = {}; let counter = -1; let valid = 0; @@ -72,56 +163,71 @@ function readSlot(view, base) { return { sections, counter, valid }; } -function checksumOk(view, sectionOff, id) { +function g3ChecksumOk(view, sectionOff, id) { const size = SECTION_DATA_SIZE[id] ?? 3968; let sum = 0; for (let i = 0; i < size; i += 4) sum = (sum + view.getUint32(sectionOff + i, true)) >>> 0; - const folded = ((sum >>> 16) + (sum & 0xffff)) & 0xffff; - return folded === view.getUint16(sectionOff + 0x0ff6, true); + return (((sum >>> 16) + (sum & 0xffff)) & 0xffff) === view.getUint16(sectionOff + 0x0ff6, true); } +function tryGen3(bytes) { + if (bytes.length < SLOT_SIZE + SECTION_SIZE) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const a = readSlot(view, 0); + const b = bytes.length >= 2 * SLOT_SIZE ? readSlot(view, SLOT_SIZE) : { sections: {}, counter: -1, valid: 0 }; + if (a.valid < 3 && b.valid < 3) return null; + const slot = b.counter > a.counter ? b : a; + const s0 = slot.sections[0]; + if (s0 == null) return null; + + const magic = bytes[s0 + DEX_MAGIC]; + const seen = bitsToList(bytes, s0 + G3_SEEN, G3_SPECIES); + const caught = bitsToList(bytes, s0 + G3_OWNED, G3_SPECIES); + if (!seen.length && !caught.length) return null; + return { + gen: 3, + game: magic === 0xb9 ? 'FireRed / LeafGreen' : 'Ruby / Sapphire / Emerald', + trainer: decodeName(bytes.subarray(s0, s0 + 7), GBA_CHARS, 0xff), + seen, + caught, + nationalUnlocked: magic === 0xda || magic === 0xb9, + checksumOk: g3ChecksumOk(view, s0, 0), + }; +} + +/* ------------------------------------------------------------------ * + * Public: sniff the file and parse whatever generation it is. + * ------------------------------------------------------------------ */ + /** * @param {ArrayBuffer} buffer - * @returns {{ game: string, trainer: string, seen: number[], caught: number[], nationalUnlocked: boolean }} - * @throws {Error} with a user-facing message if it isn't a Gen 3 save. + * @returns {{ gen:number, game:string, trainer:string, seen:number[], caught:number[], nationalUnlocked:boolean }} + * @throws {Error} with a user-facing message if it can't be read. */ -export function parseGen3Dex(buffer) { +export function parseSaveDex(buffer) { const bytes = new Uint8Array(buffer); - if (bytes.length < SLOT_SIZE + SECTION_SIZE) { - throw new Error('That file is too small to be a Game Boy Advance save.'); - } - const view = new DataView(buffer); - - const a = readSlot(view, 0); - const b = bytes.length >= 2 * SLOT_SIZE ? readSlot(view, SLOT_SIZE) : { sections: {}, counter: -1, valid: 0 }; - if (a.valid < 3 && b.valid < 3) { - throw new Error("This doesn't look like a Generation III (Ruby/Sapphire/Emerald/FireRed/LeafGreen) save."); - } - const slot = b.counter > a.counter ? b : a; - const s0 = slot.sections[0]; - if (s0 == null) throw new Error('The save is missing its trainer-info section.'); - - // Soft integrity check — warn via console, still import. - if (!checksumOk(view, s0, 0)) { - console.warn('savedex: section 0 checksum mismatch — importing anyway.'); + if (bytes.length < 0x8000) { + throw new Error('That file is too small to be a Pokémon save.'); } - const magic = bytes[s0 + DEX_MAGIC]; - const nationalUnlocked = magic === 0xda || magic === 0xb9; - const game = magic === 0xb9 ? 'FireRed / LeafGreen' : 'Ruby / Sapphire / Emerald'; - const trainer = decodeName(bytes.subarray(s0, s0 + 7)); + // 128 KB → Gen 3. 32–64 KB → Gen 1 or 2 (checksum picks the winner). + const candidates = + bytes.length >= SLOT_SIZE + SECTION_SIZE + ? [tryGen3(bytes), tryGen2(bytes), tryGen1(bytes)] + : [tryGen2(bytes), tryGen1(bytes)]; - const seen = []; - const caught = []; - for (let n = 1; n <= MAX_DEX; n++) { - const byte = (n - 1) >> 3; - const mask = 1 << ((n - 1) & 7); - if (bytes[s0 + SEEN + byte] & mask) seen.push(n); - if (bytes[s0 + OWNED + byte] & mask) caught.push(n); + const hits = candidates.filter(Boolean); + if (!hits.length) { + throw new Error( + "Couldn't find a Pokédex in that file — it may be a game this doesn't support yet, a corrupted save, or a Japanese cartridge.", + ); } - if (!seen.length && !caught.length) { - throw new Error('Read the save, but its Pokédex is empty — is this the right file?'); + const best = hits.find((h) => h.checksumOk) || hits[0]; + if (!best.checksumOk) { + console.warn('savedex: no checksum matched — importing the most likely reading anyway.'); } - - return { game, trainer, seen, caught, nationalUnlocked }; + return best; } + +// Back-compat: earlier code imported this name. +export const parseGen3Dex = parseSaveDex; diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js index a9784f1..35f9514 100644 --- a/src/views/SettingsView.js +++ b/src/views/SettingsView.js @@ -4,7 +4,7 @@ import { selection, importFlags } from '../store/selection.js'; import { team } from '../store/team.js'; import { formTracking } from '../store/formTracking.js'; import { shinyHunts } from '../store/shinyHunts.js'; -import { parseGen3Dex } from '../lib/savedex.js'; +import { parseSaveDex } from '../lib/savedex.js'; import { canInstall, onInstallChange, promptInstall } from '../lib/install.js'; export async function SettingsView() { @@ -134,7 +134,7 @@ export async function SettingsView() { if (!file) return; saveNote.textContent = 'Reading…'; try { - const dex = parseGen3Dex(await file.arrayBuffer()); + const dex = parseSaveDex(await file.arrayBuffer()); const who = dex.trainer || dex.game; if ( confirm( @@ -229,8 +229,8 @@ export async function SettingsView() { el( 'p', { class: 'settings__note' }, - el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a GBA save'), - ' — Gen 3 .sav / .srm (Ruby · Sapphire · Emerald · FireRed · LeafGreen). Merges seen/caught, removes nothing.', + el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a game save'), + ' — a .sav / .srm from Gen 1 (R/B/Y), Gen 2 (G/S/C) or Gen 3 (R/S/E, FR/LG). Reads the game’s own Pokédex; merges seen/caught, removes nothing.', ), saveInput, saveNote,