From c1ce972ce9cd69acf6315a00e2d4664ec98868e8 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 10 Sep 2026 10:10:56 -0400 Subject: [PATCH] Save import: add Gen 4 and Gen 5 (NDS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit savedex.js now also reads 512 KB DS saves: - Gen 4 (D/P/Pt, HG/SS): dex struct at slot+0x12 — caught 0x40 bytes at +0x16, four per-form "seen" copies at +0x56/0x96/0xD6/0x116 OR'd together, 493 species. - Gen 5 (B/W, B2/W2): dex block at slot+0x21600 (BW) / +0x21400 (B2W2) — caught at +0x08, four "seen" copies at +0x5C/0xB0/0x104/0x158, 649 species; the offset that validates picks the game. - Handles both save slots (picks the fuller one), a DeSmuME `.dsv` footer, and oversized flash dumps. No block CRC — validated structurally instead (nothing set past the last species, every caught species also seen), so a wrong offset is rejected rather than importing garbage. Offsets are from PKHeX; worth checking against a real save. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- README.md | 12 +-- src/lib/savedex.js | 150 +++++++++++++++++++++++++++++++++++--- src/views/SettingsView.js | 4 +- 3 files changed, 149 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ea8ed43..bd55fc7 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,13 @@ 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 **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. +the Pokédex straight out of a **game save file** (`.sav` / `.srm` / +`.dsv`): Gen 1 (R/B/Y), Gen 2 (G/S/C), Gen 3 (R/S/E, FR/LG), Gen 4 +(D/P/Pt, HG/SS) and Gen 5 (B/W, B2/W2). It reads the game's own +seen/owned bitfields — checksum-picking Gold/Silver vs Crystal, Gen 3's +newer slot and rotated sections, Gen 4/5's active NDS slot (DeSmuME +footer stripped) and OR-ing their per-form "seen" copies — then a +dialog offers to merge or replace. ## Scripts diff --git a/src/lib/savedex.js b/src/lib/savedex.js index bde319b..afb6543 100644 --- a/src/lib/savedex.js +++ b/src/lib/savedex.js @@ -1,10 +1,13 @@ /** - * 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. + * Read the Pokédex (seen / owned) out of a Pokémon save file, Generations + * I–V, as dumped by emulators (`.sav`, `.srm`, DeSmuME `.dsv`). These read + * the game's own dex bitfields — not an approximation. * - * Refs: Bulbapedia "Save data structure (Generation I / II / III)", and - * the pret disassemblies (pokered / pokecrystal / pokeemerald). + * Gen 1–3 offsets are from Bulbapedia and the pret disassemblies and are + * checksum-verified. Gen 4–5 offsets are from PKHeX's SAV4 / SAV5 and are + * validated structurally (nothing set past the last species; every caught + * species is also seen) rather than by CRC — solid, but worth a sanity + * check against a known save. */ /* ------------------------------------------------------------------ * @@ -195,6 +198,128 @@ function tryGen3(bytes) { }; } +/* ------------------------------------------------------------------ * + * Generations IV & V — NDS (512 KB save, two slots @ 0 and 0x40000) + * + * The dex bitfields aren't encrypted (only stored Pokémon are), so it's + * still "read bits at a known offset". Offsets from PKHeX's SAV4 / SAV5. + * Both gens keep several `seen` copies (per gender / form shown in the + * dex) — we OR them for a plain "have I seen it". + * ------------------------------------------------------------------ */ + +const NDS_SLOT = 0x40000; + +// Strip a DeSmuME `.dsv` footer / oversized flash dump down to 512 KB. +function ndsNormalise(bytes) { + if (bytes.length === 0x80000 || bytes.length === 0x40000) return bytes; + if (bytes.length > 0x80000) { + // DeSmuME appends ~0x7A bytes ending with this marker. + const tail = bytes.subarray(bytes.length - 0x100); + const marker = '|-DESMUME SAVE-|'; + let hasMarker = false; + for (let i = 0; i <= tail.length - marker.length; i++) { + let ok = true; + for (let j = 0; j < marker.length; j++) if (tail[i + j] !== marker.charCodeAt(j)) { ok = false; break; } + if (ok) { hasMarker = true; break; } + } + if (hasMarker || bytes.length >= 0x80000) return bytes.subarray(0, 0x80000); + } + return null; +} + +// A bitfield is "sane" for `species` if nothing is set past the last one. +function noOverflow(bytes, start, byteLen, species) { + for (let i = 0; i < byteLen; i++) { + const b = bytes[start + i]; + if (!b) continue; + const firstSpecies = i * 8 + 1; + if (firstSpecies > species) return false; + for (let bit = 0; bit < 8; bit++) { + if (b & (1 << bit) && i * 8 + bit + 1 > species) return false; + } + } + return true; +} + +function orSeen(bytes, base, offsets, byteLen, species) { + const merged = new Uint8Array(byteLen); + for (const o of offsets) for (let i = 0; i < byteLen; i++) merged[i] |= bytes[base + o + i]; + return bitsToList(merged, 0, species); +} + +const GEN4 = { dex: 0x12, caught: 0x16, seen: [0x56, 0x96, 0xd6, 0x116], size: 0x40, species: 493 }; +const GEN5 = { + bw: { dex: 0x21600, label: 'Black / White' }, + b2w2: { dex: 0x21400, label: 'Black 2 / White 2' }, + caught: 0x08, + seen: [0x5c, 0xb0, 0x104, 0x158], + size: 0x54, + species: 649, +}; + +function readGen4Slot(bytes, slotBase) { + const p = slotBase + GEN4.dex; + if (!noOverflow(bytes, p + GEN4.caught, GEN4.size, GEN4.species)) return null; + const caught = bitsToList(bytes, p + GEN4.caught, GEN4.species); + const seen = orSeen(bytes, p, GEN4.seen, GEN4.size, GEN4.species); + if (!seen.length || caught.some((n) => !seen.includes(n))) return null; + return { seen, caught }; +} + +function tryGen4(raw) { + const bytes = ndsNormalise(raw); + if (!bytes) return null; + const hits = []; + for (const base of bytes.length > NDS_SLOT ? [0, NDS_SLOT] : [0]) { + const r = readGen4Slot(bytes, base); + if (r) hits.push(r); + } + if (!hits.length) return null; + const best = hits.reduce((a, b) => (b.caught.length > a.caught.length ? b : a)); + return { + gen: 4, + game: 'Diamond / Pearl / Platinum · HeartGold / SoulSilver', + trainer: '', + seen: best.seen, + caught: best.caught, + nationalUnlocked: true, + checksumOk: true, // validated structurally via noOverflow / caught⊆seen + }; +} + +function readGen5Slot(bytes, slotBase, v) { + const p = slotBase + v.dex; + if (p + GEN5.seen[3] + GEN5.size > bytes.length) return null; + if (!noOverflow(bytes, p + GEN5.caught, GEN5.size, GEN5.species)) return null; + const caught = bitsToList(bytes, p + GEN5.caught, GEN5.species); + const seen = orSeen(bytes, p, GEN5.seen, GEN5.size, GEN5.species); + if (!seen.length || caught.some((n) => !seen.includes(n))) return null; + return { seen, caught, label: v.label }; +} + +function tryGen5(raw) { + const bytes = ndsNormalise(raw); + if (!bytes) return null; + const hits = []; + for (const base of bytes.length > NDS_SLOT ? [0, NDS_SLOT] : [0]) { + for (const v of [GEN5.bw, GEN5.b2w2]) { + const r = readGen5Slot(bytes, base, v); + if (r) hits.push(r); + } + } + if (!hits.length) return null; + const best = hits.reduce((a, b) => (b.caught.length > a.caught.length ? b : a)); + return { + gen: 5, + game: best.label, + trainer: '', + seen: best.seen, + caught: best.caught, + nationalUnlocked: true, + checksumOk: true, + }; +} + /* ------------------------------------------------------------------ * * Public: sniff the file and parse whatever generation it is. * ------------------------------------------------------------------ */ @@ -210,11 +335,16 @@ export function parseSaveDex(buffer) { throw new Error('That file is too small to be a Pokémon save.'); } - // 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)]; + // 512 KB (± DeSmuME footer) → NDS, Gen 4 or 5. + // 128 KB → Gen 3. 32–64 KB → Gen 1 or 2 (checksum picks the winner). + let candidates; + if (bytes.length >= 0x40000) { + candidates = [tryGen5(bytes), tryGen4(bytes), tryGen3(bytes), tryGen2(bytes), tryGen1(bytes)]; + } else if (bytes.length >= SLOT_SIZE + SECTION_SIZE) { + candidates = [tryGen3(bytes), tryGen2(bytes), tryGen1(bytes)]; + } else { + candidates = [tryGen2(bytes), tryGen1(bytes)]; + } const hits = candidates.filter(Boolean); if (!hits.length) { diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js index 085fa5b..ee72f63 100644 --- a/src/views/SettingsView.js +++ b/src/views/SettingsView.js @@ -128,7 +128,7 @@ export async function SettingsView() { const saveNote = el('p', { class: 'settings__note' }); const saveInput = el('input', { type: 'file', - accept: '.sav,.srm,.sa1,.fla,application/octet-stream', + accept: '.sav,.srm,.dsv,.sa1,.fla,application/octet-stream', style: 'display:none', onchange: async (e) => { const file = e.target.files[0]; @@ -242,7 +242,7 @@ export async function SettingsView() { 'div', { class: 'settings__save' }, el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a game save'), - el('p', { class: 'settings__note' }, 'A .sav / .srm from a Gen 1–3 game.'), + el('p', { class: 'settings__note' }, 'A .sav / .srm / .dsv from a Gen 1–5 game.'), ), saveInput, saveNote,