Save import: add Gen 1 and Gen 2

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-10 09:44:38 -04:00
parent 5bce0f93f3
commit 78df1a32e8
3 changed files with 188 additions and 81 deletions

View File

@ -20,10 +20,11 @@ framework.
Export / import of all local state (settings, tracking, team, form Export / import of all local state (settings, tracking, team, form
tracking, shiny hunts) as JSON lives in **Settings**, which also imports tracking, shiny hunts) as JSON lives in **Settings**, which also imports
the Pokédex straight out of a **Generation III save file** (`.sav` / the Pokédex straight out of a **game save file** (`.sav` / `.srm`):
`.srm`, Ruby/Sapphire/Emerald/FireRed/LeafGreen) — it reads the game's Gen 1 (R/B/Y), Gen 2 (G/S/C) and Gen 3 (R/S/E, FR/LG). It reads the
own seen/owned bitfields (newer save slot, section rotation and all) and game's own seen/owned bitfields — checksum-picking Gold/Silver vs
merges them in. Crystal, resolving Gen 3's newer slot and rotated sections — and merges
them in without removing anything.
## Scripts ## Scripts

View File

@ -1,38 +1,42 @@
/** /**
* Read the Pokédex (seen / owned) out of a Generation III save file * Read the Pokédex (seen / owned) out of a Game Boy / GBA save file
* Ruby, Sapphire, Emerald, FireRed, LeafGreen as dumped by emulators * Generations I, II and III, as dumped by emulators (`.sav`) or RetroArch
* (`.sav`) or RetroArch (`.srm`). Wild-only importers this is not: it * (`.srm`). These read the game's own dex bitfields, not an approximation.
* pulls the actual dex bitfields the game keeps.
* *
* Layout (Bulbapedia "Save data structure (Generation III)", pret): * Refs: Bulbapedia "Save data structure (Generation I / II / III)", and
* - 128 KB file = two 57 344-byte save slots (A @ 0, B @ 0xE000). * the pret disassemblies (pokered / pokecrystal / pokeemerald).
* - 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.
*/ */
const SECTION_SIZE = 4096; /* ------------------------------------------------------------------ *
const SECTIONS_PER_SLOT = 14; * Shared helpers
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,
];
// Section 0 -> struct Pokedex const bitsToList = (bytes, start, count) => {
const DEX_OFFSET = 0x18; const list = [];
const DEX_MAGIC = DEX_OFFSET + 2; // 0xDA = RSE national, 0xB9 = FRLG national for (let n = 1; n <= count; n++) {
const OWNED = DEX_OFFSET + 16; if (bytes[start + ((n - 1) >> 3)] & (1 << ((n - 1) & 7))) list.push(n);
const SEEN = DEX_OFFSET + 68; }
const MAX_DEX = 386; return list;
};
// Gen 3 in-game text -> ASCII, enough for trainer names. // GB (Gen 1 & 2) in-game text -> ASCII, enough for trainer names.
const CHARS = (() => { const GB_CHARS = (() => {
const t = {}; const t = { 0x7f: ' ', 0x50: '\0' };
t[0x00] = ' '; const up = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
for (let i = 0; i < 10; i++) t[0xa1 + i] = String(i); // 0-9 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 up = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const lo = 'abcdefghijklmnopqrstuvwxyz'; const lo = 'abcdefghijklmnopqrstuvwxyz';
for (let i = 0; i < 26; i++) { for (let i = 0; i < 26; i++) {
@ -40,23 +44,110 @@ const CHARS = (() => {
t[0xd5 + i] = lo[i]; t[0xd5 + i] = lo[i];
} }
Object.assign(t, { Object.assign(t, {
0xad: '.', 0xae: '-', 0xb0: '…', 0xb1: '“', 0xb2: '”', 0xb3: '', 0xb4: '', 0xad: '.', 0xae: '-', 0xba: '/', 0xac: ',', 0xb8: '&',
0xba: '/', 0xac: ',', 0xb8: '&', 0x2d: '=',
}); });
return t; return t;
})(); })();
function decodeName(bytes) { function decodeName(bytes, table, terminator) {
let out = ''; let out = '';
for (const b of bytes) { for (const b of bytes) {
if (b === 0xff) break; if (b === terminator) break;
out += CHARS[b] ?? ''; const c = table[b];
if (c === '\0' || c === undefined) {
if (c === '\0') break;
continue;
}
out += c;
} }
return out.trim(); 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) { function readSlot(view, base) {
// Map real section id -> byte offset, and read the slot's save counter.
const sections = {}; const sections = {};
let counter = -1; let counter = -1;
let valid = 0; let valid = 0;
@ -72,56 +163,71 @@ function readSlot(view, base) {
return { sections, counter, valid }; return { sections, counter, valid };
} }
function checksumOk(view, sectionOff, id) { function g3ChecksumOk(view, sectionOff, id) {
const size = SECTION_DATA_SIZE[id] ?? 3968; const size = SECTION_DATA_SIZE[id] ?? 3968;
let sum = 0; let sum = 0;
for (let i = 0; i < size; i += 4) sum = (sum + view.getUint32(sectionOff + i, true)) >>> 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 (((sum >>> 16) + (sum & 0xffff)) & 0xffff) === view.getUint16(sectionOff + 0x0ff6, true);
return folded === 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 * @param {ArrayBuffer} buffer
* @returns {{ game: string, trainer: string, seen: number[], caught: number[], nationalUnlocked: boolean }} * @returns {{ gen:number, 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. * @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); const bytes = new Uint8Array(buffer);
if (bytes.length < SLOT_SIZE + SECTION_SIZE) { if (bytes.length < 0x8000) {
throw new Error('That file is too small to be a Game Boy Advance save.'); throw new Error('That file is too small to be a Pokémon 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.');
} }
const magic = bytes[s0 + DEX_MAGIC]; // 128 KB → Gen 3. 3264 KB → Gen 1 or 2 (checksum picks the winner).
const nationalUnlocked = magic === 0xda || magic === 0xb9; const candidates =
const game = magic === 0xb9 ? 'FireRed / LeafGreen' : 'Ruby / Sapphire / Emerald'; bytes.length >= SLOT_SIZE + SECTION_SIZE
const trainer = decodeName(bytes.subarray(s0, s0 + 7)); ? [tryGen3(bytes), tryGen2(bytes), tryGen1(bytes)]
: [tryGen2(bytes), tryGen1(bytes)];
const seen = []; const hits = candidates.filter(Boolean);
const caught = []; if (!hits.length) {
for (let n = 1; n <= MAX_DEX; n++) { throw new Error(
const byte = (n - 1) >> 3; "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.",
const mask = 1 << ((n - 1) & 7); );
if (bytes[s0 + SEEN + byte] & mask) seen.push(n);
if (bytes[s0 + OWNED + byte] & mask) caught.push(n);
} }
if (!seen.length && !caught.length) { const best = hits.find((h) => h.checksumOk) || hits[0];
throw new Error('Read the save, but its Pokédex is empty — is this the right file?'); if (!best.checksumOk) {
console.warn('savedex: no checksum matched — importing the most likely reading anyway.');
} }
return best;
return { game, trainer, seen, caught, nationalUnlocked };
} }
// Back-compat: earlier code imported this name.
export const parseGen3Dex = parseSaveDex;

View File

@ -4,7 +4,7 @@ import { selection, importFlags } from '../store/selection.js';
import { team } from '../store/team.js'; import { team } from '../store/team.js';
import { formTracking } from '../store/formTracking.js'; import { formTracking } from '../store/formTracking.js';
import { shinyHunts } from '../store/shinyHunts.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'; import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
export async function SettingsView() { export async function SettingsView() {
@ -134,7 +134,7 @@ export async function SettingsView() {
if (!file) return; if (!file) return;
saveNote.textContent = 'Reading…'; saveNote.textContent = 'Reading…';
try { try {
const dex = parseGen3Dex(await file.arrayBuffer()); const dex = parseSaveDex(await file.arrayBuffer());
const who = dex.trainer || dex.game; const who = dex.trainer || dex.game;
if ( if (
confirm( confirm(
@ -229,8 +229,8 @@ export async function SettingsView() {
el( el(
'p', 'p',
{ class: 'settings__note' }, { class: 'settings__note' },
el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a GBA save'), el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a game save'),
' — Gen 3 .sav / .srm (Ruby · Sapphire · Emerald · FireRed · LeafGreen). Merges seen/caught, removes nothing.', ' — a .sav / .srm from Gen 1 (R/B/Y), Gen 2 (G/S/C) or Gen 3 (R/S/E, FR/LG). Reads the games own Pokédex; merges seen/caught, removes nothing.',
), ),
saveInput, saveInput,
saveNote, saveNote,