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:
1 parent
9ac111f262
commit
451464f7c6
38 files changed
+5600
-490
No files matched your search
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user