Files
Crystal/tests/overworld-parity.spec.ts
T
Sébastien QUEROLandClaude Opus 5.5 451464f7c6
Checks / web (push) Canceled after 0s
Complete the overworld foundation
Port forced movement (turn delays, bumps, ice, currents, pits), dynamic
return warps, elevators, Surf, Cut, Strength and cycling rules, working
map blocks with changeblock/changemapblocks, and the remaining NPC
movement types and movement commands.

Add tileset animation from the ROM's command tables, grass sprite
priority and special map palettes; room decorations with ownership, the
bedroom PC and save migration; and Key Items access to the Bicycle.

Fourteen original-engine traces compare positions, facing, actors, event
flags and scripted poses. Tile animation matches original VRAM for four
tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now
keeps the $60 sprite offset its step function leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:47:29 +02:00

446 lines
15 KiB
TypeScript

import { initialDecorations } from "../src/game/world/decorations";
import { isDeepStrictEqual } from "node:util";
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { emptyScriptState } from "../src/game/scripts/interpreter";
import type { Direction } from "../src/game/world/types";
import type { ReferenceTick } from "./reference-overworld";
import { TilesetAnimator } from "../src/game/rendering/tileset-animation";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
/** Labels the reference driver reads or calls. */
const LABELS = [
"ResetWRAM",
"NewBarkTown_TeacherRunsToYouMovement1",
"FinishContinueFunction",
"wDefaultSpawnpoint",
"wMapGroup",
"wMapNumber",
"wXCoord",
"wYCoord",
"hMapEntryMethod",
"wPlayerMapX",
"wPlayerMapY",
"wPlayerDirection",
"wMapStatus",
"wOverworldDelay",
"wStatusFlags",
"wTimeOfDay",
"wNewBarkTownSceneID",
"wPlayersHouse1FSceneID",
"wPlayerStruct",
"wTileAnimationTimer",
"hTileAnimFrame",
"wTileAnimBuffer",
"wEventFlags",
"wCelebiEvent",
"wTinTower1FSceneID",
];
function referenceSymbols() {
const table: Record<string, { bank: number; address: number }> = {};
for (const line of readFileSync(symbolsPath, "utf8").split("\n")) {
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
if (match && LABELS.includes(match[3]))
table[match[3]] = {
bank: parseInt(match[1], 16),
address: parseInt(match[2], 16),
};
}
return table;
}
type Inputs = (Direction | undefined)[];
/** Hold `direction` (or nothing) for `ticks` overworld ticks. */
const run = (direction: Direction | undefined, ticks: number): Inputs =>
Array.from({ length: ticks }, () => direction);
interface Scenario {
name: string;
start: { map: string; x: number; y: number };
inputs: Inputs;
/** Maps entered on the way that are not enabled in normal play. */
extraMaps?: string[];
/** Scene variables set in both engines, keyed by the owning map and its
* wram label, so that coordinate events stay quiet. */
scenes?: { map: string; label: string; value: number }[];
/** Map objects whose positions are compared too (a scene's actors). */
objects?: number[];
patchMovement?: number[];
}
function nativeTrace(bytes: Uint8Array, scenario: Scenario, timeOfDay: number) {
const assets = new CrystalAssets(bytes);
const ported = assets.isPorted.bind(assets);
const extra = new Set(scenario.extraMaps ?? []);
assets.isPorted = (id) => ported(id) || extra.has(id);
// The original's clock decides which timed objects appear; match it.
const hour = [7, 12, 22][timeOfDay] ?? 12;
const now = () =>
new Date(`2026-09-22T${String(hour).padStart(2, "0")}:00:00`);
const world = new GameWorld(assets, now, () => 0);
// _ResetWRAM clears every event and engine flag before this warp.
world.state = emptyScriptState();
world.state.decorations = initialDecorations();
for (const scene of scenario.scenes ?? [])
world.state.scenes[scene.map] = scene.value;
world.warpTo(scenario.start.map, scenario.start.x, scenario.start.y);
return scenario.inputs.map((held) => {
world.tick(held);
if (world.notice) throw new Error(`${world.notice} ${world.diagnostic}`);
const objects: Record<number, [number, number]> = {};
for (const id of scenario.objects ?? []) {
const object = world.objects().find((candidate) => candidate.id === id);
if (object) objects[id] = [object.x, object.y];
}
const poses: ReferenceTick["poses"] = {};
// Blocking text is sampled before ApplyObjectFacing in the original
// and after it natively. Compare poses only while movement is running.
if (scenario.patchMovement && world.activity === "scene" && !world.dialogue)
for (const id of scenario.objects ?? []) {
const object = world.objects().find((o) => o.id === id);
const pose = world.poses().find((p) => p.id === id);
if (object)
poses[id] = {
direction: object.direction,
visible: Boolean(pose),
lift: pose?.lift ?? 0,
};
}
// The original samples a tick that waits in text before the text, so
// native state is taken before pressing A through the pages; the rest
// of the script then runs within the same tick in both engines.
const sample = {
map: world.mapId,
x: world.x,
y: world.y,
facing: world.direction,
objects,
poses,
events: [...world.liveState.events].sort((a, b) => a - b),
};
for (let page = 0; world.dialogue && page < 64; page++) {
if (world.dialogue.holdTicks !== undefined) break;
if (world.dialogue.choice) world.interact();
else world.advanceDialogue();
}
return sample;
});
}
async function referenceTrace(
page: Page,
bytes: Uint8Array,
scenario: Scenario,
) {
const symbols = referenceSymbols();
return page.evaluate(
async ({ rom, symbols, scenario }) => {
const driver = await import("/tests/reference-overworld.ts");
const start = await driver.bootReference(
new Uint8Array(rom),
symbols,
scenario.start,
(scenario.scenes ?? []).map(({ label, value }) => ({ label, value })),
);
const trace = driver.traceReference(scenario.inputs);
driver.disposeReference();
return { start, trace };
},
{ rom: [...bytes], symbols, scenario },
) as Promise<{
start: ReferenceTick & { timeOfDay: number };
trace: ReferenceTick[];
}>;
}
/** A tick that began a warp or connection is still sampled on the old map
* in the original, before map setup runs; native enters the map at once.
* Those ticks are skipped; every other tick must match exactly. */
function compare(
native: ReturnType<typeof nativeTrace>,
reference: ReferenceTick[],
) {
const differences: string[] = [];
reference.forEach((original, tick) => {
if (original.status === 1) {
// Map setup blocks inside the original tick. Compare its arrival at
// the first sample after setup with the native transition result.
const arrival = reference
.slice(tick + 1)
.find((sample) => sample.status !== 1);
if (!arrival)
throw new Error("Trace ends before a map transition completes.");
if (native[tick].map !== arrival.map)
differences.push(
`transition ${tick + 1}: native ${native[tick].map}, original destination ${arrival.map}`,
);
return;
}
const {
status: _status,
objects: all,
poses: allPoses,
...player
} = original;
const expected = {
...player,
poses: Object.fromEntries(
Object.keys(native[tick].poses).map((id) => {
const pose = allPoses[Number(id)];
return [id, { ...pose, lift: pose.visible ? pose.lift : 0 }];
}),
),
objects: Object.fromEntries(
Object.keys(native[tick].objects).map((id) => [id, all[Number(id)]]),
),
};
if (!isDeepStrictEqual(native[tick], expected))
differences.push(
`tick ${tick + 1}: native ${JSON.stringify(native[tick])}, original ${JSON.stringify(expected)}`,
);
});
return differences;
}
const QUIET_TOWN = { map: "24:4", label: "wNewBarkTownSceneID", value: 1 };
const QUIET_HOUSE = { map: "24:6", label: "wPlayersHouse1FSceneID", value: 1 };
const SCENARIOS: Scenario[] = [
{
name: "New Bark Town: turning, tapping, walking and bumping",
start: { map: "24:4", x: 9, y: 6 },
inputs: [
...run("right", 14),
...run(undefined, 6),
...run("left", 1),
...run(undefined, 6),
...run("up", 2),
...run(undefined, 3),
...run("down", 10),
...run("left", 9),
...run(undefined, 12),
...run("up", 20),
...run("right", 3),
...run(undefined, 4),
],
},
{
name: "New Bark Town to Route 29 and back across the connection",
start: { map: "24:4", x: 4, y: 9 },
scenes: [QUIET_TOWN],
inputs: [
...run("left", 60),
...run(undefined, 3),
...run("right", 50),
...run(undefined, 10),
],
},
{
name: "Route 29 ledge jump and walking on",
start: { map: "24:3", x: 25, y: 6 },
inputs: [
...run("down", 44),
...run(undefined, 20),
...run("up", 12),
...run(undefined, 6),
],
},
{
name: "Interior: the house's stairs, the bedroom and the front door",
start: { map: "24:6", x: 8, y: 5 },
scenes: [QUIET_HOUSE, QUIET_TOWN],
inputs: [
...run("up", 40),
...run("right", 6),
...run("up", 12),
...run(undefined, 30),
...run("down", 6),
...run("left", 12),
...run("up", 20),
...run("right", 30),
...run(undefined, 30),
...run("left", 6),
...run("down", 40),
...run("left", 10),
...run("down", 40),
...run(undefined, 30),
],
},
{
name: "Scene: the teacher stops the player at New Bark Town's west edge",
start: { map: "24:4", x: 4, y: 8 },
objects: [2],
inputs: [...run("left", 30), ...run(undefined, 60)],
},
{
name: "Cave: Ice Path slides, bumps and turns on ice",
start: { map: "3:61", x: 15, y: 14 },
extraMaps: ["3:61"],
inputs: [
...run("up", 30),
...run("left", 40),
...run(undefined, 10),
...run("down", 20),
...run("right", 30),
...run(undefined, 10),
...run("up", 3),
...run(undefined, 20),
],
},
];
for (const [name, movement] of [
["scripted spin", [0x4f, 16, 0x47]],
["teleport out and in", [0x4c, 0x4d, 0x47]],
["skyfall", [0x4e, 0x47]],
["skyfall departure", [0x59, 0x47]],
["tree shake", [0x56, 0x47]],
["fishing", [0x51, 0x52, 0x47]],
["flicker", [0x57, 8, 0x47]],
["spin flicker", [0x58, 8, 0x47]],
] as const)
SCENARIOS.push({
name: `Scene movement: ${name}`,
start: { map: "24:4", x: 4, y: 8 },
objects: [2],
patchMovement: [...movement],
inputs: [...run("left", 30), ...run(undefined, 140)],
});
test.describe("overworld traces match the original engine", () => {
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Requires the local user ROM and the private PRET rebuild.",
);
for (const scenario of SCENARIOS)
test(scenario.name, async ({ page }, info) => {
test.skip(
info.project.name !== "chromium",
"Engine traces are browser-independent.",
);
await page.goto("/reference.html");
const bytes = new Uint8Array(readFileSync(romPath));
if (scenario.patchMovement) {
// Replace only a five-byte movement list in the in-memory test ROM.
// Both engines execute the same scene and movement bytecode.
const symbol = referenceSymbols().NewBarkTown_TeacherRunsToYouMovement1;
bytes.set(
scenario.patchMovement,
symbol.bank * 0x4000 + symbol.address - 0x4000,
);
}
const { start, trace: reference } = await referenceTrace(
page,
bytes,
scenario,
);
expect([start.map, start.x, start.y]).toEqual([
scenario.start.map,
scenario.start.x,
scenario.start.y,
]);
const native = nativeTrace(bytes, scenario, start.timeOfDay);
const differences = compare(native, reference);
if (differences.length && process.env.TRACE)
console.log(
reference
.map(
(original, tick) =>
`${tick + 1} ${JSON.stringify(scenario.inputs[tick] ?? "-")} ref ${original.x},${original.y},${original.facing} ${JSON.stringify(Object.fromEntries((scenario.objects ?? []).map((id) => [id, original.objects[id]])))} nat ${native[tick].x},${native[tick].y},${native[tick].facing} ${JSON.stringify(native[tick].objects)}`,
)
.join("\n"),
);
expect(differences.slice(0, 8)).toEqual([]);
});
});
test.describe("tile animation matches the original's VRAM", () => {
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Requires the local user ROM and the private PRET rebuild.",
);
for (const start of [
{ name: "Johto water and flowers", map: "24:4", x: 9, y: 6 },
{ name: "Ice Path's scrolling cave water", map: "3:61", x: 15, y: 14 },
{
name: "Ilex Forest restless trees",
map: "3:52",
x: 10,
y: 10,
restless: true,
},
{ name: "Tin Tower pillars", map: "3:5", x: 9, y: 7 },
])
test(start.name, async ({ page }, info) => {
test.skip(
info.project.name !== "chromium",
"Engine traces are browser-independent.",
);
await page.goto("/reference.html");
const bytes = new Uint8Array(readFileSync(romPath));
const symbols = referenceSymbols();
const reference = await page.evaluate(
async ({ rom, symbols, start }) => {
const driver = await import("/tests/reference-overworld.ts");
await driver.bootReference(
new Uint8Array(rom),
symbols,
start,
start.restless
? [{ label: "wCelebiEvent", value: 4 }]
: start.map === "3:4"
? [{ label: "wTinTower1FSceneID", value: 1 }]
: [],
);
const peek = driver.peek();
const state = {
frame: peek(symbols.hTileAnimFrame.address),
timer: peek(symbols.wTileAnimationTimer.address),
buffer: Array.from({ length: 16 }, (_, i) =>
peek(symbols.wTileAnimBuffer.address + i),
),
tiles: driver.readTiles(),
};
const frames: number[][] = [];
for (let frame = 0; frame < 96; frame++) {
driver.stepFrame();
frames.push(driver.readTiles());
}
driver.disposeReference();
return { state, frames };
},
{ rom: [...bytes], symbols, start },
);
const assets = new CrystalAssets(bytes);
const animator = new TilesetAnimator(
assets,
assets.map(start.map),
Uint8Array.from(reference.state.tiles),
);
// Start from the original's animation state at the same moment.
Object.assign(animator, {
frame: reference.state.frame,
timer: reference.state.timer,
buffer: Uint8Array.from(reference.state.buffer),
});
const mismatches: number[] = [];
reference.frames.forEach((tiles, frame) => {
animator.advance(1, false, start.restless ?? false);
if (!tiles.every((value, index) => animator.graphics[index] === value))
mismatches.push(frame);
});
expect(mismatches).toEqual([]);
// Something actually animated.
expect(reference.frames[0]).not.toEqual(reference.frames[47]);
});
});