226 lines
8.4 KiB
TypeScript
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);
|
|
});
|
|
},
|
|
);
|