From 7d03f5c5ca0f73a4d720688fe01edc42130da450 Mon Sep 17 00:00:00 2001 From: kawa Date: Fri, 16 Oct 2026 11:27:41 +0200 Subject: [PATCH] Keep several saves, with migration, atomic writes and import previews (Closes #37) Savegames now lists independent save slots made with the loaded ROM: new game, continue, rename, duplicate, export and delete. The native database moves to IndexedDB version 2; the singleton save becomes "Save 1" inside the upgrade transaction, so it migrates exactly once and the old store is left untouched. Each write adds a generation and drops older ones in one transaction, keeping two records with CRC-32 checksums: a damaged newest record falls back to the previous one. Slots and records from a newer player are kept and never written. Imports are checked in a scratch world before anything changes and previewed with the trainer, progress, saved time, game data version and the slots of the same trainer; they go into a new save or replace one, whose previous record stays recoverable. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- docs/ARCHITECTURE.md | 4 +- docs/NATIVE_PORT.md | 2 + docs/PLAN.md | 11 +- src/app/slot-session.ts | 488 +++++++++++++++++++++++++++++++ src/game/runtime.ts | 30 ++ src/game/world/world.ts | 29 ++ src/main.ts | 397 ++++++++++++++++++++++--- src/storage/slots.ts | 335 +++++++++++++++++++++ src/ui/shell.ts | 12 +- src/ui/styles.css | 130 ++++++++ tests/battle-browser.spec.ts | 2 +- tests/human-test-saves.test.ts | 6 +- tests/native-browser.spec.ts | 10 +- tests/native.test.ts | 48 +++ tests/quest-browser.spec.ts | 2 +- tests/save-slots-browser.spec.ts | 167 +++++++++++ tests/save-slots.test.ts | 454 ++++++++++++++++++++++++++++ 18 files changed, 2079 insertions(+), 56 deletions(-) create mode 100644 src/app/slot-session.ts create mode 100644 src/storage/slots.ts create mode 100644 tests/save-slots-browser.spec.ts create mode 100644 tests/save-slots.test.ts diff --git a/README.md b/README.md index 0e26b10..2b09864 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included. -**Current milestone: all of Johto, from the opening to Blackthorn City, the Rising Badge and the Battle Tower.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data. +**Current milestone: all of Johto, from the opening to Blackthorn City, the Rising Badge and the Battle Tower.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Savegames keeps several independent playthroughs: start a new game, continue, rename, duplicate, export or delete any save, and preview a backup before importing it. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data. Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming. @@ -47,9 +47,9 @@ npm run dev Open the printed local URL and select your uncompressed **English US/Europe revision 1.1** ROM (`.gbc`, 2 MiB). Its checksum is verified locally. ROMs are never uploaded or stored in browser storage; select yours again after reloading to resume. A new game starts with Professor Oak's introduction, where you choose your character, set the clock and pick a name. -Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. **Settings** changes these keys, the text size and the sound volume for this browser. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Sessions save every 10 seconds and when paused. Switching apps pauses play. Download a backup before clearing browser data. +Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. **Settings** changes these keys, the text size and the sound volume for this browser. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Games save every 10 seconds and when paused, each into its own save. Switching apps pauses play. Download a backup before clearing browser data. -Native saves are separate from emulator snapshots and cartridge `.sav` files. Saving during a scene keeps the progress from before it, so the scene replays after loading. The time you give Oak is kept as an offset from the device clock (the steppers start at the device's time), and the weekday you give Mom as an offset from that clock's weekday. The tile counter measures in-game movement, not real-world steps. +Native saves are separate from emulator snapshots and cartridge `.sav` files. Each save keeps its newest and previous write; if the newest was interrupted or damaged, the previous one loads and nothing is overwritten. The single save of earlier versions becomes "Save 1" the first time this version opens it. Saves and backups from a newer version are kept as they are and refused, never rewritten. Saving during a scene keeps the progress from before it, so the scene replays after loading. The time you give Oak is kept as an offset from the device clock (the steppers start at the device's time), and the weekday you give Mom as an offset from that clock's weekday. The tile counter measures in-game movement, not real-world steps. ## Reference emulator @@ -87,7 +87,7 @@ CRYSTAL_ROM_PATH=/nonexistent/crystal-ci.gbc npm run test:e2e -- --workers=2 `git config core.hooksPath .githooks` keeps the forge's project board in step with commits: `Closes #N` in a message moves card N to Fait and closes it, any other `#N` moves it to En cours, and a commit on `main` without a reference adds a closed card to Fait. `[no-card]` skips a commit. See [project-board.mjs](scripts/project-board.mjs). -The [full port plan](docs/PLAN.md) covers remaining game systems, save slots, the world map, Pokédex, internet Link, friends and optional mobile step support for egg hatching. See the [architecture](docs/ARCHITECTURE.md) and [routine map and verification](docs/NATIVE_PORT.md) for module boundaries and reference checks. +The [full port plan](docs/PLAN.md) covers remaining game systems, the world map, Pokédex, internet Link, friends and optional mobile step support for egg hatching. See the [architecture](docs/ARCHITECTURE.md) and [routine map and verification](docs/NATIVE_PORT.md) for module boundaries and reference checks. References: PRET [documentation](https://pret.github.io/pokecrystal/), [wiki](https://github.com/pret/pokecrystal/wiki) and [source](https://github.com/pret/pokecrystal). The development reference uses [binjgb](https://github.com/binji/binjgb) under its [MIT license](reference/public/vendor/binjgb/LICENSE); see [provenance](reference/public/vendor/binjgb/PROVENANCE.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0ac8952..be3205b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,7 +29,7 @@ flowchart LR | `src/game/runtime.ts` | 30 Hz tick scheduling from real time, input routing and presentation updates | | `src/input` | Abstract buttons, multiple sources and browser bindings | | `src/app` | Runtime-independent load/play/pause/backup lifecycle | -| `src/storage` | Versioned envelope, IndexedDB transactions and ordered writes | +| `src/storage` | Versioned envelope, save slots with atomic generations and checksums, the singleton migration | | `src/ui`, `src/main.ts` | Accessible game tabs, UI composition and DOM dialogue, no ROM offsets | | `src/platform` | Browser locks, the installable app (service worker, install, storage) and future native adapters | | `src/reference`, `src/emulator` | Original-game reference, excluded from the native build | @@ -39,7 +39,7 @@ flowchart LR Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival. -Native and reference sessions use different IndexedDB databases and Web Locks. Imports reject incompatible schemas and invalid positions before changing the world. A failed import restores the prior state. Original cartridge saves and emulator snapshots cannot be used as native saves. +Native and reference sessions use different IndexedDB databases and Web Locks. The native player keeps save slots (`storage/slots.ts`, `app/slot-session.ts`): each slot keeps its newest two records with checksums, written in one transaction, and loading falls back to the previous record when the newest is damaged. The singleton save of database version 1 becomes the first slot inside the version-2 upgrade. Imports are previewed in a scratch world and reject incompatible schemas and invalid positions before changing the world or any slot. A failed import, switch or new game restores the prior state. Original cartridge saves and emulator snapshots cannot be used as native saves. `GameWorld` advances in overworld ticks of two video frames, as the original loop does. The runtime converts elapsed time to ticks. Tile events follow `CheckTileEvent`: a completed step checks connections, then warps, then coordinate events for the current scene. Idle ticks run the current scene script. Map entry runs the NEWMAP, TILES and OBJECTS callbacks on a copy first, so an unsupported callback refuses the step instead of entering a half-initialized map. Objects spawn from event flags at map load, and later flag changes leave them in place until `appear`/`disappear` or the next load. diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index 74eaf6c..0255f5b 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -113,6 +113,8 @@ Native production is `dist/`; the optional reference output is `dist-reference/` ## Save schema +Saves live in slots (see [PLAN.md](PLAN.md), save slots): the slot model is version 1, each record keeps the version-1 envelope below, and the game state inside has its own schema. + Native saves are version 3. Version 2 added scene values by owning map, phone contacts, the weekday offset and DST flag, and positions of objects that scripts moved on the current map. Version 3 adds the player ID, party, bag, Pokédex seen/caught sets and money, and later Mom's savings (`momsMoney`, migrated by absence to 0). Early version-3 backups without money receive the original starting balance; explicitly invalid balances are rejected. Restores recompute each Pokémon's stats from its DVs and reject any mismatch, and check every item against its ROM pocket. Older saves migrate with new-game defaults; a missing player ID is drawn as a new game draws it. Objects are restored from the save on continue, and only the TILES callback runs, as in `MapSetupScript_Continue`. Later fields keep, by absence-migrated defaults: `memory`, the saved WRAM bytes that scripts read and write directly, by PRET label (`SAVED_WRAM`); and `unownDex` with `firstUnownSeen` (`wUnownDex`, `wFirstUnownSeen`). diff --git a/docs/PLAN.md b/docs/PLAN.md index 4edfbc0..d2b47dd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -483,6 +483,15 @@ The numbered sections below keep their full scope. Each one opens with a checkli - Buena's show at the Radio Tower: `AskRememberPassword`, `BuenasPassword` (the category's three words, B disabled) and `BuenaPrize` (prizes and points beside the Blue Card balance, confirm, pay, receive). Tests: `tests/phone.test.ts` (rematches, a random call, no call without a caller, the daily reset, swarms, each station's lines and music, the radio card, a house radio, Buena's password and prizes) and `tests/radio-browser.spec.ts` (the radio card in the browser). - Not ported: the radio card's tuning dial graphics (the frequency is shown as text), holding up or down to turn the knob continuously, the Poké Flute channel waking Snorlax (Kanto is closed), and calls that use the mobile adapter. +## Implemented: save slots (board #37) + +- `storage/slots.ts`: the native database (`crystal-native-port`) moves to IndexedDB version 2 with `slots` (durable ID, ROM hash, name, created/updated time, newest generation, and a display summary), `records` (keyed by slot and generation) and `meta` (the slot last played per ROM, the migration record). The version-1 `saves` store is copied into "Save 1" inside the upgrade transaction itself, so it happens exactly once (an aborted upgrade retries; a committed one never runs again); the record is copied byte for byte and the old store is left untouched. +- Writes are atomic: one transaction adds generation n+1, drops generations below n and updates the slot. Each record carries a CRC-32 of its fields and state. Loading takes the newest intact record that the game accepts, falling back to the previous one (reported to the player); the next write supersedes the damaged one. +- Versioning: slots carry `version: 1`, records the existing envelope (`version: 1`, `coreId`) and the game state its schema (1-3, migrated by `migrateSave`). A slot whose model or newest record is newer is listed, exportable and deletable, but never opened, copied or written. +- `app/slot-session.ts` (`SlotSession`, native only; the reference keeps `GameSession`): load (last played slot, otherwise the newest readable one, otherwise a new game in a new slot), save into the slot captured with the snapshot (an autosave queued before a switch lands in its own slot), switch, new game, rename (1-24 characters), duplicate, delete (not the running slot), export, and import. Switching, new games and imports save the running game first and restore it if the target cannot be opened. `NativeRuntime.reset()` rebuilds the world from the loaded ROM so nothing carries over between slots. +- Import preview (`previewImport`) checks the envelope, cartridge, player, base64 and the game state in a scratch world (`NativeRuntime.inspectState`) before anything changes, and shows the trainer and ID, place, play time, badges, caught count, saved time, the game data version (and its upgrade) and the slots of the same trainer, marking one with identical progress. The player adds the backup as a new save or replaces the running save or a conflicting one; a replaced slot keeps its previous record. +- Tests: `tests/save-slots.test.ts` (migration once, a newer singleton kept, slot lifecycle, autosave isolation, recovery from a damaged or missing newest record, an interrupted write, rollback when a slot cannot be opened, rejected imports, conflicts and both import targets), `tests/native.test.ts` (`describe` on saves of schema 1, 2 and 3) and `tests/save-slots-browser.spec.ts` (the migration and every Savegames action in Chromium and WebKit). + ## Native battles: current state The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains. @@ -606,7 +615,7 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba - [x] Pokédex screen: New mode list and entries - [x] Scalable text and configurable keyboard controls - [ ] Fuller accessibility: announcements for every native screen and touch control remapping -- [ ] Save slots with migration, atomic writes and import previews +- [x] Save slots with migration, atomic writes and import previews - [x] Installable web app with offline app shell, safe updates and quota handling - [ ] Acceptance on physical iPhone and Android devices diff --git a/src/app/slot-session.ts b/src/app/slot-session.ts new file mode 100644 index 0000000..c763ed0 --- /dev/null +++ b/src/app/slot-session.ts @@ -0,0 +1,488 @@ +import type { GameRuntime } from "./runtime"; +import type { SessionState } from "./session"; +import type { ValidatedRom } from "../rom/validate"; +import { encodeSave, type SaveRecord } from "../storage/saves"; +import { + SLOT_VERSION, + type SaveSlot, + type SlotRepository, + type SlotSummary, +} from "../storage/slots"; + +/** What the slot session needs from the game beyond the lifecycle. */ +export interface SlotRuntime extends GameRuntime { + newGame(): void; + reset(): void; + summary(): SlotSummary; + inspectState(state: Uint8Array): { summary: SlotSummary; schema: number }; +} + +export interface SlotEntry { + slot: SaveSlot; + /** This player can open and write it. Others came from a newer player + * and are only kept, exported or deleted. */ + compatible: boolean; + active: boolean; +} + +/** An import checked against the ROM, the player and the existing slots, + * before anything changes. */ +export type ImportPreview = + | { + ok: true; + record: SaveRecord; + summary: SlotSummary; + /** The native schema the backup was saved with; older ones upgrade. */ + schema: number; + /** Slots of the same trainer (name and ID), newest first; + * `identical` when the slot's newest record holds the same game. */ + conflicts: { slot: SaveSlot; identical: boolean }[]; + } + | { + ok: false; + reason: string; + /** Whatever identity the file still shows. */ + identity?: { version?: unknown; coreId?: unknown; savedAt?: unknown }; + }; + +export interface LoadResult { + restored: boolean; + /** The newest record was damaged; an earlier one was loaded. */ + recovered: boolean; + /** Slots that could not be opened and were kept as they are. */ + skipped: SaveSlot[]; +} + +const MAX_BACKUP = 2_000_000; +const NAME_PATTERN = /^[^\p{Cc}]{1,24}$/u; + +/** The native game's saves: several independent slots, each written + * atomically with the previous record kept for recovery. */ +export class SlotSession { + state: SessionState = "empty"; + active?: SaveSlot; + private romHash?: string; + // Writes run in capture order, each into the slot it was captured from: + // switching slots never lets an earlier autosave land in the new one. + private writes: Promise = Promise.resolve(); + constructor( + readonly runtime: SlotRuntime, + private store: SlotRepository, + ) {} + + async load( + rom: ValidatedRom, + canvas: HTMLCanvasElement, + ): Promise { + this.state = "loading"; + this.romHash = undefined; + this.active = undefined; + try { + await this.runtime.load(rom.bytes, canvas); + this.romHash = rom.sha1; + const slots = await this.store.list(rom.sha1); + const preferred = await this.store.active(rom.sha1); + slots.sort( + (a, b) => Number(b.id === preferred) - Number(a.id === preferred), + ); + const skipped: SaveSlot[] = []; + for (const slot of slots) { + try { + const { recovered } = await this.open(slot); + this.state = "paused"; + return { restored: true, recovered, skipped }; + } catch { + skipped.push(slot); + } + } + // A slot that failed to open may have left part of its game behind; + // otherwise the world is as the ROM's load left it. + if (skipped.length) this.runtime.reset(); + this.runtime.newGame(); + this.active = await this.store.create( + rom.sha1, + this.freeName(slots), + this.snapshot(true), + this.runtime.summary(), + ); + await this.store.setActive(rom.sha1, this.active.id); + this.state = "paused"; + return { restored: false, recovered: false, skipped }; + } catch (error) { + this.runtime.dispose(); + this.romHash = undefined; + this.state = "error"; + throw error; + } + } + play() { + if (this.state !== "paused") return; + this.runtime.play(); + this.state = "playing"; + } + pause() { + if (this.state !== "playing") return; + this.runtime.pause(); + this.state = "paused"; + } + private get ready() { + return Boolean(this.romHash) && ["paused", "playing"].includes(this.state); + } + snapshot(force = false): SaveRecord { + if (!this.romHash || (!force && !this.ready)) + throw new Error("Load a ROM before saving."); + return { + version: 1, + romHash: this.romHash, + coreId: this.runtime.id, + savedAt: new Date().toISOString(), + state: this.runtime.captureState(), + }; + } + /** Saves the running game into its slot. */ + save(): Promise { + const slot = this.active; + if (!slot) return Promise.reject(new Error("Load a ROM before saving.")); + const record = this.snapshot(); + const summary = this.runtime.summary(); + const operation = this.writes + .catch(() => {}) + .then(() => this.store.write(slot.id, record, summary)) + .then((written) => { + if (this.active?.id === written.id) this.active = written; + return record.savedAt; + }); + this.writes = operation; + return operation; + } + + /** Every slot made with this ROM. */ + async slots(): Promise { + if (!this.romHash) return []; + const slots = await this.store.list(this.romHash); + return Promise.all( + slots.map(async (slot) => { + const compatible = await this.compatible(slot); + return { + slot: compatible ? await this.withSummary(slot) : slot, + compatible, + active: slot.id === this.active?.id, + }; + }), + ); + } + + /** Continues another slot. The running game is saved first; if the slot + * cannot be opened, the running game is restored as it was. */ + async switchTo(id: string) { + const target = await this.slot(id); + if (target.id === this.active?.id) return { recovered: false }; + await this.leave(); + const previous = this.runtime.captureState(); + try { + return await this.open(target); + } catch (error) { + this.rollback(previous); + throw error; + } + } + + /** Starts a new game in a new slot. */ + async newGame(name?: string) { + if (!this.romHash) throw new Error("Load your ROM first."); + await this.leave(); + const previous = this.runtime.captureState(); + try { + this.runtime.reset(); + this.runtime.newGame(); + const slots = await this.store.list(this.romHash); + const slot = await this.store.create( + this.romHash, + this.checkName(name ?? this.freeName(slots)), + this.snapshot(), + this.runtime.summary(), + ); + await this.select(slot); + return slot; + } catch (error) { + this.rollback(previous); + throw error; + } + } + + async rename(id: string, name: string) { + const slot = await this.store.rename(id, this.checkName(name)); + if (this.active?.id === id) + this.active = { ...this.active, name: slot.name }; + return slot; + } + + /** Copies a slot's newest readable record into a new slot. */ + async duplicate(id: string) { + const source = await this.slot(id); + if (!(await this.compatible(source))) + throw new Error( + "Saves from a newer version of Crystal cannot be copied.", + ); + if (source.id === this.active?.id) await this.save(); + const { record, summary } = await this.latest(source); + const slots = await this.store.list(source.romHash); + return this.store.create( + source.romHash, + this.copyName(source.name, slots), + { ...record, savedAt: new Date().toISOString() }, + summary, + ); + } + + async delete(id: string) { + if (id === this.active?.id) + throw new Error("Load another save before deleting this one."); + await this.store.delete(id); + } + + /** A backup file of the slot: the running game for the active slot, + * otherwise its newest readable record, unchanged. */ + async exportSlot(id: string) { + const slot = await this.slot(id); + const record = + slot.id === this.active?.id + ? this.snapshot() + : (await this.store.records(id)).find((entry) => entry.intact)?.record; + if (!record) + throw new Error(`“${slot.name}” has no readable record to export.`); + // A newer player's record is exported as it is stored. + const text = + record.state instanceof Uint8Array + ? encodeSave(record) + : JSON.stringify(record); + return { slot, text }; + } + + /** Checks a backup without changing the running game or any slot. */ + async previewImport(text: string): Promise { + if (!this.romHash) + throw new Error("Load your ROM before importing a backup."); + if (text.length > MAX_BACKUP) + return { ok: false, reason: "This backup is too large." }; + let value: Record; + try { + value = JSON.parse(text); + if (!value || typeof value !== "object") throw new Error(); + } catch { + return { ok: false, reason: "This file is not a Crystal backup." }; + } + const identity = { + version: value.version, + coreId: value.coreId, + savedAt: value.savedAt, + }; + if (typeof value.version === "number" && value.version > 1) + return { + ok: false, + reason: `This backup uses save format ${value.version}, made by a newer version of Crystal. Update Crystal to import it.`, + identity, + }; + if (value.version !== 1) + return { + ok: false, + reason: "This file is not a Crystal backup.", + identity, + }; + if (value.romHash !== this.romHash) + return { + ok: false, + reason: "This backup was made with a different cartridge.", + identity, + }; + if (value.coreId !== this.runtime.id) + return { + ok: false, + reason: `This backup belongs to another player (${String(value.coreId)}). Only native backups can be imported here.`, + identity, + }; + if ( + typeof value.savedAt !== "string" || + !Number.isFinite(Date.parse(value.savedAt)) || + typeof value.state !== "string" || + !/^[A-Za-z0-9+/]+={0,2}$/.test(value.state) || + value.state.length % 4 !== 0 + ) + return { ok: false, reason: "The backup is damaged.", identity }; + const record: SaveRecord = { + version: 1, + romHash: this.romHash, + coreId: this.runtime.id, + savedAt: value.savedAt, + state: Uint8Array.from(atob(value.state), (char) => char.charCodeAt(0)), + }; + let inspected: ReturnType; + try { + inspected = this.runtime.inspectState(record.state); + } catch (error) { + return { + ok: false, + reason: `The backup's game could not be read: ${error instanceof Error ? error.message : String(error)}`, + identity, + }; + } + const conflicts = []; + for (const listed of await this.store.list(this.romHash)) { + const slot = await this.withSummary(listed); + if ( + slot.summary?.player !== inspected.summary.player || + slot.summary.playerId !== inspected.summary.playerId + ) + continue; + const newest = (await this.store.records(slot.id))[0]?.record.state; + conflicts.push({ + slot, + identical: + newest instanceof Uint8Array && + newest.length === record.state.length && + newest.every((byte, index) => byte === record.state[index]), + }); + } + return { ok: true, record, ...inspected, conflicts }; + } + + /** Imports a previewed backup into a new slot, or over a slot (whose + * previous record stays recoverable), and continues it. On failure the + * running game and every slot stay as they were. */ + async importBackup( + preview: Extract, + target: "new" | string, + ) { + if (!this.romHash) + throw new Error("Load your ROM before importing a backup."); + const replaced = target === "new" ? undefined : await this.slot(target); + if (replaced && !(await this.compatible(replaced))) + throw new Error( + "Saves from a newer version of Crystal cannot be replaced.", + ); + await this.leave(); + const previous = this.runtime.captureState(); + try { + this.runtime.reset(); + this.runtime.restoreState(preview.record.state); + const record = this.snapshot(); + const summary = this.runtime.summary(); + const slot = replaced + ? await this.store.write(replaced.id, record, summary) + : await this.store.create( + this.romHash, + this.freeName(await this.store.list(this.romHash), summary.player), + record, + summary, + ); + await this.select(slot); + return slot; + } catch (error) { + this.rollback(previous); + throw error; + } + } + + /** Pauses and writes the running game before it is replaced. */ + private async leave() { + this.pause(); + if (this.active) await this.save(); + } + private rollback(previous: Uint8Array) { + this.runtime.reset(); + this.runtime.restoreState(previous, false); + } + private async select(slot: SaveSlot) { + this.active = slot; + await this.store.setActive(slot.romHash, slot.id); + } + private async slot(id: string) { + const slot = await this.store.get(id); + if (!slot || slot.romHash !== this.romHash) + throw new Error("This save no longer exists."); + return slot; + } + + /** Opens a compatible slot from its newest intact record, or the one + * before it when the newest was damaged or not written. */ + private async open(slot: SaveSlot) { + if (!(await this.compatible(slot))) + throw new Error( + `“${slot.name}” was made by a newer version of Crystal. It has been kept.`, + ); + for (const entry of await this.store.records(slot.id)) { + if (!entry.intact || !this.readable(entry.record)) continue; + try { + this.runtime.reset(); + this.runtime.restoreState(entry.record.state); + } catch { + continue; + } + await this.select(slot); + return { recovered: entry.generation !== slot.head }; + } + throw new Error(`“${slot.name}” could not be read. It has been kept.`); + } + private async latest(slot: SaveSlot) { + for (const entry of await this.store.records(slot.id)) { + if (!entry.intact || !this.readable(entry.record)) continue; + try { + return { + record: entry.record, + summary: this.runtime.inspectState(entry.record.state).summary, + }; + } catch { + // Try the previous record. + } + } + throw new Error(`“${slot.name}” could not be read.`); + } + /** Slots migrated from the singleton save have no summary until their + * first write; it is read from their record instead. */ + private async withSummary(slot: SaveSlot) { + if (slot.summary) return slot; + try { + return { ...slot, summary: (await this.latest(slot)).summary }; + } catch { + return slot; + } + } + private readable(record: SaveRecord) { + return ( + record.version === 1 && + record.coreId === this.runtime.id && + record.state instanceof Uint8Array + ); + } + /** A slot is newer when its model or its newest record's envelope is. + * Only a damaged newest record lets an older one be used: a newer one + * must never be replaced by a write from this player. */ + private async compatible(slot: SaveSlot) { + if (slot.version > SLOT_VERSION) return false; + const records = await this.store.records(slot.id); + const newest = records.find((entry) => entry.intact); + return !newest || this.readable(newest.record); + } + + private checkName(name: string) { + const trimmed = name.trim(); + if (!NAME_PATTERN.test(trimmed)) + throw new Error("Save names have 1 to 24 characters."); + return trimmed; + } + private freeName(slots: SaveSlot[], base?: string) { + const names = new Set(slots.map((slot) => slot.name)); + if (base && !names.has(base)) return base; + for (let n = slots.length + 1; ; n++) { + const name = base ? `${base} ${n}` : `Save ${n}`; + if (!names.has(name)) return name; + } + } + private copyName(name: string, slots: SaveSlot[]) { + const names = new Set(slots.map((slot) => slot.name)); + const base = `${name.slice(0, 18)} copy`; + if (!names.has(base)) return base; + for (let n = 2; ; n++) + if (!names.has(`${base} ${n}`)) return `${base} ${n}`; + } +} diff --git a/src/game/runtime.ts b/src/game/runtime.ts index 5045f9b..70ab894 100644 --- a/src/game/runtime.ts +++ b/src/game/runtime.ts @@ -41,6 +41,36 @@ export class NativeRuntime implements GameRuntime { this.world?.beginIntro(); this.publish(); } + /** A fresh world from the loaded ROM, as before a save is restored or a + * new game starts, so nothing of the previous game carries over. */ + reset() { + if (!this.world) throw new Error("Load your ROM first."); + this.pause(); + this.world = this.world.fresh(); + this.publish(); + } + /** The Savegames entry for the running game's committed state. */ + summary() { + if (!this.world) throw new Error("Load your ROM first."); + return this.world.describe(); + } + /** Reads a state into a separate world, leaving the running game alone: + * throws when the game cannot load it. Returns its Savegames entry and + * the native schema version it was saved with. */ + inspectState(bytes: Uint8Array) { + if (!this.world || bytes.length > 64_000) + throw new Error("Invalid native save size."); + const value: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ); + const world = this.world.fresh(); + world.restore(value); + const schema = (value as { version?: unknown }).version; + return { + summary: world.describe(), + schema: typeof schema === "number" ? schema : 0, + }; + } play() { if (!this.world || this.raf) return; // Starting play is a user gesture in the app; browsers allow sound then. diff --git a/src/game/world/world.ts b/src/game/world/world.ts index c9beabb..9c1895d 100644 --- a/src/game/world/world.ts +++ b/src/game/world/world.ts @@ -63,6 +63,7 @@ import { type DayTimerName, pageCues, type SoundCue, + landmarkName, } from "../scripts/interpreter"; import { initialMagikarpRecord, type MagikarpRecord } from "../scripts/lake-of-rage"; import { @@ -126,6 +127,7 @@ import { type BattleSound, } from "../battle/battle"; import { C } from "../battle/constants"; +import type { SlotSummary } from "../../storage/slots"; import { DIRECTION_ORDER, VECTORS, @@ -845,6 +847,11 @@ export class GameWorld { this.initCallReceiveDelay(); } + /** A new world on the same ROM, clock and random source. */ + fresh() { + return new GameWorld(this.assets, this.now, this.random); + } + get x() { return this.player.x; } @@ -4886,6 +4893,28 @@ export class GameWorld { // ---------------------------------------------------------------- saves + /** The Savegames list entry for a save: the trainer, the landmark (the + * backup map's in LANDMARK_SPECIAL rooms), play time, Johto badges and + * caught count. */ + describe(save: NativeSave = this.save()): SlotSummary { + let landmark = this.assets.map(save.map).landmark; + if (landmark === OW.LANDMARK_SPECIAL && save.backupWarp) + landmark = this.assets.map(save.backupWarp.map).landmark; + let badges = 0; + for (let bit = 0; bit < OW.NUM_JOHTO_BADGES; bit++) + if (save.flags.includes(OW.ENGINE_ZEPHYRBADGE + bit)) badges++; + return { + player: save.name, + playerId: save.playerId, + place: landmarkName(this.assets.reader, landmark, this.host(PLAYER).text()), + hours: save.gameTime.hours, + minutes: save.gameTime.minutes, + badges, + caught: save.pokedex.caught.length, + newGame: save.intro, + }; + } + save(): NativeSave { // Incomplete interactions are replayed after reload; no partial quest writes. // The play time keeps counting through scenes, like GameTimer. diff --git a/src/main.ts b/src/main.ts index 7673637..e73c985 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,11 +42,11 @@ import { mailMessage, validMailMessage, } from "./game/party/mail"; -import { GameSession } from "./app/session"; +import { SlotSession, type ImportPreview, type SlotEntry } from "./app/slot-session"; import { InputController } from "./input/controller"; import { bindControls } from "./input/bindings"; import { validateRom } from "./rom/validate"; -import { BrowserSaveRepository, encodeSave } from "./storage/saves"; +import { SlotRepository, type SlotSummary } from "./storage/slots"; import { acquireSessionLock } from "./platform/session-lock"; import { InstallableApp, @@ -1704,10 +1704,7 @@ const runtime = new NativeRuntime( }, handleBattleInput, ); -const session = new GameSession( - runtime, - new BrowserSaveRepository("crystal-native-port"), -); +const session = new SlotSession(runtime, new SlotRepository("crystal-native-port")); const input = new InputController((button, pressed) => runtime.setButton(button, pressed), ); @@ -1733,7 +1730,10 @@ const tabs = bindTabs( (id) => { if (id === "pokedex") showPokedexTab(); if (id === "map") showAtlas(); - if (id === "saves") showAppSettings(); + if (id === "saves") { + showAppSettings(); + void showSlots(); + } }, ); @@ -1751,9 +1751,15 @@ function message(text: string) { function update() { const playing = session.state === "playing"; root.classList.toggle("has-game", ready()); - for (const id of ["play", "save", "backup", "import-save", "screen-resume"]) { + for (const id of ["play", "save", "new-game", "backup", "import-save", "screen-resume"]) { get(id).disabled = busy || !ready(); } + get("slot-list") + .querySelectorAll("button") + .forEach((button) => { + button.disabled = + busy || !ready() || button.dataset.locked === "true"; + }); root .querySelectorAll("[data-game-button]") .forEach((button) => { @@ -1791,6 +1797,7 @@ async function save() { .then((time) => { saveStatus.textContent = `Session saved on this device · ${new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; saveStatus.classList.remove("error"); + if (tabs.active() === "saves") void showSlots(); }) .catch((error) => { saveStatus.textContent = quotaExceeded(error) @@ -1842,13 +1849,23 @@ get("rom-file").addEventListener("change", async (event) => { await saving; await save(); } - const restored = await session.load(rom, canvas); + const { restored, recovered, skipped } = await session.load(rom, canvas); tabs.select("game"); message( - restored - ? "Your saved session is ready. Press Play to continue." - : "Cartridge verified. Press Play to explore your room and New Bark Town.", + [ + restored + ? "Your saved session is ready. Press Play to continue." + : "Cartridge verified. Press Play to explore your room and New Bark Town.", + recovered && + `The last write of “${session.active?.name}” was damaged; its previous save was loaded.`, + ...skipped.map( + (slot) => `“${slot.name}” could not be opened here and was kept unchanged.`, + ), + ] + .filter(Boolean) + .join(" "), ); + void showSlots(); } catch (error) { showError(error); } finally { @@ -1884,17 +1901,235 @@ function download(contents: BlobPart, filename: string, type: string) { setTimeout(() => URL.revokeObjectURL(url), 1000); } get("backup").addEventListener("click", () => { + if (session.active) void exportSlot(session.active.id); +}); +async function exportSlot(id: string) { try { + const { slot, text } = await session.exportSlot(id); + const name = slot.name.replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-|-$/g, ""); download( - encodeSave(session.snapshot()), - `crystal-${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`, + text, + `crystal-${name ? `${name}-` : ""}${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`, "application/json", ); - message("Session backup exported. Keep it somewhere safe."); + message(`“${slot.name}” exported. Keep the backup somewhere safe.`); } catch (error) { showError(error); } +} + +/** Savegames: the slots made with this ROM, newest first. */ +let slotsShown = 0; +async function showSlots() { + const list = get("slot-list"); + const request = ++slotsShown; + let entries: SlotEntry[] = []; + try { + entries = ready() ? await session.slots() : []; + } catch (error) { + showError(error); + } + // A later refresh may have finished first. + if (request !== slotsShown) return; + // Autosaves refresh the list; keep the keyboard focus where it was. + const focused = list.contains(document.activeElement) + ? (document.activeElement as HTMLElement) + : undefined; + const focusId = focused?.closest("[data-slot-id]")?.dataset.slotId; + const focusAction = focused?.dataset.slotAction; + get("slots-empty").hidden = entries.length > 0; + list.hidden = entries.length === 0; + list.replaceChildren(...entries.map(slotItem)); + update(); + if (focusId && focusAction) + [...list.querySelectorAll("button[data-slot-action]")] + .find( + (button) => + button.dataset.slotAction === focusAction && + button.closest("[data-slot-id]")?.dataset.slotId === focusId, + ) + ?.focus(); +} +function element( + tag: K, + className: string, + text = "", +) { + const node = document.createElement(tag); + if (className) node.className = className; + node.textContent = text; + return node; +} +function summaryLines(summary?: SlotSummary) { + if (!summary) return ["No details available"]; + if (summary.newGame) return ["New game", "The introduction is not finished"]; + const badges = `${summary.badges} badge${summary.badges === 1 ? "" : "s"}`; + return [ + `${summary.player} · ${summary.place}`, + `${summary.hours}:${String(summary.minutes).padStart(2, "0")} played · ${badges} · ${summary.caught} caught`, + ]; +} +const savedTime = (iso: string) => + new Date(iso).toLocaleString([], { + dateStyle: "medium", + timeStyle: "short", + }); +function slotItem({ slot, compatible, active }: SlotEntry) { + const item = element("li", "slot"); + item.dataset.slotId = slot.id; + if (active) item.setAttribute("aria-current", "true"); + const heading = element("h3", "slot-title"); + heading.append(element("span", "slot-name", slot.name)); + if (active) heading.append(element("span", "slot-tag", "Playing")); + if (!compatible) heading.append(element("span", "slot-tag warning", "Newer version")); + item.append(heading); + const lines = compatible + ? summaryLines(slot.summary) + : ["Made by a newer version of Crystal. It is kept unchanged."]; + for (const line of lines) item.append(element("p", "slot-detail", line)); + item.append(element("p", "slot-saved", `Saved ${savedTime(slot.updatedAt)}`)); + const actions = element("div", "slot-actions"); + const action = (name: string, label: string, locked = false) => { + const button = element("button", "", label); + button.dataset.slotAction = name; + button.setAttribute("aria-label", `${label} ${slot.name}`); + if (locked) button.dataset.locked = "true"; + actions.append(button); + }; + if (compatible) { + if (!active) action("load", "Continue"); + action("rename", "Rename"); + action("duplicate", "Duplicate"); + } + action("export", "Export"); + action("delete", "Delete", active); + if (active) + actions.append( + element("p", "slot-hint", "Continue another save to delete this one."), + ); + item.append(actions); + return item; +} + +get("slot-list").addEventListener("click", (event) => { + const button = (event.target as HTMLElement).closest( + "button[data-slot-action]", + ); + const id = button?.closest("[data-slot-id]")?.dataset.slotId; + if (!button || !id || busy || button.disabled) return; + const name = + button.closest("li")?.querySelector(".slot-name")?.textContent ?? ""; + void slotAction(button.dataset.slotAction!, id, name); }); +/** Runs one Savegames action with the session paused and saved. */ +async function withSlots(work: () => Promise) { + busy = true; + pause(); + update(); + try { + await saving; + const text = await work(); + if (text) message(text); + } catch (error) { + showError(error); + } finally { + busy = false; + await showSlots(); + } +} +async function slotAction(action: string, id: string, name: string) { + switch (action) { + case "load": + return withSlots(async () => { + const { recovered } = await session.switchTo(id); + return recovered + ? `“${name}” is ready. Its last write was damaged; the previous save was loaded. Press Play to continue.` + : `“${name}” is ready. Press Play to continue.`; + }); + case "rename": { + const newName = await askSlot({ + title: `Rename “${name}”`, + text: "Names have 1 to 24 characters.", + confirm: "Rename", + value: name, + }); + if (newName === undefined) return; + return withSlots(async () => { + const slot = await session.rename(id, newName); + return `Renamed to “${slot.name}”.`; + }); + } + case "duplicate": + return withSlots(async () => { + const slot = await session.duplicate(id); + return `“${name}” copied to “${slot.name}”.`; + }); + case "export": + return exportSlot(id); + case "delete": { + const confirmed = await askSlot({ + title: `Delete “${name}”?`, + text: "Its progress is removed from this device. Export it first to keep a backup.", + confirm: "Delete", + }); + if (confirmed === undefined) return; + return withSlots(async () => { + await session.delete(id); + return `“${name}” deleted.`; + }); + } + } +} +get("new-game").addEventListener("click", () => { + void withSlots(async () => { + const slot = await session.newGame(); + tabs.select("game"); + return `“${slot.name}” created. Press Play to start your new game.`; + }); +}); + +/** The rename and delete dialog. Resolves with the name entered (or "" for + * a confirmation), or undefined when cancelled. */ +function askSlot(options: { + title: string; + text: string; + confirm: string; + value?: string; +}): Promise { + const dialog = get("slot-dialog"); + const field = get("slot-name"); + const error = get("slot-dialog-error"); + get("slot-dialog-title").textContent = options.title; + get("slot-dialog-text").textContent = options.text; + get("slot-dialog-confirm").textContent = options.confirm; + get("slot-name-label").hidden = options.value === undefined; + field.required = options.value !== undefined; + field.value = options.value ?? ""; + error.textContent = ""; + dialog.showModal(); + if (options.value !== undefined) field.select(); + return new Promise((resolve) => { + const submit = (event: SubmitEvent) => { + const confirmed = + (event.submitter as HTMLButtonElement | null)?.value === "confirm"; + if (confirmed && options.value !== undefined && !field.value.trim()) { + event.preventDefault(); + error.textContent = "Enter a name."; + return; + } + dialog.removeEventListener("close", closed); + dialog.querySelector("form")!.removeEventListener("submit", submit); + resolve(confirmed ? field.value : undefined); + }; + const closed = () => { + dialog.querySelector("form")!.removeEventListener("submit", submit); + resolve(undefined); + }; + dialog.querySelector("form")!.addEventListener("submit", submit); + dialog.addEventListener("close", closed, { once: true }); + }); +} + get("import-save").addEventListener("click", () => { pause(); get("save-file").click(); @@ -1903,32 +2138,124 @@ get("save-file").addEventListener("change", async (event) => { const field = event.target as HTMLInputElement; const file = field.files?.[0]; field.value = ""; - if (!file || busy) return; - if (file.size > 2_000_000) { - showError(new Error("This backup is too large.")); - return; - } - if ( - !window.confirm( - "Replace your current session with this backup? Export your current session first if you want to keep it.", - ) - ) - return; - busy = true; - update(); + if (!file || busy || !ready()) return; + let preview: ImportPreview; try { - await saving; - await session.importSave(await file.text()); - message("Backup restored. Press Play to continue."); - saveStatus.textContent = "Imported session saved on this device."; + preview = + file.size > 2_000_000 + ? { ok: false, reason: "This backup is too large." } + : await session.previewImport(await file.text()); } catch (error) { showError(error); - } finally { - busy = false; - update(); + return; } + const target = await previewImport(file.name, preview); + if (!target || !preview.ok) return; + const ok = preview; + await withSlots(async () => { + const slot = await session.importBackup(ok, target); + saveStatus.textContent = `Imported into “${slot.name}” on this device.`; + saveStatus.classList.remove("error"); + return "Backup restored. Press Play to continue."; + }); }); +/** Shows what a backup holds and what importing it would replace. + * Resolves with "new", a slot ID to replace, or undefined. */ +function previewImport( + file: string, + preview: ImportPreview, +): Promise { + const dialog = get("import-dialog"); + const details = get("import-details"); + const conflicts = get("import-conflicts"); + const actions = get("import-actions"); + const row = (term: string, value: string) => { + details.append(element("dt", "", term), element("dd", "", value)); + }; + details.replaceChildren(); + conflicts.replaceChildren(); + actions.replaceChildren(); + const button = (value: string, label: string, primary = false, id = "") => { + const node = element("button", primary ? "menu-button" : "secondary-button", label); + node.value = value; + if (id) node.id = id; + actions.append(node); + }; + if (!preview.ok) { + get("import-title").textContent = "This backup cannot be imported"; + get("import-text").textContent = `${preview.reason} Your saves are unchanged.`; + row("File", file); + if (typeof preview.identity?.savedAt === "string") + row("Saved", savedTime(preview.identity.savedAt)); + button("cancel", "Close", true); + } else { + const { summary } = preview; + get("import-title").textContent = summary.newGame + ? "A new game" + : `${summary.player}'s game`; + get("import-text").textContent = + "Check the backup before importing it. Importing continues it in place of the running game, which is saved first."; + row("File", file); + row( + "Trainer", + summary.newGame + ? "Not named yet" + : `${summary.player} · ID No. ${String(summary.playerId).padStart(5, "0")}`, + ); + row("Place", summary.newGame ? "Introduction" : summary.place); + for (const line of summaryLines(summary).slice(1)) row("Progress", line); + row("Saved", savedTime(preview.record.savedAt)); + row( + "Format", + preview.schema < 3 + ? `Native save, game data version ${preview.schema}: upgraded to version 3 on import` + : `Native save, game data version ${preview.schema}`, + ); + if (preview.conflicts.length) { + conflicts.append(element("h3", "", "Saves of the same trainer")); + const list = element("ul", ""); + for (const { slot, identical } of preview.conflicts) + list.append( + element( + "li", + "", + `“${slot.name}”, saved ${savedTime(slot.updatedAt)}${identical ? ": the same progress as this backup" : slot.updatedAt > preview.record.savedAt ? ": saved after this backup" : ": saved before this backup"}`, + ), + ); + conflicts.append(list); + } + button("new", "Add as a new save", true, "import-new"); + const active = session.active; + const replaceable = [ + ...(active ? [active] : []), + ...preview.conflicts + .map((conflict) => conflict.slot) + .filter((slot) => slot.id !== active?.id), + ]; + for (const slot of replaceable) + button( + slot.id, + `Replace “${slot.name}”`, + false, + slot.id === active?.id ? "import-current" : "", + ); + button("cancel", "Cancel"); + } + dialog.returnValue = ""; + dialog.showModal(); + return new Promise((resolve) => { + dialog.addEventListener( + "close", + () => { + const value = dialog.returnValue; + resolve(value && value !== "cancel" ? value : undefined); + }, + { once: true }, + ); + }); +} + /** The installable app: updates wait for the player, and the Savegames * tab offers installation and persistent storage. */ const app = new InstallableApp({ diff --git a/src/storage/slots.ts b/src/storage/slots.ts new file mode 100644 index 0000000..521fbb1 --- /dev/null +++ b/src/storage/slots.ts @@ -0,0 +1,335 @@ +import type { SaveRecord } from "./saves"; + +/** The slot model this player writes. Slots of a higher version come from + * a newer player: they are listed and kept, never rewritten. */ +export const SLOT_VERSION = 1; +/** Records kept per slot: the newest and the one before it, which a + * damaged newest record falls back to. */ +const KEPT_GENERATIONS = 2; + +/** What a slot's list entry shows, taken from its newest record. */ +export interface SlotSummary { + player: string; + /** wPlayerID: with the name, it tells playthroughs apart. */ + playerId: number; + /** The landmark name, as the Pokégear and Town Map show it. */ + place: string; + hours: number; + minutes: number; + badges: number; + caught: number; + /** The introduction has not finished. */ + newGame: boolean; +} + +export interface SaveSlot { + version: number; + /** Durable ID, kept across renames, exports and recoveries. */ + id: string; + romHash: string; + name: string; + createdAt: string; + updatedAt: string; + /** The newest record's generation. */ + head: number; + summary?: SlotSummary; +} + +/** One write of a slot. The checksum covers the whole record, so a torn or + * damaged write is recognized and the previous generation is used. */ +interface StoredRecord { + slot: string; + generation: number; + checksum: number; + record: SaveRecord; +} + +export interface SlotRecord { + generation: number; + record: SaveRecord; + /** The checksum matches: the record is as it was written. */ + intact: boolean; +} + +/** Native saves in IndexedDB. Version 1 of the database held one record per + * ROM in `saves`; version 2 keeps that store untouched and adds slots. */ +export class SlotRepository { + private database?: Promise; + constructor(private databaseName = "crystal-native-port") {} + + private open() { + return (this.database ??= new Promise((resolve, reject) => { + const request = indexedDB.open(this.databaseName, 2); + request.onupgradeneeded = (event) => { + const db = request.result; + db.createObjectStore("slots", { keyPath: "id" }).createIndex( + "romHash", + "romHash", + ); + db.createObjectStore("records", { keyPath: ["slot", "generation"] }); + const meta = db.createObjectStore("meta"); + // The upgrade commits or aborts as a whole, and runs once per + // database version: the singleton save moves into a slot exactly once. + if (event.oldVersion === 1) + migrateSingleton(request.transaction!.objectStore("saves"), meta); + }; + request.onsuccess = () => { + const db = request.result; + // A newer tab upgrading the database closes this connection. + db.onversionchange = () => { + db.close(); + this.database = undefined; + }; + resolve(db); + }; + request.onerror = () => { + this.database = undefined; + reject(request.error); + }; + request.onblocked = () => + reject( + new Error("Close other Crystal tabs, then reload to enable saves."), + ); + })); + } + + private async run( + stores: string[], + mode: IDBTransactionMode, + body: (transaction: IDBTransaction) => Promise | T, + ): Promise { + const db = await this.open(); + const transaction = db.transaction(stores, mode, { durability: "strict" }); + const done = new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => + reject(transaction.error ?? new Error("Saving was interrupted.")); + }); + let result: T; + try { + result = await body(transaction); + } catch (error) { + try { + transaction.abort(); + } catch { + // Already finished. + } + done.catch(() => {}); + throw error; + } + await done; + return result; + } + + /** The slots made with this ROM, most recently saved first. */ + async list(romHash: string): Promise { + const slots = await this.run(["slots"], "readonly", (t) => + request( + t.objectStore("slots").index("romHash").getAll(romHash), + ), + ); + return slots.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } + + async get(id: string): Promise { + return this.run(["slots"], "readonly", (t) => + request(t.objectStore("slots").get(id)), + ); + } + + /** The slot's records, newest first. */ + async records(id: string): Promise { + const stored = await this.run(["records"], "readonly", (t) => + request(t.objectStore("records").getAll(range(id))), + ); + return stored + .sort((a, b) => b.generation - a.generation) + .map(({ generation, record, checksum }) => ({ + generation, + record, + intact: checksum === recordChecksum(record), + })); + } + + async create( + romHash: string, + name: string, + record: SaveRecord, + summary?: SlotSummary, + ): Promise { + const slot: SaveSlot = { + version: SLOT_VERSION, + id: crypto.randomUUID(), + romHash, + name, + createdAt: record.savedAt, + updatedAt: record.savedAt, + head: 1, + ...(summary && { summary }), + }; + await this.run(["slots", "records"], "readwrite", (t) => { + t.objectStore("slots").add(slot); + t.objectStore("records").add(stored(slot.id, 1, record)); + }); + return slot; + } + + /** Adds a record to the slot and drops the oldest, in one transaction: + * an interrupted write leaves the slot as it was. */ + async write( + id: string, + record: SaveRecord, + summary?: SlotSummary, + ): Promise { + return this.run(["slots", "records"], "readwrite", async (t) => { + const slots = t.objectStore("slots"); + const current = await request(slots.get(id)); + if (!current) throw new Error("This save no longer exists."); + if (current.version > SLOT_VERSION) + throw new Error("This save was made by a newer version of Crystal."); + const head = current.head + 1; + const slot: SaveSlot = { + ...current, + head, + updatedAt: record.savedAt, + ...(summary && { summary }), + }; + const records = t.objectStore("records"); + records.put(stored(id, head, record)); + if (head > KEPT_GENERATIONS) + records.delete(range(id, head - KEPT_GENERATIONS)); + slots.put(slot); + return slot; + }); + } + + async rename(id: string, name: string): Promise { + return this.run(["slots"], "readwrite", async (t) => { + const slots = t.objectStore("slots"); + const current = await request(slots.get(id)); + if (!current) throw new Error("This save no longer exists."); + if (current.version > SLOT_VERSION) + throw new Error("This save was made by a newer version of Crystal."); + const slot = { ...current, name }; + slots.put(slot); + return slot; + }); + } + + async delete(id: string) { + await this.run(["slots", "records", "meta"], "readwrite", async (t) => { + const slot = await request( + t.objectStore("slots").get(id), + ); + t.objectStore("slots").delete(id); + t.objectStore("records").delete(range(id)); + if (!slot) return; + const meta = t.objectStore("meta"); + if ((await request(meta.get(activeKey(slot.romHash)))) === id) + meta.delete(activeKey(slot.romHash)); + }); + } + + /** The slot last played with this ROM. */ + async active(romHash: string): Promise { + return this.run(["meta"], "readonly", (t) => + request( + t.objectStore("meta").get(activeKey(romHash)), + ), + ); + } + + async setActive(romHash: string, id: string) { + await this.run(["meta"], "readwrite", (t) => { + t.objectStore("meta").put(id, activeKey(romHash)); + }); + } +} + +/** Copies every record of the version-1 `saves` store into its own slot, + * unchanged: a record from another or a newer player keeps its bytes and is + * listed as such. The `saves` store itself is left as it was. */ +function migrateSingleton(saves: IDBObjectStore, meta: IDBObjectStore) { + const slots = meta.transaction.objectStore("slots"); + const records = meta.transaction.objectStore("records"); + const migrated: string[] = []; + const cursor = saves.openCursor(); + cursor.onsuccess = () => { + const entry = cursor.result; + if (!entry) { + meta.put( + { from: 1, slots: migrated, at: new Date().toISOString() }, + "migration", + ); + return; + } + const record = entry.value as SaveRecord; + const romHash = String(entry.key); + const savedAt = + typeof record.savedAt === "string" && + Number.isFinite(Date.parse(record.savedAt)) + ? record.savedAt + : new Date().toISOString(); + const id = crypto.randomUUID(); + slots.add({ + version: SLOT_VERSION, + id, + romHash, + name: "Save 1", + createdAt: savedAt, + updatedAt: savedAt, + head: 1, + } satisfies SaveSlot); + records.add(stored(id, 1, record)); + meta.put(id, activeKey(romHash)); + migrated.push(id); + entry.continue(); + }; +} + +const activeKey = (romHash: string) => `active:${romHash}`; + +/** A slot's records, or those up to `last`. */ +function range(slot: string, last = Number.MAX_SAFE_INTEGER) { + return IDBKeyRange.bound([slot, 0], [slot, last]); +} + +function stored(slot: string, generation: number, record: SaveRecord) { + return { + slot, + generation, + checksum: recordChecksum(record), + record, + } satisfies StoredRecord; +} + +function request(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result as T); + request.onerror = () => reject(request.error); + }); +} + +/** CRC-32 of the record's fields and state bytes. It runs synchronously, so + * the migration can compute it inside the upgrade transaction. */ +export function recordChecksum(record: SaveRecord): number { + const { state, ...fields } = record as SaveRecord & Record; + const header = new TextEncoder().encode( + JSON.stringify(fields, Object.keys(fields).sort()), + ); + let crc = crc32(0xffffffff, header); + if (state instanceof Uint8Array) crc = crc32(crc, state); + else crc = crc32(crc, new TextEncoder().encode(JSON.stringify(state))); + return (crc ^ 0xffffffff) >>> 0; +} + +let table: Uint32Array | undefined; +function crc32(crc: number, bytes: Uint8Array) { + table ??= Uint32Array.from({ length: 256 }, (_, n) => { + for (let k = 0; k < 8; k++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1; + return n >>> 0; + }); + for (const byte of bytes) crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return crc >>> 0; +} diff --git a/src/ui/shell.ts b/src/ui/shell.ts index 3af3d1d..e7f3cfc 100644 --- a/src/ui/shell.ts +++ b/src/ui/shell.ts @@ -60,9 +60,11 @@ export function renderShell(root: HTMLElement) {