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
459 lines
15 KiB
JavaScript
459 lines
15 KiB
JavaScript
/**
|
||
* 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;
|