Complete the overworld foundation
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>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 16:47:29 +02:00
1 parent 9ac111f262
commit 451464f7c6
38 files changed
+5600 -490

No files matched your search

+445
View File
@@ -0,0 +1,445 @@
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]);
});
});