Compare commits

..

No commits in common. "78df1a32e85fce56b4494a077aca88e1e224dd16" and "a437f539fc78994d0ef01fb232153a347559e1ab" have entirely different histories.

10 changed files with 29 additions and 401 deletions

View File

@ -19,12 +19,7 @@ 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**, 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.
tracking, shiny hunts) as JSON lives in **Settings**.
## Scripts

View File

@ -7,7 +7,6 @@ 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;
@ -135,18 +134,20 @@ export async function openGameSheet() {
}
}
const head = el(
'div',
{ class: 'gsheet__head' },
el('h2', {}, 'Choose your game'),
panel.append(
el(
'button',
{ class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close },
'✕',
'div',
{ class: 'gsheet__head' },
el('h2', {}, 'Choose your game'),
el(
'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,7 +3,6 @@ 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;
@ -84,14 +83,16 @@ export async function openPokemonPicker(onPick, { closeAfterPick = false } = {})
}
}
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 }, '✕'),
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,
);
dragToClose(panel, head, close);
panel.append(head, input, results);
backdrop.append(panel);
document.body.append(backdrop);
document.addEventListener('keydown', onKey);

View File

@ -30,28 +30,19 @@ 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, tries = 0) => {
const place = (i) => {
current = i;
const b = seg.querySelectorAll('.seg__btn')[i];
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));
}
if (!b || !b.offsetWidth) return;
thumb.style.width = `${b.offsetWidth}px`;
thumb.style.transform = `translateX(${b.offsetLeft - 3}px)`;
};
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;
}

View File

@ -1,233 +0,0 @@
/**
* 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;

View File

@ -1,43 +0,0 @@
/**
* 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,24 +14,6 @@ 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) {
@ -43,7 +25,6 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
t0 = Date.now();
active = true;
claimed = false;
startNode = e.target;
};
const move = (e) => {
@ -62,15 +43,8 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
active = false;
return;
}
// 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;
}
// Only claim a horizontal page-swipe once the drag is unambiguous.
if (adx > 20 && adx > ady * 2) claimed = true;
}
if (claimed) e.preventDefault();
};

View File

@ -46,20 +46,6 @@ 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,11 +1725,6 @@
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: "";
@ -3352,7 +3347,7 @@
position: relative;
z-index: 1;
}
.seg--thumbed .seg__btn.is-active {
.seg:has(.seg__thumb) .seg__btn.is-active {
background: none;
box-shadow: none;
}

View File

@ -1,10 +1,9 @@
import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js';
import { selection, importFlags } from '../store/selection.js';
import { selection } 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() {
@ -123,36 +122,6 @@ 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',
@ -226,14 +195,6 @@ 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', {