Save import: add Gen 1 and Gen 2
savedex.js now sniffs the file and parses whichever generation it is: - Gen 1 (R/B/Y): 32 KB SRAM, fixed offsets — owned 0x25A3, seen 0x25B6 (151 species), name 0x2598, one-byte complement checksum. - Gen 2 (G/S/C): tries Gold/Silver offsets (0x2A4C/0x2A6C, checksum 0x2D69) and Crystal (0x2A27/0x2A47, checksum 0x2D0D); the 16-bit checksum picks which, with a caught⊆seen / no-overflow sanity fallback. - Gen 3: unchanged (parseGen3Dex kept as an alias). parseSaveDex(buffer) is the new entry point; the Settings picker relabels to "Import from a game save". Verified against synthetic Gen 1 / GS / Crystal saves and end-to-end through the UI (a Gen 1 save merged 11 caught / 84 seen). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
parent
5bce0f93f3
commit
78df1a32e8
@ -20,10 +20,11 @@ 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 **Generation III save file** (`.sav` /
|
the Pokédex straight out of a **game save file** (`.sav` / `.srm`):
|
||||||
`.srm`, Ruby/Sapphire/Emerald/FireRed/LeafGreen) — it reads the game's
|
Gen 1 (R/B/Y), Gen 2 (G/S/C) and Gen 3 (R/S/E, FR/LG). It reads the
|
||||||
own seen/owned bitfields (newer save slot, section rotation and all) and
|
game's own seen/owned bitfields — checksum-picking Gold/Silver vs
|
||||||
merges them in.
|
Crystal, resolving Gen 3's newer slot and rotated sections — and merges
|
||||||
|
them in without removing anything.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
|
|||||||
@ -1,38 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* Read the Pokédex (seen / owned) out of a Generation III save file —
|
* Read the Pokédex (seen / owned) out of a Game Boy / GBA save file —
|
||||||
* Ruby, Sapphire, Emerald, FireRed, LeafGreen — as dumped by emulators
|
* Generations I, II and III, as dumped by emulators (`.sav`) or RetroArch
|
||||||
* (`.sav`) or RetroArch (`.srm`). Wild-only importers this is not: it
|
* (`.srm`). These read the game's own dex bitfields, not an approximation.
|
||||||
* pulls the actual dex bitfields the game keeps.
|
|
||||||
*
|
*
|
||||||
* Layout (Bulbapedia "Save data structure (Generation III)", pret):
|
* Refs: Bulbapedia "Save data structure (Generation I / II / III)", and
|
||||||
* - 128 KB file = two 57 344-byte save slots (A @ 0, B @ 0xE000).
|
* the pret disassemblies (pokered / pokecrystal / pokeemerald).
|
||||||
* - Each slot = 14 x 4096-byte sections, stored rotated; a section's real
|
|
||||||
* id and the slot's save counter live in its last 128 bytes.
|
|
||||||
* - Use the slot with the higher save counter. Section 0 (trainer info)
|
|
||||||
* holds `struct Pokedex` at offset 0x18: nat-dex magic at +2, then
|
|
||||||
* owned[52] at +16, seen[52] at +68. Bit (dexNo-1) per species.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SECTION_SIZE = 4096;
|
/* ------------------------------------------------------------------ *
|
||||||
const SECTIONS_PER_SLOT = 14;
|
* Shared helpers
|
||||||
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,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Section 0 -> struct Pokedex
|
const bitsToList = (bytes, start, count) => {
|
||||||
const DEX_OFFSET = 0x18;
|
const list = [];
|
||||||
const DEX_MAGIC = DEX_OFFSET + 2; // 0xDA = RSE national, 0xB9 = FRLG national
|
for (let n = 1; n <= count; n++) {
|
||||||
const OWNED = DEX_OFFSET + 16;
|
if (bytes[start + ((n - 1) >> 3)] & (1 << ((n - 1) & 7))) list.push(n);
|
||||||
const SEEN = DEX_OFFSET + 68;
|
}
|
||||||
const MAX_DEX = 386;
|
return list;
|
||||||
|
};
|
||||||
|
|
||||||
// Gen 3 in-game text -> ASCII, enough for trainer names.
|
// GB (Gen 1 & 2) in-game text -> ASCII, enough for trainer names.
|
||||||
const CHARS = (() => {
|
const GB_CHARS = (() => {
|
||||||
const t = {};
|
const t = { 0x7f: ' ', 0x50: '\0' };
|
||||||
t[0x00] = ' ';
|
const up = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
for (let i = 0; i < 10; i++) t[0xa1 + i] = String(i); // 0-9
|
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 up = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
const lo = 'abcdefghijklmnopqrstuvwxyz';
|
const lo = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
for (let i = 0; i < 26; i++) {
|
for (let i = 0; i < 26; i++) {
|
||||||
@ -40,23 +44,110 @@ const CHARS = (() => {
|
|||||||
t[0xd5 + i] = lo[i];
|
t[0xd5 + i] = lo[i];
|
||||||
}
|
}
|
||||||
Object.assign(t, {
|
Object.assign(t, {
|
||||||
0xad: '.', 0xae: '-', 0xb0: '…', 0xb1: '“', 0xb2: '”', 0xb3: '‘', 0xb4: '’',
|
0xad: '.', 0xae: '-', 0xba: '/', 0xac: ',', 0xb8: '&',
|
||||||
0xba: '/', 0xac: ',', 0xb8: '&', 0x2d: '=',
|
|
||||||
});
|
});
|
||||||
return t;
|
return t;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function decodeName(bytes) {
|
function decodeName(bytes, table, terminator) {
|
||||||
let out = '';
|
let out = '';
|
||||||
for (const b of bytes) {
|
for (const b of bytes) {
|
||||||
if (b === 0xff) break;
|
if (b === terminator) break;
|
||||||
out += CHARS[b] ?? '';
|
const c = table[b];
|
||||||
|
if (c === '\0' || c === undefined) {
|
||||||
|
if (c === '\0') break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += c;
|
||||||
}
|
}
|
||||||
return out.trim();
|
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',
|
||||||
|
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 = {
|
||||||
|
gs: { name: 0x200b, owned: 0x2a4c, seen: 0x2a6c, ck: 0x2d69, ckFrom: 0x2009, ckTo: 0x2d68, label: 'Gold / Silver' },
|
||||||
|
cr: { name: 0x2009, owned: 0x2a27, seen: 0x2a47, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82, label: 'Crystal' },
|
||||||
|
};
|
||||||
|
const G2_SPECIES = 251;
|
||||||
|
|
||||||
|
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,
|
||||||
|
trainer: decodeName(bytes.subarray(v.name, v.name + 11), GB_CHARS, 0x50),
|
||||||
|
seen,
|
||||||
|
caught,
|
||||||
|
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) {
|
function readSlot(view, base) {
|
||||||
// Map real section id -> byte offset, and read the slot's save counter.
|
|
||||||
const sections = {};
|
const sections = {};
|
||||||
let counter = -1;
|
let counter = -1;
|
||||||
let valid = 0;
|
let valid = 0;
|
||||||
@ -72,56 +163,71 @@ function readSlot(view, base) {
|
|||||||
return { sections, counter, valid };
|
return { sections, counter, valid };
|
||||||
}
|
}
|
||||||
|
|
||||||
function checksumOk(view, sectionOff, id) {
|
function g3ChecksumOk(view, sectionOff, id) {
|
||||||
const size = SECTION_DATA_SIZE[id] ?? 3968;
|
const size = SECTION_DATA_SIZE[id] ?? 3968;
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (let i = 0; i < size; i += 4) sum = (sum + view.getUint32(sectionOff + i, true)) >>> 0;
|
for (let i = 0; i < size; i += 4) sum = (sum + view.getUint32(sectionOff + i, true)) >>> 0;
|
||||||
const folded = ((sum >>> 16) + (sum & 0xffff)) & 0xffff;
|
return (((sum >>> 16) + (sum & 0xffff)) & 0xffff) === view.getUint16(sectionOff + 0x0ff6, true);
|
||||||
return folded === 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',
|
||||||
|
trainer: decodeName(bytes.subarray(s0, s0 + 7), GBA_CHARS, 0xff),
|
||||||
|
seen,
|
||||||
|
caught,
|
||||||
|
nationalUnlocked: magic === 0xda || magic === 0xb9,
|
||||||
|
checksumOk: g3ChecksumOk(view, s0, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Public: sniff the file and parse whatever generation it is.
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ArrayBuffer} buffer
|
* @param {ArrayBuffer} buffer
|
||||||
* @returns {{ game: string, trainer: string, seen: number[], caught: number[], nationalUnlocked: boolean }}
|
* @returns {{ gen:number, game:string, trainer:string, seen:number[], caught:number[], nationalUnlocked:boolean }}
|
||||||
* @throws {Error} with a user-facing message if it isn't a Gen 3 save.
|
* @throws {Error} with a user-facing message if it can't be read.
|
||||||
*/
|
*/
|
||||||
export function parseGen3Dex(buffer) {
|
export function parseSaveDex(buffer) {
|
||||||
const bytes = new Uint8Array(buffer);
|
const bytes = new Uint8Array(buffer);
|
||||||
if (bytes.length < SLOT_SIZE + SECTION_SIZE) {
|
if (bytes.length < 0x8000) {
|
||||||
throw new Error('That file is too small to be a Game Boy Advance save.');
|
throw new Error('That file is too small to be a Pokémon save.');
|
||||||
}
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
|
|
||||||
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) {
|
|
||||||
throw new Error("This doesn't look like a Generation III (Ruby/Sapphire/Emerald/FireRed/LeafGreen) save.");
|
|
||||||
}
|
|
||||||
const slot = b.counter > a.counter ? b : a;
|
|
||||||
const s0 = slot.sections[0];
|
|
||||||
if (s0 == null) throw new Error('The save is missing its trainer-info section.');
|
|
||||||
|
|
||||||
// Soft integrity check — warn via console, still import.
|
|
||||||
if (!checksumOk(view, s0, 0)) {
|
|
||||||
console.warn('savedex: section 0 checksum mismatch — importing anyway.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const magic = bytes[s0 + DEX_MAGIC];
|
// 128 KB → Gen 3. 32–64 KB → Gen 1 or 2 (checksum picks the winner).
|
||||||
const nationalUnlocked = magic === 0xda || magic === 0xb9;
|
const candidates =
|
||||||
const game = magic === 0xb9 ? 'FireRed / LeafGreen' : 'Ruby / Sapphire / Emerald';
|
bytes.length >= SLOT_SIZE + SECTION_SIZE
|
||||||
const trainer = decodeName(bytes.subarray(s0, s0 + 7));
|
? [tryGen3(bytes), tryGen2(bytes), tryGen1(bytes)]
|
||||||
|
: [tryGen2(bytes), tryGen1(bytes)];
|
||||||
|
|
||||||
const seen = [];
|
const hits = candidates.filter(Boolean);
|
||||||
const caught = [];
|
if (!hits.length) {
|
||||||
for (let n = 1; n <= MAX_DEX; n++) {
|
throw new Error(
|
||||||
const byte = (n - 1) >> 3;
|
"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 mask = 1 << ((n - 1) & 7);
|
);
|
||||||
if (bytes[s0 + SEEN + byte] & mask) seen.push(n);
|
|
||||||
if (bytes[s0 + OWNED + byte] & mask) caught.push(n);
|
|
||||||
}
|
}
|
||||||
if (!seen.length && !caught.length) {
|
const best = hits.find((h) => h.checksumOk) || hits[0];
|
||||||
throw new Error('Read the save, but its Pokédex is empty — is this the right file?');
|
if (!best.checksumOk) {
|
||||||
|
console.warn('savedex: no checksum matched — importing the most likely reading anyway.');
|
||||||
}
|
}
|
||||||
|
return best;
|
||||||
return { game, trainer, seen, caught, nationalUnlocked };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Back-compat: earlier code imported this name.
|
||||||
|
export const parseGen3Dex = parseSaveDex;
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { selection, importFlags } from '../store/selection.js';
|
|||||||
import { team } from '../store/team.js';
|
import { team } from '../store/team.js';
|
||||||
import { formTracking } from '../store/formTracking.js';
|
import { formTracking } from '../store/formTracking.js';
|
||||||
import { shinyHunts } from '../store/shinyHunts.js';
|
import { shinyHunts } from '../store/shinyHunts.js';
|
||||||
import { parseGen3Dex } from '../lib/savedex.js';
|
import { parseSaveDex } from '../lib/savedex.js';
|
||||||
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
|
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
|
||||||
|
|
||||||
export async function SettingsView() {
|
export async function SettingsView() {
|
||||||
@ -134,7 +134,7 @@ export async function SettingsView() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
saveNote.textContent = 'Reading…';
|
saveNote.textContent = 'Reading…';
|
||||||
try {
|
try {
|
||||||
const dex = parseGen3Dex(await file.arrayBuffer());
|
const dex = parseSaveDex(await file.arrayBuffer());
|
||||||
const who = dex.trainer || dex.game;
|
const who = dex.trainer || dex.game;
|
||||||
if (
|
if (
|
||||||
confirm(
|
confirm(
|
||||||
@ -229,8 +229,8 @@ export async function SettingsView() {
|
|||||||
el(
|
el(
|
||||||
'p',
|
'p',
|
||||||
{ class: 'settings__note' },
|
{ class: 'settings__note' },
|
||||||
el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a GBA save'),
|
el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a game save'),
|
||||||
' — Gen 3 .sav / .srm (Ruby · Sapphire · Emerald · FireRed · LeafGreen). Merges seen/caught, removes nothing.',
|
' — a .sav / .srm from Gen 1 (R/B/Y), Gen 2 (G/S/C) or Gen 3 (R/S/E, FR/LG). Reads the game’s own Pokédex; merges seen/caught, removes nothing.',
|
||||||
),
|
),
|
||||||
saveInput,
|
saveInput,
|
||||||
saveNote,
|
saveNote,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user