The sound engine (audio/engine.asm, home/audio.asm) is ported on a byte copy of its audio WRAM and a sound register model. A reference test runs the cartridge's routines beside it and matches the WRAM and registers after every UpdateSound for all songs, effects and cries, fades, the danger tone and effect priority. apu.ts synthesizes the four channels in an AudioWorklet; its output matches the reference emulator's APU by spectrum. The world runs the engine twice per tick; Play or the first press unlocks sound, pausing suspends it, Settings sets the volume. Wired in: map music by map setup and special map music, script audio commands, text fanfares, WaitSFX and cries that hold the script frozen (the last page stays shown), battle music, cries, fainting, victory, capture and the low HP tone, Pokémon Center healing, evolution, the intro, the Pokédex's cries, PCs, marts, healing items, talk and menu clicks, bumps, ledges, warps, boulders, the Bicycle and Surf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1020 lines
39 KiB
TypeScript
1020 lines
39 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { readFileSync, existsSync } from "node:fs";
|
||
import { createHash } from "node:crypto";
|
||
import { RomReader } from "../src/game/rom/reader";
|
||
import { decompress } from "../src/game/rom/lz";
|
||
import { CrystalAssets } from "../src/game/rom/assets";
|
||
import { GameWorld } from "../src/game/world/world";
|
||
import { NATIVE_MAPS, SYMBOLS } from "../src/game/rom/profile";
|
||
import {
|
||
ScriptRun,
|
||
emptyScriptState,
|
||
runScript,
|
||
} from "../src/game/scripts/interpreter";
|
||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||
import type { Evolution } from "../src/game/rom/pokemon";
|
||
import { readMovement } from "../src/game/scripts/movement";
|
||
import { defaultTextContext, readText } from "../src/game/scripts/text";
|
||
import { settle, walkTo, withoutPlayTime } from "./world-driver";
|
||
import { drawTile } from "../src/game/rendering/pixels";
|
||
import { CRYSTAL_ROM } from "../src/rom/validate";
|
||
import { VECTORS, type Direction } from "../src/game/world/types";
|
||
|
||
describe("native ROM formats", () => {
|
||
it("resolves fixed and banked pointers and rejects out-of-bounds reads", () => {
|
||
const reader = new RomReader(new Uint8Array(0xc000));
|
||
expect(reader.address(2, 0x4321)).toBe(0x8321);
|
||
expect(reader.address(2, 0x1234)).toBe(0x1234);
|
||
expect(() => reader.address(3, 0x4000)).toThrow();
|
||
expect(() => reader.word(0xbfff)).toThrow();
|
||
expect(() => reader.slice(-1, 1)).toThrow();
|
||
});
|
||
it("decodes all LZ commands, long runs, overlapping copies and termination", () => {
|
||
const decode = (input: number[]) => [
|
||
...decompress(new RomReader(Uint8Array.from(input)), 0),
|
||
];
|
||
expect(decode([0x02, 1, 2, 3, 0x22, 7, 0x43, 8, 9, 0x61, 0xff])).toEqual([
|
||
1, 2, 3, 7, 7, 7, 8, 9, 8, 9, 0, 0,
|
||
]);
|
||
expect(decode([0, 5, 0x83, 0x80, 0xff])).toEqual([5, 5, 5, 5, 5]);
|
||
expect(decode([0, 0b10010000, 0xa0, 0, 0, 0xff])).toEqual([144, 9]);
|
||
expect(decode([2, 1, 2, 3, 0xc2, 0, 2, 0xff])).toEqual([1, 2, 3, 3, 2, 1]);
|
||
expect(decode([0xec, 39, 0xff])).toEqual(Array(40).fill(0));
|
||
expect(() => decode([0x80, 0x80, 0xff])).toThrow("back-reference");
|
||
expect(() =>
|
||
decompress(new RomReader(new Uint8Array([0xec, 99, 0xff])), 0, 10),
|
||
).toThrow("oversized");
|
||
expect(() => decode([0x20, 4])).toThrow();
|
||
});
|
||
it("decodes planar pixels with transparency and flips", () => {
|
||
const pixels = new Uint8ClampedArray(8 * 8 * 4);
|
||
const tile = new Uint8Array(16);
|
||
tile[0] = 0x80;
|
||
tile[1] = 0x40;
|
||
const palette = [
|
||
[0, 0, 0],
|
||
[255, 0, 0],
|
||
[0, 255, 0],
|
||
[0, 0, 255],
|
||
] as const;
|
||
drawTile(pixels, 8, 8, tile, 0, 0, 0, palette, {
|
||
transparent: true,
|
||
flipX: true,
|
||
});
|
||
expect([...pixels.slice(7 * 4, 8 * 4)]).toEqual([255, 0, 0, 255]);
|
||
expect([...pixels.slice(6 * 4, 7 * 4)]).toEqual([0, 255, 0, 255]);
|
||
expect(pixels[3]).toBe(0);
|
||
});
|
||
it("aborts unknown script commands without committing earlier event writes", () => {
|
||
const reader = new RomReader(Uint8Array.from([0x33, 5, 0, 0x0e, 0, 0, 0]));
|
||
const state = emptyScriptState();
|
||
expect(() => runScript(reader, 0, state, "TEST")).toThrow("not ported");
|
||
expect(state.events.size).toBe(0);
|
||
});
|
||
it("bounds looping scripts", () => {
|
||
const reader = new RomReader(Uint8Array.from([0x03, 0, 0]));
|
||
expect(() => runScript(reader, 0, emptyScriptState(), "TEST")).toThrow(
|
||
"budget",
|
||
);
|
||
});
|
||
it("resumes scripts after choices and writes only to a private draft", () => {
|
||
const bytes = new Uint8Array(0x100);
|
||
// writetext $40; yesorno; iftrue $10; end; $10: setevent 7; end
|
||
bytes.set([0x4c, 0x40, 0x00, 0x4e, 0x09, 0x10, 0x00, 0x91], 0);
|
||
bytes.set([0x33, 7, 0, 0x91], 0x10);
|
||
// "HI" then the player's name through text_ram, then done.
|
||
bytes.set([0x00, 0x87, 0x88, 0x7f, 0x52, 0x57], 0x40);
|
||
const state = emptyScriptState();
|
||
const host = {
|
||
mapId: "1:1",
|
||
lastTalked: 0,
|
||
sceneOwner: () => undefined,
|
||
timeMask: () => 2,
|
||
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
|
||
clockDay: () => 0,
|
||
text: () => defaultTextContext("KAWA"),
|
||
};
|
||
const yes = new ScriptRun(new RomReader(bytes), 0, state, host);
|
||
expect(yes.resume()).toEqual({ kind: "yesno", pages: ["HI KAWA"] });
|
||
expect(yes.resume(1)).toBeUndefined();
|
||
expect(yes.state.events.has(7)).toBe(true);
|
||
expect(state.events.size).toBe(0);
|
||
const no = new ScriptRun(new RomReader(bytes), 0, state, host);
|
||
no.resume();
|
||
expect(no.resume(0)).toBeUndefined();
|
||
expect(no.state.events.size).toBe(0);
|
||
});
|
||
it("decodes movement data and refuses unported movement commands", () => {
|
||
const reader = new RomReader(
|
||
Uint8Array.from([
|
||
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([
|
||
{ kind: "turn", direction: "left", ticks: 1 },
|
||
{ kind: "step", direction: "right", ticks: 8, animate: true },
|
||
{ kind: "step", direction: "down", ticks: 16, animate: true },
|
||
{ kind: "jump", direction: "up", ticks: 8 },
|
||
{ kind: "fix-facing", fixed: true },
|
||
{ kind: "sleep", ticks: 3 },
|
||
{ 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
|
||
const bytes = Uint8Array.from([
|
||
0x01, 0xac, 0xd0, 0x00, 0x7f, 0x50, 0x14, 0x00, 0x50, 0x50,
|
||
]);
|
||
const context = {
|
||
...defaultTextContext("KAWA"),
|
||
strings: { 0xd0ac: "POKéGEAR", 0xd099: "ELM" },
|
||
};
|
||
expect(readText(new RomReader(bytes), 0, context)).toBe("POKéGEAR ELM");
|
||
expect(() =>
|
||
readText(new RomReader(bytes), 0, defaultTextContext("KAWA")),
|
||
).toThrow("no native value");
|
||
});
|
||
});
|
||
|
||
const romPath =
|
||
process.env.CRYSTAL_ROM_PATH ??
|
||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||
describe.skipIf(!existsSync(romPath))(
|
||
"native engine with local user ROM (never checked in)",
|
||
() => {
|
||
const load = () => {
|
||
const bytes = readFileSync(romPath);
|
||
expect(createHash("sha1").update(bytes).digest("hex")).toBe(
|
||
CRYSTAL_ROM.sha1,
|
||
);
|
||
return new CrystalAssets(new Uint8Array(bytes));
|
||
};
|
||
it("decodes every supported map, tileset, event table and sprite", () => {
|
||
const assets = load();
|
||
for (const id of Object.keys(NATIVE_MAPS)) {
|
||
const map = assets.map(id),
|
||
tileset = assets.tileset(map);
|
||
expect(map.blocks.length).toBe((map.width * map.height) / 4);
|
||
expect(tileset.graphics.length).toBeGreaterThanOrEqual(0x600);
|
||
expect(assets.palettes(map, 1).length).toBe(8);
|
||
for (const warp of map.warps) {
|
||
expect(warp.x).toBeLessThan(map.width);
|
||
expect(warp.y).toBeLessThan(map.height);
|
||
// -1 returns through the saved entrance warp (e.g. elevators).
|
||
if (warp.targetWarp !== 0xff && warp.targetMap in NATIVE_MAPS)
|
||
expect(
|
||
assets.map(warp.targetMap).warps[warp.targetWarp - 1],
|
||
`${map.id} -> ${warp.targetMap}, warp ${warp.targetWarp}`,
|
||
).toBeDefined();
|
||
}
|
||
}
|
||
expect(assets.sprite(1)?.graphics.length).toBe(384);
|
||
expect(assets.sprite(0x60)?.graphics.length).toBe(384);
|
||
expect(assets.map("24:7").warps[0]).toEqual({
|
||
x: 7,
|
||
y: 0,
|
||
targetMap: "24:6",
|
||
targetWarp: 3,
|
||
});
|
||
});
|
||
it.skipIf(
|
||
!existsSync(".reference/pokecrystal/gfx/tilesets/players_room.2bpp"),
|
||
)(
|
||
"matches decompressed graphics against the locally rebuilt PRET reference",
|
||
() => {
|
||
const path = ".reference/pokecrystal/gfx/tilesets/players_room.2bpp";
|
||
const assets = load();
|
||
expect(assets.tileset(assets.map("24:7")).graphics).toEqual(
|
||
new Uint8Array(readFileSync(path)),
|
||
);
|
||
},
|
||
);
|
||
it("walks with the original timing: eight overworld ticks per tile", () => {
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
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;
|
||
world.tick("right");
|
||
if (world.x !== x) ticks.push(tick);
|
||
}
|
||
// Steps start on ticks 1, 9, 17... and continue without a gap.
|
||
expect(ticks).toEqual([1, 9, 17, 25]);
|
||
// Two pixels per tick: the step started on tick 25, now two ticks in.
|
||
world.tick();
|
||
expect(world.poses().find((pose) => pose.id === 0)?.x).toBe(6 * 16 + 4);
|
||
});
|
||
it("decodes ROM location names and draws connected maps beyond the edges", () => {
|
||
const assets = load();
|
||
const town = assets.map("24:4");
|
||
expect(town.location).toBe("New Bark Town");
|
||
expect(assets.map("24:5").name).toBe("Elm’s lab");
|
||
expect(assets.map("24:5").location).toBe("New Bark Town");
|
||
const west = town.connections.find((c) => c.direction === "left")!;
|
||
const route = assets.map(west.targetMap);
|
||
expect(route.location).toBe("Route 29");
|
||
expect(assets.isPorted(route.id)).toBe(true);
|
||
for (let y = 0; y < town.height; y++)
|
||
expect(assets.visibleBlock(town, -1, y)).toBe(
|
||
assets.block(route, west.x, y + west.y),
|
||
);
|
||
expect(assets.visibleBlock(town, -1, -1)).toBe(town.border);
|
||
expect(() => assets.map("24:99")).toThrow("Unknown map");
|
||
expect(() => assets.map("27:1")).toThrow("Unknown map");
|
||
});
|
||
it("plays Mom's opening scene with native choices, then walks outside", () => {
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
// The bedroom's NEWMAP callback ran the original initialization script.
|
||
expect(world.state.events.size).toBeGreaterThan(100);
|
||
walkTo(world, 7, 0);
|
||
expect(world.mapId).toBe("24:6");
|
||
expect([world.x, world.y]).toEqual([9, 1]);
|
||
const flags = world.state.flags.size;
|
||
// Monday, confirm, DST yes, confirm time, "know how to use the phone".
|
||
const pages = walkTo(world, 9, 4, [1, 1, 1, 1, 1]);
|
||
expect(pages[0]).toBe("Oh, CHRIS…! Our\nneighbor, PROF.");
|
||
expect(pages).toContain("CHRIS received\nPOKéGEAR.");
|
||
expect(pages).toContain("What day is it?");
|
||
expect(pages).toContain("MONDAY, is it?");
|
||
expect(pages.some((page) => /^12:00 PM DST,/.test(page))).toBe(true);
|
||
expect(pages).toContain("Don't you just\nturn the POKéGEAR");
|
||
const save = world.save();
|
||
expect(save.scenes).toEqual({ "24:6": 1 });
|
||
expect(save.phone).toEqual([1]);
|
||
// 2026-09-22 is a Tuesday; the chosen Monday is six days ahead.
|
||
expect(save.clock).toEqual({ minuteOffset: 0, weekdayOffset: 6, dst: true });
|
||
expect(world.clock().weekday).toBe(1);
|
||
expect(world.state.flags.size).toBe(flags + 2);
|
||
expect(world.state.events.has(1735)).toBe(true);
|
||
// Stepping on the tile again does not replay the scene.
|
||
expect(world.move("up")).toBe(true);
|
||
expect(settle(world)).toEqual([]);
|
||
// Objects spawn when the map loads: the scene's Mom stays until the
|
||
// player leaves, then the daytime Mom replaces her.
|
||
expect(world.objects().find((object) => object.id === 2)).toMatchObject({
|
||
x: 7,
|
||
y: 4,
|
||
direction: "left",
|
||
});
|
||
walkTo(world, 6, 7);
|
||
expect(world.mapId).toBe("24:6");
|
||
expect(world.move("down")).toBe(true);
|
||
settle(world);
|
||
expect(world.mapId).toBe("24:4");
|
||
const home = world.map.warps.find((warp) => warp.targetMap === "24:6")!;
|
||
walkTo(world, home.x, home.y);
|
||
expect(world.objects().map((object) => object.id)).toEqual([4, 6]);
|
||
walkTo(world, 6, 7);
|
||
const restored = new GameWorld(
|
||
world.assets,
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
restored.restore(world.save());
|
||
expect(restored.save()).toEqual(world.save());
|
||
expect(() => world.restore({ ...world.save(), x: 127 })).toThrow(
|
||
"position",
|
||
);
|
||
expect(() =>
|
||
world.restore({ ...world.save(), scenes: { "24:4": 3 } }),
|
||
).not.toThrow();
|
||
expect(() =>
|
||
world.restore({ ...world.save(), scenes: { "1:1": 3 } }),
|
||
).toThrow("Invalid");
|
||
});
|
||
it("saves the committed state during a scene and replays it after restore", () => {
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
walkTo(world, 7, 0);
|
||
walkTo(world, 9, 3);
|
||
const before = world.save();
|
||
expect(world.move("down")).toBe(true);
|
||
expect(world.busy).toBe(true);
|
||
// A mid-scene save keeps the position before the triggering step.
|
||
expect(world.save()).toEqual(before);
|
||
const restored = new GameWorld(
|
||
world.assets,
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
restored.restore(world.save());
|
||
expect(restored.move("down")).toBe(true);
|
||
expect(settle(restored, [1, 1, 1, 1, 1]).length).toBeGreaterThan(10);
|
||
expect(restored.save().scenes).toEqual({ "24:6": 1 });
|
||
});
|
||
it("runs New Bark Town's teacher and rival scenes with scripted movement", () => {
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
world.restore({ ...world.save(), map: "24:4", x: 5, y: 8 });
|
||
walkTo(world, 2, 8);
|
||
const events = world.save().events;
|
||
expect(world.move("left")).toBe(true);
|
||
const pages = settle(world);
|
||
expect(pages[0]).toBe("Wait, CHRIS!");
|
||
expect(pages).toContain("It's dangerous to\ngo out without a\nPOKéMON!");
|
||
// The teacher runs over and leads the player back east.
|
||
expect([world.x, world.y, world.direction]).toEqual([5, 8, "right"]);
|
||
// Back home, SPRITEMOVEDATA_SPINRANDOM_SLOW spins her again at once.
|
||
const teacher = world.objects().find((object) => object.id === 2)!;
|
||
expect([teacher.x, teacher.y]).toEqual([6, 8]);
|
||
expect(world.save().events).toEqual(events);
|
||
walkTo(world, 2, 2);
|
||
expect(world.move("right")).toBe(false);
|
||
world.interact();
|
||
const rival = settle(world);
|
||
expect(rival).toContain("…What are you\nstaring at?");
|
||
// Pushed one step and shoved two more with a jump.
|
||
expect([world.x, world.y, world.direction]).toEqual([2, 5, "up"]);
|
||
const silver = world.objects().find((object) => object.id === 4)!;
|
||
expect([silver.x, silver.y, silver.direction]).toEqual([3, 2, "right"]);
|
||
});
|
||
it("runs Elm's introduction, the lab's exit guard and a declined Poké Ball", () => {
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
world.restore({
|
||
...world.save(),
|
||
map: "24:4",
|
||
x: 6,
|
||
y: 4,
|
||
scenes: { "24:6": 1 },
|
||
});
|
||
const door = world.map.warps.find((warp) => warp.targetMap === "24:5")!;
|
||
walkTo(world, door.x, door.y + 1);
|
||
expect(world.move("up")).toBe(true);
|
||
expect(world.mapId).toBe("24:5");
|
||
// The OBJECTS callback moved Elm for the introduction.
|
||
expect(world.objects().find((object) => object.id === 2)).toMatchObject({
|
||
x: 3,
|
||
y: 4,
|
||
});
|
||
const pages = settle(world, [0, 1]);
|
||
expect(pages[0]).toBe("ELM: CHRIS!\nThere you are!");
|
||
expect(pages).toContain("But… Please, I\nneed your help!");
|
||
expect(pages.at(-1)).toBe("Go on. Pick one!");
|
||
expect(world.save().scenes).toMatchObject({ "24:5": 1 });
|
||
// The warp places the player on the mat at y=11; the ROM walks up seven tiles.
|
||
expect([world.x, world.y, world.direction]).toEqual([4, 4, "right"]);
|
||
// Elm stops the player from leaving without a Pokémon.
|
||
walkTo(world, 4, 5);
|
||
expect(world.move("down")).toBe(true);
|
||
expect(settle(world).length).toBeGreaterThan(0);
|
||
expect([world.x, world.y]).toEqual([4, 5]);
|
||
walkTo(world, 6, 4);
|
||
world.move("up");
|
||
const before = world.save();
|
||
world.interact();
|
||
// Declining a Poké Ball commits nothing.
|
||
expect(settle(world, [0])).toEqual([
|
||
"",
|
||
"ELM: You'll take\nCYNDAQUIL, the\nfire POKéMON?",
|
||
"ELM: Think it over\ncarefully.",
|
||
"Your partner is\nimportant.",
|
||
]);
|
||
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(before));
|
||
});
|
||
it("jumps over representative Route 29 ledges from the actual collision data", () => {
|
||
const assets = load();
|
||
const route = assets.map("24:3");
|
||
const world = new GameWorld(assets);
|
||
const checked = new Set<number>();
|
||
const directions: Record<number, Direction> = {
|
||
0xa0: "right",
|
||
0xa1: "left",
|
||
0xa3: "down",
|
||
0xa4: "down",
|
||
0xa5: "down",
|
||
};
|
||
for (let y = 1; y < route.height - 2; y++) {
|
||
for (let x = 2; x < route.width - 2; x++) {
|
||
const collision = assets.collision(route, x, y),
|
||
direction = directions[collision];
|
||
if (!direction || checked.has(collision)) continue;
|
||
const [dx, dy] = VECTORS[direction];
|
||
if (
|
||
assets.permission(assets.collision(route, x + dx, y + dy)) === 0 ||
|
||
assets.permission(
|
||
assets.collision(route, x + dx * 2, y + dy * 2),
|
||
) !== 0 ||
|
||
route.objects.some((o) => o.x === x + dx * 2 && o.y === y + dy * 2)
|
||
)
|
||
continue;
|
||
world.restore({
|
||
...world.save(),
|
||
map: route.id,
|
||
x,
|
||
y,
|
||
direction,
|
||
objects: [],
|
||
});
|
||
world.tick(direction);
|
||
// 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);
|
||
}
|
||
}
|
||
expect([...checked].sort()).toEqual([0xa0, 0xa1, 0xa3, 0xa4, 0xa5]);
|
||
});
|
||
it("gives the Route 29 item ball's Potion through FindItemInBallScript, hides the ball and commits atomically", () => {
|
||
const assets = load();
|
||
const route = assets.map("24:3");
|
||
const world = new GameWorld(assets);
|
||
const ball = route.objects.find((object) => object.kind === 1)!; // OBJECTTYPE_ITEMBALL
|
||
// The tile west of the ball is the only walkable approach.
|
||
world.restore({
|
||
...world.save(),
|
||
map: route.id,
|
||
x: ball.x - 1,
|
||
y: ball.y,
|
||
direction: "right",
|
||
objects: [],
|
||
});
|
||
expect(world.objects().some((object) => object.id === ball.id)).toBe(
|
||
true,
|
||
);
|
||
world.interact();
|
||
expect(settle(world)).toEqual([
|
||
"CHRIS found\nPOTION!",
|
||
"CHRIS put the\nPOTION in\nthe ITEM POCKET.",
|
||
]);
|
||
expect(world.diagnostic).toBe("");
|
||
expect(world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]);
|
||
expect(world.state.events.has(ball.eventFlag)).toBe(true);
|
||
// The ball disappears for the rest of the session...
|
||
expect(world.objects().some((object) => object.id === ball.id)).toBe(
|
||
false,
|
||
);
|
||
// ...and the pickup survives save()/restore().
|
||
const saved = world.save();
|
||
const restored = new GameWorld(assets);
|
||
restored.restore(saved);
|
||
expect(restored.save()).toEqual(saved);
|
||
expect(restored.objects().some((object) => object.id === ball.id)).toBe(
|
||
false,
|
||
);
|
||
});
|
||
it("shows the original no-room text and changes nothing when the item pocket is full", () => {
|
||
const assets = load();
|
||
const route = assets.map("24:3");
|
||
const world = new GameWorld(assets);
|
||
const ball = route.objects.find((object) => object.kind === 1)!;
|
||
const full = world.save();
|
||
// Twenty full POTION stacks: PutItemInPocket finds no room for another.
|
||
full.bag.items = Array.from({ length: 20 }, () => ({
|
||
item: 18,
|
||
quantity: 99,
|
||
}));
|
||
world.restore({
|
||
...full,
|
||
map: route.id,
|
||
x: ball.x - 1,
|
||
y: ball.y,
|
||
direction: "right",
|
||
objects: [],
|
||
});
|
||
world.interact();
|
||
expect(settle(world)).toEqual([
|
||
"CHRIS found\nPOTION!",
|
||
"But CHRIS can't\ncarry any more\nitems.",
|
||
]);
|
||
expect(world.diagnostic).toBe("");
|
||
expect(world.state.bag).toEqual(full.bag);
|
||
expect(world.state.events.has(ball.eventFlag)).toBe(false);
|
||
expect(world.objects().some((object) => object.id === ball.id)).toBe(
|
||
true,
|
||
);
|
||
});
|
||
it("refuses connections into unported maps and imports version 1 saves", () => {
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
const start = world.save();
|
||
// New Bark Town's east edge is water leading to Route 27, which
|
||
// stays closed: a surfer is refused.
|
||
const town = world.assets.map("24:4"),
|
||
right = town.width - 1;
|
||
const edge = Array.from({ length: town.height }, (_, y) => y).find(
|
||
(y) =>
|
||
world.assets.permission(world.assets.collision(town, right, y)) === 1,
|
||
)!;
|
||
world.restore({
|
||
...world.save(),
|
||
map: "24:4",
|
||
x: right,
|
||
y: edge,
|
||
playerState: 4, // PLAYER_SURF
|
||
});
|
||
expect(world.move("right")).toBe(false);
|
||
expect(world.notice).toBe("Route 27 is not ported yet.");
|
||
world.restore(start);
|
||
const {
|
||
scenes,
|
||
phone,
|
||
clock,
|
||
objects,
|
||
playerId,
|
||
party,
|
||
bag,
|
||
pokedex,
|
||
...legacy
|
||
} = world.save();
|
||
void [scenes, phone, objects, playerId, party, bag, pokedex];
|
||
world.restore({ ...legacy, version: 1 });
|
||
expect(world.save()).toMatchObject({
|
||
version: 3,
|
||
scenes: {},
|
||
phone: [],
|
||
objects: [],
|
||
party: [],
|
||
pokedex: { seen: [], caught: [] },
|
||
});
|
||
world.restore({
|
||
...legacy,
|
||
version: 2,
|
||
scenes: { "24:6": 1 },
|
||
phone: [1],
|
||
clock,
|
||
objects: [],
|
||
});
|
||
expect(world.save()).toMatchObject({
|
||
version: 3,
|
||
scenes: { "24:6": 1 },
|
||
phone: [1],
|
||
});
|
||
expect(world.save().playerId).toBeLessThan(0x10000);
|
||
expect(() => world.restore({ ...legacy, version: 4 })).toThrow("Invalid");
|
||
});
|
||
it("decodes species, moves, items and growth from the ROM", () => {
|
||
const data = load().pokemon;
|
||
expect([152, 155, 158].map((id) => data.speciesName(id))).toEqual([
|
||
"CHIKORITA",
|
||
"CYNDAQUIL",
|
||
"TOTODILE",
|
||
]);
|
||
expect(data.speciesName(122)).toBe("MR.MIME");
|
||
expect(data.baseData(155)).toMatchObject({
|
||
species: 155,
|
||
stats: [39, 52, 43, 65, 60, 50],
|
||
growthRate: 3,
|
||
picSize: 5,
|
||
});
|
||
// Medium Slow: 135 at level 5, and the original level-1 underflow.
|
||
expect(data.expAtLevel(3, 5)).toBe(135);
|
||
expect(data.expAtLevel(3, 1)).toBe(16777162);
|
||
expect(data.expAtLevel(0, 100)).toBe(1_000_000);
|
||
expect(data.learnset(155).slice(0, 2)).toEqual([
|
||
{ level: 1, move: 33 },
|
||
{ level: 1, move: 43 },
|
||
]);
|
||
expect(data.moveName(33)).toBe("TACKLE");
|
||
expect(data.move(33)).toMatchObject({ power: 35, accuracy: 242, pp: 35 });
|
||
expect(data.itemName(18)).toBe("POTION");
|
||
expect(data.itemPocket(18)).toBe("item");
|
||
expect(data.pocketName("item")).toBe("ITEM POCKET");
|
||
expect(data.frontPic(155)).toHaveLength(49 * 16);
|
||
expect(() => data.speciesName(252)).toThrow("Unknown");
|
||
});
|
||
it.skipIf(
|
||
!existsSync(
|
||
".reference/pokecrystal/gfx/pokemon/cyndaquil/front.animated.2bpp",
|
||
),
|
||
)(
|
||
"matches front pictures against the locally rebuilt PRET reference",
|
||
() => {
|
||
const data = load().pokemon;
|
||
for (const [species, name] of [
|
||
[152, "chikorita"],
|
||
[155, "cyndaquil"],
|
||
[158, "totodile"],
|
||
] as const)
|
||
expect(data.frontPicData(species)).toEqual(
|
||
new Uint8Array(
|
||
readFileSync(
|
||
`.reference/pokecrystal/gfx/pokemon/${name}/front.animated.2bpp`,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
it("decodes evolution lists, including stat and happiness triggers", () => {
|
||
const data = load().pokemon;
|
||
expect(data.evolutions(155)).toEqual([
|
||
{ method: "level", level: 14, species: 156 },
|
||
]);
|
||
expect(data.evolutions(133).map((e) => e.method)).toEqual([
|
||
"item",
|
||
"item",
|
||
"item",
|
||
"happiness",
|
||
"happiness",
|
||
]);
|
||
expect(data.evolutions(133)[4]).toEqual({
|
||
method: "happiness",
|
||
time: "nite",
|
||
species: 197,
|
||
});
|
||
expect(data.evolutions(61)).toContainEqual({
|
||
method: "trade",
|
||
item: 82, // KING'S ROCK
|
||
species: 186,
|
||
});
|
||
expect(data.evolutions(64)).toEqual([
|
||
{ method: "trade", item: null, species: 65 },
|
||
]);
|
||
expect(data.evolutions(236)).toEqual([
|
||
{ method: "stat", level: 20, attack: "lt", species: 107 },
|
||
{ method: "stat", level: 20, attack: "gt", species: 106 },
|
||
{ method: "stat", level: 20, attack: "eq", species: 237 },
|
||
]);
|
||
expect(data.evolutions(251)).toEqual([]);
|
||
// The four-byte stat records must not shift the learnset.
|
||
expect(data.learnset(236)[0]).toEqual({ level: 1, move: 33 });
|
||
});
|
||
it.skipIf(
|
||
!existsSync(".reference/pokecrystal/data/pokemon/evos_attacks.asm"),
|
||
)("matches every evolution list against the pinned PRET source", () => {
|
||
const data = load().pokemon;
|
||
const root = ".reference/pokecrystal";
|
||
const constants = (file: string, first: number) => {
|
||
const ids = new Map<string, number>();
|
||
let value = first;
|
||
for (const line of readFileSync(`${root}/${file}`, "utf8").split(
|
||
"\n",
|
||
)) {
|
||
if (/^DEF NUM_/.test(line)) break;
|
||
const name = /^\s*const (\w+)/.exec(line)?.[1];
|
||
if (name) ids.set(name, value++);
|
||
}
|
||
return ids;
|
||
};
|
||
const species = constants("constants/pokemon_constants.asm", 1),
|
||
items = constants("constants/item_constants.asm", 0);
|
||
const id = (map: Map<string, number>, name: string) => {
|
||
const value = map.get(name);
|
||
if (value === undefined) throw new Error(`Unknown ${name}`);
|
||
return value;
|
||
};
|
||
const lists: Evolution[][] = [];
|
||
let current: Evolution[] | null = null;
|
||
for (const line of readFileSync(
|
||
`${root}/data/pokemon/evos_attacks.asm`,
|
||
"utf8",
|
||
).split("\n")) {
|
||
if (/^\w+EvosAttacks:/.test(line)) lists.push((current = []));
|
||
const args = /^\s*db (EVOLVE_\w+), (.+)$/.exec(line);
|
||
if (!args || !current) {
|
||
if (/^\s*db 0 ; no more evolutions/.test(line)) current = null;
|
||
continue;
|
||
}
|
||
const [a, b, c] = args[2].split(",").map((s) => s.trim());
|
||
if (args[1] === "EVOLVE_LEVEL")
|
||
current.push({ method: "level", level: +a, species: id(species, b) });
|
||
else if (args[1] === "EVOLVE_ITEM")
|
||
current.push({
|
||
method: "item",
|
||
item: id(items, a),
|
||
species: id(species, b),
|
||
});
|
||
else if (args[1] === "EVOLVE_TRADE")
|
||
current.push({
|
||
method: "trade",
|
||
item: a === "-1" ? null : id(items, a),
|
||
species: id(species, b),
|
||
});
|
||
else if (args[1] === "EVOLVE_HAPPINESS")
|
||
current.push({
|
||
method: "happiness",
|
||
time: a.slice(3).toLowerCase() as "anytime",
|
||
species: id(species, b),
|
||
});
|
||
else
|
||
current.push({
|
||
method: "stat",
|
||
level: +a,
|
||
attack: b.slice(4, 6).toLowerCase() as "gt",
|
||
species: id(species, c),
|
||
});
|
||
}
|
||
expect(lists).toHaveLength(251);
|
||
lists.forEach((list, i) => expect(data.evolutions(i + 1)).toEqual(list));
|
||
});
|
||
it("gives the chosen starter and the aide's Potion, then saves them", () => {
|
||
let seed = 7;
|
||
const random = () => (seed = (seed * 73 + 41) & 255);
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
random,
|
||
);
|
||
world.restore({
|
||
...world.save(),
|
||
map: "24:5",
|
||
x: 4,
|
||
y: 3,
|
||
scenes: { "24:6": 1, "24:5": 1 },
|
||
});
|
||
walkTo(world, 6, 4);
|
||
expect(world.move("up")).toBe(false);
|
||
world.interact();
|
||
// pokepic shows the ROM's front picture; the cry plays (PlayMonCry
|
||
// waits for it) and waitbutton then waits for a button.
|
||
expect(world.picture).toBe(155);
|
||
expect(world.dialogue).toBeUndefined();
|
||
expect(world.sound.sfxPlaying).toBe(true);
|
||
for (let tick = 0; tick < 300 && !world.dialogue; tick++) world.tick();
|
||
expect(world.sound.sfxPlaying).toBe(false);
|
||
expect(world.dialogue?.pages).toEqual([""]);
|
||
world.advanceDialogue();
|
||
expect(world.picture).toBeUndefined();
|
||
expect(world.dialogue?.choice?.kind).toBe("yesno");
|
||
// Take it, then give it a nickname through the naming screen.
|
||
const pages = settle(world, [1, 1]);
|
||
expect(pages).toContain("CHRIS received\nCYNDAQUIL!");
|
||
expect(world.dialogue?.choice).toMatchObject({
|
||
kind: "name",
|
||
current: "CYNDAQUIL",
|
||
});
|
||
expect(world.dialogue?.pages).toEqual(["CYNDAQUIL'S\nNICKNAME?"]);
|
||
// Characters the naming screen cannot enter keep the prompt open.
|
||
world.choose("BAD~NAME");
|
||
expect(world.dialogue?.choice?.kind).toBe("name");
|
||
world.choose("BLAZE");
|
||
expect(settle(world)).toContain("CHRIS got ELM's\nphone number.");
|
||
const save = world.save();
|
||
expect(save.party).toHaveLength(1);
|
||
expect(save.party[0]).toMatchObject({
|
||
species: 155,
|
||
nickname: "BLAZE",
|
||
ot: "CHRIS",
|
||
otId: world.playerId,
|
||
level: 5,
|
||
exp: 135,
|
||
item: 173,
|
||
moves: [33, 43, 0, 0],
|
||
pp: [35, 30, 0, 0],
|
||
happiness: 70,
|
||
// Day, level 5; male player in New Bark Town.
|
||
caughtData: 0x8501,
|
||
});
|
||
expect(save.party[0].hp).toBe(save.party[0].stats[0]);
|
||
expect(save.pokedex).toEqual({ seen: [155], caught: [155] });
|
||
expect(save.scenes).toEqual({ "24:6": 1, "24:5": 5, "24:4": 1 });
|
||
expect(save.events).toContain(26); // EVENT_GOT_A_POKEMON_FROM_ELM
|
||
// The other balls now only describe themselves.
|
||
walkTo(world, 7, 4);
|
||
world.move("up");
|
||
world.interact();
|
||
expect(settle(world)).toHaveLength(1);
|
||
expect(world.save().party).toHaveLength(1);
|
||
walkTo(world, 4, 7);
|
||
expect(world.move("down")).toBe(true);
|
||
expect(settle(world)).toEqual([
|
||
"CHRIS, I want\nyou to have this\nfor your errand.",
|
||
"CHRIS received\nPOTION.",
|
||
"CHRIS put the\nPOTION in\nthe ITEM POCKET.",
|
||
"There are only two\nof us, so we're\nalways busy.",
|
||
]);
|
||
const final = world.save();
|
||
expect(final.bag.items).toEqual([{ item: 18, quantity: 1 }]);
|
||
expect(final.scenes["24:5"]).toBe(2);
|
||
const restored = new GameWorld(
|
||
world.assets,
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
restored.restore(final);
|
||
expect(restored.save()).toEqual(final);
|
||
// Stored stats must match the ROM's formula for the stored DVs.
|
||
const tampered = structuredClone(final);
|
||
tampered.party[0].stats[1] += 1;
|
||
expect(() => restored.restore(tampered)).toThrow("Invalid");
|
||
const potion = structuredClone(final);
|
||
potion.bag.keyItems = [18];
|
||
expect(() => restored.restore(potion)).toThrow("Invalid");
|
||
expect(restored.save()).toEqual(final);
|
||
});
|
||
it("reads the bedroom radio through native event and text interpreters", () => {
|
||
const world = new GameWorld(load());
|
||
world.restore({ ...world.save(), x: 3, y: 2, direction: "up" });
|
||
const previousEvents = world.state.events.size;
|
||
world.interact();
|
||
expect(world.diagnostic).toBe("");
|
||
// Each radio line holds for 45 ticks (pause 45), ignoring buttons.
|
||
expect(world.dialogue?.holdTicks).toBe(45);
|
||
world.advanceDialogue();
|
||
expect(world.dialogue?.holdTicks).toBe(45);
|
||
expect(world.state.events.size).toBe(previousEvents);
|
||
expect(settle(world)).toHaveLength(4);
|
||
expect(world.state.events.size).toBe(previousEvents + 1);
|
||
world.interact();
|
||
expect(settle(world)).toHaveLength(1);
|
||
});
|
||
it("heals a damaged, statused party through the Cherrygrove nurse at every time of day", () => {
|
||
const greetings = {
|
||
morning: ["Good morning!\nWelcome to our\nPOKéMON CENTER."],
|
||
day: ["Hello!\nWelcome to our\nPOKéMON CENTER."],
|
||
night: [
|
||
"Good evening!\nYou're out late.",
|
||
"Welcome to our\nPOKéMON CENTER.",
|
||
],
|
||
};
|
||
for (const [label, hour] of [
|
||
["morning", 8],
|
||
["day", 13],
|
||
["night", 21],
|
||
] as const) {
|
||
const assets = load();
|
||
const world = new GameWorld(
|
||
assets,
|
||
() => new Date(`2026-09-22T${String(hour).padStart(2, "0")}:00:00`),
|
||
);
|
||
const mon = generatePartyMon(assets.pokemon, {
|
||
species: 155,
|
||
level: 10,
|
||
item: 0,
|
||
otId: world.playerId,
|
||
ot: "CHRIS",
|
||
dvs: 0x823b,
|
||
caughtData: 0,
|
||
});
|
||
mon.moves = [45, 0, 0, 0]; // GROWL: base PP 40
|
||
mon.pp = [(3 << 6) | 2, 0, 0, 0]; // three PP Ups, nearly spent
|
||
mon.hp = 1;
|
||
mon.status = 0x08; // paralysis
|
||
world.state.party.push(mon);
|
||
world.restore({
|
||
...world.save(),
|
||
map: "26:5",
|
||
x: 3,
|
||
y: 3,
|
||
direction: "up",
|
||
});
|
||
// The counter at (3,2) lets interact() reach the nurse behind it.
|
||
world.interact();
|
||
expect(world.dialogue?.pages).toEqual(greetings[label]);
|
||
const pages = settle(world, [1]);
|
||
expect(pages).toContain("Your POKéMON are\nfully healed.");
|
||
const healed = world.save().party[0];
|
||
expect(healed.hp).toBe(healed.stats[0]);
|
||
expect(healed.status).toBe(0);
|
||
// ComputeMaxPP clamps each PP Up's bonus to 7, not 8: 40 + 3*7 = 61.
|
||
expect(healed.pp).toEqual([(3 << 6) | 61, 0, 0, 0]);
|
||
expect(world.diagnostic).toBe("");
|
||
}
|
||
});
|
||
it("leaves the party unchanged when healing is declined", () => {
|
||
const assets = load();
|
||
const world = new GameWorld(
|
||
assets,
|
||
() => new Date("2026-09-22T13:00:00"),
|
||
);
|
||
const mon = generatePartyMon(assets.pokemon, {
|
||
species: 155,
|
||
level: 10,
|
||
item: 0,
|
||
otId: world.playerId,
|
||
ot: "CHRIS",
|
||
dvs: 0x823b,
|
||
caughtData: 0,
|
||
});
|
||
mon.hp = 1;
|
||
mon.status = 0x08;
|
||
world.state.party.push(mon);
|
||
world.restore({
|
||
...world.save(),
|
||
map: "26:5",
|
||
x: 3,
|
||
y: 3,
|
||
direction: "up",
|
||
});
|
||
world.interact();
|
||
const before = structuredClone(world.save().party);
|
||
const pages = settle(world, [0]);
|
||
expect(pages.at(-1)).toBe("We hope to see you\nagain.");
|
||
expect(pages).not.toContain("Your POKéMON are\nfully healed.");
|
||
expect(world.save().party).toEqual(before);
|
||
expect(world.diagnostic).toBe("");
|
||
});
|
||
it("records the whiteout spawn by walking into the Cherrygrove Pokécenter, and keeps it through save/restore", () => {
|
||
const assets = load();
|
||
const world = new GameWorld(
|
||
assets,
|
||
() => new Date("2026-09-22T13:00:00"),
|
||
);
|
||
expect(world.save().lastSpawnMap).toBeNull();
|
||
world.restore({
|
||
...world.save(),
|
||
map: "26:3",
|
||
x: 29,
|
||
y: 4,
|
||
direction: "up",
|
||
});
|
||
// The Center's door is an automatic warp; EnterMapWarp's SetSpawn
|
||
// records the outdoor map (Cherrygrove City) as the new blackout spot.
|
||
expect(world.move("up")).toBe(true);
|
||
expect(world.mapId).toBe("26:5");
|
||
expect([world.x, world.y]).toEqual([3, 7]);
|
||
expect(world.save().lastSpawnMap).toBe("26:3");
|
||
const restored = new GameWorld(
|
||
world.assets,
|
||
() => new Date("2026-09-22T13:00:00"),
|
||
);
|
||
restored.restore(world.save());
|
||
expect(restored.save()).toEqual(world.save());
|
||
});
|
||
it("honors the recorded spawn for the whiteout warp, and defaults to the player's house", () => {
|
||
const assets = load();
|
||
const host = {
|
||
mapId: "1:1",
|
||
lastTalked: 0,
|
||
sceneOwner: () => undefined,
|
||
timeOfDay: () => 1,
|
||
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
|
||
clockDay: () => 0,
|
||
text: () => defaultTextContext("CHRIS"),
|
||
data: assets.pokemon,
|
||
player: () => ({ id: 0, gender: 0 as const }),
|
||
random: () => 0,
|
||
landmark: () => 0,
|
||
facing: () => 0,
|
||
position: () => ({ x: 0, y: 0 }),
|
||
objectPosition: () => undefined,
|
||
};
|
||
// Runs the ROM's own battleWhiteoutScript end to end: HealParty,
|
||
// GetWhiteoutSpawn and WarpToSpawnPoint all through the interpreter.
|
||
const whiteoutWarp = (lastSpawnMap: string | null) => {
|
||
const state = emptyScriptState();
|
||
state.lastSpawnMap = lastSpawnMap;
|
||
const run = new ScriptRun(
|
||
assets.reader,
|
||
SYMBOLS.battleWhiteoutScript,
|
||
state,
|
||
host,
|
||
);
|
||
let effect = run.resume();
|
||
while (effect && effect.kind !== "warp") effect = run.resume();
|
||
return effect;
|
||
};
|
||
expect(whiteoutWarp("26:3")).toEqual({
|
||
kind: "warp",
|
||
map: "26:3",
|
||
x: 29,
|
||
y: 4,
|
||
});
|
||
// No recorded Center, or one the spawn table no longer lists: SPAWN_HOME.
|
||
expect(whiteoutWarp(null)).toEqual({
|
||
kind: "warp",
|
||
map: "24:7",
|
||
x: 3,
|
||
y: 3,
|
||
});
|
||
expect(whiteoutWarp("1:1")).toEqual({
|
||
kind: "warp",
|
||
map: "24:7",
|
||
x: 3,
|
||
y: 3,
|
||
});
|
||
});
|
||
it("migrates a save without the spawn field to the player's house default", () => {
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T13:00:00"),
|
||
);
|
||
const { lastSpawnMap, ...withoutSpawn } = world.save();
|
||
void lastSpawnMap;
|
||
expect(() => world.restore(withoutSpawn)).not.toThrow();
|
||
expect(world.save().lastSpawnMap).toBeNull();
|
||
});
|
||
},
|
||
);
|