/** * 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. * * 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. */ /* ------------------------------------------------------------------ * * Shared helpers * ------------------------------------------------------------------ */ const bitsToList = (bytes, start, count) => { const list = []; for (let n = 1; n <= count; n++) { if (bytes[start + ((n - 1) >> 3)] & (1 << ((n - 1) & 7))) list.push(n); } return list; }; // GB (Gen 1 & 2) in-game text -> ASCII, enough for trainer names. const GB_CHARS = (() => { const t = { 0x7f: ' ', 0x50: '\0' }; const up = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; const lo = 'abcdefghijklmnopqrstuvwxyz'; for (let i = 0; i < 26; i++) { t[0x80 + i] = up[i]; t[0xa0 + i] = lo[i]; } for (let i = 0; i < 10; i++) t[0xf6 + i] = String(i); Object.assign(t, { 0xe8: '.', 0xe3: '-', 0xf3: '/', 0xf4: ',', 0x9a: '(', 0x9b: ')', 0xe6: '?', 0xe7: '!', }); return t; })(); // GBA (Gen 3) in-game text -> ASCII. const GBA_CHARS = (() => { const t = { 0x00: ' ' }; for (let i = 0; i < 10; i++) t[0xa1 + i] = String(i); 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: '-', 0xba: '/', 0xac: ',', 0xb8: '&', }); return t; })(); function decodeName(bytes, table, terminator) { let out = ''; for (const b of bytes) { if (b === terminator) break; const c = table[b]; if (c === '\0' || c === undefined) { if (c === '\0') break; continue; } out += c; } return out.trim(); } const sumBytes = (bytes, from, to) => { let s = 0; for (let i = from; i <= to; i++) s = (s + bytes[i]) & 0xffffffff; return s >>> 0; }; /* ------------------------------------------------------------------ * * Generation I — Red / Blue / Yellow (International, 32 KB SRAM) * ------------------------------------------------------------------ */ const G1 = { name: 0x2598, owned: 0x25a3, seen: 0x25b6, species: 151, ckByte: 0x3523, ckFrom: 0x2598, ckTo: 0x3522, }; function tryGen1(bytes) { if (bytes.length < 0x8000) return null; const caught = bitsToList(bytes, G1.owned, G1.species); const seen = bitsToList(bytes, G1.seen, G1.species); const checksumOk = (~sumBytes(bytes, G1.ckFrom, G1.ckTo) & 0xff) === bytes[G1.ckByte]; if (!seen.length && !caught.length) return null; return { gen: 1, game: 'Red / Blue / Yellow', versionGroups: ['red-blue', 'yellow'], trainer: decodeName(bytes.subarray(G1.name, G1.name + 11), GB_CHARS, 0x50), seen, caught, nationalUnlocked: true, checksumOk, }; } /* ------------------------------------------------------------------ * * Generation II — Gold / Silver / Crystal (International, 32 KB SRAM) * ------------------------------------------------------------------ */ const G2 = { // `unown` is the Unown Report list — the letter forms caught, in // first-caught order, one byte each (1 = A … 26 = Z), zero-terminated. It // sits right after the 32-byte Seen flag array (`unown === seen + 0x20`). gs: { name: 0x200b, owned: 0x2a4c, seen: 0x2a6c, unown: 0x2a8c, ck: 0x2d69, ckFrom: 0x2009, ckTo: 0x2d68, label: 'Gold / Silver', vgs: ['gold-silver'], }, cr: { name: 0x2009, owned: 0x2a27, seen: 0x2a47, unown: 0x2a67, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82, label: 'Crystal', vgs: ['crystal'], }, }; const G2_SPECIES = 251; // Letter index (1 = A) → the app's cosmetic-form slug. The base "A" form is // tracked under the bare species slug; B…Z are `unown-b` … `unown-z`. const unownSlug = (n) => (n === 1 ? 'unown' : `unown-${String.fromCharCode(96 + n)}`); /** * Read the Gen 2 Unown Report. Returns the caught letters as form slugs, or * `null` if the bytes don't look like a valid ordered list (wrong offset, * corruption) — a species-level import shouldn't tick form boxes on a guess. */ function readUnownDex(bytes, offset) { const slugs = []; const seen = new Set(); let ended = false; for (let i = 0; i < 26; i++) { const b = bytes[offset + i]; if (b === 0) { ended = true; continue; } if (ended || b > 26 || seen.has(b)) return null; seen.add(b); slugs.push(unownSlug(b)); } return slugs; } function tryGen2Variant(bytes, v) { const caught = bitsToList(bytes, v.owned, G2_SPECIES); const seen = bitsToList(bytes, v.seen, G2_SPECIES); if (!seen.length && !caught.length) return null; // No species may be "caught" but not "seen", and the last used byte must // not spill past species 251 — cheap sanity checks for the right offsets. if (caught.some((n) => !seen.includes(n))) return null; if (bytes[v.owned + 31] & 0xf8 || bytes[v.seen + 31] & 0xf8) return null; const stored = bytes[v.ck] | (bytes[v.ck + 1] << 8); const checksumOk = (sumBytes(bytes, v.ckFrom, v.ckTo) & 0xffff) === stored; return { gen: 2, game: v.label, versionGroups: v.vgs, trainer: decodeName(bytes.subarray(v.name, v.name + 11), GB_CHARS, 0x50), seen, caught, // Gen 2 is the only mainline dex that records which Unown letters you // have — tick them in the app's per-letter Form Dex. formCaught: readUnownDex(bytes, v.unown) || [], nationalUnlocked: true, checksumOk, }; } function tryGen2(bytes) { if (bytes.length < 0x8000) return null; const gs = tryGen2Variant(bytes, G2.gs); const cr = tryGen2Variant(bytes, G2.cr); if (gs?.checksumOk) return gs; if (cr?.checksumOk) return cr; return gs || cr || null; } /* ------------------------------------------------------------------ * * Generation III — RSE / FRLG (128 KB, two slots, rotated sections) * ------------------------------------------------------------------ */ 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, ]; const DEX_OFFSET = 0x18; const DEX_MAGIC = DEX_OFFSET + 2; // 0xDA = RSE national, 0xB9 = FRLG national const G3_OWNED = DEX_OFFSET + 16; const G3_SEEN = DEX_OFFSET + 68; const G3_SPECIES = 386; function readSlot(view, base) { 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 g3ChecksumOk(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; return (((sum >>> 16) + (sum & 0xffff)) & 0xffff) === view.getUint16(sectionOff + 0x0ff6, true); } function tryGen3(bytes) { if (bytes.length < SLOT_SIZE + SECTION_SIZE) return null; const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); 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) return null; const slot = b.counter > a.counter ? b : a; const s0 = slot.sections[0]; if (s0 == null) return null; const magic = bytes[s0 + DEX_MAGIC]; const seen = bitsToList(bytes, s0 + G3_SEEN, G3_SPECIES); const caught = bitsToList(bytes, s0 + G3_OWNED, G3_SPECIES); if (!seen.length && !caught.length) return null; return { gen: 3, game: magic === 0xb9 ? 'FireRed / LeafGreen' : 'Ruby / Sapphire / Emerald', versionGroups: magic === 0xb9 ? ['firered-leafgreen'] : ['ruby-sapphire', 'emerald'], trainer: decodeName(bytes.subarray(s0, s0 + 7), GBA_CHARS, 0xff), seen, caught, nationalUnlocked: magic === 0xda || magic === 0xb9, checksumOk: g3ChecksumOk(view, s0, 0), }; } /* ------------------------------------------------------------------ * * 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', versionGroups: ['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, vg: v.dex === GEN5.bw.dex ? 'black-white' : 'black-2-white-2', }; } 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, versionGroups: [best.vg], trainer: '', seen: best.seen, caught: best.caught, nationalUnlocked: true, checksumOk: true, }; } /* ------------------------------------------------------------------ * * Public: sniff the file and parse whatever generation it is. * ------------------------------------------------------------------ */ /** * @param {ArrayBuffer} buffer * @returns {{ gen:number, game:string, versionGroups:string[], trainer:string, seen:number[], caught:number[], formCaught?:string[], nationalUnlocked:boolean }} * @throws {Error} with a user-facing message if it can't be read. */ export function parseSaveDex(buffer) { const bytes = new Uint8Array(buffer); if (bytes.length < 0x8000) { throw new Error('That file is too small to be a Pokémon save.'); } // 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) { throw new Error( "Couldn't find a Pokédex in that file — it may be a game this doesn't support yet, a corrupted save, or a Japanese cartridge.", ); } const best = hits.find((h) => h.checksumOk) || hits[0]; if (!best.checksumOk) { console.warn('savedex: no checksum matched — importing the most likely reading anyway.'); } return best; } // Back-compat: earlier code imported this name. export const parseGen3Dex = parseSaveDex;