diff --git a/README.md b/README.md index 779a83f..3ba53af 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/lib/savedex.js b/src/lib/savedex.js new file mode 100644 index 0000000..f31d323 --- /dev/null +++ b/src/lib/savedex.js @@ -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 }; +} diff --git a/src/store/selection.js b/src/store/selection.js index 26414c4..d06064b 100644 --- a/src/store/selection.js +++ b/src/store/selection.js @@ -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; diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js index 1fe7307..a9784f1 100644 --- a/src/views/SettingsView.js +++ b/src/views/SettingsView.js @@ -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', {