This commit is contained in:
1 parent
2127f5f459
commit
8b80d05ca2
56 files changed
+6910
-1
No files matched your search
@@ -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([]);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user