Compare commits

..

3 Commits

Author SHA1 Message Date
78df1a32e8 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
2026-09-10 09:44:38 -04:00
5bce0f93f3 Fix swipe hijacking h-scroll; drag bottom sheets down to dismiss
- onSwipe: before claiming a horizontal page-swipe, walk up from the
  touch target for an overflow-x auto/scroll ancestor that can still
  scroll that way (tab strip, moves list, coverage grid) and bail if
  found — sliding those no longer flips to the next Pokémon.
- segThumb: retry placement until the seg is laid out, and observe it for
  resizes, so the sliding pill is positioned on first paint instead of
  only after the first click. Dropped the :has() selector for a
  JS-applied .seg--thumbed class.
- New lib/sheet-drag.js: drag the game sheet / Pokémon picker down by its
  header to dismiss (past ~110px or a flick), else it springs back.
  Header gets touch-action:none + a grab cursor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 09:39:16 -04:00
17e8e30f9b Import the Pokédex from a Gen 3 GBA save (.sav / .srm)
lib/savedex.js parses a Generation III save (Ruby/Sapphire/Emerald/
FireRed/LeafGreen): picks the newer of the two save slots, resolves the
rotated section order via each section's footer, and reads the seen /
owned bitfields out of `struct Pokedex` in section 0 (bit dexNo-1, 386
species). Also decodes the trainer name and detects the national-dex
magic byte. Soft checksum check.

Settings > Your data: "Import from a GBA save" — file picker → confirm →
selection.importFlags() merges seen/caught in (never removes). Verified
against synthetic saves (slot selection, rotation, FRLG vs RSE, bit
decode) and end-to-end through the Settings UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 09:29:34 -04:00
10 changed files with 401 additions and 29 deletions

View File

@ -19,7 +19,12 @@ framework.
- **Sprites** → cache-first with a capped LRU.
Export / import of all local state (settings, tracking, team, form
tracking, shiny hunts) as JSON lives in **Settings**.
tracking, shiny hunts) as JSON lives in **Settings**, which also imports
the Pokédex straight out of a **game save file** (`.sav` / `.srm`):
Gen 1 (R/B/Y), Gen 2 (G/S/C) and Gen 3 (R/S/E, FR/LG). It reads the
game's own seen/owned bitfields — checksum-picking Gold/Silver vs
Crystal, resolving Gen 3's newer slot and rotated sections — and merges
them in without removing anything.
## Scripts

View File

@ -7,6 +7,7 @@ import {
} from '../data/pokedex-resolver.js';
import { settings } from '../store/settings.js';
import { gameColors } from '../data/game-colors.js';
import { dragToClose } from '../lib/sheet-drag.js';
let openInstance = null;
@ -134,20 +135,18 @@ export async function openGameSheet() {
}
}
panel.append(
const head = el(
'div',
{ class: 'gsheet__head' },
el('h2', {}, 'Choose your game'),
el(
'div',
{ class: 'gsheet__head' },
el('h2', {}, 'Choose your game'),
el(
'button',
{ class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close },
'✕',
),
'button',
{ class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close },
'✕',
),
subdex,
list,
);
dragToClose(panel, head, close);
panel.append(head, subdex, list);
backdrop.append(panel);
document.body.append(backdrop);
document.addEventListener('keydown', onKey);

View File

@ -3,6 +3,7 @@ import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { Sprite } from './Sprite.js';
import { TypeChip } from './TypeChip.js';
import { dragToClose } from '../lib/sheet-drag.js';
let openInstance = null;
@ -83,16 +84,14 @@ export async function openPokemonPicker(onPick, { closeAfterPick = false } = {})
}
}
panel.append(
el(
'div',
{ class: 'gsheet__head' },
el('h2', {}, 'Add a Pokémon'),
el('button', { class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close }, '✕'),
),
input,
results,
const head = el(
'div',
{ class: 'gsheet__head' },
el('h2', {}, 'Add a Pokémon'),
el('button', { class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close }, '✕'),
);
dragToClose(panel, head, close);
panel.append(head, input, results);
backdrop.append(panel);
document.body.append(backdrop);
document.addEventListener('keydown', onKey);

View File

@ -30,19 +30,28 @@ export function countUp(node, to, { from = 0, duration = 550, decimals = 0, pref
* reduced-motion users just get an instant jump.
*/
export function segThumb(seg, _count, active = 0) {
seg.classList.add('seg--thumbed');
const thumb = document.createElement('div');
thumb.className = 'seg__thumb';
seg.prepend(thumb);
let current = active;
const place = (i) => {
const place = (i, tries = 0) => {
current = i;
const b = seg.querySelectorAll('.seg__btn')[i];
if (!b || !b.offsetWidth) return;
thumb.style.width = `${b.offsetWidth}px`;
thumb.style.transform = `translateX(${b.offsetLeft - 3}px)`;
if (b && b.offsetWidth) {
thumb.style.width = `${b.offsetWidth}px`;
thumb.style.transform = `translateX(${b.offsetLeft - 3}px)`;
} else if (tries < 20) {
// Not laid out yet (hidden tab, webfont pending) — try again.
requestAnimationFrame(() => place(i, tries + 1));
}
};
requestAnimationFrame(() => place(active));
if (document.fonts?.ready) document.fonts.ready.then(() => place(current));
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => place(current));
ro.observe(seg);
}
return place;
}

233
src/lib/savedex.js Normal file
View File

@ -0,0 +1,233 @@
/**
* Read the Pokédex (seen / owned) out of a Game Boy / GBA save file
* Generations I, II and III, as dumped by emulators (`.sav`) or RetroArch
* (`.srm`). These read the game's own dex bitfields, not an approximation.
*
* Refs: Bulbapedia "Save data structure (Generation I / II / III)", and
* the pret disassemblies (pokered / pokecrystal / pokeemerald).
*/
/* ------------------------------------------------------------------ *
* 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',
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) {
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',
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
* @returns {{ gen:number, game:string, trainer:string, seen:number[], caught:number[], 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.');
}
// 128 KB → Gen 3. 3264 KB → Gen 1 or 2 (checksum picks the winner).
const candidates =
bytes.length >= SLOT_SIZE + SECTION_SIZE
? [tryGen3(bytes), tryGen2(bytes), tryGen1(bytes)]
: [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;

43
src/lib/sheet-drag.js Normal file
View File

@ -0,0 +1,43 @@
/**
* Drag a bottom sheet down by its handle to dismiss it. `panel` is the
* `.gsheet`, `handle` the grab area (its header), `close` the dismiss fn.
* Past ~110px (or a quick flick) it closes; otherwise it springs back.
*/
export function dragToClose(panel, handle, close) {
let y0 = 0;
let dy = 0;
let t0 = 0;
let dragging = false;
const start = (e) => {
if (e.touches.length !== 1) return;
y0 = e.touches[0].clientY;
dy = 0;
t0 = Date.now();
dragging = true;
panel.style.transition = 'none';
};
const move = (e) => {
if (!dragging) return;
dy = e.touches[0].clientY - y0;
// Rubber-band an upward pull; follow a downward one.
const shown = dy < 0 ? dy * 0.25 : dy;
panel.style.transform = `translateY(${shown}px)`;
if (dy > 0) e.preventDefault();
};
const end = () => {
if (!dragging) return;
dragging = false;
panel.style.transition = '';
panel.style.transform = '';
const flick = dy > 40 && Date.now() - t0 < 300;
if (dy > 110 || flick) close();
};
handle.addEventListener('touchstart', start, { passive: true });
handle.addEventListener('touchmove', move, { passive: false });
handle.addEventListener('touchend', end, { passive: true });
handle.addEventListener('touchcancel', end, { passive: true });
}

View File

@ -14,6 +14,24 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
let t0 = 0;
let active = false;
let claimed = false;
let startNode = null;
// Would a horizontal drag from `node` scroll a nested element instead of
// the page? (moves list, tab strip, coverage grid…) `dir` < 0 means the
// finger is moving left — that scrolls such an element to the right.
const scrollsInside = (node, dir) => {
for (let n = node; n && n !== document.body && n !== target.parentElement; n = n.parentElement) {
if (n.scrollWidth - n.clientWidth > 2) {
const ox = getComputedStyle(n).overflowX;
if (ox === 'auto' || ox === 'scroll') {
const max = n.scrollWidth - n.clientWidth;
if (dir < 0 && n.scrollLeft < max - 1) return true;
if (dir > 0 && n.scrollLeft > 1) return true;
}
}
}
return false;
};
const start = (e) => {
if (e.touches.length !== 1) {
@ -25,6 +43,7 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
t0 = Date.now();
active = true;
claimed = false;
startNode = e.target;
};
const move = (e) => {
@ -43,8 +62,15 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
active = false;
return;
}
// Only claim a horizontal page-swipe once the drag is unambiguous.
if (adx > 20 && adx > ady * 2) claimed = true;
// Only claim a horizontal page-swipe once the drag is unambiguous —
// and not when it should scroll something nested instead.
if (adx > 20 && adx > ady * 2) {
if (scrollsInside(startNode, dx)) {
active = false;
return;
}
claimed = true;
}
}
if (claimed) e.preventDefault();
};

View File

@ -46,6 +46,20 @@ export function setNote(id, note) {
});
}
/** Merge in seen/caught species lists (e.g. from a save-file import). Never clears. */
export function importFlags({ seen = [], caught = [] } = {}) {
selection.set((s) => {
const pokemon = { ...s.pokemon };
const stamp = new Date().toISOString();
const bump = (id, patch) => {
pokemon[id] = { ...(pokemon[id] || BLANK), ...patch, updatedAt: stamp };
};
for (const id of seen) bump(id, { seen: true });
for (const id of caught) bump(id, { seen: true, caught: true });
return { ...s, pokemon };
});
}
/** Aggregate seen/caught counts over an arbitrary list of national ids. */
export function stats(speciesIds) {
const p = selection.get().pokemon;

View File

@ -1725,6 +1725,11 @@
justify-content: space-between;
padding: 16px 0 8px;
background: var(--surface);
cursor: grab;
touch-action: none;
}
.gsheet__head:active {
cursor: grabbing;
}
.gsheet__head::before {
content: "";
@ -3347,7 +3352,7 @@
position: relative;
z-index: 1;
}
.seg:has(.seg__thumb) .seg__btn.is-active {
.seg--thumbed .seg__btn.is-active {
background: none;
box-shadow: none;
}

View File

@ -1,9 +1,10 @@
import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js';
import { selection } from '../store/selection.js';
import { selection, importFlags } from '../store/selection.js';
import { team } from '../store/team.js';
import { formTracking } from '../store/formTracking.js';
import { shinyHunts } from '../store/shinyHunts.js';
import { parseSaveDex } from '../lib/savedex.js';
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
export async function SettingsView() {
@ -122,6 +123,36 @@ export async function SettingsView() {
storageNote.textContent = 'Storage estimate not available in this browser.';
}
// Import the Pokédex out of a Gen 3 GBA save (.sav / .srm).
const saveNote = el('p', { class: 'settings__note' });
const saveInput = el('input', {
type: 'file',
accept: '.sav,.srm,.sa1,.fla,application/octet-stream',
style: 'display:none',
onchange: async (e) => {
const file = e.target.files[0];
if (!file) return;
saveNote.textContent = 'Reading…';
try {
const dex = parseSaveDex(await file.arrayBuffer());
const who = dex.trainer || dex.game;
if (
confirm(
`${dex.game}${dex.trainer ? `${dex.trainer}` : ''}\n${dex.seen.length} seen, ${dex.caught.length} caught.\n\nMerge into your Pokédex? Nothing is removed.`,
)
) {
importFlags({ seen: dex.seen, caught: dex.caught });
saveNote.textContent = `Merged ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`;
} else {
saveNote.textContent = '';
}
} catch (err) {
saveNote.textContent = err.message || 'Could not read that save file.';
}
e.target.value = '';
},
});
const importInput = el('input', {
type: 'file',
accept: 'application/json',
@ -195,6 +226,14 @@ export async function SettingsView() {
' · ',
el('a', { class: 'link', href: '#/shiny' }, 'Shiny hunts'),
),
el(
'p',
{ class: 'settings__note' },
el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a game save'),
' — a .sav / .srm from Gen 1 (R/B/Y), Gen 2 (G/S/C) or Gen 3 (R/S/E, FR/LG). Reads the games own Pokédex; merges seen/caught, removes nothing.',
),
saveInput,
saveNote,
storageNote,
el('div', { class: 'settings__actions' },
el('button', {