Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 16:09:22 +02:00
1 parent 8b80d05ca2
commit c08d250cf6
39 files changed
+10727 -1524

No files matched your search

+502 -88
View File
@@ -6,9 +6,17 @@ import { decompress } from "../src/game/rom/lz";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { NATIVE_MAPS } from "../src/game/rom/profile";
import { runScript } from "../src/game/scripts/interpreter";
import {
ScriptRun,
emptyScriptState,
runScript,
} from "../src/game/scripts/interpreter";
import { readMovement } from "../src/game/scripts/movement";
import { defaultTextContext, readText } from "../src/game/scripts/text";
import { settle, walkTo } 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", () => {
@@ -57,24 +65,73 @@ describe("native ROM formats", () => {
});
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 = {
events: new Set<number>(),
flags: new Set<number>(),
variables: {},
};
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 }),
deviceWeekday: () => 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, 0x4c, 0x47,
]),
);
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");
});
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(() =>
runScript(
reader,
0,
{ events: new Set(), flags: new Set(), variables: {} },
"TEST",
),
).toThrow("budget");
readText(new RomReader(bytes), 0, defaultTextContext("KAWA")),
).toThrow("no native value");
});
});
@@ -129,84 +186,438 @@ describe.skipIf(!existsSync(romPath))(
);
},
);
it("walks from the bedroom to downstairs and the town, with collision and return warps", () => {
it("walks with the original timing: eight overworld ticks per tile", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
expect(world.x).toBe(3);
expect(world.y).toBe(3);
expect(world.move("up")).toBe(true);
const steps = world.tilesWalked;
expect(world.move("up")).toBe(false); // Radio/furniture is solid.
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 2, steps]);
expect(world.move("down")).toBe(true);
const routeTo = (targetX: number, targetY: number) => {
const directions = [
["up", 0, -1],
["down", 0, 1],
["left", -1, 0],
["right", 1, 0],
] as const;
const queue = [
{
x: world.x,
y: world.y,
path: [] as ("up" | "down" | "left" | "right")[],
},
];
const visited = new Set<string>();
for (let i = 0; i < queue.length; i++) {
const node = queue[i],
key = `${node.x},${node.y}`;
if (visited.has(key)) continue;
visited.add(key);
if (node.x === targetX && node.y === targetY) {
for (const direction of node.path)
expect(world.move(direction)).toBe(true);
return;
}
for (const [direction, dx, dy] of directions) {
const x = node.x + dx,
y = node.y + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
continue;
if (
world.assets.permission(
world.assets.collision(world.map, x, y),
) !== 0
)
continue;
if (
world.objects().some((object) => object.x === x && object.y === y)
)
continue;
if (
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
(x !== targetX || y !== targetY)
)
continue;
queue.push({ x, y, path: [...node.path, direction] });
}
}
throw new Error(`No route to ${targetX},${targetY}`);
};
routeTo(7, 0);
expect(world.mapId).toBe("24:6");
routeTo(6, 7);
expect(world.mapId).toBe("24:4");
expect(world.tilesWalked).toBeGreaterThan(10);
const arrival = world.map.warps.find(
(warp) => warp.targetMap === "24:6",
)!;
routeTo(arrival.x, arrival.y);
world.restore({ ...world.save(), x: 3, y: 2, direction: "down" });
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(false);
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(() => world.restore({ ...save, x: 127 })).toThrow("position");
expect(world.save()).toEqual(save);
const restored = new GameWorld(world.assets);
restored.restore(save);
expect(restored.save()).toEqual(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({ 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"]);
const teacher = world.objects().find((object) => object.id === 2)!;
expect([teacher.x, teacher.y, teacher.direction]).toEqual([6, 8, "left"]);
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(world.save()).toEqual(before);
});
it("jumps over representative Route 29 ledges from the actual collision data", () => {
const assets = load();
const route = assets.map("24:3");
expect(assets.isPorted(route.id)).toBe(false);
// Enable only in this fixture: production still refuses Route 29's unported scripts.
const isPorted = assets.isPorted.bind(assets);
assets.isPorted = (id) => id === route.id || isPorted(id);
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);
expect([world.x, world.y]).toEqual([x + dx * 2, y + dy * 2]);
expect(
world.poses().find((pose) => pose.id === 0)?.lift,
).toBeLessThan(0);
settle(world);
expect(world.notice).toBe("");
checked.add(collision);
}
}
expect([...checked].sort()).toEqual([0xa0, 0xa1, 0xa3, 0xa4, 0xa5]);
});
it("refuses connections into unported maps and imports version 1 saves", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
const town = world.assets.map("24:4");
const edge = Array.from({ length: town.height }, (_, y) => y).find(
(y) =>
world.assets.permission(world.assets.collision(town, 0, y)) === 0,
)!;
world.restore({ ...world.save(), map: "24:4", x: 0, y: edge });
expect(world.move("left")).toBe(false);
expect(world.notice).toBe("Route 29 is not ported yet.");
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("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 and waits for a button.
expect(world.picture).toBe(155);
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());
@@ -214,12 +625,15 @@ describe.skipIf(!existsSync(romPath))(
const previousEvents = world.state.events.size;
world.interact();
expect(world.diagnostic).toBe("");
expect(world.dialogue?.pages.length).toBe(4);
// 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);
while (world.dialogue) world.advanceDialogue();
expect(settle(world)).toHaveLength(4);
expect(world.state.events.size).toBe(previousEvents + 1);
world.interact();
expect(world.dialogue?.pages.length).toBe(1);
expect(settle(world)).toHaveLength(1);
});
},
);