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}`;
}
}
+30
View File
@@ -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.
+29
View File
@@ -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.
+362 -35
View File
@@ -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<HTMLButtonElement>(id).disabled = busy || !ready();
}
get("slot-list")
.querySelectorAll<HTMLButtonElement>("button")
.forEach((button) => {
button.disabled =
busy || !ready() || button.dataset.locked === "true";
});
root
.querySelectorAll<HTMLButtonElement>("[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<HTMLInputElement>("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<HTMLElement>("[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<HTMLButtonElement>("button[data-slot-action]")]
.find(
(button) =>
button.dataset.slotAction === focusAction &&
button.closest<HTMLElement>("[data-slot-id]")?.dataset.slotId === focusId,
)
?.focus();
}
function element<K extends keyof HTMLElementTagNameMap>(
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<HTMLButtonElement>(
"button[data-slot-action]",
);
const id = button?.closest<HTMLElement>("[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<string | undefined>) {
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<string | undefined> {
const dialog = get<HTMLDialogElement>("slot-dialog");
const field = get<HTMLInputElement>("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<HTMLInputElement>("save-file").click();
@@ -1903,32 +2138,124 @@ get<HTMLInputElement>("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<string | undefined> {
const dialog = get<HTMLDialogElement>("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({
+335
View File
@@ -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<IDBDatabase>;
constructor(private databaseName = "crystal-native-port") {}
private open() {
return (this.database ??= new Promise<IDBDatabase>((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<T>(
stores: string[],
mode: IDBTransactionMode,
body: (transaction: IDBTransaction) => Promise<T> | T,
): Promise<T> {
const db = await this.open();
const transaction = db.transaction(stores, mode, { durability: "strict" });
const done = new Promise<void>((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<SaveSlot[]> {
const slots = await this.run(["slots"], "readonly", (t) =>
request<SaveSlot[]>(
t.objectStore("slots").index("romHash").getAll(romHash),
),
);
return slots.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
async get(id: string): Promise<SaveSlot | undefined> {
return this.run(["slots"], "readonly", (t) =>
request<SaveSlot | undefined>(t.objectStore("slots").get(id)),
);
}
/** The slot's records, newest first. */
async records(id: string): Promise<SlotRecord[]> {
const stored = await this.run(["records"], "readonly", (t) =>
request<StoredRecord[]>(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<SaveSlot> {
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<SaveSlot> {
return this.run(["slots", "records"], "readwrite", async (t) => {
const slots = t.objectStore("slots");
const current = await request<SaveSlot | undefined>(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<SaveSlot> {
return this.run(["slots"], "readwrite", async (t) => {
const slots = t.objectStore("slots");
const current = await request<SaveSlot | undefined>(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<SaveSlot | undefined>(
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<string | undefined> {
return this.run(["meta"], "readonly", (t) =>
request<string | undefined>(
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<T>(request: IDBRequest): Promise<T> {
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<string, unknown>;
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;
}
+8 -4
View File
@@ -60,9 +60,11 @@ export function renderShell(root: HTMLElement) {
</div>
</section>
<section id="panel-saves" class="tab-panel menu-panel" role="tabpanel" aria-labelledby="tab-saves" data-panel="saves" hidden>
<header><p class="menu-title">SAVEGAMES</p><h2>Your local session</h2></header>
<div class="save-actions"><button id="save" disabled>Save session</button><button id="backup" disabled>Export backup</button><button id="import-save" disabled>Import native backup</button><input id="save-file" class="file-input" type="file" accept=".json"></div>
<div class="save-copy"><p>Native backups include your character, position and completed interactions. They are separate from cartridge .sav files.</p><p>Export before clearing browser data.</p></div>
<header><p class="menu-title">SAVEGAMES</p><h2>Your saves</h2></header>
<div class="save-actions"><button id="save" disabled>Save now</button><button id="new-game" disabled>New game</button><button id="backup" disabled>Export current save</button><button id="import-save" disabled>Import backup</button><input id="save-file" class="file-input" type="file" accept=".json"></div>
<p id="slots-empty" class="slots-empty">Choose your ROM to see the saves made with it.</p>
<ul id="slot-list" class="slot-list" aria-label="Saves on this device" hidden></ul>
<div class="save-copy"><p>Each save keeps its own playthrough and saves itself every 10 seconds and when paused. Backups include your character, position and completed interactions. They are separate from cartridge .sav files.</p><p>Export before clearing browser data.</p></div>
<div class="replace-rom"><h3>Cartridge</h3><p>Select a ROM again after reloading, or choose another compatible copy.</p><label class="menu-button file-button" for="rom-file"><span aria-hidden="true">▶</span> Choose or replace ROM</label></div><div class="app-settings"><h3>App</h3><p id="install-status">Install Crystal to open it from your home screen and play the app offline. Select your ROM again each time you open it.</p><button id="install-app" class="menu-button" hidden>Install Crystal</button><h3>Storage</h3><p id="storage-status">Checking this browser's storage…</p><button id="persist-storage" class="menu-button" hidden>Keep saves on this device</button></div>
</section>
<section id="panel-map" class="tab-panel menu-panel map-panel" role="tabpanel" aria-labelledby="tab-map" data-panel="map" hidden>
@@ -81,6 +83,8 @@ export function renderShell(root: HTMLElement) {
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
</div>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
<dialog id="help-dialog" aria-labelledby="help-title"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2 id="help-title">Playing Crystal in your browser</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl id="help-keys"></dl><p>On a touch screen, use the on-screen pad and buttons. Settings changes the keys and the text size.</p><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play. The Map tab lists the areas open in this port; the others appear at the edges but cannot be entered yet.</p></dialog>
<dialog id="help-dialog" aria-labelledby="help-title"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2 id="help-title">Playing Crystal in your browser</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl id="help-keys"></dl><p>On a touch screen, use the on-screen pad and buttons. Settings changes the keys and the text size.</p><p>Games save every 10 seconds and when paused. Savegames keeps several playthroughs. Switching tabs or apps pauses play. The Map tab lists the areas open in this port; the others appear at the edges but cannot be entered yet.</p></dialog>
<dialog id="slot-dialog" aria-labelledby="slot-dialog-title"><form method="dialog"><p class="menu-title">SAVEGAMES</p><h2 id="slot-dialog-title"></h2><p id="slot-dialog-text"></p><label id="slot-name-label" class="slot-name-field">Name <input id="slot-name" maxlength="24" autocomplete="off" spellcheck="false"></label><p id="slot-dialog-error" class="dialog-error" role="alert"></p><div class="dialog-actions"><button value="cancel" formnovalidate class="secondary-button">Cancel</button><button id="slot-dialog-confirm" value="confirm" class="menu-button"></button></div></form></dialog>
<dialog id="import-dialog" aria-labelledby="import-title"><form method="dialog"><p class="menu-title">IMPORT BACKUP</p><h2 id="import-title"></h2><p id="import-text"></p><dl id="import-details" class="import-details"></dl><div id="import-conflicts" class="import-conflicts"></div><div id="import-actions" class="dialog-actions"></div></form></dialog>
<dialog id="settings-dialog" aria-labelledby="settings-title"><button id="close-settings" class="dialog-close" aria-label="Close settings">×</button><p class="menu-title">SETTINGS</p><h2 id="settings-title">Display, sound and controls</h2><fieldset class="text-size"><legend>Text size</legend><label><input type="radio" name="text-scale" value="1"> Standard</label><label><input type="radio" name="text-scale" value="1.25"> Large</label><label><input type="radio" name="text-scale" value="1.5"> Largest</label></fieldset><fieldset class="text-size"><legend>Sound</legend><label><input type="radio" name="volume" value="0"> Off</label><label><input type="radio" name="volume" value="0.3"> Quiet</label><label><input type="radio" name="volume" value="0.6"> Medium</label><label><input type="radio" name="volume" value="1"> Full</label></fieldset><section class="key-settings" aria-labelledby="keys-title"><h3 id="keys-title">Keyboard controls</h3><p>Choose Change, then press the key to use. Tab, Escape and Space keep their usual roles.</p><table><thead><tr><th scope="col">Button</th><th scope="col">Key</th><th scope="col"><span class="visually-hidden">Change</span></th></tr></thead><tbody id="key-table"></tbody></table><button id="reset-keys" class="menu-button">Reset controls</button><p id="settings-status" class="settings-status" role="status" aria-live="polite"></p></section></dialog>`;
}
+130
View File
@@ -971,6 +971,83 @@ dialog h2 {
max-width: 720px;
color: #4e5157;
}
/* Savegames: one card per slot, the running one marked. */
.slots-empty {
color: #4e5157;
}
.slot-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
gap: 12px;
max-width: 960px;
margin: 0 0 22px;
padding: 0;
list-style: none;
}
.slot {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 14px;
background: var(--cream);
border: 3px double #747269;
}
.slot[aria-current="true"] {
border: 3px double var(--blue);
box-shadow: inset 4px 0 0 var(--blue);
}
.slot-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
margin: 0 0 4px;
font-size: calc(14px * var(--text-scale));
}
.slot-tag {
padding: 1px 6px;
color: #fff;
background: var(--blue);
font-size: calc(10px * var(--text-scale));
font-weight: 700;
}
.slot-tag.warning {
background: #8a4b16;
}
.slot-detail,
.slot-saved {
margin: 0;
}
.slot-saved {
color: #4e5157;
font-size: calc(11px * var(--text-scale));
}
.slot-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.slot-actions button,
.secondary-button {
min-height: 36px;
padding: 4px 10px;
color: var(--ink);
background: var(--cream-dark);
border: 2px solid var(--ink);
cursor: pointer;
}
.slot-actions button:disabled,
.secondary-button:disabled {
cursor: default;
opacity: 0.45;
}
.slot-hint {
flex-basis: 100%;
margin: 0;
color: #4e5157;
font-size: calc(10px * var(--text-scale));
}
.replace-rom {
max-width: 720px;
margin-top: 24px;
@@ -1250,6 +1327,59 @@ dialog dd {
min-height: 1.5em;
margin: 8px 0 0;
}
/* Savegames dialogs: rename, delete and the import preview. */
.dialog-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.dialog-actions .menu-button {
min-height: 36px;
}
.slot-name-field {
display: grid;
gap: 4px;
font-weight: 700;
}
.slot-name-field input {
min-height: 40px;
padding: 6px 8px;
font-weight: 400;
background: #fff;
border: 2px solid var(--ink);
}
.dialog-error:empty {
display: none;
}
.dialog-error {
margin: 6px 0 0;
color: #a12b16;
}
#import-dialog {
max-height: calc(100% - 24px);
overflow-y: auto;
}
#import-dialog .import-details {
grid-template-columns: max-content minmax(0, 1fr);
gap: 4px 14px;
margin: 0;
}
.import-details dt {
font-weight: 700;
}
.import-details dd {
overflow-wrap: anywhere;
}
.import-conflicts h3 {
margin: 16px 0 4px;
font-size: calc(13px * var(--text-scale));
}
.import-conflicts ul {
margin: 0;
padding-left: 20px;
}
.dialog-close {
float: right;
background: transparent;