Port forced movement (turn delays, bumps, ice, currents, pits), dynamic return warps, elevators, Surf, Cut, Strength and cycling rules, working map blocks with changeblock/changemapblocks, and the remaining NPC movement types and movement commands. Add tileset animation from the ROM's command tables, grass sprite priority and special map palettes; room decorations with ownership, the bedroom PC and save migration; and Key Items access to the Bicycle. Fourteen original-engine traces compare positions, facing, actors, event flags and scripted poses. Tile animation matches original VRAM for four tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now keeps the $60 sprite offset its step function leaves behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ac111f262
commit
451464f7c6
38 files changed
+5600
-490
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]),
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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)) {
|
||||
|
||||
Reference in new issue
Block a user