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