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 = {}; 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 = {}; 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, 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]); }); });