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

+3 -1
View File
@@ -25,8 +25,10 @@ async function start(page: Page, scenario: BattleScenario = "win") {
await page.locator("#play").click();
for (const direction of ENCOUNTER_WALK) {
const key = `Arrow${direction[0].toUpperCase()}${direction.slice(1)}`;
// Long enough to turn in place first (4 ticks) and start one step,
// short of a second step when already facing that way.
await page.keyboard.down(key);
await page.clock.runFor(70);
await page.clock.runFor(250);
await page.keyboard.up(key);
await page.clock.runFor(300);
}
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import {
DecorationData,
initialDecorations,
} from "../src/game/world/decorations";
import { EVENTS } from "../src/game/rom/profile";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
function fixture() {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
return {
assets,
world,
data: new DecorationData(assets.reader, assets.pokemon),
};
}
function pc(world: GameWorld) {
const save = world.save();
world.restore({
...save,
map: "24:7",
x: 2,
y: 2,
direction: "up",
objects: [],
});
world.interact();
expect(world.notice, world.diagnostic).toBe("");
expect(world.dialogue?.choice?.kind).toBe("menu");
}
function choose(world: GameWorld, name: string) {
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error("Expected decoration menu");
const index = choice.items.indexOf(name);
expect(index, name).toBeGreaterThanOrEqual(0);
world.choose(index);
}
function acknowledge(world: GameWorld) {
for (let i = 0; world.dialogue && !world.dialogue.choice && i < 20; i++)
world.advanceDialogue();
}
describe.skipIf(!existsSync(romPath))("room decorations", () => {
it("starts with the original bed and Town Map and migrates old saves", () => {
const { world, assets } = fixture();
expect(world.state.decorations).toEqual(initialDecorations());
expect(assets.block(world.map, 0, 4)).toBe(0x1b);
expect(assets.block(world.map, 6, 0)).toBe(0x1f);
expect(world.state.events.has(EVENTS.playersRoomPoster)).toBe(true);
const old = world.save() as Partial<ReturnType<GameWorld["save"]>>;
delete old.decorations;
world.restore(old);
expect(world.state.decorations).toEqual(initialDecorations());
});
it("PC puts away and restores a bed; mid-menu saves retain committed choices", () => {
const { world, assets } = fixture();
pc(world);
choose(world, "BED");
choose(world, "PUT IT AWAY");
expect(world.liveState.decorations.bed).toBe(0);
expect(world.save().decorations.bed).toBe(2);
acknowledge(world);
choose(world, "EXIT");
settle(world);
expect(world.state.decorations.bed).toBe(0);
expect(assets.block(world.map, 0, 4)).toBe(
assets.block(assets.map("24:7"), 0, 4),
);
world.restore(world.save());
pc(world);
choose(world, "BED");
choose(world, "FEATHERY BED");
acknowledge(world);
choose(world, "EXIT");
settle(world);
expect(assets.block(world.map, 0, 4)).toBe(0x1b);
});
it("owned ornaments move between sides without duplication and reload as sprites", () => {
const { world, data } = fixture();
world.state.events.add(data.attribute(30).event);
pc(world);
choose(world, "ORNAMENT");
choose(world, "PIKACHU DOLL");
choose(world, "RIGHT SIDE");
acknowledge(world);
choose(world, "ORNAMENT");
choose(world, "PIKACHU DOLL");
choose(world, "LEFT SIDE");
acknowledge(world);
choose(world, "EXIT");
settle(world);
expect([
world.state.decorations.left,
world.state.decorations.right,
]).toEqual([30, 0]);
expect(world.objects().some((object) => object.id === 3)).toBe(true);
expect(world.objects().some((object) => object.id === 4)).toBe(false);
world.restore(world.save());
expect(world.state.variables[1]).toBe(data.attribute(30).sprite);
});
it("validates ownership and categories before changing the current game", () => {
const { world } = fixture();
const before = world.save();
for (const decorations of [
null,
{ ...before.decorations, bed: 30 },
{ ...before.decorations, bed: 3 },
{ ...before.decorations, poster: 999 },
]) {
expect(() => world.restore({ ...before, decorations })).toThrow(
/Invalid native save/,
);
expect(world.save()).toEqual(before);
}
});
it("sets every tile category and sprite category from the owned ROM attributes", () => {
const { world, assets, data } = fixture();
for (let id = 1; id <= 52; id++) {
const a = data.attribute(id);
if (a.action & 1) world.state.events.add(a.event);
}
world.state.decorations = {
bed: 5,
carpet: 10,
plant: 14,
poster: 19,
console: 24,
bigDoll: 28,
left: 51,
right: 52,
};
world.warpTo("24:7", 2, 2);
expect(
[
[0, 4],
[7, 4],
[6, 0],
[0, 0],
[0, 2],
[2, 2],
[4, 2],
].map(([x, y]) => assets.block(world.map, x, y)),
).toEqual([0x1e, 0x22, 0x25, 0x11, 0x12, 0x13, 0x12]);
expect([0, 1, 2, 3].map((i) => world.state.variables[i])).toEqual(
[24, 51, 52, 28].map((id) => data.attribute(id).sprite),
);
expect(world.objects()).toHaveLength(4);
world.restore(world.save());
expect(world.objects()).toHaveLength(4);
});
});
+400
View File
@@ -0,0 +1,400 @@
import { describe, expect, it } from "vitest";
import { WALK_TICKS } from "../src/game/world/world";
import { OW } from "../src/game/world/constants";
import { SYMBOLS } from "../src/game/rom/profile";
import type { Direction } from "../src/game/world/types";
import { fixture } from "./movement-fixture";
import { settle } from "./world-driver";
const pose = (world: ReturnType<typeof fixture>["world"]) =>
world.poses().find((candidate) => candidate.id === 0)!;
/** Hold a direction for `ticks` ticks and record the tick numbers on which
* the player's tile changed. */
function hold(
world: ReturnType<typeof fixture>["world"],
direction: Direction | undefined,
ticks: number,
) {
const moved: number[] = [];
for (let tick = 1; tick <= ticks; tick++) {
const at = `${world.x},${world.y}`;
world.tick(direction);
if (`${world.x},${world.y}` !== at) moved.push(tick);
}
return moved;
}
describe("turning in place (DoPlayerMovement .CheckTurning)", () => {
it("turns on the next tick and walks on the fourth tick after a turn", () => {
const { world } = fixture();
expect(world.direction).toBe("down");
world.tick("right");
// StepFunction_Turn shows the new facing a tick later.
expect([world.x, world.direction, world.busy]).toEqual([3, "down", true]);
world.tick("right");
expect(world.direction).toBe("right");
expect(pose(world).frame).toBe(1);
// ChangeDirectionScript's deactivatefacing 3 holds input until then.
expect(hold(world, "right", 2)).toEqual([]);
expect(hold(world, "right", 1)).toEqual([1]);
// Continuous walking needs no further turns.
expect(hold(world, "right", WALK_TICKS * 2)).toEqual([WALK_TICKS, WALK_TICKS * 2]);
});
it("turns without walking when the direction is only tapped", () => {
const { world } = fixture();
world.tick("left");
expect(hold(world, undefined, 8)).toEqual([]);
expect([world.x, world.y, world.direction, world.busy]).toEqual([3, 3, "left", false]);
// Facing that way now, the next press walks at once.
expect(hold(world, "left", 1)).toEqual([1]);
});
it("changes direction without turning while walking, and turns again after stopping", () => {
const { world } = fixture();
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
// Still walking: the new direction steps on the next decision.
expect(hold(world, "right", 1)).toEqual([1]);
expect(world.direction).toBe("right");
hold(world, undefined, WALK_TICKS + 1);
expect(hold(world, "up", 4)).toEqual([]);
expect(world.direction).toBe("up");
});
it("bumps into walls in place at half animation speed and must turn after a bump", () => {
const { assets, world } = fixture();
assets.tile(3, 4, 7);
const frames: number[] = [];
for (let tick = 0; tick < 16; tick++) {
world.tick("down");
frames.push(pose(world).frame);
}
expect([world.x, world.y]).toEqual([3, 3]);
// SetFacingBumpAction: OBJECT_STEP_FRAME >> 3.
expect(frames).toEqual([0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2]);
// The bump cleared wPlayerTurningDirection, so a new direction turns first.
expect(hold(world, "left", 4)).toEqual([]);
expect(hold(world, "left", 1)).toEqual([1]);
});
it("walking into a wall in a new direction bumps without facing it, then turns", () => {
const { assets, world } = fixture();
assets.tile(4, 4, 7);
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
// Mid-walk turning is not needed, but the blocked step does not turn.
world.tick("right");
expect([world.x, world.y, world.direction]).toEqual([3, 4, "down"]);
world.tick("right");
world.tick("right");
expect(world.direction).toBe("right");
});
it("does not turn toward the direction the player already faces", () => {
const { world } = fixture();
expect(hold(world, "down", 1)).toEqual([1]);
});
});
describe("forced terrain (DoPlayerMovement .CheckTile)", () => {
it.each<[number, Direction | undefined]>([
[0x40, undefined],
[0x41, "right"],
[0x42, "left"],
[0x43, "up"],
[0x44, "down"],
[0x45, undefined],
[0x50, "right"],
[0x51, "left"],
[0x52, "up"],
[0x53, "down"],
[0x54, undefined],
])("walk tile %i moves the player %s without input", (tile, direction) => {
const { assets, world } = fixture();
assets.tile(3, 3, tile);
world.tick();
if (!direction) {
expect([world.x, world.y, world.busy]).toEqual([3, 3, false]);
return;
}
const vectors = { right: [4, 3], left: [2, 3], up: [3, 2], down: [3, 4] };
expect([world.x, world.y, world.direction]).toEqual([...vectors[direction], direction]);
expect(pose(world).frame).toBe(0);
});
it("forced steps ignore held input and walls, like .continue_walk", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0x41);
assets.tile(4, 3, 7);
world.tick("left");
expect([world.x, world.y]).toEqual([4, 3]);
});
it.each<[number, Direction]>([
[0x30, "right"],
[0x31, "left"],
[0x32, "up"],
[0x33, "down"],
[0x38, "right"],
[0x3b, "down"],
])("currents and waterfalls (%i) carry a surfer %s", (tile, direction) => {
const { assets, world } = fixture();
world.state.playerState = OW.PLAYER_SURF;
assets.tile(3, 3, tile);
world.tick();
expect(world.direction).toBe(direction);
expect(world.activity).toBe("walking");
});
});
describe("ice (STEP_ICE and .CheckForced)", () => {
it("slides at four ticks per tile until something blocks the slide", () => {
const { assets, world } = fixture();
for (let x = 3; x <= 6; x++) assets.tile(x, 3, OW.COLL_ICE);
assets.tile(7, 3, 7);
world.restore({ ...world.save(), direction: "right" });
// Stepping from floor onto ice is still a walk; here we start on ice.
const moved = hold(world, "right", 1);
expect(moved).toEqual([1]);
expect(pose(world).frame).toBe(0);
// Input no longer matters while sliding.
expect(hold(world, "up", 16)).toEqual([4, 8]);
expect([world.x, world.y]).toEqual([6, 3]);
// The wall stopped the slide with a bump, so the player turned up during
// the last hold; still on ice, the step off it is another four-tick slide.
expect(world.direction).toBe("up");
expect(hold(world, "up", 5)).toEqual([1, 5]);
});
it("does not allow button actions while sliding", () => {
const { assets, world } = fixture();
for (let x = 3; x <= 6; x++) assets.tile(x, 3, OW.COLL_ICE);
world.restore({ ...world.save(), direction: "right" });
hold(world, "right", 4);
world.interact();
expect(world.notice).toBe("");
});
it("walks normally from floor onto ice", () => {
const { assets, world } = fixture();
assets.tile(3, 4, OW.COLL_ICE);
const moved = hold(world, "down", WALK_TICKS);
expect(moved).toEqual([1]);
expect(pose(world).frame).not.toBe(undefined);
});
});
describe("bikes, surfing and side buoys", () => {
it("rides a bike at four ticks per tile", () => {
const { world } = fixture();
world.state.playerState = OW.PLAYER_BIKE;
expect(hold(world, "down", 8)).toEqual([1, 5]);
});
it("rides downhill without input and slowly uphill", () => {
const { world } = fixture();
world.state.playerState = OW.PLAYER_BIKE;
world.state.flags.add(OW.ENGINE_DOWNHILL);
world.restore({ ...world.save(), y: 1 });
world.state.playerState = OW.PLAYER_BIKE;
world.state.flags.add(OW.ENGINE_DOWNHILL);
expect(hold(world, undefined, 8)).toEqual([1, 5]);
// Still moving, so no turn: uphill against the slope is a walk.
expect(hold(world, "up", 16)).toEqual([1, 9]);
});
it("surfs over water, refuses walls and gets out onto land", () => {
const { assets, world } = fixture();
world.state.playerState = OW.PLAYER_SURF;
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) assets.tile(x, y, 0x29);
assets.tile(3, 5, 0x27);
assets.tile(4, 4, 0);
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
expect(world.state.playerState).toBe(OW.PLAYER_SURF);
expect(hold(world, "down", 4)).toEqual([]);
world.tick("right");
world.tick("right");
world.tick("right");
world.tick("right");
expect(hold(world, "right", 1)).toEqual([1]);
expect(world.state.playerState).toBe(OW.PLAYER_NORMAL);
});
it("buoys block surfing like side walls", () => {
const { assets, world } = fixture();
world.state.playerState = OW.PLAYER_SURF;
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) assets.tile(x, y, 0x29);
// COLL_UP_BUOY below the player blocks a downward move into it.
assets.tile(3, 4, 0xc2);
expect(hold(world, "down", 4)).toEqual([]);
assets.tile(3, 4, 0xc3);
expect(hold(world, "down", 1)).toEqual([1]);
});
it("walking cannot enter water", () => {
const { assets, world } = fixture();
assets.tile(3, 4, 0x29);
expect(hold(world, "down", 4)).toEqual([]);
});
});
describe("dynamic warps (-1)", () => {
it("remembers the entrance on arrival and returns through a -1 warp", () => {
const { assets, world } = fixture();
// 24:7's warp 1 at (3,4) leads to 24:6 warp 1, a -1 warp back.
assets.map("24:7").warps.push({ x: 3, y: 4, targetMap: "24:6", targetWarp: 1 });
assets.map("24:7").warps.push({ x: 5, y: 4, targetMap: "24:6", targetWarp: 1 });
assets.map("24:6").warps.push({ x: 2, y: 2, targetMap: "24:6", targetWarp: 0xff });
assets.tile(3, 4, 0x72);
assets.tile(5, 4, 0x72);
assets.tile(2, 3, 0x72, "24:6");
assets.map("24:6").warps.push({ x: 2, y: 3, targetMap: "24:6", targetWarp: 0xff });
world.restore({ ...world.save(), x: 5 });
expect(world.move("down")).toBe(true);
expect([world.mapId, world.x, world.y]).toEqual(["24:6", 2, 2]);
expect(world.state.backupWarp).toEqual({ map: "24:7", warp: 2 });
const saved = world.save();
expect(saved.backupWarp).toEqual({ map: "24:7", warp: 2 });
world.restore(saved);
expect(world.move("down")).toBe(true);
expect([world.mapId, world.x, world.y]).toEqual(["24:7", 5, 4]);
});
it("refuses a -1 warp without a recorded destination", () => {
const { assets, world } = fixture();
assets.map("24:7").warps.push({ x: 3, y: 4, targetMap: "24:6", targetWarp: 0xff });
assets.tile(3, 4, 0x72);
expect(world.move("down")).toBe(false);
expect(world.notice).toMatch(/not ported/);
});
it("rejects malformed backup warps in saves", () => {
const { world } = fixture();
for (const backupWarp of [{ map: "x", warp: 1 }, { map: "24:6", warp: 0 }, 5])
expect(() => world.restore({ ...world.save(), backupWarp })).toThrow("Invalid native save");
expect(() => world.restore({ ...world.save(), playerState: 3 })).toThrow("Invalid native save");
});
});
describe("Strength boulders", () => {
it("pushes a boulder one tile over sixteen ticks while the player bumps", () => {
const { assets, world } = fixture();
const type = 19; // SPRITEMOVEDATA_STRENGTH_BOULDER
const entry = SYMBOLS.spriteMovementData + type * 6;
// MovementFunction_Strength; WONT_DELETE | FIXED_FACING | SLIDING |
// MOVE_ANYWHERE; STRENGTH_BOULDER.
assets.bytes.set([0x11, 0, 1, 0x2e, 0, 0x40], entry);
assets.map("24:7").objects.push({
id: 2, x: 3, y: 4, sprite: 1, movement: type, radius: 0,
palette: 0, kind: 0, script: 0, eventFlag: 0xffff, hour1: 0xff, hour2: 0xff,
});
world.restore(world.save());
// Without Strength the boulder does not move.
hold(world, "down", 20);
expect(world.objects()[0]).toMatchObject({ x: 3, y: 4 });
world.state.flags.add(OW.ENGINE_STRENGTH_ACTIVE);
world.tick("down");
expect(world.objects()[0]).toMatchObject({ x: 3, y: 5 });
expect([world.x, world.y]).toEqual([3, 3]);
hold(world, undefined, 15);
expect(world.save().objects).toEqual([{ id: 2, x: 3, y: 5, direction: "down" }]);
// It stops at walls.
assets.tile(3, 7, 7);
hold(world, "down", 60);
expect(world.objects()[0]).toMatchObject({ x: 3, y: 6 });
});
});
describe("changeblock", () => {
const trigger = (script: number[]) => {
const { assets, world } = fixture();
assets.bytes.set(script, 0x100);
assets.map("24:7").coordinateEvents.push({ x: 3, y: 4, scene: 0xff, script: 0x100 });
return { assets, world };
};
it("changes the working block data until the map loads again", () => {
// changeblock 2, 2, $05; end
const { world } = trigger([0x7a, 2, 2, 5, 0x91]);
expect(world.move("down")).toBe(true);
settle(world);
expect(world.notice).toBe("");
expect(world.map.blocks[1 * 4 + 1]).toBe(5);
expect(world.blockRevision).toBe(1);
world.warpTo("24:7", 3, 3);
expect(world.map.blocks[1 * 4 + 1]).toBe(0);
});
it("restores the blocks when a script that changed them is refused", () => {
// changeblock 2, 2, $05; an unported command
const { world } = trigger([0x7a, 2, 2, 5, 0xff]);
world.move("down");
settle(world);
expect(world.notice).toMatch(/not been ported/);
expect(world.map.blocks[1 * 4 + 1]).toBe(0);
});
it("refuses blocks outside the map or beyond the tileset", () => {
for (const script of [[0x7a, 8, 2, 5, 0x91], [0x7a, 2, 2, 0x80, 0x91]]) {
const { world } = trigger(script);
world.move("down");
settle(world);
expect(world.notice).toMatch(/not been ported/);
}
});
});
describe("NPC movement functions", () => {
const withObject = (fn: number, action = 1, flags = 0, sprite = 1) => {
const { assets, world } = fixture();
const type = 30;
assets.bytes.set([fn, 0, action, flags, 0, 0], SYMBOLS.spriteMovementData + type * 6);
assets.map("24:7").objects.push({
id: 2, x: 5, y: 3, sprite, movement: type, radius: 0,
palette: 0, kind: 0, script: 0, eventFlag: 0xffff, hour1: 0xff, hour2: 0xff,
});
world.restore(world.save());
return { assets, world };
};
const facingOf = (world: ReturnType<typeof fixture>["world"]) =>
world.objects().find((object) => object.id === 2)!.direction;
it.each<[number, Direction[]]>([
[0x18, ["down", "left", "up", "right", "down"]],
[0x19, ["down", "right", "up", "left", "down"]],
])("SPRITEMOVEFN %i turns every sixteen ticks", (fn, expected) => {
const { world } = withObject(fn);
const seen: Direction[] = [];
for (let tick = 0; tick < 16 * 5; tick++) {
world.tick();
if (tick % 16 === 0) seen.push(facingOf(world));
}
expect(seen).toEqual(expected);
});
it("bounces between the down and up standing frames every eight ticks", () => {
const { world } = withObject(0x16, 0x0a, 0x2e);
const facings: (number | undefined)[] = [];
for (let tick = 0; tick < 17; tick++) {
world.tick();
facings.push(world.poses().find((pose) => pose.id === 2)?.facing);
}
expect(facings.slice(0, 7).every((facing) => facing === 0)).toBe(true);
expect(facings.slice(7, 15).every((facing) => facing === 4)).toBe(true);
expect(facings[15]).toBe(0);
});
it("draws big dolls with their own facing layouts", () => {
const { world } = withObject(0x15, 0x09);
world.tick();
expect(world.poses().find((pose) => pose.id === 2)?.facing).toBe(0x17);
});
it("resolves variable sprites from wVariableSprites and hides unset ones", () => {
const { world } = withObject(0x06, 1, 0, 0xf3);
expect(world.objects()).toHaveLength(0);
world.state.variables[3] = 0x2a;
world.restore(world.save());
expect(world.poses().find((pose) => pose.id === 2)?.sprite).toBe(0x2a);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
test.beforeEach(() =>
test.skip(!existsSync(romPath), "Requires the local user ROM."),
);
async function start(page: Page, room: boolean | "poster" = false) {
await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch(),
source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
await route.fulfill({
response,
body: source.replace(
needle,
`const { foundationFixture } = await import('/tests/foundation-fixture.ts'); const world = foundationFixture(assets, ${JSON.stringify(room)});`,
),
});
});
await page.clock.install({ time: new Date("2026-09-22T12:00:00") });
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.clock.pauseAt(new Date("2026-09-22T12:05:00"));
await page.locator("#play").click();
}
async function backup(page: Page) {
await page.locator("#tab-saves").click();
const download = page.waitForEvent("download");
await page.locator("#backup").click();
const path = (await (await download).path())!;
const record = JSON.parse(readFileSync(path, "utf8"));
return {
path,
state: JSON.parse(Buffer.from(record.state, "base64").toString("utf8")),
};
}
test("Start opens Key Items, freezes movement, and uses the Bicycle with keyboard and touch", async ({
page,
}) => {
await start(page);
await page.keyboard.press("Enter");
await expect(page.locator("#field-menu")).toBeVisible();
await page.keyboard.down("ArrowDown");
await page.clock.runFor(1000);
await page.keyboard.up("ArrowDown");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "6");
await page
.locator("#field-items")
.getByRole("button", { name: "BICYCLE", exact: true })
.click();
await page.keyboard.press("KeyX");
await page.clock.runFor(500);
await expect(page.locator("#field-menu")).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
expect((await backup(page)).state.playerState).toBe(1);
});
test("the bedroom PC puts away a decoration and saves the result", async ({
page,
}) => {
await start(page, true);
await page.keyboard.press("KeyX");
await expect(page.locator("#game-notice")).toHaveText("");
await expect(page.locator("#dialogue-text")).toHaveText("DECORATION");
const choices = page.locator("#dialogue-choice");
await choices.getByRole("option", { name: "BED", exact: true }).click();
await choices
.getByRole("option", { name: "PUT IT AWAY", exact: true })
.click();
await page.keyboard.press("KeyX");
await choices.getByRole("option", { name: "EXIT", exact: true }).click();
await page.clock.runFor(500);
await expect(page.locator("#game-notice")).toHaveText("");
expect((await backup(page)).state.decorations.bed).toBe(0);
});
test("B cancels the pack and an indoor Bicycle attempt leaves the player walking", async ({
page,
}) => {
await start(page, true);
await page.keyboard.press("Enter");
await page.keyboard.press("KeyZ");
await expect(page.locator("#field-menu")).toBeHidden();
await page.keyboard.press("Enter");
await page
.locator("#field-items")
.getByRole("button", { name: "BICYCLE", exact: true })
.click();
await expect(page.locator("#game-notice")).toContainText("can’t use");
expect((await backup(page)).state.playerState).toBe(0);
});
test("the Town Map poster opens a native map and closes with B", async ({
page,
}) => {
await page.setViewportSize({ width: 320, height: 568 });
await start(page, "poster");
await page.keyboard.press("KeyX");
await expect(page.locator("#dialogue-text")).toContainText("TOWN MAP");
await page.keyboard.press("KeyX");
await expect(page.locator("#town-map-view")).toBeVisible();
const colors = await page
.locator("#town-map-canvas")
.evaluate(
(canvas: HTMLCanvasElement) =>
new Set(canvas.getContext("2d")!.getImageData(0, 0, 160, 144).data)
.size,
);
expect(colors).toBeGreaterThan(8);
await page.keyboard.press("KeyZ");
await expect(page.locator("#town-map-view")).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
});
+26
View File
@@ -0,0 +1,26 @@
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { OW } from "../src/game/world/constants";
export function foundationFixture(
assets: CrystalAssets,
room: boolean | "poster",
) {
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
world.state.scenes["24:4"] = 1;
world.state.bag.keyItems.push(OW.BICYCLE);
const save = world.save();
world.restore({
...save,
map: room ? "24:7" : "24:4",
x: room === "poster" ? 6 : room ? 2 : 9,
y: room === "poster" ? 1 : room ? 2 : 6,
direction: "up",
objects: [],
});
return world;
}
+90
View File
@@ -0,0 +1,90 @@
import { emptyDecorations } from "../src/game/world/decorations";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import type { GameMap } from "../src/game/world/types";
/** Synthetic maps and script bytes: these tests run without a private ROM. */
export class MovementAssets extends CrystalAssets {
readonly fixtures = new Map<string, GameMap>();
readonly tiles = new Map<string, number>();
readonly bytes: Uint8Array;
constructor() {
const bytes = new Uint8Array(0xa0000);
bytes[0] = 0x50; // Empty weekday name for synthetic script hosts.
super(bytes);
this.bytes = bytes;
for (const id of ["24:7", "24:6"]) {
this.fixtures.set(id, {
id,
group: 24,
number: Number(id.split(":")[1]),
name: id,
location: id,
landmark: 1,
width: 8,
height: 8,
border: 0,
tileset: 1,
environment: 1,
paletteMode: 1,
blocks: new Uint8Array(16),
warps: [],
backgroundEvents: [],
objects: [],
connections: [],
sceneScripts: [],
callbacks: [],
coordinateEvents: [],
});
}
}
override map(id: string) {
return this.fixtures.get(id)!;
}
override isPorted(id: string) {
return this.fixtures.has(id);
}
override sceneOwner() {
return undefined;
}
override collision(map: GameMap, x: number, y: number) {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
return this.tiles.get(`${map.id}:${x},${y}`) ?? 0;
}
/** CollisionPermissionTable in brief: walls, then the water rows
* ($2x, $3x, buoys $cx) apart from ice and the wall-like buoy tiles. */
override permission(collision: number) {
if (collision === 7 || collision === 0x27 || collision === 0x2f) return 15;
if (collision === 0x23 || collision === 0x2b) return 0;
return [0x20, 0x30, 0xc0].includes(collision & 0xf0) ? 1 : 0;
}
override grassCollisions() {
return [];
}
override sprite(id: number) {
return id ? { graphics: new Uint8Array(0), palette: 0, walking: false } : undefined;
}
tile(x: number, y: number, collision: number, map = "24:7") {
this.tiles.set(`${map}:${x},${y}`, collision);
}
}
export function fixture() {
const assets = new MovementAssets();
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
world.state.decorations = emptyDecorations();
return { assets, world };
}
export function connectWarp(assets: MovementAssets, x = 3, y = 4) {
assets.map("24:7").warps.push({ x, y, targetMap: "24:6", targetWarp: 1 });
assets
.map("24:6")
.warps.push({ x: 2, y: 2, targetMap: "24:7", targetWarp: 1 });
assets.tile(2, 2, 0x70, "24:6");
}
+25 -92
View File
@@ -1,88 +1,8 @@
import { describe, expect, it } from "vitest";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, WALK_TICKS } from "../src/game/world/world";
import { VECTORS, type Direction, type GameMap } from "../src/game/world/types";
import { settle } from "./world-driver";
/** Synthetic maps and script bytes: these tests run without a private ROM. */
class MovementAssets extends CrystalAssets {
readonly fixtures = new Map<string, GameMap>();
readonly tiles = new Map<string, number>();
readonly bytes: Uint8Array;
constructor() {
const bytes = new Uint8Array(0xa0000);
bytes[0] = 0x50; // Empty weekday name for synthetic script hosts.
super(bytes);
this.bytes = bytes;
for (const id of ["24:7", "24:6"]) {
this.fixtures.set(id, {
id,
group: 24,
number: Number(id.split(":")[1]),
name: id,
location: id,
landmark: 1,
width: 8,
height: 8,
border: 0,
tileset: 1,
environment: 1,
paletteMode: 1,
blocks: new Uint8Array(16),
warps: [],
backgroundEvents: [],
objects: [],
connections: [],
sceneScripts: [],
callbacks: [],
coordinateEvents: [],
});
}
}
override map(id: string) {
return this.fixtures.get(id)!;
}
override isPorted(id: string) {
return this.fixtures.has(id);
}
override sceneOwner() {
return undefined;
}
override collision(map: GameMap, x: number, y: number) {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
return this.tiles.get(`${map.id}:${x},${y}`) ?? 0;
}
override permission(collision: number) {
return collision === 7 ? 15 : 0;
}
override grassCollisions() {
return [];
}
override sprite() {
return { graphics: new Uint8Array(0), palette: 0, walking: false };
}
tile(x: number, y: number, collision: number, map = "24:7") {
this.tiles.set(`${map}:${x},${y}`, collision);
}
}
function fixture() {
const assets = new MovementAssets();
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
return { assets, world };
}
function connectWarp(assets: MovementAssets, x = 3, y = 4) {
assets.map("24:7").warps.push({ x, y, targetMap: "24:6", targetWarp: 1 });
assets
.map("24:6")
.warps.push({ x: 2, y: 2, targetMap: "24:7", targetWarp: 1 });
assets.tile(2, 2, 0x70, "24:6");
}
import { MovementAssets, connectWarp, fixture } from "./movement-fixture";
describe("native ledge movement", () => {
it.each<[number, Direction]>([
@@ -105,8 +25,12 @@ describe("native ledge movement", () => {
const [dx, dy] = VECTORS[direction];
assets.tile(3, 3, tile);
assets.tile(3 + dx, 3 + dy, 7);
// Facing the ledge already, so no turn in place precedes the jump.
world.restore({ ...world.save(), direction });
world.tick(direction);
expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]);
// StepFunction_PlayerJump: the map position reaches the tile jumped
// over first, and the landing tile halfway through.
expect([world.x, world.y]).toEqual([3 + dx, 3 + dy]);
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
x: 48 + dx * 2,
y: 48 + dy * 2,
@@ -114,12 +38,20 @@ describe("native ledge movement", () => {
shadow: dx ? 12 : 14,
});
expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 });
for (let tick = 1; tick < WALK_TICKS * 2 - 1; tick++) world.tick();
expect(world.busy).toBe(true);
for (let tick = 1; tick < WALK_TICKS; tick++) world.tick();
expect([world.x, world.y]).toEqual([3 + dx, 3 + dy]);
world.tick();
expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]);
expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 });
for (let tick = WALK_TICKS + 1; tick < WALK_TICKS * 2 - 1; tick++) world.tick();
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeDefined();
world.tick();
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
// The landing's tile events run on the next tick (PLAYERSTEP_STOP).
expect([world.busy, world.tilesWalked]).toEqual([true, 0]);
world.tick();
expect(world.busy).toBe(false);
expect(world.tilesWalked).toBe(1);
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
},
);
@@ -134,17 +66,15 @@ describe("native ledge movement", () => {
expect(world.tilesWalked).toBe(2);
});
it("refuses the wrong direction, blocked or unsupported landings and map-edge jumps", () => {
it("refuses the wrong direction, blocked landings and map-edge jumps", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0xa3);
assets.tile(3, 2, 7);
expect(world.move("up")).toBe(false);
assets.tile(3, 4, 7);
for (const landing of [7, 0x30, 0x40, 0x60]) {
assets.tile(3, 5, landing);
expect(world.move("down")).toBe(false);
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 3, 0]);
}
assets.tile(3, 5, 7);
expect(world.move("down")).toBe(false);
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 3, 0]);
assets.tile(3, 6, 0xa3);
assets.tile(3, 7, 7);
world.restore({ ...world.save(), y: 6 });
@@ -262,6 +192,9 @@ describe("native warp movement", () => {
2,
"down",
]);
// WarpToNewMapScript's `end` takes the first tick in the new map.
world.tick("up");
expect([world.x, world.y, world.busy]).toEqual([2, 2, false]);
world.tick("up"); // Forced exit overrides held input.
expect(world.poses().find((pose) => pose.id === 0)?.y).toBe(34);
const save = world.save();
@@ -294,7 +227,7 @@ describe("native warp movement", () => {
assets
.map("24:7")
.connections.push({ direction: "right", targetMap: "24:6", x: 0, y: 0 });
world.restore({ ...world.save(), x: 7 });
world.restore({ ...world.save(), x: 7, direction: "right" });
world.tick("right");
const save = world.save();
expect(save).toMatchObject({ map: "24:7", x: 7, y: 3, tilesWalked: 0 });
+19 -3
View File
@@ -106,7 +106,10 @@ describe("native ROM formats", () => {
it("decodes movement data and refuses unported movement commands", () => {
const reader = new RomReader(
Uint8Array.from([
0x02, 0x0f, 0x08, 0x31, 0x3b, 0x40, 0x46, 12, 0x47, 0x4c, 0x47,
0x02, 0x0f, 0x08, 0x31, 0x3b, 0x40, 0x46, 12, 0x47, 0x5a, 0x47,
// turn_step, turn_in, set_sliding, step_dig 16, skyfall,
// step_shake $54, fish_got_bite, step_bump, step_wait_end 5
0x05, 0x26, 0x39, 0x4f, 16, 0x4e, 0x55, 0x54, 0x51, 0x50, 0x48, 5,
]),
);
expect(readMovement(reader, 0)).toEqual([
@@ -119,6 +122,17 @@ describe("native ROM formats", () => {
{ kind: "sleep", ticks: 12 },
]);
expect(() => readMovement(reader, 9)).toThrow("not ported");
expect(readMovement(reader, 11)).toEqual([
{ kind: "turn-step", direction: "up", ticks: 3 },
{ kind: "step", direction: "left", ticks: 8, animate: true, spin: true },
{ kind: "sliding", sliding: true },
{ kind: "pose", pose: "spin", ticks: 16 },
{ kind: "pose", pose: "skyfall", ticks: 31 },
{ kind: "shake", displacement: 0x54 },
{ kind: "pose", pose: "fish-bite", ticks: 8 },
{ kind: "bump", ticks: 1 },
{ kind: "wait-end", ticks: 5 },
]);
});
it("reads RAM-backed names and string buffers only from native values", () => {
// text_ram wStringBuffer4, " ", text_buffer 0, done
@@ -192,7 +206,7 @@ describe.skipIf(!existsSync(romPath))(
load(),
() => new Date("2026-09-22T12:00:00"),
);
world.restore({ ...world.save(), x: 3, y: 2, direction: "down" });
world.restore({ ...world.save(), x: 3, y: 2, direction: "right" });
const ticks: number[] = [];
for (let tick = 1; tick <= 40 && world.x < 7; tick++) {
const x = world.x;
@@ -415,11 +429,13 @@ describe.skipIf(!existsSync(romPath))(
objects: [],
});
world.tick(direction);
expect([world.x, world.y]).toEqual([x + dx * 2, y + dy * 2]);
// The first half of the jump occupies the tile jumped over.
expect([world.x, world.y]).toEqual([x + dx, y + dy]);
expect(
world.poses().find((pose) => pose.id === 0)?.lift,
).toBeLessThan(0);
settle(world);
expect([world.x, world.y]).toEqual([x + dx * 2, y + dy * 2]);
expect(world.notice).toBe("");
checked.add(collision);
}
+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]);
});
});
+455
View File
@@ -0,0 +1,455 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { emptyScriptState } from "../src/game/scripts/interpreter";
import { generatePartyMon } from "../src/game/party/pokemon";
import type { Direction } from "../src/game/world/types";
import { settle, walkTo } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const BURNED_TOWER_1F = "3:13",
BURNED_TOWER_B1F = "3:14";
const EVENT_HOLE_IN_BURNED_TOWER = 818;
/** A world on maps that normal play does not enable yet. */
function fixture(maps: string[]) {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || maps.includes(id);
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => 0);
world.state = emptyScriptState();
return { assets, world };
}
function hold(world: GameWorld, direction: Direction | undefined, ticks: number) {
for (let tick = 0; tick < ticks; tick++) world.tick(direction);
}
describe.skipIf(!existsSync(romPath))("overworld foundation with the local ROM", () => {
it("changeblock in Burned Tower's TILES callback closes the hole until the event is set", () => {
const { assets, world } = fixture([BURNED_TOWER_1F, BURNED_TOWER_B1F]);
world.state.scenes[BURNED_TOWER_1F] = 2; // SCENE_BURNEDTOWER1F_NOOP
world.warpTo(BURNED_TOWER_1F, 10, 10);
expect(assets.permission(assets.collision(world.map, 10, 9))).toBe(0);
expect(assets.collision(world.map, 10, 9)).not.toBe(0x60);
// The ROM's own block data still has the hole; only the working copy changed.
expect(assets.collision(assets.map(BURNED_TOWER_1F), 10, 9)).toBe(0x60);
world.state.events.add(EVENT_HOLE_IN_BURNED_TOWER);
world.warpTo(BURNED_TOWER_1F, 10, 10);
expect(assets.collision(world.map, 10, 9)).toBe(0x60);
});
it("falls through a pit with FallIntoMapScript's skyfall and earthquake", () => {
const { world } = fixture([BURNED_TOWER_1F, BURNED_TOWER_B1F]);
world.state.scenes[BURNED_TOWER_1F] = 2;
world.state.scenes[BURNED_TOWER_B1F] = 1;
world.state.events.add(EVENT_HOLE_IN_BURNED_TOWER);
world.warpTo(BURNED_TOWER_1F, 10, 10);
// Turn, then walk up onto the hole.
hold(world, "up", 4);
hold(world, "up", 8);
expect([world.mapId, world.x, world.y]).toEqual([BURNED_TOWER_1F, 10, 9]);
// The arrival tick falls through; the next starts the rest of the script.
hold(world, undefined, 2);
expect(world.notice, world.diagnostic).toBe("");
expect([world.mapId, world.x, world.y]).toEqual([BURNED_TOWER_B1F, 10, 9]);
expect(world.activity).toBe("scene");
// Invisible above the screen at first, then falling into view.
expect(world.poses().some((pose) => pose.id === 0)).toBe(false);
let shook = false;
for (let tick = 0; tick < 200 && world.busy; tick++) {
world.tick();
if (world.screenShake) shook = true;
}
expect(world.busy).toBe(false);
expect(shook).toBe(true);
expect(world.notice, world.diagnostic).toBe("");
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({ x: 160, y: 144 });
});
it("rides the Goldenrod Dept. Store elevator through its menu and -1 warps", () => {
const [store1F, store3F, elevator] = ["11:11", "11:13", "11:17"];
const { world } = fixture([store1F, store3F, elevator]);
world.warpTo(store1F, 2, 1);
walkTo(world, 2, 0);
expect([world.mapId, world.x, world.y]).toEqual([elevator, 1, 3]);
// The arrival warp is numbered -1, so the entrance became the backup.
expect(world.state.backupWarp).toEqual({ map: store1F, warp: 4 });
walkTo(world, 3, 1);
world.move("up");
world.interact();
const menu = world.dialogue!;
const shown = settle(world, [3]);
expect(shown.join("\n")).toContain("Now on: 1F");
expect(menu.choice).toMatchObject({ kind: "menu" });
expect((menu.choice as { items: string[] }).items).toEqual([
"B1F", "1F", "2F", "3F", "4F", "5F", "6F",
]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.backupWarp).toEqual({ map: store3F, warp: 3 });
walkTo(world, 2, 3);
world.move("down");
settle(world);
// Arrival at warp 3 (2, 0), then the doorway's forced step down.
expect([world.mapId, world.x, world.y]).toEqual([store3F, 2, 1]);
expect(world.notice, world.diagnostic).toBe("");
});
it("keeps the floor when the elevator menu is cancelled or the same floor is chosen", () => {
const [store1F, elevator] = ["11:11", "11:17"];
const { world } = fixture([store1F, elevator]);
world.warpTo(store1F, 2, 1);
walkTo(world, 2, 0);
walkTo(world, 3, 1);
world.move("up");
for (const answer of [-1, 1]) {
world.interact();
settle(world, [answer]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.backupWarp).toEqual({ map: store1F, warp: 4 });
}
});
describe("wild encounters (CanEncounterWildMon and ChooseWildEncounter)", () => {
const UNION_CAVE_1F = "3:37";
const cave = () => {
const { assets, world } = fixture([UNION_CAVE_1F]);
world.state.party = [
generatePartyMon(assets.pokemon, {
species: 155, level: 20, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
}),
];
return { assets, world };
};
/** Walk back and forth until a battle starts or the steps run out. */
const pace = (world: GameWorld, steps: number, directions: Direction[]) => {
for (let step = 0; step < steps && !world.battle; step++) {
world.move(directions[step % directions.length]);
for (let tick = 0; tick < 4 && !world.battle; tick++) world.tick();
}
return world.battle?.engine;
};
it("starts on any cave floor after the five-step cooldown", () => {
const { world } = cave();
world.warpTo(UNION_CAVE_1F, 9, 6);
const battle = pace(world, 12, ["right", "left"]);
expect(world.notice, world.diagnostic).toBe("");
expect(battle).toBeDefined();
expect(world.tilesWalked).toBe(5);
});
it("stays off with wildoff", () => {
const { world } = cave();
world.state.noWildEncounters = true;
world.warpTo(UNION_CAVE_1F, 9, 6);
expect(pace(world, 12, ["right", "left"])).toBeUndefined();
});
it("uses the water table's rate and slots while surfing", () => {
const { assets, world } = cave();
const sea = assets.waterWildMons(UNION_CAVE_1F)!;
expect(sea).toBeDefined();
world.state.playerState = 4; // PLAYER_SURF
world.restore({ ...world.save(), map: UNION_CAVE_1F, x: 7, y: 4, direction: "right", objects: [] });
const battle = pace(world, 12, ["right", "left"]);
expect(battle).toBeDefined();
// Random is always 0: the first slot, with no level variety.
expect(battle!.enemy.mon).toMatchObject({
species: sea.slots[0].species,
level: sea.slots[0].level,
});
});
});
it("draws overworld Pokémon from their party menu icons (GetMonSprite)", () => {
const { assets } = fixture([]);
const unown = assets.sprite(0x80)!; // SPRITE_UNOWN
expect(unown.walking).toBe(true);
expect(unown.palette).toBe(0);
expect(unown.graphics).toHaveLength(8 * 16);
expect(assets.sprite(0x80 + 35)).toBeUndefined();
expect(assets.sprite(103)).toBeUndefined();
});
it("surfing onto a whirlpool spins the player back with Script_ForcedMovement", () => {
const { assets, world } = fixture([]);
// Find a whirlpool with water on the side a surfer comes from.
let found: { map: string; x: number; y: number } | undefined;
for (let group = 1; group <= 26 && !found; group++)
for (let number = 1; number <= 40 && !found; number++) {
let map;
try {
map = assets.map(`${group}:${number}`);
} catch {
continue;
}
for (let y = 1; y < map.height - 1 && !found; y++)
for (let x = 0; x < map.width && !found; x++)
if (
assets.collision(map, x, y) === 0x24 &&
assets.permission(assets.collision(map, x, y + 1)) === 1 &&
assets.permission(assets.collision(map, x, y + 2)) === 1 &&
!map.objects.some((o) => o.x === x && (o.y === y + 1 || o.y === y + 2))
)
found = { map: map.id, x, y };
}
expect(found).toBeDefined();
const { map, x, y } = found!;
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === map;
world.state.playerState = 4; // PLAYER_SURF
world.restore({ ...world.save(), map, x, y: y + 2, direction: "up", objects: [] });
hold(world, "up", 8);
expect([world.x, world.y]).toEqual([x, y + 1]);
hold(world, "up", 9);
expect([world.x, world.y]).toEqual([x, y]);
expect(world.activity).toBe("scene");
const directions = new Set<string>();
for (let tick = 0; tick < 120 && world.busy; tick++) {
world.tick();
directions.add(world.direction);
}
expect(world.notice, world.diagnostic).toBe("");
// .MovementData_up: spin, turn_in DOWN back off the whirlpool, spin,
// then face down.
expect([world.x, world.y, world.direction]).toEqual([x, y + 1, "down"]);
expect(directions.size).toBe(4);
});
});
describe.skipIf(!existsSync(romPath))("field moves with the local ROM", () => {
const TOTODILE = 158;
const partyWith = (assets: CrystalAssets, moves: number[], species = TOTODILE) => {
const mon = generatePartyMon(assets.pokemon, {
species, level: 30, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
});
mon.moves = [...moves, 0, 0, 0, 0].slice(0, 4) as typeof mon.moves;
mon.pp = [10, 10, 10, 10];
mon.nickname = "SWIMMY";
return [mon];
};
/** Find a tile on some map, searching every group. */
const find = (
assets: CrystalAssets,
test: (map: ReturnType<CrystalAssets["map"]>, x: number, y: number) => boolean,
) => {
for (let group = 1; group <= 26; group++)
for (let number = 1; number <= 60; number++) {
let map;
try {
map = assets.map(`${group}:${number}`);
} catch {
continue;
}
for (let y = 1; y < map.height - 1; y++)
for (let x = 1; x < map.width - 1; x++) if (test(map, x, y)) return { map: map.id, x, y };
}
throw new Error("No suitable tile.");
};
it("surfs from New Bark Town's shore through AskSurfScript and lands again", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x39]); // SURF
world.state.flags.add(30); // ENGINE_FOGBADGE
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nSURF!");
expect(world.state.playerState).toBe(4);
expect([world.x, world.y]).toEqual([18, 7]);
// The surfer sprite (SPRITE_SURF) replaces Chris.
expect(world.poses().find((pose) => pose.id === 0)?.sprite).toBe(0x53);
// Water saves and reloads with the surfer on it.
world.restore(world.save());
expect(world.state.playerState).toBe(4);
world.move("left");
expect([world.x, world.y, world.state.playerState]).toEqual([17, 7, 0]);
});
it("does not offer Surf without the Fog Badge or a Pokémon that knows it", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x39]);
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
world.interact();
expect(world.dialogue).toBeUndefined();
world.state.flags.add(30);
world.state.party = partyWith(assets, [0x0f]);
world.interact();
expect(world.dialogue).toBeUndefined();
});
it("cuts a tree with AskCutScript until the map loads again", () => {
const { assets, world } = fixture([]);
const spot = find(assets, (map, x, y) =>
[1, 2, 3, 12, 25].includes(map.tileset) &&
assets.collision(map, x, y) === 0x12 &&
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
map.callbacks.length === 0,
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x0f]); // CUT
world.state.flags.add(28); // ENGINE_HIVEBADGE
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "up" });
const before = world.map.blocks.slice();
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nCUT!");
expect(world.map.blocks).not.toEqual(before);
expect(assets.permission(assets.collision(world.map, spot.x, spot.y))).toBe(0);
world.move("up");
expect([world.x, world.y]).toEqual([spot.x, spot.y]);
world.warpTo(spot.map, spot.x, spot.y + 1);
expect(world.map.blocks).toEqual(before);
});
it("explains a Cut tree without the move", () => {
const { assets, world } = fixture([]);
const spot = find(assets, (map, x, y) =>
assets.collision(map, x, y) === 0x12 &&
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
map.callbacks.length === 0,
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x21]);
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "up" });
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown.length).toBeGreaterThan(0);
expect(assets.permission(assets.collision(world.map, spot.x, spot.y))).not.toBe(0);
});
it("uses Strength through AskStrengthScript and pushes a boulder", () => {
const { assets, world } = fixture([]);
// A Strength boulder object with floor below it and two floor tiles above.
const spot = find(assets, (map, x, y) => {
const boulder = map.objects.find(
(o) => o.x === x && o.y === y && o.movement === 25 && o.eventFlag === 0xffff,
);
return Boolean(
boulder &&
map.callbacks.length === 0 &&
[y + 1, y - 1, y - 2].every(
(row) =>
assets.permission(assets.collision(map, x, row)) === 0 &&
!map.objects.some((o) => o !== boulder && o.x === x && o.y === row),
),
);
});
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x46]); // STRENGTH
world.state.flags.add(29); // ENGINE_PLAINBADGE
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "up" });
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nSTRENGTH!");
expect(world.state.flags.has(24)).toBe(true); // ENGINE_STRENGTH_ACTIVE
for (let tick = 0; tick < 20; tick++) world.tick("up");
const boulder = world.objects().find((o) => o.movement === 25 && o.x === spot.x);
expect(boulder?.y).toBe(spot.y - 1);
// Talking to it again: "Boulders may now be moved!"
settle(world);
});
it("rides the Bicycle on a route and gets off indoors", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x21]);
world.state.bag.keyItems.push(7); // BICYCLE
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
expect(world.useBicycle()).toBe(true);
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("got on the\nBICYCLE.");
expect(world.state.playerState).toBe(1);
expect(world.poses().find((pose) => pose.id === 0)?.sprite).toBe(2); // SPRITE_CHRIS_BIKE
// Turn left (four ticks), then four ticks per tile on the bike.
for (let tick = 0; tick < 4; tick++) world.tick("left");
world.tick("left");
expect(world.x).toBe(12);
for (let tick = 0; tick < 4; tick++) world.tick("left");
expect(world.x).toBe(11);
// Entering the house (indoors) gets the player off (CheckUpdatePlayerSprite).
settle(world);
walkTo(world, 13, 5);
expect(world.mapId).toBe("24:6");
expect(world.state.playerState).toBe(0);
expect(world.useBicycle()).toBe(false);
});
it("offers Whirlpool and Waterfall only with the move and badge", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x39]);
const whirlpool = find(assets, (map, x, y) =>
assets.collision(map, x, y) === 0x24 &&
assets.permission(assets.collision(map, x, y + 1)) === 1 &&
!map.objects.some((o) => o.x === x && o.y === y + 1),
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === whirlpool.map;
world.state.playerState = 4;
world.restore({ ...world.save(), map: whirlpool.map, x: whirlpool.x, y: whirlpool.y + 1, direction: "up", objects: [] });
world.interact();
const mighty = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(mighty.length).toBeGreaterThan(0);
world.state.party = partyWith(assets, [0x39, 250]); // SURF, WHIRLPOOL
world.state.flags.add(33); // ENGINE_GLACIERBADGE
const before = world.map.blocks.slice();
world.interact();
const used = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(used).toContain("used\nWHIRLPOOL!");
expect(world.map.blocks).not.toEqual(before);
});
});
describe.skipIf(!existsSync(romPath))("Strength boulders and stone tables with the local ROM", () => {
it("drops a boulder pushed into Ice Path B1F's hole through its stone table script", () => {
const ICE_PATH_B1F = "3:62";
const EVENT_BOULDER_IN_ICE_PATH_2A = 1806;
const { assets, world } = fixture([ICE_PATH_B1F]);
const mon = generatePartyMon(assets.pokemon, {
species: 158, level: 30, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
});
world.state.party = [mon];
world.warpTo(ICE_PATH_B1F, 4, 5);
world.state.flags.add(24); // ENGINE_STRENGTH_ACTIVE
world.state.events.add(EVENT_BOULDER_IN_ICE_PATH_2A);
world.restore({
...world.save(),
x: 4,
y: 5,
direction: "down",
objects: [{ id: 3, x: 4, y: 6, direction: "down" }],
});
world.state.flags.add(24);
expect(world.objects().some((o) => o.id === 3)).toBe(true);
world.tick("down");
const shown: string[] = [];
for (let tick = 0; tick < 40 && world.activity !== "scene"; tick++) world.tick();
expect(world.activity).toBe("scene");
shown.push(...settle(world));
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/fell\s+through/i);
expect(world.objects().some((o) => o.id === 3)).toBe(false);
expect(world.state.events.has(EVENT_BOULDER_IN_ICE_PATH_2A)).toBe(false);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { test, expect } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
test("all tileset palettes match the original LoadMapPals routine", async ({
page,
}, info) => {
test.skip(
info.project.name !== "chromium" ||
!existsSync(romPath) ||
!existsSync(symbolsPath),
"Requires the local ROM/reference; engine comparison runs in Chromium.",
);
const symbols: Record<string, { bank: number; address: number }> = {};
for (const line of readFileSync(symbolsPath, "utf8").split("\n")) {
const m = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
if (m)
symbols[m[3]] = { bank: parseInt(m[1], 16), address: parseInt(m[2], 16) };
}
const bytes = new Uint8Array(readFileSync(romPath));
const cases = Array.from({ length: 36 * 4 }, (_, i) => ({
tileset: 1 + Math.floor(i / 4),
time: i % 4,
environment: 3,
group: 24,
}));
for (const environment of [1, 2, 4, 7])
for (const time of [0, 1, 2, 3])
for (const tileset of [1, 29, 30])
cases.push({ tileset, time, environment, group: 24 });
await page.goto("/reference.html");
const original = await page.evaluate(
async ({ rom, symbols, cases }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const emu = new BinjgbEmulator();
await emu.load(new Uint8Array(rom), document.createElement("canvas"));
const write = (name: string, value: number) =>
emu.writeMemory(symbols[name].address, value);
const target = symbols.LoadMapPals;
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
target.bank,
0xe0,
0x9d,
0xea,
0,
0x20,
0xcd,
target.address & 255,
target.address >> 8,
0x3e,
0x42,
0xea,
0xff,
0xc6,
0x18,
0xfe,
];
const output = cases.map((c) => {
emu.writeMemory(0xff70, 1);
write("wMapTileset", c.tileset);
write("wEnvironment", c.environment);
write("wTimeOfDayPal", c.time);
write("wMapGroup", c.group);
emu.writeMemory(0xc6ff, 0);
program.forEach((v, i) => emu.writeMemory(0xc000 + i, v));
emu.setProgramCounter(0xc000);
emu.advanceFrames(2);
if (emu.readMemory(0xc6ff) !== 0x42)
throw new Error("LoadMapPals did not return");
emu.writeMemory(0xff70, symbols.wBGPals1.bank);
return Array.from({ length: 32 }, (_, i) => {
const p = symbols.wBGPals1.address + i * 2;
const value = emu.readMemory(p) | (emu.readMemory(p + 1) << 8);
return [0, 5, 10].map((shift) =>
Math.round((((value >> shift) & 31) * 255) / 31),
);
});
});
emu.dispose();
return output;
},
{ rom: [...bytes], symbols, cases },
);
const assets = new CrystalAssets(bytes),
base = assets.map("24:6");
cases.forEach((c, i) =>
expect(
assets.palettes({ ...base, ...c }, c.time).flat(),
JSON.stringify(c),
).toEqual(original[i]),
);
});
+8
View File
@@ -60,6 +60,14 @@ async function drive(page: Page, name = "") {
} else if (await page.locator("#game-screen").getAttribute("data-activity") !== "idle") {
await page.clock.runFor(500);
} else {
// A scene script starts a tick after the tick that ends a warp, so a
// single idle observation is not the end of the scene.
await page.clock.runFor(200);
if (
(await page.locator("#game-screen").getAttribute("data-activity")) !== "idle" ||
(await page.locator("#native-dialogue").isVisible())
)
continue;
await expect(page.locator("#game-notice")).toHaveText("");
return messages.join("\n");
}
+218
View File
@@ -0,0 +1,218 @@
import { BinjgbEmulator } from "../src/emulator/binjgb";
import type { Direction } from "../src/game/world/types";
/** CPU addresses (and banks for ROM routines) from the private PRET build's
* symbol file, read by the Node side of the test. */
export interface ReferenceSymbols {
[label: string]: { bank: number; address: number };
}
/** One overworld tick of the original engine, sampled after HandleMap ran. */
export interface ReferenceTick {
map: string;
x: number;
y: number;
facing: Direction;
/** wMapStatus: 1 (MAPSTATUS_ENTER) when this tick began a map change. */
status: number;
/** Map objects with an object struct: OBJECT_MAP_X/Y by script object
* number (OBJECT_MAP_OBJECT_INDEX + 1, as object_const_def starts at 2). */
objects: Record<number, [number, number]>;
events: number[];
poses: Record<
number,
{ direction: Direction; visible: boolean; lift: number }
>;
}
const FACINGS: Direction[] = ["down", "up", "left", "right"];
let emulator: BinjgbEmulator | undefined;
let symbols: ReferenceSymbols;
const at = (label: string) => {
const symbol = symbols[label];
if (!symbol) throw new Error(`Missing reference symbol ${label}.`);
return symbol.address;
};
const read = (label: string) => emulator!.readMemory(at(label));
function sample(): ReferenceTick {
const objects: Record<number, [number, number]> = {};
const poses: ReferenceTick["poses"] = {};
// wObjectStructs after the player: OBJECT_SPRITE, OBJECT_MAP_OBJECT_INDEX,
// OBJECT_MAP_X/Y at $10/$11, $28 bytes each.
for (let slot = 1; slot < 13; slot++) {
const base = at("wPlayerStruct") + slot * 0x28;
if (!emulator!.readMemory(base)) continue;
const id = emulator!.readMemory(base + 1) + 1;
const offset = emulator!.readMemory(base + 0x1a);
poses[id] = {
direction: FACINGS[(emulator!.readMemory(base + 8) >> 2) & 3],
visible: emulator!.readMemory(base + 0xb) !== 0,
lift: offset < 128 ? offset : offset - 256,
};
objects[id] = [
emulator!.readMemory(base + 0x10) - 4,
emulator!.readMemory(base + 0x11) - 4,
];
}
return {
map: `${read("wMapGroup")}:${read("wMapNumber")}`,
// OBJECT_MAP_X/Y include the four-tile border offset.
x: read("wPlayerMapX") - 4,
y: read("wPlayerMapY") - 4,
facing: FACINGS[(read("wPlayerDirection") >> 2) & 3],
status: read("wMapStatus"),
objects,
poses,
events: Array.from({ length: 2048 }, (_, event) => event).filter((event) =>
Boolean(
emulator!.readMemory(at("wEventFlags") + (event >> 3)) &
(1 << (event & 7)),
),
),
};
}
/** Advance until HandleMap has run once more. ResetOverworldDelay sets
* wOverworldDelay to 2 at the start of each HandleMap and VBlank counts it
* down, so a sampled 2 marks a new overworld tick. Map setup between ticks
* (warps, connections) takes frames that are not ticks. */
/** Advance to the next time rLY reads 143, just before VBlank: HandleMap's
* work for the tick is done and VBlank has not yet counted wOverworldDelay
* down. Map setup turns the LCD off (rLY stays 0), so the phase is found
* again on every frame rather than once. */
function nextFrame() {
const line = emulator!.readMemory(0xff44);
emulator!.advanceCycles(
(line === 143 ? 154 : (143 - line + 154) % 154) * 456,
);
}
function nextTick() {
let pressing = false;
for (let frame = 0; frame < 3000; frame++) {
// Text waits inside one tick of the original; after a few frames with no
// tick, press A in pulses until the script moves on.
if (frame >= 8 && frame % 2 === 0) {
pressing = !pressing;
emulator!.setButton("a", pressing);
}
nextFrame();
const status = read("wMapStatus");
if (read("wOverworldDelay") === 2 && (status === 2 || status === 1)) {
emulator!.setButton("a", false);
return;
}
}
throw new Error("The reference overworld did not advance.");
}
function hold(held: Direction | undefined) {
for (const direction of FACINGS)
emulator!.setButton(direction, direction === held);
}
/** Boot the user's ROM, then run the original ResetWRAM and enter a map
* through FinishContinueFunction with MAPSETUP_WARP and SPAWN_N_A, from a
* WRAM trampoline on the stack page (which _ResetWRAM leaves alone). */
export async function bootReference(
bytes: Uint8Array,
table: ReferenceSymbols,
start: { map: string; x: number; y: number },
/** Work RAM written after ResetWRAM, such as scene variables. */
writes: { label: string; value: number }[] = [],
) {
emulator?.dispose();
symbols = table;
emulator = new BinjgbEmulator();
await emulator.load(bytes, document.createElement("canvas"));
emulator.advanceFrames(400);
const [group, number] = start.map.split(":").map(Number);
const routine = (label: string) => {
const { bank, address } = table[label];
return [0x3e, bank, 0x21, address & 255, address >> 8, 0xcf]; // rst FarCall
};
const store = (value: number, label: string) => {
const address = at(label);
return [0x3e, value, 0xea, address & 255, address >> 8];
};
const hram = (value: number, label: string) => [
0x3e,
value,
0xe0,
at(label) & 255,
];
const program = [
0xf3, // di
0x3e,
0x01,
0xe0,
0x70, // rSVBK = 1
0x31,
0xff,
0xc0, // ld sp, wStackTop
0xfb, // ei
...routine("ResetWRAM"),
...store(0xff, "wDefaultSpawnpoint"), // SPAWN_N_A
...store(group, "wMapGroup"),
...store(number, "wMapNumber"),
...store(start.x, "wXCoord"),
...store(start.y, "wYCoord"),
// STATUSFLAGS_NO_WILD_ENCOUNTERS_F: traces must not start battles.
...store(1 << 5, "wStatusFlags"),
...writes.flatMap(({ label, value }) => store(value, label)),
...hram(0xf1, "hMapEntryMethod"), // MAPSETUP_WARP
...routine("FinishContinueFunction"),
0x18,
0xfe,
];
// The trampoline shares the stack page, whose top the game uses.
if (program.length > 0x80) throw new Error("Trampoline too long.");
program.forEach((value, index) =>
emulator!.writeMemory(0xc000 + index, value),
);
emulator.setProgramCounter(0xc000);
hold(undefined);
nextTick();
// Let the map name sign and any entry scene settle before tracing.
for (let tick = 0; tick < 8; tick++) nextTick();
return { ...sample(), timeOfDay: read("wTimeOfDay") };
}
/** Hold the given direction (or nothing) for one tick each. */
export function traceReference(
inputs: (Direction | undefined)[],
): ReferenceTick[] {
return inputs.map((held) => {
hold(held);
nextTick();
return sample();
});
}
/** Development aid: read reference memory directly. */
export function peek() {
return (address: number) => emulator!.readMemory(address);
}
/** One video frame, sampled just before VBlank like the ticks. */
export function stepFrame() {
nextFrame();
}
/** vTiles2 in VRAM bank 0 as the original has animated it: 128 tiles. */
export function readTiles(): number[] {
const bank = emulator!.readMemory(0xff4f);
emulator!.writeMemory(0xff4f, 0);
const tiles = Array.from({ length: 0x800 }, (_, index) =>
emulator!.readMemory(0x9000 + index),
);
emulator!.writeMemory(0xff4f, bank);
return tiles;
}
export function disposeReference() {
emulator?.dispose();
emulator = undefined;
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { drawTile } from "../src/game/rendering/pixels";
import { TilesetAnimator } from "../src/game/rendering/tileset-animation";
import { CrystalAssets } from "../src/game/rom/assets";
import { OW } from "../src/game/world/constants";
import type { Palette } from "../src/game/world/types";
it("grass priority uses background indices, preserving transparent sprite pixels", () => {
const palette: Palette = [
[255, 255, 255],
[80, 160, 80],
[32, 64, 32],
[0, 0, 0],
];
const target = new Uint8ClampedArray(8 * 8 * 4);
const indices = new Uint8Array(8 * 8);
const background = new Uint8Array(16);
background[0] = 0x80; // Colored grass at x=0; color zero elsewhere.
drawTile(target, 8, 8, background, 0, 0, 0, palette, { indices });
const sprite = new Uint8Array(16);
sprite[1] = 0xc0; // Sprite color 2 at x=0 and x=1, transparent at x=2.
drawTile(target, 8, 8, sprite, 0, 0, 0, palette, {
indices,
behind: true,
transparent: true,
});
expect([...target.slice(0, 12)]).toEqual([
80, 160, 80, 255, 32, 64, 32, 255, 255, 255, 255, 255,
]);
expect([...indices.slice(0, 3)]).toEqual([1, 0, 0]);
drawTile(target, 8, 8, sprite, 0, 0, 0, palette, {
indices,
transparent: true,
});
expect([...target.slice(0, 4)]).toEqual([32, 64, 32, 255]);
});
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))(
"overworld rendering with the local ROM",
() => {
it("runs every tileset animation without changing the cached ROM graphics", () => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const base = assets.map("24:4");
for (let tileset = 1; tileset <= OW.NUM_TILESETS; tileset++) {
const map = { ...base, tileset };
const graphics = assets.tileset(map).graphics;
const original = graphics.slice();
const animator = new TilesetAnimator(assets, map, graphics);
animator.advance(1024, false);
animator.advance(1024, true);
expect(graphics, `tileset ${tileset}`).toEqual(original);
expect(animator.graphics.length).toBe(original.length);
}
});
it("keeps special indoor palettes fixed and uses normal palettes for the Hall of Fame", () => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const base = assets.map("24:6");
for (const tileset of [
OW.TILESET_POKECOM_CENTER,
OW.TILESET_BATTLE_TOWER_INSIDE,
OW.TILESET_HOUSE,
OW.TILESET_RADIO_TOWER,
OW.TILESET_MANSION,
]) {
const map = { ...base, tileset };
expect(assets.palettes(map, 0)).toEqual(assets.palettes(map, 2));
expect(assets.palettes(map, 0)).toHaveLength(8);
}
const ice = {
...base,
tileset: OW.TILESET_ICE_PATH,
environment: OW.CAVE,
};
expect(assets.palettes(ice, 0)).toEqual(assets.palettes(ice, 2));
const hall = { ...ice, environment: OW.INDOOR };
expect(assets.palettes(hall, 1)).not.toEqual(assets.palettes(ice, 1));
expect(assets.palettes(hall, 1)).toEqual(
assets.palettes({ ...hall, tileset: OW.TILESET_POKECENTER }, 1),
);
});
},
);
+2 -1
View File
@@ -104,7 +104,8 @@ export function walkTo(
targetY: number,
answers: (number | string)[] = [],
): string[] {
const shown: string[] = [];
// Finish anything in progress first, such as the tick after a warp.
const shown: string[] = settle(world, answers);
for (let attempt = 0; attempt < 20; attempt++) {
let blocked = false;
for (const direction of findPath(world, targetX, targetY)) {