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:
parent
3f887b2f8a
commit
bbc4cb48ca
@ -143,9 +143,9 @@ marks the Pokémon caught.
|
||||
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 |
|
||||
| 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 |
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 = '';
|
||||
}
|
||||
|
||||
37
test/formTracking.test.js
Normal file
37
test/formTracking.test.js
Normal 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({});
|
||||
});
|
||||
});
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user