Add native Crystal exploration port and reference tooling
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 09:23:29 +02:00
1 parent 2127f5f459
commit 8b80d05ca2
56 files changed
+6910 -1

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
import { expect, test } from "@playwright/test";
import { syntheticRom } from "./synthetic-rom";
test("ROM gate, help and responsive layouts", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await expect(
page.getByRole("heading", { name: "A little adventure. Anywhere." }),
).toBeVisible();
await expect(page.locator("#play")).toBeDisabled();
await page.locator("#rom-file").setInputFiles({
name: "wrong.gbc",
mimeType: "application/octet-stream",
buffer: Buffer.from("wrong"),
});
await expect(page.locator("#status")).toContainText("2 MiB");
await page.locator("#help").click();
await expect(page.locator("#help-dialog")).toBeVisible();
await page.locator("#close-help").click();
for (const [width, height] of [
[1440, 1000],
[1024, 900],
[768, 1024],
[390, 844],
[320, 740],
[844, 390],
]) {
await page.setViewportSize({ width, height });
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
const buttons = await page
.locator("[data-game-button]")
.evaluateAll((elements) =>
elements.map((el) => {
const rect = el.getBoundingClientRect();
return { width: rect.width, height: rect.height };
}),
);
for (const button of buttons) {
expect(button.width).toBeGreaterThanOrEqual(44);
expect(button.height).toBeGreaterThanOrEqual(44);
}
}
expect(errors).toEqual([]);
});
test("actual WASM executes an original cartridge, reads input and round-trips state", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
const result = await page.evaluate(async (bytes) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const canvas = document.createElement("canvas");
document.body.append(canvas);
const emulator = new BinjgbEmulator((error) => {
throw error;
});
const wait = () => new Promise((resolve) => setTimeout(resolve, 180));
await emulator.load(new Uint8Array(bytes), canvas);
emulator.play();
await wait();
emulator.pause();
const before = emulator.exportBattery();
const state = emulator.captureState();
const pixels = canvas.getContext("2d")!.getImageData(0, 0, 160, 144).data;
const colors = new Set<string>();
for (let i = 0; i < pixels.length; i += 4)
colors.add(`${pixels[i]},${pixels[i + 1]},${pixels[i + 2]}`);
emulator.setButton("a", true);
emulator.play();
await wait();
emulator.pause();
const pressed = emulator.exportBattery()[0];
emulator.restoreState(state);
const restored = emulator.exportBattery()[0];
const badSizeRejected = (() => {
try {
emulator.restoreState(new Uint8Array([1]));
return false;
} catch {
return true;
}
})();
emulator.dispose();
await emulator.load(new Uint8Array(bytes), canvas);
emulator.restoreState(state);
const reopened = emulator.exportBattery()[0];
emulator.dispose();
canvas.remove();
return {
unpressed: before[0],
pressed,
restored,
reopened,
colors: colors.size,
stateSize: state.length,
ramSize: before.length,
badSizeRejected,
};
}, Array.from(syntheticRom()));
expect(result.ramSize).toBe(32768);
expect(result.stateSize).toBeGreaterThan(32768);
expect(result.colors).toBeGreaterThan(1);
expect(result.unpressed & 1).toBe(1);
expect(result.pressed & 1).toBe(0);
expect(result.restored).toBe(result.unpressed);
expect(result.reopened).toBe(result.unpressed);
expect(result.badSizeRejected).toBe(true);
expect(errors).toEqual([]);
});
test("full player lifecycle with test-only validator replacement", async ({
page,
context,
browserName,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
// The shipped validator is never relaxed. Only this browser's module response
// is replaced to exercise the real UI with our original synthetic cartridge.
await page.route("**/src/rom/validate.ts", (route) =>
route.fulfill({
contentType: "application/javascript",
body: 'export async function validateRom(file) { return { bytes: new Uint8Array(await file.arrayBuffer()), sha1: "synthetic-browser-test", name: "Original test cartridge" }; }',
}),
);
await page.goto("/reference.html");
const cartridge = {
name: "synthetic.gbc",
mimeType: "application/octet-stream",
buffer: Buffer.from(syntheticRom()),
};
await page.locator("#rom-file").setInputFiles(cartridge);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#play").click();
await expect(page.locator("#power-label")).toHaveText("PLAYING");
const action = page.locator('[data-game-button="a"]');
await action.scrollIntoViewIfNeeded();
const bounds = (await action.boundingBox())!;
await page.mouse.move(
bounds.x + bounds.width / 2,
bounds.y + bounds.height / 2,
);
await page.mouse.down();
await expect(action).toHaveAttribute("aria-pressed", "true");
await page.mouse.up();
await expect(action).toHaveAttribute("aria-pressed", "false");
await page.locator("#game-screen").focus();
await page.keyboard.down("KeyX");
await expect(page.locator('[data-game-button="a"]')).toHaveAttribute(
"aria-pressed",
"true",
);
await page.keyboard.up("KeyX");
await action.focus();
await page.keyboard.down("Space");
await expect(action).toHaveAttribute("aria-pressed", "true");
await page.keyboard.up("Space");
await expect(action).toHaveAttribute("aria-pressed", "false");
// Loaded layouts must keep screen and gameplay controls visible together.
for (const [width, height] of [
[390, 844],
[320, 740],
[844, 390],
]) {
await page.setViewportSize({ width, height });
await page.evaluate(() => scrollTo(0, 0));
const geometry = await page.locator(".console").evaluate((el) => {
const rect = el.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
viewport: innerHeight,
fits: document.documentElement.scrollWidth <= innerWidth,
};
});
expect(geometry.fits).toBe(true);
expect(geometry.top).toBeGreaterThanOrEqual(0);
expect(geometry.bottom).toBeLessThanOrEqual(geometry.viewport);
}
await page.setViewportSize({ width: 390, height: 844 });
if (browserName === "chromium") {
const cdp = await context.newCDPSession(page);
const up = page.locator('[data-game-button="up"]');
const points = await Promise.all(
[up, action].map(async (button, id) => {
const rect = (await button.boundingBox())!;
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, id };
}),
);
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: points,
});
await expect(up).toHaveAttribute("aria-pressed", "true");
await expect(action).toHaveAttribute("aria-pressed", "true");
await cdp.send("Input.dispatchTouchEvent", {
type: "touchCancel",
touchPoints: [],
});
await expect(up).toHaveAttribute("aria-pressed", "false");
await expect(action).toHaveAttribute("aria-pressed", "false");
await cdp.detach();
}
await page.locator("#play").click();
await expect(page.locator("#power-label")).toHaveText("PAUSED");
await expect(page.locator("#save-status")).toContainText("Session saved");
const downloadEvent = page.waitForEvent("download");
await page.locator("#backup").click();
const download = await downloadEvent;
expect(download.suggestedFilename()).toContain(".crystal-save.json");
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles((await download.path())!);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.reload();
await page.locator("#rom-file").setInputFiles(cartridge);
await expect(page.locator("#status")).toContainText("saved session is ready");
const second = await context.newPage();
await second.route("**/src/rom/validate.ts", (route) =>
route.fulfill({
contentType: "application/javascript",
body: 'export async function validateRom(file) { return { bytes: new Uint8Array(await file.arrayBuffer()), sha1: "synthetic-browser-test", name: "Test" }; }',
}),
);
await second.goto("/reference.html");
await second.locator("#rom-file").setInputFiles(cartridge);
await expect(second.locator("#status")).toContainText("another tab");
await second.close();
expect(errors).toEqual([]);
});
+163
View File
@@ -0,0 +1,163 @@
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 the supported");
});
});
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));
});
});
+106
View File
@@ -0,0 +1,106 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
test("development server does not serve ROMs or the private reference checkout", async ({
request,
}) => {
for (const path of [
"/Pokemon%20-%20Crystal%20Version%20(UE)%20(V1.1)%20%5BC%5D%5B!%5D.gbc",
"/.reference/pokecrystal/README.md",
]) {
const response = await request.get(path);
expect([403, 404]).toContain(response.status());
}
});
test("native player loads the actual ROM without loading an emulator and persists movement", async ({
page,
context,
}, testInfo) => {
test.skip(
!existsSync(romPath),
"Requires a local user-supplied ROM; never downloaded or committed.",
);
const requests: string[] = [],
errors: string[] = [];
page.on("request", (request) => requests.push(request.url()));
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await page.locator("#player-name").fill("KAWA");
await page.locator("#player-avatar").selectOption("kris");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-map",
"24:7",
);
await page.locator("#play").click();
await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "3");
await page.keyboard.down("ArrowUp");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2");
await page.keyboard.up("ArrowUp");
await page.waitForTimeout(150);
await page.keyboard.press("KeyX");
await expect(page.locator("#native-dialogue")).toBeVisible();
await expect(page.locator("#dialogue-page")).toHaveText("1 / 4");
for (let i = 0; i < 4; i++) await page.locator("#dialogue-next").click();
await expect(page.locator("#native-dialogue")).toBeHidden();
await page.locator("#play").click();
await expect(page.locator("#save-status")).toContainText("Session saved");
const exported = page.waitForEvent("download");
await page.locator("#backup").click();
const backup = await exported;
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles((await backup.path())!);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2");
await page.locator("#play").click();
await page.keyboard.press("KeyX");
await expect(page.locator("#dialogue-page")).toHaveText("1 / 1");
await page.locator("#dialogue-next").click();
for (const [width, height] of [
[1440, 1000],
[1024, 900],
[390, 844],
[320, 740],
[844, 390],
]) {
await page.setViewportSize({ width, height });
await page.evaluate(() => scrollTo(0, 0));
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
if (width <= 390 || height <= 390) {
const box = (await page.locator(".console").boundingBox())!;
expect(box.y + box.height).toBeLessThanOrEqual(height);
}
}
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({
path: testInfo.outputPath("native-local-rom.png"),
fullPage: true,
});
const other = await context.newPage();
await other.goto("/");
await other.locator("#rom-file").setInputFiles(romPath);
await expect(other.locator("#status")).toContainText("another tab");
await other.close();
expect(
requests.some((url) => /binjgb|\.wasm|\/src\/emulator\//.test(url)),
).toBe(false);
expect(requests.every((url) => new URL(url).hostname === "127.0.0.1")).toBe(
true,
);
expect(errors).toEqual([]);
});
+225
View File
@@ -0,0 +1,225 @@
import { describe, expect, it } from "vitest";
import { readFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { RomReader } from "../src/game/rom/reader";
import { decompress } from "../src/game/rom/lz";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { NATIVE_MAPS } from "../src/game/rom/profile";
import { runScript } from "../src/game/scripts/interpreter";
import { drawTile } from "../src/game/rendering/pixels";
import { CRYSTAL_ROM } from "../src/rom/validate";
describe("native ROM formats", () => {
it("resolves fixed and banked pointers and rejects out-of-bounds reads", () => {
const reader = new RomReader(new Uint8Array(0xc000));
expect(reader.address(2, 0x4321)).toBe(0x8321);
expect(reader.address(2, 0x1234)).toBe(0x1234);
expect(() => reader.address(3, 0x4000)).toThrow();
expect(() => reader.word(0xbfff)).toThrow();
expect(() => reader.slice(-1, 1)).toThrow();
});
it("decodes all LZ commands, long runs, overlapping copies and termination", () => {
const decode = (input: number[]) => [
...decompress(new RomReader(Uint8Array.from(input)), 0),
];
expect(decode([0x02, 1, 2, 3, 0x22, 7, 0x43, 8, 9, 0x61, 0xff])).toEqual([
1, 2, 3, 7, 7, 7, 8, 9, 8, 9, 0, 0,
]);
expect(decode([0, 5, 0x83, 0x80, 0xff])).toEqual([5, 5, 5, 5, 5]);
expect(decode([0, 0b10010000, 0xa0, 0, 0, 0xff])).toEqual([144, 9]);
expect(decode([2, 1, 2, 3, 0xc2, 0, 2, 0xff])).toEqual([1, 2, 3, 3, 2, 1]);
expect(decode([0xec, 39, 0xff])).toEqual(Array(40).fill(0));
expect(() => decode([0x80, 0x80, 0xff])).toThrow("back-reference");
expect(() =>
decompress(new RomReader(new Uint8Array([0xec, 99, 0xff])), 0, 10),
).toThrow("oversized");
expect(() => decode([0x20, 4])).toThrow();
});
it("decodes planar pixels with transparency and flips", () => {
const pixels = new Uint8ClampedArray(8 * 8 * 4);
const tile = new Uint8Array(16);
tile[0] = 0x80;
tile[1] = 0x40;
const palette = [
[0, 0, 0],
[255, 0, 0],
[0, 255, 0],
[0, 0, 255],
] as const;
drawTile(pixels, 8, 8, tile, 0, 0, 0, palette, {
transparent: true,
flipX: true,
});
expect([...pixels.slice(7 * 4, 8 * 4)]).toEqual([255, 0, 0, 255]);
expect([...pixels.slice(6 * 4, 7 * 4)]).toEqual([0, 255, 0, 255]);
expect(pixels[3]).toBe(0);
});
it("aborts unknown script commands without committing earlier event writes", () => {
const reader = new RomReader(Uint8Array.from([0x33, 5, 0, 0x0e, 0, 0, 0]));
const state = {
events: new Set<number>(),
flags: new Set<number>(),
variables: {},
};
expect(() => runScript(reader, 0, state, "TEST")).toThrow("not ported");
expect(state.events.size).toBe(0);
});
it("bounds looping scripts", () => {
const reader = new RomReader(Uint8Array.from([0x03, 0, 0]));
expect(() =>
runScript(
reader,
0,
{ events: new Set(), flags: new Set(), variables: {} },
"TEST",
),
).toThrow("budget");
});
});
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))(
"native engine with local user ROM (never checked in)",
() => {
const load = () => {
const bytes = readFileSync(romPath);
expect(createHash("sha1").update(bytes).digest("hex")).toBe(
CRYSTAL_ROM.sha1,
);
return new CrystalAssets(new Uint8Array(bytes));
};
it("decodes every supported map, tileset, event table and sprite", () => {
const assets = load();
for (const id of Object.keys(NATIVE_MAPS)) {
const map = assets.map(id),
tileset = assets.tileset(map);
expect(map.blocks.length).toBe((map.width * map.height) / 4);
expect(tileset.graphics.length).toBeGreaterThanOrEqual(0x600);
expect(assets.palettes(map, 1).length).toBe(8);
for (const warp of map.warps) {
expect(warp.x).toBeLessThan(map.width);
expect(warp.y).toBeLessThan(map.height);
if (warp.targetMap in NATIVE_MAPS)
expect(
assets.map(warp.targetMap).warps[warp.targetWarp - 1],
).toBeDefined();
}
}
expect(assets.sprite(1)?.graphics.length).toBe(384);
expect(assets.sprite(0x60)?.graphics.length).toBe(384);
expect(assets.map("24:7").warps[0]).toEqual({
x: 7,
y: 0,
targetMap: "24:6",
targetWarp: 3,
});
});
it.skipIf(
!existsSync(".reference/pokecrystal/gfx/tilesets/players_room.2bpp"),
)(
"matches decompressed graphics against the locally rebuilt PRET reference",
() => {
const path = ".reference/pokecrystal/gfx/tilesets/players_room.2bpp";
const assets = load();
expect(assets.tileset(assets.map("24:7")).graphics).toEqual(
new Uint8Array(readFileSync(path)),
);
},
);
it("walks from the bedroom to downstairs and the town, with collision and return warps", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
expect(world.x).toBe(3);
expect(world.y).toBe(3);
expect(world.move("up")).toBe(true);
const steps = world.tilesWalked;
expect(world.move("up")).toBe(false); // Radio/furniture is solid.
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 2, steps]);
expect(world.move("down")).toBe(true);
const routeTo = (targetX: number, targetY: number) => {
const directions = [
["up", 0, -1],
["down", 0, 1],
["left", -1, 0],
["right", 1, 0],
] as const;
const queue = [
{
x: world.x,
y: world.y,
path: [] as ("up" | "down" | "left" | "right")[],
},
];
const visited = new Set<string>();
for (let i = 0; i < queue.length; i++) {
const node = queue[i],
key = `${node.x},${node.y}`;
if (visited.has(key)) continue;
visited.add(key);
if (node.x === targetX && node.y === targetY) {
for (const direction of node.path)
expect(world.move(direction)).toBe(true);
return;
}
for (const [direction, dx, dy] of directions) {
const x = node.x + dx,
y = node.y + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
continue;
if (
world.assets.permission(
world.assets.collision(world.map, x, y),
) !== 0
)
continue;
if (
world.objects().some((object) => object.x === x && object.y === y)
)
continue;
if (
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
(x !== targetX || y !== targetY)
)
continue;
queue.push({ x, y, path: [...node.path, direction] });
}
}
throw new Error(`No route to ${targetX},${targetY}`);
};
routeTo(7, 0);
expect(world.mapId).toBe("24:6");
routeTo(6, 7);
expect(world.mapId).toBe("24:4");
expect(world.tilesWalked).toBeGreaterThan(10);
const arrival = world.map.warps.find(
(warp) => warp.targetMap === "24:6",
)!;
routeTo(arrival.x, arrival.y);
expect(world.mapId).toBe("24:6");
const save = world.save();
expect(() => world.restore({ ...save, x: 127 })).toThrow("position");
expect(world.save()).toEqual(save);
const restored = new GameWorld(world.assets);
restored.restore(save);
expect(restored.save()).toEqual(save);
});
it("reads the bedroom radio through native event and text interpreters", () => {
const world = new GameWorld(load());
world.restore({ ...world.save(), x: 3, y: 2, direction: "up" });
const previousEvents = world.state.events.size;
world.interact();
expect(world.diagnostic).toBe("");
expect(world.dialogue?.pages.length).toBe(4);
expect(world.state.events.size).toBe(previousEvents);
while (world.dialogue) world.advanceDialogue();
expect(world.state.events.size).toBe(previousEvents + 1);
world.interact();
expect(world.dialogue?.pages.length).toBe(1);
});
},
);
+106
View File
@@ -0,0 +1,106 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
test("native decompression matches original GB assembly executing in the reference emulator", async ({
page,
}) => {
test.skip(!existsSync(romPath), "Requires the local user ROM.");
await page.goto("/reference.html");
// File chooser supplies bytes to an isolated test input, without an HTTP ROM endpoint.
await page.evaluate(() => {
const input = document.createElement("input");
input.type = "file";
input.id = "reference-fixture";
document.body.append(input);
});
await page.locator("#reference-fixture").setInputFiles(romPath);
const result = await page.evaluate(async () => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { decompress } = await import("/src/game/rom/lz.ts");
const { SYMBOLS, NATIVE_MAPS } = await import("/src/game/rom/profile.ts");
const file = (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0];
const bytes = new Uint8Array(await file.arrayBuffer());
const canvas = document.createElement("canvas");
const reference = new BinjgbEmulator();
const assets = new CrystalAssets(bytes);
await reference.load(bytes, canvas);
const comparisons: {
map: string;
bytes: number;
equal: boolean;
returned: boolean;
}[] = [];
for (const id of Object.keys(NATIVE_MAPS)) {
const map = assets.map(id);
const pointer = assets.reader.far(SYMBOLS.tilesets + map.tileset * 15);
const bank = Math.floor(pointer / 0x4000),
address = 0x4000 + (pointer % 0x4000);
const native = decompress(assets.reader, pointer);
// Original test trampoline in WRAM: DI; SP=$c7fe; A=bank;
// HL=compressed ROM address; DE=$d000; CALL FarDecompress ($0b40);
// write a sentinel and loop. The referenced routine is the user's ROM.
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
bank,
0x21,
address & 255,
address >> 8,
0x11,
0,
0xd0,
0xcd,
0x40,
0x0b,
0x3e,
0x42,
0xea,
0xff,
0xc6,
0x18,
0xfe,
];
reference.writeMemory(0xff70, 6);
reference.writeMemory(0xff9d, 1);
reference.writeMemory(0xc6ff, 0);
program.forEach((value, index) =>
reference.writeMemory(0xc000 + index, value),
);
reference.setProgramCounter(0xc000);
reference.advanceFrames(30);
comparisons.push({
map: id,
bytes: native.length,
returned: reference.readMemory(0xc6ff) === 0x42,
equal: native.every(
(value, index) => reference.readMemory(0xd000 + index) === value,
),
});
}
reference.dispose();
return comparisons;
});
expect(result.length).toBe(6);
for (const comparison of result) {
expect(
comparison.returned,
`${comparison.map}: original routine returned`,
).toBe(true);
expect(
comparison.equal,
`${comparison.map}: ${comparison.bytes} decompressed bytes match`,
).toBe(true);
}
});
+83
View File
@@ -0,0 +1,83 @@
/** Original test cartridge: no Nintendo logo, boot ROM, graphics, or game code.
* A tiny CPU loop draws a striped tile and writes joypad reads to battery RAM.
* This intentionally bypasses the product ROM gate ONLY in the test harness.
*/
export function syntheticRom(): Uint8Array {
const rom = new Uint8Array(32768);
rom.set([0xc3, 0x50, 0x01], 0x100); // JP $0150
rom.set(new TextEncoder().encode("CRYSTALTEST"), 0x134);
rom[0x143] = 0x80; // CGB-capable
rom[0x147] = 0x10; // MBC3 + RTC + RAM + battery
rom[0x148] = 0;
rom[0x149] = 3; // 32 KiB RAM
const code = [
0xf3, // DI
0x31,
0xfe,
0xff, // LD SP,$fffe
0xaf,
0xe0,
0x40, // LCD off
0x3e,
0x0a,
0xea,
0,
0, // enable cartridge RAM
0x3e,
0x80,
0xe0,
0x68, // CGB background palette auto-increment
0x3e,
0xff,
0xe0,
0x69,
0x3e,
0x7f,
0xe0,
0x69, // white
0xaf,
0xe0,
0x69,
0xe0,
0x69, // black
0x21,
0,
0x80,
0x06,
0x10,
0x3e,
0xaa, // HL=$8000, B=16, A=$aa
0x22,
0x05,
0x20,
0xfc, // tile pattern
0x3e,
0xe4,
0xe0,
0x47, // DMG palette
0x3e,
0x91,
0xe0,
0x40, // LCD on, background tile 0
];
const loop = 0x150 + code.length;
code.push(
0x3e,
0x10,
0xe0,
0,
0xf0,
0,
0xea,
0,
0xa0,
0xc3,
loop & 255,
loop >> 8,
);
rom.set(code, 0x150);
let checksum = 0;
for (let i = 0x134; i <= 0x14c; i++) checksum = (checksum - rom[i] - 1) & 255;
rom[0x14d] = checksum;
return rom;
}