Keep several saves, with migration, atomic writes and import previews (Closes #37)
Checks / web (push) Successful in 2m33s
Private ROM checks / private-rom (push) Successful in 20m32s

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 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-16 11:27:41 +02:00
1 parent 7c12c81a27
commit 7d03f5c5ca
18 files changed
+2079 -56

No files matched your search

+488
View File
@@ -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<unknown> = Promise.resolve();
constructor(
readonly runtime: SlotRuntime,
private store: SlotRepository,
) {}
async load(
rom: ValidatedRom,
canvas: HTMLCanvasElement,
): Promise<LoadResult> {
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<string> {
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<SlotEntry[]> {
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<ImportPreview> {
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<string, unknown>;
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<SlotRuntime["inspectState"]>;
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<ImportPreview, { ok: true }>,
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}`;
}
}