Files
Crystal/tests/native.test.ts
T
2026-09-23 09:23:29 +02:00

226 lines
8.4 KiB
TypeScript

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);
});
},
);