dex/src/lib/savedex.js
chris bbc4cb48ca Save import: read the Gen 2 Unown Report
Gen 2 (GSC) is the one mainline dex that records which Unown letters
you've caught — a 26-byte ordered list right after the Seen flag array
(seen + 0x20). Parse it, map letters to the app's Form Dex slugs
('unown' for A, 'unown-b'…'unown-z'), and tick them via a new
markFormsCaught() on the formTracking store. A garbage/out-of-range list
is ignored rather than guessed at.

The import dialog and result line now surface "· N Unown letters".

Verified: 6 new unit tests (synthetic Gen 2 SRAM → slugs, empty list,
garbage rejection; markFormsCaught behaviour) + a real end-to-end import
of a synthetic Crystal save writing {unown, unown-c, unown-o, unown-z}
to pdx.formTracking.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 12:21:41 -04:00

459 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Read the Pokédex (seen / owned) out of a Pokémon save file, Generations
* IV, as dumped by emulators (`.sav`, `.srm`, DeSmuME `.dsv`). These read
* the game's own dex bitfields — not an approximation.
*
* Gen 13 offsets are from Bulbapedia and the pret disassemblies and are
* 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.
*/
/* ------------------------------------------------------------------ *
* 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. 3264 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;