PRET's crystal and crystal_au builds reproduce both retail cartridges, and every label the profile pins keeps its address in all three; check:reference now verifies that when the rebuilds are present. The revision comes from the header. Revision 1.0's Battle Tower draws only the first 21 trainers and picks its texts' gender from the name's sixth character, checked against each cartridge. A native backup imports across the three, which share every map, event and object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
164 lines
5.5 KiB
TypeScript
164 lines
5.5 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import "fake-indexeddb/auto";
|
|
import { InputController } from "../src/input/controller";
|
|
import { CRYSTAL_ROM, validateRom } from "../src/rom/validate";
|
|
import {
|
|
BrowserSaveRepository,
|
|
decodeSave,
|
|
encodeSave,
|
|
type SaveRecord,
|
|
} from "../src/storage/saves";
|
|
import { GameSession } from "../src/app/session";
|
|
import type { Emulator } from "../src/emulator/types";
|
|
|
|
const record: SaveRecord = {
|
|
version: 1,
|
|
romHash: "test-rom",
|
|
coreId: "test-core",
|
|
savedAt: "2026-09-22T12:00:00.000Z",
|
|
state: new Uint8Array([0, 128, 255]),
|
|
};
|
|
|
|
describe("ROM validation", () => {
|
|
it("rejects incorrect sizes without reading the file", async () => {
|
|
const file = new File(["not a game"], "crystal.gbc");
|
|
await expect(validateRom(file)).rejects.toThrow("2 MiB");
|
|
});
|
|
it("rejects a correctly sized file with a forged title and revision", async () => {
|
|
const bytes = new Uint8Array(CRYSTAL_ROM.size);
|
|
bytes.set(new TextEncoder().encode("PM_CRYSTAL"), 0x134);
|
|
bytes[0x14c] = 1;
|
|
await expect(
|
|
validateRom(new File([bytes], "Pokemon Crystal EU 1.1.gbc")),
|
|
).rejects.toThrow("not a supported English Pokémon Crystal");
|
|
});
|
|
});
|
|
|
|
describe("input aggregation", () => {
|
|
it("keeps a button pressed until all sources release it", () => {
|
|
const send = vi.fn();
|
|
const input = new InputController(send);
|
|
input.press("finger:1", "a");
|
|
input.press("keyboard:x", "a");
|
|
input.release("finger:1");
|
|
expect(send.mock.calls).toEqual([["a", true]]);
|
|
input.release("keyboard:x");
|
|
expect(send.mock.calls).toEqual([
|
|
["a", true],
|
|
["a", false],
|
|
]);
|
|
});
|
|
it("slides a pointer to a different direction and releases everything on interruption", () => {
|
|
const send = vi.fn();
|
|
const input = new InputController(send);
|
|
input.press("finger:1", "up");
|
|
input.press("finger:1", "left");
|
|
expect(send.mock.calls).toEqual([
|
|
["up", true],
|
|
["up", false],
|
|
["left", true],
|
|
]);
|
|
input.releaseAll();
|
|
expect(input.isPressed("left")).toBe(false);
|
|
expect(send).toHaveBeenCalledWith("left", false);
|
|
});
|
|
});
|
|
|
|
describe("saves", () => {
|
|
it("round trips binary state and rejects incompatible imports", () => {
|
|
const text = encodeSave(record);
|
|
expect(decodeSave(text, record.romHash, record.coreId)).toEqual(record);
|
|
expect(() => decodeSave(text, "another-rom", record.coreId)).toThrow(
|
|
"not compatible",
|
|
);
|
|
expect(() => decodeSave(text, record.romHash, "another-core")).toThrow(
|
|
"not compatible",
|
|
);
|
|
expect(() =>
|
|
decodeSave(
|
|
JSON.stringify({ ...record, state: "???=" }),
|
|
record.romHash,
|
|
record.coreId,
|
|
),
|
|
).toThrow("damaged");
|
|
});
|
|
it("persists isolated ROM slots through a fresh repository", async () => {
|
|
const first = new BrowserSaveRepository();
|
|
await first.write(record);
|
|
const second = new BrowserSaveRepository();
|
|
expect(await second.read(record.romHash)).toEqual(record);
|
|
expect(await second.read("another-rom")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
function setup(saved?: SaveRecord) {
|
|
let bytes = new Uint8Array([3]);
|
|
const emulator: Emulator = {
|
|
id: record.coreId,
|
|
load: vi.fn(async () => {}),
|
|
play: vi.fn(),
|
|
pause: vi.fn(),
|
|
setButton: vi.fn(),
|
|
setMuted: vi.fn(),
|
|
unlockAudio: vi.fn(),
|
|
captureState: () => bytes.slice(),
|
|
restoreState: vi.fn((state) => {
|
|
if (state[0] === 255) throw new Error("Bad state");
|
|
bytes = new Uint8Array(state);
|
|
}),
|
|
exportBattery: () => bytes.slice(),
|
|
dispose: vi.fn(),
|
|
};
|
|
const saves = {
|
|
read: vi.fn(async () => saved),
|
|
write: vi.fn(async (_value: SaveRecord) => {}),
|
|
};
|
|
const session = new GameSession(emulator, saves);
|
|
const rom = {
|
|
bytes: new Uint8Array([1]),
|
|
sha1: record.romHash,
|
|
name: "Synthetic",
|
|
};
|
|
return { emulator, saves, session, rom };
|
|
}
|
|
|
|
describe("session lifecycle", () => {
|
|
it("loads a saved session paused and saves while playing without pausing", async () => {
|
|
const { emulator, saves, session, rom } = setup(record);
|
|
expect(await session.load(rom, {} as HTMLCanvasElement)).toBe(true);
|
|
expect(session.state).toBe("paused");
|
|
expect(emulator.restoreState).toHaveBeenCalledWith(record.state);
|
|
session.play();
|
|
await session.save();
|
|
expect(session.state).toBe("playing");
|
|
expect(saves.write).toHaveBeenCalledOnce();
|
|
session.pause();
|
|
expect(session.state).toBe("paused");
|
|
});
|
|
it("preserves saves from another core and refuses to overwrite them", async () => {
|
|
const { session, rom, saves } = setup({ ...record, coreId: "old" });
|
|
await expect(session.load(rom, {} as HTMLCanvasElement)).rejects.toThrow(
|
|
"migration",
|
|
);
|
|
expect(saves.write).not.toHaveBeenCalled();
|
|
expect(() => session.snapshot()).toThrow("Load a ROM");
|
|
});
|
|
it("rolls back a failed backup import", async () => {
|
|
const { session, rom } = setup();
|
|
await session.load(rom, {} as HTMLCanvasElement);
|
|
await expect(
|
|
session.importSave(
|
|
encodeSave({ ...record, state: new Uint8Array([255]) }),
|
|
),
|
|
).rejects.toThrow("Bad state");
|
|
expect(session.snapshot().state).toEqual(new Uint8Array([3]));
|
|
});
|
|
it("reports write failures and allows a later save to succeed", async () => {
|
|
const { session, rom, saves } = setup();
|
|
await session.load(rom, {} as HTMLCanvasElement);
|
|
saves.write.mockRejectedValueOnce(new Error("Quota exceeded"));
|
|
await expect(session.save()).rejects.toThrow("Quota exceeded");
|
|
await expect(session.save()).resolves.toEqual(expect.any(String));
|
|
});
|
|
});
|