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:
parent
a437f539fc
commit
17e8e30f9b
@ -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
127
src/lib/savedex.js
Normal 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 };
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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', {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user