From bbc4cb48ca72bbb3fb91644e5cb3f563c282bccc Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 10 Sep 2026 12:21:41 -0400 Subject: [PATCH] Save import: read the Gen 2 Unown Report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- README.md | 19 ++++++++++++------- src/lib/savedex.js | 36 +++++++++++++++++++++++++++++++++++- src/store/formTracking.js | 10 ++++++++++ src/views/SettingsView.js | 12 ++++++++---- test/formTracking.test.js | 37 +++++++++++++++++++++++++++++++++++++ test/savedex.test.js | 38 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 test/formTracking.test.js diff --git a/README.md b/README.md index 4f5fe6a..d273a4b 100644 --- a/README.md +++ b/README.md @@ -142,13 +142,13 @@ marks the Pokémon caught. **Settings → Import from a game save.** Reads the game's own seen/owned bitfields — not an approximation — from: -| Gen | Games | Format | -| --- | -------------------------------------------------- | -------------------------------------------------------- | -| 1 | Red / Blue / Yellow | 32 KB SRAM (`.sav` / `.srm`) | -| 2 | Gold / Silver / Crystal | 32 KB SRAM — checksum picks G/S vs Crystal | -| 3 | Ruby / Sapphire / Emerald, FireRed / LeafGreen | 128 KB — newer slot, rotated sections | -| 4 | Diamond / Pearl / Platinum, HeartGold / SoulSilver | 512 KB NDS — active slot, DeSmuME `.dsv` footer stripped | -| 5 | Black / White, Black 2 / White 2 | 512 KB NDS — per-form "seen" copies OR'd | +| Gen | Games | Format | +| --- | -------------------------------------------------- | ----------------------------------------------------------------------- | +| 1 | Red / Blue / Yellow | 32 KB SRAM (`.sav` / `.srm`) | +| 2 | Gold / Silver / Crystal | 32 KB SRAM — checksum picks G/S vs Crystal; also reads the Unown Report | +| 3 | Ruby / Sapphire / Emerald, FireRed / LeafGreen | 128 KB — newer slot, rotated sections | +| 4 | Diamond / Pearl / Platinum, HeartGold / SoulSilver | 512 KB NDS — active slot, DeSmuME `.dsv` footer stripped | +| 5 | Black / White, Black 2 / White 2 | 512 KB NDS — per-form "seen" copies OR'd | Gen 1–3 are checksum-verified; Gen 4–5 offsets come from PKHeX and are validated structurally (nothing set past the last species; every caught @@ -156,6 +156,11 @@ species is also seen). A dialog then offers **merge** or **replace** into one game's bucket — the selected game if the save could be it, otherwise the parser's best guess. +The dex bitfields are per-species, so form sets (Unown letters, Vivillon +patterns…) can't be recovered from them — **except** the Gen 2 Unown +Report, which records the letters you've caught; a Gen 2 import ticks +those in the per-letter Form Dex. + Full local state (settings, per-game tracking, team, form tracking, shiny hunts, played games) also exports / imports as a single JSON file. diff --git a/src/lib/savedex.js b/src/lib/savedex.js index bb75c43..f4f2aff 100644 --- a/src/lib/savedex.js +++ b/src/lib/savedex.js @@ -122,10 +122,14 @@ function tryGen1(bytes) { * ------------------------------------------------------------------ */ 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, @@ -136,6 +140,7 @@ const G2 = { name: 0x2009, owned: 0x2a27, seen: 0x2a47, + unown: 0x2a67, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82, @@ -145,6 +150,32 @@ const G2 = { }; 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); @@ -162,6 +193,9 @@ function tryGen2Variant(bytes, v) { 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, }; @@ -387,7 +421,7 @@ function tryGen5(raw) { /** * @param {ArrayBuffer} buffer - * @returns {{ gen:number, game:string, versionGroups:string[], trainer:string, seen:number[], caught:number[], nationalUnlocked:boolean }} + * @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) { diff --git a/src/store/formTracking.js b/src/store/formTracking.js index 9ea5aa1..a6add14 100644 --- a/src/store/formTracking.js +++ b/src/store/formTracking.js @@ -18,3 +18,13 @@ export function toggleFormCaught(slug) { caught: { ...s.caught, [slug]: !s.caught[slug] }, })); } + +/** Mark a batch of form slugs caught (e.g. Unown letters from a save import). Never un-marks. */ +export function markFormsCaught(slugs) { + if (!slugs || !slugs.length) return; + formTracking.set((s) => { + const caught = { ...s.caught }; + for (const slug of slugs) caught[slug] = true; + return { ...s, caught }; + }); +} diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js index 5a21d9d..35a1702 100644 --- a/src/views/SettingsView.js +++ b/src/views/SettingsView.js @@ -4,7 +4,7 @@ import { selection, importFlags, replaceFlags, normalizeSelection } from '../sto import { prettify } from '../data/pokedex-resolver.js'; import { chooseDialog } from '../lib/dialog.js'; import { team } from '../store/team.js'; -import { formTracking } from '../store/formTracking.js'; +import { formTracking, markFormsCaught } from '../store/formTracking.js'; import { shinyHunts } from '../store/shinyHunts.js'; import { playedGames, markPlayed } from '../store/playedGames.js'; import { parseSaveDex } from '../lib/savedex.js'; @@ -180,9 +180,11 @@ export async function SettingsView() { const target = cands.includes(settings.get().versionGroup) ? settings.get().versionGroup : cands[0]; + const unown = dex.formCaught?.length || 0; + const unownLine = unown ? ` · ${unown} Unown letter${unown === 1 ? '' : 's'}` : ''; const choice = await chooseDialog({ title: `${dex.game}${dex.trainer ? ` · ${dex.trainer}` : ''}`, - body: `${dex.seen.length} seen · ${dex.caught.length} caught in this save.`, + body: `${dex.seen.length} seen · ${dex.caught.length} caught${unownLine} in this save.`, choices: [ { key: 'merge', label: `Add to ${prettify(target)}` }, { @@ -195,12 +197,14 @@ export async function SettingsView() { }); if (choice === 'merge') { importFlags({ seen: dex.seen, caught: dex.caught, game: target }); + markFormsCaught(dex.formCaught); markPlayed(...cands); - saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen to ${prettify(target)}.`; + saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen${unownLine} to ${prettify(target)}.`; } else if (choice === 'replace') { replaceFlags({ seen: dex.seen, caught: dex.caught, game: target }); + markFormsCaught(dex.formCaught); markPlayed(...cands); - saveNote.textContent = `Set ${prettify(target)}'s Pokédex from ${who}.`; + saveNote.textContent = `Set ${prettify(target)}'s Pokédex from ${who}${unownLine ? ` (${unown} Unown letters)` : ''}.`; } else { saveNote.textContent = ''; } diff --git a/test/formTracking.test.js b/test/formTracking.test.js new file mode 100644 index 0000000..05e6052 --- /dev/null +++ b/test/formTracking.test.js @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + formTracking, + isFormCaught, + toggleFormCaught, + markFormsCaught, +} from '../src/store/formTracking.js'; + +beforeEach(() => { + localStorage.clear(); + formTracking.replace({ caught: {} }); +}); + +describe('formTracking', () => { + it('toggle flips a slug', () => { + expect(isFormCaught('unown-c')).toBe(false); + toggleFormCaught('unown-c'); + expect(isFormCaught('unown-c')).toBe(true); + toggleFormCaught('unown-c'); + expect(isFormCaught('unown-c')).toBe(false); + }); + + it('markFormsCaught sets a batch and never un-marks', () => { + toggleFormCaught('unown-z'); // already caught + markFormsCaught(['unown', 'unown-c', 'unown-z']); + expect(isFormCaught('unown')).toBe(true); + expect(isFormCaught('unown-c')).toBe(true); + expect(isFormCaught('unown-z')).toBe(true); // stays caught + expect(isFormCaught('unown-q')).toBe(false); // untouched + }); + + it('markFormsCaught tolerates empty / missing input', () => { + markFormsCaught([]); + markFormsCaught(undefined); + expect(formTracking.get().caught).toEqual({}); + }); +}); diff --git a/test/savedex.test.js b/test/savedex.test.js index bff0034..f9021db 100644 --- a/test/savedex.test.js +++ b/test/savedex.test.js @@ -59,3 +59,41 @@ describe('parseSaveDex — Gen 1', () => { expect(() => parseSaveDex(new Uint8Array(0x20000).buffer)).toThrow(); }); }); + +// --- Synthetic Gen 2 (Crystal) SRAM --------------------------------- +// Offsets mirror src/lib/savedex.js `G2.cr`. +const G2 = { owned: 0x2a27, seen: 0x2a47, unown: 0x2a67, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82 }; + +function makeGen2Save({ caught, seen, unown = [] }) { + const bytes = new Uint8Array(0x8000); + setBits(bytes, G2.owned, caught); + setBits(bytes, G2.seen, seen); + unown.forEach((letter, i) => (bytes[G2.unown + i] = letter)); // 1 = A … 26 = Z + let sum = 0; + for (let i = G2.ckFrom; i <= G2.ckTo; i++) sum = (sum + bytes[i]) & 0xffffffff; + bytes[G2.ck] = sum & 0xff; + bytes[G2.ck + 1] = (sum >> 8) & 0xff; + return bytes.buffer; +} + +describe('parseSaveDex — Gen 2 Unown Report', () => { + it('turns the caught-letter list into Form Dex slugs', () => { + const dex = parseSaveDex( + makeGen2Save({ caught: [1, 25], seen: [1, 25, 201], unown: [3, 1, 20] }), // C, A, T + ); + expect(dex.gen).toBe(2); + expect(dex.game).toBe('Crystal'); + expect(dex.checksumOk).toBe(true); + expect([...dex.formCaught].sort()).toEqual(['unown', 'unown-c', 'unown-t']); + }); + + it('no Unown caught → empty list', () => { + const dex = parseSaveDex(makeGen2Save({ caught: [1], seen: [1], unown: [] })); + expect(dex.formCaught).toEqual([]); + }); + + it('ignores a garbage list rather than ticking wrong boxes', () => { + const dex = parseSaveDex(makeGen2Save({ caught: [1], seen: [1], unown: [3, 99, 5] })); + expect(dex.formCaught).toEqual([]); + }); +});