Save import: add Gen 4 and Gen 5 (NDS)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-10 10:10:56 -04:00
parent f71f1842cd
commit c1ce972ce9
3 changed files with 149 additions and 17 deletions

View File

@ -20,11 +20,13 @@ 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 **game save file** (`.sav` / `.srm`): 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 `.dsv`): Gen 1 (R/B/Y), Gen 2 (G/S/C), Gen 3 (R/S/E, FR/LG), Gen 4
game's own seen/owned bitfields — checksum-picking Gold/Silver vs (D/P/Pt, HG/SS) and Gen 5 (B/W, B2/W2). It reads the game's own
Crystal, resolving Gen 3's newer slot and rotated sections — and merges seen/owned bitfields — checksum-picking Gold/Silver vs Crystal, Gen 3's
them in without removing anything. 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 ## Scripts

View File

@ -1,10 +1,13 @@
/** /**
* Read the Pokédex (seen / owned) out of a Game Boy / GBA save file * Read the Pokédex (seen / owned) out of a Pokémon save file, Generations
* Generations I, II and III, as dumped by emulators (`.sav`) or RetroArch * IV, as dumped by emulators (`.sav`, `.srm`, DeSmuME `.dsv`). These read
* (`.srm`). These read the game's own dex bitfields, not an approximation. * the game's own dex bitfields not an approximation.
* *
* Refs: Bulbapedia "Save data structure (Generation I / II / III)", and * Gen 13 offsets are from Bulbapedia and the pret disassemblies and are
* the pret disassemblies (pokered / pokecrystal / pokeemerald). * checksum-verified. Gen 45 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. * 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.'); throw new Error('That file is too small to be a Pokémon save.');
} }
// 128 KB → Gen 3. 3264 KB → Gen 1 or 2 (checksum picks the winner). // 512 KB (± DeSmuME footer) → NDS, Gen 4 or 5.
const candidates = // 128 KB → Gen 3. 3264 KB → Gen 1 or 2 (checksum picks the winner).
bytes.length >= SLOT_SIZE + SECTION_SIZE let candidates;
? [tryGen3(bytes), tryGen2(bytes), tryGen1(bytes)] if (bytes.length >= 0x40000) {
: [tryGen2(bytes), tryGen1(bytes)]; 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); const hits = candidates.filter(Boolean);
if (!hits.length) { if (!hits.length) {

View File

@ -128,7 +128,7 @@ export async function SettingsView() {
const saveNote = el('p', { class: 'settings__note' }); const saveNote = el('p', { class: 'settings__note' });
const saveInput = el('input', { const saveInput = el('input', {
type: 'file', type: 'file',
accept: '.sav,.srm,.sa1,.fla,application/octet-stream', accept: '.sav,.srm,.dsv,.sa1,.fla,application/octet-stream',
style: 'display:none', style: 'display:none',
onchange: async (e) => { onchange: async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
@ -242,7 +242,7 @@ export async function SettingsView() {
'div', 'div',
{ class: 'settings__save' }, { class: 'settings__save' },
el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a game 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 13 game.'), el('p', { class: 'settings__note' }, 'A .sav / .srm / .dsv from a Gen 15 game.'),
), ),
saveInput, saveInput,
saveNote, saveNote,