Checks / web (push) Canceled after 0s
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>
446 lines
15 KiB
TypeScript
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]);
|
|
});
|
|
});
|