Import the Pokédex from a Gen 3 GBA save (.sav / .srm)

lib/savedex.js parses a Generation III save (Ruby/Sapphire/Emerald/
FireRed/LeafGreen): picks the newer of the two save slots, resolves the
rotated section order via each section's footer, and reads the seen /
owned bitfields out of `struct Pokedex` in section 0 (bit dexNo-1, 386
species). Also decodes the trainer name and detects the national-dex
magic byte. Soft checksum check.

Settings > Your data: "Import from a GBA save" — file picker → confirm →
selection.importFlags() merges seen/caught in (never removes). Verified
against synthetic saves (slot selection, rotation, FRLG vs RSE, bit
decode) and end-to-end through the Settings UI.

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:29:34 -04:00
parent a437f539fc
commit 17e8e30f9b
4 changed files with 186 additions and 2 deletions

View File

@ -19,7 +19,11 @@ framework.
- **Sprites** → cache-first with a capped LRU.
Export / import of all local state (settings, tracking, team, form
tracking, shiny hunts) as JSON lives in **Settings**.
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.
## Scripts

127
src/lib/savedex.js Normal file
View File

@ -0,0 +1,127 @@
/**
* 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.
*
* 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.
*/
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,
];
// 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;
// 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
const up = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const lo = 'abcdefghijklmnopqrstuvwxyz';
for (let i = 0; i < 26; i++) {
t[0xbb + i] = up[i];
t[0xd5 + i] = lo[i];
}
Object.assign(t, {
0xad: '.', 0xae: '-', 0xb0: '…', 0xb1: '“', 0xb2: '”', 0xb3: '', 0xb4: '',
0xba: '/', 0xac: ',', 0xb8: '&', 0x2d: '=',
});
return t;
})();
function decodeName(bytes) {
let out = '';
for (const b of bytes) {
if (b === 0xff) break;
out += CHARS[b] ?? '';
}
return out.trim();
}
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;
for (let i = 0; i < SECTIONS_PER_SLOT; i++) {
const off = base + i * SECTION_SIZE;
if (view.getUint32(off + 0x0ffc, true) !== SIGNATURE) continue;
const id = view.getUint16(off + 0x0ff4, true);
if (id > 13) continue;
sections[id] = off;
counter = Math.max(counter, view.getUint32(off + 0x0ff8, true));
valid++;
}
return { sections, counter, valid };
}
function checksumOk(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);
}
/**
* @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.
*/
export function parseGen3Dex(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.');
}
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));
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);
}
if (!seen.length && !caught.length) {
throw new Error('Read the save, but its Pokédex is empty — is this the right file?');
}
return { game, trainer, seen, caught, nationalUnlocked };
}

View File

@ -46,6 +46,20 @@ export function setNote(id, note) {
});
}
/** Merge in seen/caught species lists (e.g. from a save-file import). Never clears. */
export function importFlags({ seen = [], caught = [] } = {}) {
selection.set((s) => {
const pokemon = { ...s.pokemon };
const stamp = new Date().toISOString();
const bump = (id, patch) => {
pokemon[id] = { ...(pokemon[id] || BLANK), ...patch, updatedAt: stamp };
};
for (const id of seen) bump(id, { seen: true });
for (const id of caught) bump(id, { seen: true, caught: true });
return { ...s, pokemon };
});
}
/** Aggregate seen/caught counts over an arbitrary list of national ids. */
export function stats(speciesIds) {
const p = selection.get().pokemon;

View File

@ -1,9 +1,10 @@
import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js';
import { selection } from '../store/selection.js';
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 { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
export async function SettingsView() {
@ -122,6 +123,36 @@ export async function SettingsView() {
storageNote.textContent = 'Storage estimate not available in this browser.';
}
// Import the Pokédex out of a Gen 3 GBA save (.sav / .srm).
const saveNote = el('p', { class: 'settings__note' });
const saveInput = el('input', {
type: 'file',
accept: '.sav,.srm,.sa1,.fla,application/octet-stream',
style: 'display:none',
onchange: async (e) => {
const file = e.target.files[0];
if (!file) return;
saveNote.textContent = 'Reading…';
try {
const dex = parseGen3Dex(await file.arrayBuffer());
const who = dex.trainer || dex.game;
if (
confirm(
`${dex.game}${dex.trainer ? `${dex.trainer}` : ''}\n${dex.seen.length} seen, ${dex.caught.length} caught.\n\nMerge into your Pokédex? Nothing is removed.`,
)
) {
importFlags({ seen: dex.seen, caught: dex.caught });
saveNote.textContent = `Merged ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`;
} else {
saveNote.textContent = '';
}
} catch (err) {
saveNote.textContent = err.message || 'Could not read that save file.';
}
e.target.value = '';
},
});
const importInput = el('input', {
type: 'file',
accept: 'application/json',
@ -195,6 +226,14 @@ export async function SettingsView() {
' · ',
el('a', { class: 'link', href: '#/shiny' }, 'Shiny hunts'),
),
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.',
),
saveInput,
saveNote,
storageNote,
el('div', { class: 'settings__actions' },
el('button', {