Savegames → Import backup now reads an English Crystal .sav (from an emulator, a cartridge dump or the 3DS Virtual Console's sav.dat) and converts it into a native save after a preview. - world/cartridge-save.ts: picks the copy TryLoadSaveFile would load (check values and checksums of the main copy, else the backup), reads the saved WRAM block, the numbered boxes, mail backups, Hall of Fame and Battle Tower by PRET labels (SRAM, SAVE_BLOCK in the profile, checked by check:reference with the SAV constants), engine flags via EngineFlags, scenes via MapScenes, and the map's objects and masks. - Clock: an emulator MBC3 footer plus the time since it was written, FixDays/FixTime with wStart*, mapped to the minute and weekday offsets; without one, the device's time and the saved wCurDay. Day countdowns move with the day difference. - Refused with the reason: unported maps, the Bug-Catching Contest, a Battle Tower challenge, characters outside the naming screen. - Names keep <PK>/<MN> as ᴾᴷ/ᴹᴺ (one character, one tile); trainer names accept the naming screen's alphabet; boxed Eggs validate; native states up to 256 kB load (a full PC exceeded 64 kB). - Tests: the committed debug saves, layout offsets against the symbols, backup fallback, clock footers, day counters, refusals, slot session and the Savegames flow in Chromium and WebKit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
509 lines
18 KiB
TypeScript
509 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import "fake-indexeddb/auto";
|
|
import { SlotSession, type SlotRuntime } from "../src/app/slot-session";
|
|
import { encodeSave, type SaveRecord } from "../src/storage/saves";
|
|
import {
|
|
SlotRepository,
|
|
recordChecksum,
|
|
type SlotSummary,
|
|
} from "../src/storage/slots";
|
|
|
|
const ROM = { bytes: new Uint8Array([1]), sha1: "test-rom", name: "Test" };
|
|
const CORE = "test-native";
|
|
const canvas = {} as HTMLCanvasElement;
|
|
let databases = 0;
|
|
|
|
/** A game whose state is JSON { player, id, steps }. */
|
|
class FakeRuntime implements SlotRuntime {
|
|
readonly id = CORE;
|
|
game = { player: "", id: 0, steps: 0 };
|
|
private games = 0;
|
|
async load() {}
|
|
newGame() {
|
|
this.game = { player: "KRIS", id: ++this.games, steps: 0 };
|
|
}
|
|
reset() {
|
|
this.game = { player: "", id: 0, steps: 0 };
|
|
}
|
|
play() {}
|
|
pause() {}
|
|
setButton() {}
|
|
dispose() {}
|
|
captureState() {
|
|
return new TextEncoder().encode(JSON.stringify(this.game));
|
|
}
|
|
restoreState(state: Uint8Array) {
|
|
this.game = this.parse(state);
|
|
}
|
|
summary(): SlotSummary {
|
|
return summarize(this.game);
|
|
}
|
|
inspectState(state: Uint8Array) {
|
|
return { summary: summarize(this.parse(state)), schema: 3 };
|
|
}
|
|
/** A "cartridge save" here is the game's JSON prefixed with "SAV". */
|
|
convertCartridge(file: Uint8Array) {
|
|
const text = new TextDecoder().decode(file);
|
|
if (!text.startsWith("SAV")) throw new Error("Not a cartridge save.");
|
|
return {
|
|
state: new TextEncoder().encode(text.slice(3)),
|
|
copy: "backup" as const,
|
|
clock: "device" as const,
|
|
notes: ["A note."],
|
|
};
|
|
}
|
|
private parse(state: Uint8Array) {
|
|
const game = JSON.parse(new TextDecoder().decode(state));
|
|
if (typeof game.player !== "string" || !Number.isInteger(game.steps))
|
|
throw new Error("Bad state");
|
|
return game as FakeRuntime["game"];
|
|
}
|
|
}
|
|
function summarize(game: FakeRuntime["game"]): SlotSummary {
|
|
return {
|
|
player: game.player,
|
|
playerId: game.id,
|
|
place: "NEW BARK TOWN",
|
|
hours: 0,
|
|
minutes: game.steps,
|
|
badges: 0,
|
|
caught: 0,
|
|
newGame: false,
|
|
};
|
|
}
|
|
|
|
function setup() {
|
|
const name = `slots-${++databases}`;
|
|
const store = new SlotRepository(name);
|
|
const runtime = new FakeRuntime();
|
|
return { name, store, runtime, session: new SlotSession(runtime, store) };
|
|
}
|
|
const state = (game: object) => new TextEncoder().encode(JSON.stringify(game));
|
|
const record = (game: object, extra: Partial<SaveRecord> = {}): SaveRecord => ({
|
|
version: 1,
|
|
romHash: ROM.sha1,
|
|
coreId: CORE,
|
|
savedAt: "2026-10-01T12:00:00.000Z",
|
|
state: state(game),
|
|
...extra,
|
|
});
|
|
|
|
/** A version-1 database as the singleton player left it. */
|
|
async function legacyDatabase(name: string, saved: SaveRecord) {
|
|
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
const request = indexedDB.open(name, 1);
|
|
request.onupgradeneeded = () =>
|
|
request.result.createObjectStore("saves", { keyPath: "romHash" });
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
const transaction = db.transaction("saves", "readwrite");
|
|
transaction.objectStore("saves").put(saved);
|
|
transaction.oncomplete = () => resolve();
|
|
transaction.onerror = () => reject(transaction.error);
|
|
});
|
|
db.close();
|
|
}
|
|
async function legacyRecords(name: string) {
|
|
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
const request = indexedDB.open(name);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
const records = await new Promise<SaveRecord[]>((resolve, reject) => {
|
|
const request = db.transaction("saves").objectStore("saves").getAll();
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
db.close();
|
|
return records;
|
|
}
|
|
/** Overwrites a stored record behind the repository's back. */
|
|
async function tamper(
|
|
name: string,
|
|
slot: string,
|
|
generation: number,
|
|
change: (stored: { record: SaveRecord; checksum: number }) => void,
|
|
) {
|
|
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
const request = indexedDB.open(name);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
const transaction = db.transaction("records", "readwrite");
|
|
const records = transaction.objectStore("records");
|
|
const request = records.get([slot, generation]);
|
|
request.onsuccess = () => {
|
|
change(request.result);
|
|
records.put(request.result);
|
|
};
|
|
transaction.oncomplete = () => resolve();
|
|
transaction.onerror = () => reject(transaction.error);
|
|
});
|
|
db.close();
|
|
}
|
|
|
|
describe("save slot migration", () => {
|
|
it("moves the singleton save into a default slot exactly once", async () => {
|
|
const { name, store, runtime, session } = setup();
|
|
const saved = record({ player: "GOLD", id: 7, steps: 12 });
|
|
await legacyDatabase(name, saved);
|
|
expect(await session.load(ROM, canvas)).toMatchObject({ restored: true });
|
|
expect(runtime.game).toEqual({ player: "GOLD", id: 7, steps: 12 });
|
|
const [slot] = await store.list(ROM.sha1);
|
|
expect(slot).toMatchObject({ name: "Save 1", head: 1, version: 1 });
|
|
expect(session.active?.id).toBe(slot.id);
|
|
expect((await store.records(slot.id))[0]).toEqual({
|
|
generation: 1,
|
|
record: saved,
|
|
intact: true,
|
|
});
|
|
// The list reads the summary from the record until the first write.
|
|
expect((await session.slots())[0].slot.summary?.player).toBe("GOLD");
|
|
// The version-1 store is kept as it was.
|
|
expect(await legacyRecords(name)).toEqual([saved]);
|
|
// Deleting the slot does not bring the singleton back on reopening.
|
|
await session.newGame();
|
|
await session.delete(slot.id);
|
|
const reopened = new SlotRepository(name);
|
|
expect((await reopened.list(ROM.sha1)).map((s) => s.name)).toEqual([
|
|
"Save 2",
|
|
]);
|
|
});
|
|
|
|
it("keeps a save from a newer player without rewriting it", async () => {
|
|
const { name, store, session } = setup();
|
|
const future = {
|
|
...record({ player: "SILVER", id: 3, steps: 1 }),
|
|
version: 2,
|
|
extra: "kept",
|
|
} as unknown as SaveRecord;
|
|
await legacyDatabase(name, future);
|
|
const result = await session.load(ROM, canvas);
|
|
expect(result.restored).toBe(false);
|
|
expect(result.skipped.map((slot) => slot.name)).toEqual(["Save 1"]);
|
|
const entries = await session.slots();
|
|
const kept = entries.find((entry) => !entry.compatible)!;
|
|
expect(kept.slot.name).toBe("Save 1");
|
|
expect(entries.find((entry) => entry.active)?.slot.name).toBe("Save 2");
|
|
await session.save();
|
|
await expect(session.switchTo(kept.slot.id)).rejects.toThrow(
|
|
"newer version",
|
|
);
|
|
await expect(session.duplicate(kept.slot.id)).rejects.toThrow(
|
|
"newer version",
|
|
);
|
|
expect((await store.records(kept.slot.id))[0].record).toEqual(future);
|
|
expect(session.active?.name).toBe("Save 2");
|
|
});
|
|
|
|
it("starts a new game in a first slot on an empty database", async () => {
|
|
const { store, runtime, session } = setup();
|
|
expect(await session.load(ROM, canvas)).toEqual({
|
|
restored: false,
|
|
recovered: false,
|
|
skipped: [],
|
|
});
|
|
expect(runtime.game.player).toBe("KRIS");
|
|
expect((await store.list(ROM.sha1)).map((slot) => slot.name)).toEqual([
|
|
"Save 1",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("save slots", () => {
|
|
it("creates, loads, renames, duplicates and deletes independent slots", async () => {
|
|
const { store, runtime, session } = setup();
|
|
await session.load(ROM, canvas);
|
|
const first = session.active!;
|
|
runtime.game.steps = 5;
|
|
await session.save();
|
|
|
|
const second = await session.newGame("Nuzlocke");
|
|
expect(runtime.game.steps).toBe(0);
|
|
runtime.game.steps = 9;
|
|
await session.save();
|
|
|
|
await session.switchTo(first.id);
|
|
expect(runtime.game.steps).toBe(5);
|
|
expect(session.active?.id).toBe(first.id);
|
|
expect(await store.active(ROM.sha1)).toBe(first.id);
|
|
|
|
await session.rename(first.id, " Main run ");
|
|
expect(session.active?.name).toBe("Main run");
|
|
await expect(session.rename(first.id, "")).rejects.toThrow("1 to 24");
|
|
await expect(session.rename(first.id, "x".repeat(25))).rejects.toThrow(
|
|
"1 to 24",
|
|
);
|
|
|
|
runtime.game.steps = 6;
|
|
const copy = await session.duplicate(first.id);
|
|
expect(copy.name).toBe("Main run copy");
|
|
expect(copy.summary?.minutes).toBe(6);
|
|
// The copy is independent of its source.
|
|
runtime.game.steps = 7;
|
|
await session.save();
|
|
await session.switchTo(copy.id);
|
|
expect(runtime.game.steps).toBe(6);
|
|
|
|
await expect(session.delete(copy.id)).rejects.toThrow("Load another save");
|
|
await session.switchTo(second.id);
|
|
expect(runtime.game.steps).toBe(9);
|
|
await session.delete(copy.id);
|
|
expect(
|
|
(await session.slots()).map((entry) => entry.slot.name).sort(),
|
|
).toEqual(["Main run", "Nuzlocke"]);
|
|
expect(await store.records(copy.id)).toEqual([]);
|
|
});
|
|
|
|
it("writes each autosave into the slot it was captured from", async () => {
|
|
const { store, runtime, session } = setup();
|
|
await session.load(ROM, canvas);
|
|
const first = session.active!;
|
|
// Hold every write until released, as a slow device would.
|
|
let release!: () => void;
|
|
const held = new Promise<void>((resolve) => (release = resolve));
|
|
const write = store.write.bind(store);
|
|
store.write = async (...args) => {
|
|
await held;
|
|
return write(...args);
|
|
};
|
|
runtime.game.steps = 3;
|
|
const autosave = session.save();
|
|
const second = session.newGame();
|
|
release();
|
|
await autosave;
|
|
const slot = await second;
|
|
store.write = write;
|
|
expect(runtime.game.steps).toBe(0);
|
|
const [newest] = await store.records(first.id);
|
|
expect(
|
|
JSON.parse(new TextDecoder().decode(newest.record.state)).steps,
|
|
).toBe(3);
|
|
expect((await store.records(slot.id)).length).toBe(1);
|
|
expect((await store.get(slot.id))?.summary?.minutes).toBe(0);
|
|
});
|
|
|
|
it("keeps two records and recovers from a damaged or missing newest one", async () => {
|
|
const { name, store, runtime, session } = setup();
|
|
await session.load(ROM, canvas);
|
|
const slot = session.active!;
|
|
for (const steps of [1, 2, 3]) {
|
|
runtime.game.steps = steps;
|
|
await session.save();
|
|
}
|
|
expect((await store.records(slot.id)).map((r) => r.generation)).toEqual([
|
|
4, 3,
|
|
]);
|
|
// A torn write: the newest record no longer matches its checksum.
|
|
await tamper(name, slot.id, 4, (stored) => {
|
|
stored.record.state = state({ player: "KRIS", id: 1, steps: 99 });
|
|
});
|
|
const reloaded = new SlotSession(
|
|
new FakeRuntime(),
|
|
new SlotRepository(name),
|
|
);
|
|
expect(await reloaded.load(ROM, canvas)).toMatchObject({
|
|
restored: true,
|
|
recovered: true,
|
|
});
|
|
expect((reloaded.runtime as FakeRuntime).game.steps).toBe(2);
|
|
// The next write supersedes the damaged record.
|
|
await reloaded.save();
|
|
expect(
|
|
(await store.records(slot.id)).map((r) => [r.generation, r.intact]),
|
|
).toEqual([
|
|
[5, true],
|
|
[4, false],
|
|
]);
|
|
|
|
// A newest record the game rejects also falls back.
|
|
await tamper(name, slot.id, 5, (stored) => {
|
|
stored.record.state = state({ player: 5 });
|
|
stored.checksum = recordChecksum(stored.record);
|
|
});
|
|
const again = new SlotSession(new FakeRuntime(), new SlotRepository(name));
|
|
await expect(again.load(ROM, canvas)).resolves.toMatchObject({
|
|
restored: false,
|
|
skipped: [expect.objectContaining({ id: slot.id })],
|
|
});
|
|
});
|
|
|
|
it("leaves the slot unchanged when a write is interrupted", async () => {
|
|
const { store, runtime, session } = setup();
|
|
await session.load(ROM, canvas);
|
|
const slot = session.active!;
|
|
runtime.game.steps = 1;
|
|
await session.save();
|
|
const before = await store.records(slot.id);
|
|
// A value IndexedDB cannot store aborts the transaction midway.
|
|
const summary = {
|
|
...runtime.summary(),
|
|
place: (() => {}) as unknown as string,
|
|
};
|
|
await expect(
|
|
store.write(
|
|
slot.id,
|
|
record({ player: "KRIS", id: 1, steps: 8 }),
|
|
summary,
|
|
),
|
|
).rejects.toThrow();
|
|
expect(await store.records(slot.id)).toEqual(before);
|
|
expect((await store.get(slot.id))?.head).toBe(2);
|
|
});
|
|
|
|
it("restores the running game when a slot cannot be opened", async () => {
|
|
const { name, store, runtime, session } = setup();
|
|
await session.load(ROM, canvas);
|
|
const first = session.active!;
|
|
const broken = await store.create(
|
|
ROM.sha1,
|
|
"Broken",
|
|
record({ player: 1 }),
|
|
);
|
|
runtime.game.steps = 4;
|
|
await expect(session.switchTo(broken.id)).rejects.toThrow(
|
|
"could not be read",
|
|
);
|
|
expect(runtime.game.steps).toBe(4);
|
|
expect(session.active?.id).toBe(first.id);
|
|
expect(await new SlotRepository(name).active(ROM.sha1)).toBe(first.id);
|
|
});
|
|
});
|
|
|
|
describe("save slot import", () => {
|
|
async function loaded() {
|
|
const context = setup();
|
|
await context.session.load(ROM, canvas);
|
|
context.runtime.game.steps = 2;
|
|
await context.session.save();
|
|
return context;
|
|
}
|
|
|
|
it("rejects incompatible and damaged backups without changing a slot", async () => {
|
|
const { store, runtime, session } = await loaded();
|
|
const slot = session.active!;
|
|
const before = await store.records(slot.id);
|
|
const good = record({ player: "KRIS", id: 5, steps: 1 });
|
|
const cases: [string, string][] = [
|
|
["not json", "not a Crystal backup"],
|
|
[encodeSave({ ...good, romHash: "other" }), "different cartridge"],
|
|
[encodeSave({ ...good, coreId: "reference" }), "another player"],
|
|
[
|
|
JSON.stringify({ ...JSON.parse(encodeSave(good)), version: 2 }),
|
|
"save format 2",
|
|
],
|
|
[
|
|
JSON.stringify({ ...JSON.parse(encodeSave(good)), state: "???=" }),
|
|
"damaged",
|
|
],
|
|
[encodeSave(record({ player: 1 })), "could not be read"],
|
|
["x".repeat(2_000_001), "too large"],
|
|
];
|
|
for (const [text, reason] of cases) {
|
|
const preview = await session.previewImport(text);
|
|
expect(preview.ok).toBe(false);
|
|
expect(!preview.ok && preview.reason).toContain(reason);
|
|
}
|
|
expect(await store.records(slot.id)).toEqual(before);
|
|
expect(runtime.game.steps).toBe(2);
|
|
});
|
|
|
|
it("previews conflicts and imports as a new slot or over one", async () => {
|
|
const { store, runtime, session } = await loaded();
|
|
const first = session.active!;
|
|
const { text } = await session.exportSlot(first.id);
|
|
const preview = await session.previewImport(text);
|
|
if (!preview.ok) throw new Error(preview.reason);
|
|
expect(preview.summary).toMatchObject({ player: "KRIS", minutes: 2 });
|
|
expect(preview.schema).toBe(3);
|
|
// The export is the running game, which was just saved.
|
|
expect(preview.conflicts).toEqual([
|
|
{ slot: expect.objectContaining({ id: first.id }), identical: true },
|
|
]);
|
|
|
|
runtime.game.steps = 8;
|
|
const added = await session.importBackup(preview, "new");
|
|
expect(added.name).toBe("KRIS");
|
|
expect(session.active?.id).toBe(added.id);
|
|
expect(runtime.game.steps).toBe(2);
|
|
// The slot left behind kept the progress made before importing.
|
|
const [left] = await store.records(first.id);
|
|
expect(JSON.parse(new TextDecoder().decode(left.record.state)).steps).toBe(
|
|
8,
|
|
);
|
|
|
|
const replaced = await session.importBackup(preview, first.id);
|
|
expect(replaced.id).toBe(first.id);
|
|
expect(session.active?.id).toBe(first.id);
|
|
// The replaced record stays as the previous generation.
|
|
const records = await store.records(first.id);
|
|
expect(
|
|
records.map(
|
|
(r) => JSON.parse(new TextDecoder().decode(r.record.state)).steps,
|
|
),
|
|
).toEqual([2, 8]);
|
|
});
|
|
|
|
it("previews a cartridge save and imports it as a new slot", async () => {
|
|
const { store, runtime, session } = await loaded();
|
|
const first = session.active!;
|
|
const file = new TextEncoder().encode(
|
|
`SAV${JSON.stringify({ player: "GOLD", id: 9, steps: 4 })}`,
|
|
);
|
|
const modified = new Date("2026-10-02T08:00:00.000Z");
|
|
const preview = await session.previewCartridge(file, modified);
|
|
if (!preview.ok) throw new Error(preview.reason);
|
|
expect(preview.summary).toMatchObject({ player: "GOLD", minutes: 4 });
|
|
expect(preview.record.savedAt).toBe(modified.toISOString());
|
|
expect(preview.cartridge).toEqual({
|
|
copy: "backup",
|
|
clock: "device",
|
|
notes: ["A note."],
|
|
});
|
|
expect(preview.conflicts).toEqual([]);
|
|
const slot = await session.importBackup(preview, "new");
|
|
expect(slot.name).toBe("GOLD");
|
|
expect(runtime.game).toEqual({ player: "GOLD", id: 9, steps: 4 });
|
|
expect((await store.list(ROM.sha1)).map((s) => s.id)).toContain(first.id);
|
|
});
|
|
|
|
it("refuses a cartridge save that cannot be converted or loaded", async () => {
|
|
const { store, runtime, session } = await loaded();
|
|
const slot = session.active!;
|
|
const before = await store.records(slot.id);
|
|
const refused = await session.previewCartridge(
|
|
new TextEncoder().encode("{}"),
|
|
new Date(),
|
|
);
|
|
expect(!refused.ok && refused.reason).toBe("Not a cartridge save.");
|
|
const unreadable = await session.previewCartridge(
|
|
new TextEncoder().encode(`SAV${JSON.stringify({ player: 1 })}`),
|
|
new Date(),
|
|
);
|
|
expect(!unreadable.ok && unreadable.reason).toContain(
|
|
"did not pass the port's checks",
|
|
);
|
|
expect(await store.records(slot.id)).toEqual(before);
|
|
expect(runtime.game.steps).toBe(2);
|
|
});
|
|
|
|
it("tells a conflicting slot that moved on from an identical one", async () => {
|
|
const { session, store, runtime } = await loaded();
|
|
const first = session.active!;
|
|
const { text } = await session.exportSlot(first.id);
|
|
runtime.game.steps = 3;
|
|
await session.save();
|
|
const other = await session.newGame();
|
|
expect((await store.list(ROM.sha1)).length).toBe(2);
|
|
const preview = await session.previewImport(text);
|
|
// The second game is another trainer; the first moved on since.
|
|
expect(other.summary?.playerId).not.toBe(first.summary?.playerId);
|
|
expect(preview.ok && preview.conflicts).toEqual([
|
|
{ slot: expect.objectContaining({ id: first.id }), identical: false },
|
|
]);
|
|
});
|
|
});
|