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
This commit is contained in:
chris 2026-09-10 12:21:41 -04:00
parent 3f887b2f8a
commit bbc4cb48ca
6 changed files with 140 additions and 12 deletions

View File

@ -142,13 +142,13 @@ marks the Pokémon caught.
**Settings → Import from a game save.** Reads the game's own seen/owned **Settings → Import from a game save.** Reads the game's own seen/owned
bitfields — not an approximation — from: bitfields — not an approximation — from:
| Gen | Games | Format | | Gen | Games | Format |
| --- | -------------------------------------------------- | -------------------------------------------------------- | | --- | -------------------------------------------------- | ----------------------------------------------------------------------- |
| 1 | Red / Blue / Yellow | 32 KB SRAM (`.sav` / `.srm`) | | 1 | Red / Blue / Yellow | 32 KB SRAM (`.sav` / `.srm`) |
| 2 | Gold / Silver / Crystal | 32 KB SRAM — checksum picks G/S vs Crystal | | 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 | | 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 | | 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 | | 5 | Black / White, Black 2 / White 2 | 512 KB NDS — per-form "seen" copies OR'd |
Gen 13 are checksum-verified; Gen 45 offsets come from PKHeX and are Gen 13 are checksum-verified; Gen 45 offsets come from PKHeX and are
validated structurally (nothing set past the last species; every caught 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 one game's bucket — the selected game if the save could be it, otherwise
the parser's best guess. 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 Full local state (settings, per-game tracking, team, form tracking, shiny
hunts, played games) also exports / imports as a single JSON file. hunts, played games) also exports / imports as a single JSON file.

View File

@ -122,10 +122,14 @@ function tryGen1(bytes) {
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
const G2 = { 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: { gs: {
name: 0x200b, name: 0x200b,
owned: 0x2a4c, owned: 0x2a4c,
seen: 0x2a6c, seen: 0x2a6c,
unown: 0x2a8c,
ck: 0x2d69, ck: 0x2d69,
ckFrom: 0x2009, ckFrom: 0x2009,
ckTo: 0x2d68, ckTo: 0x2d68,
@ -136,6 +140,7 @@ const G2 = {
name: 0x2009, name: 0x2009,
owned: 0x2a27, owned: 0x2a27,
seen: 0x2a47, seen: 0x2a47,
unown: 0x2a67,
ck: 0x2d0d, ck: 0x2d0d,
ckFrom: 0x2009, ckFrom: 0x2009,
ckTo: 0x2b82, ckTo: 0x2b82,
@ -145,6 +150,32 @@ const G2 = {
}; };
const G2_SPECIES = 251; 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) { function tryGen2Variant(bytes, v) {
const caught = bitsToList(bytes, v.owned, G2_SPECIES); const caught = bitsToList(bytes, v.owned, G2_SPECIES);
const seen = bitsToList(bytes, v.seen, 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), trainer: decodeName(bytes.subarray(v.name, v.name + 11), GB_CHARS, 0x50),
seen, seen,
caught, 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, nationalUnlocked: true,
checksumOk, checksumOk,
}; };
@ -387,7 +421,7 @@ function tryGen5(raw) {
/** /**
* @param {ArrayBuffer} buffer * @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. * @throws {Error} with a user-facing message if it can't be read.
*/ */
export function parseSaveDex(buffer) { export function parseSaveDex(buffer) {

View File

@ -18,3 +18,13 @@ export function toggleFormCaught(slug) {
caught: { ...s.caught, [slug]: !s.caught[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 };
});
}

View File

@ -4,7 +4,7 @@ import { selection, importFlags, replaceFlags, normalizeSelection } from '../sto
import { prettify } from '../data/pokedex-resolver.js'; import { prettify } from '../data/pokedex-resolver.js';
import { chooseDialog } from '../lib/dialog.js'; import { chooseDialog } from '../lib/dialog.js';
import { team } from '../store/team.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 { shinyHunts } from '../store/shinyHunts.js';
import { playedGames, markPlayed } from '../store/playedGames.js'; import { playedGames, markPlayed } from '../store/playedGames.js';
import { parseSaveDex } from '../lib/savedex.js'; import { parseSaveDex } from '../lib/savedex.js';
@ -180,9 +180,11 @@ export async function SettingsView() {
const target = cands.includes(settings.get().versionGroup) const target = cands.includes(settings.get().versionGroup)
? settings.get().versionGroup ? settings.get().versionGroup
: cands[0]; : cands[0];
const unown = dex.formCaught?.length || 0;
const unownLine = unown ? ` · ${unown} Unown letter${unown === 1 ? '' : 's'}` : '';
const choice = await chooseDialog({ const choice = await chooseDialog({
title: `${dex.game}${dex.trainer ? ` · ${dex.trainer}` : ''}`, 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: [ choices: [
{ key: 'merge', label: `Add to ${prettify(target)}` }, { key: 'merge', label: `Add to ${prettify(target)}` },
{ {
@ -195,12 +197,14 @@ export async function SettingsView() {
}); });
if (choice === 'merge') { if (choice === 'merge') {
importFlags({ seen: dex.seen, caught: dex.caught, game: target }); importFlags({ seen: dex.seen, caught: dex.caught, game: target });
markFormsCaught(dex.formCaught);
markPlayed(...cands); 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') { } else if (choice === 'replace') {
replaceFlags({ seen: dex.seen, caught: dex.caught, game: target }); replaceFlags({ seen: dex.seen, caught: dex.caught, game: target });
markFormsCaught(dex.formCaught);
markPlayed(...cands); 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 { } else {
saveNote.textContent = ''; saveNote.textContent = '';
} }

37
test/formTracking.test.js Normal file
View File

@ -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({});
});
});

View File

@ -59,3 +59,41 @@ describe('parseSaveDex — Gen 1', () => {
expect(() => parseSaveDex(new Uint8Array(0x20000).buffer)).toThrow(); 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([]);
});
});