Complete the overworld foundation
Checks / web (push) Canceled after 0s

Port forced movement (turn delays, bumps, ice, currents, pits), dynamic
return warps, elevators, Surf, Cut, Strength and cycling rules, working
map blocks with changeblock/changemapblocks, and the remaining NPC
movement types and movement commands.

Add tileset animation from the ROM's command tables, grass sprite
priority and special map palettes; room decorations with ownership, the
bedroom PC and save migration; and Key Items access to the Bicycle.

Fourteen original-engine traces compare positions, facing, actors, event
flags and scripted poses. Tile animation matches original VRAM for four
tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now
keeps the $60 sprite offset its step function leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 16:47:29 +02:00
1 parent 9ac111f262
commit 451464f7c6
38 files changed
+5600 -490

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
import { test, expect } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
test("all tileset palettes match the original LoadMapPals routine", async ({
page,
}, info) => {
test.skip(
info.project.name !== "chromium" ||
!existsSync(romPath) ||
!existsSync(symbolsPath),
"Requires the local ROM/reference; engine comparison runs in Chromium.",
);
const symbols: Record<string, { bank: number; address: number }> = {};
for (const line of readFileSync(symbolsPath, "utf8").split("\n")) {
const m = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
if (m)
symbols[m[3]] = { bank: parseInt(m[1], 16), address: parseInt(m[2], 16) };
}
const bytes = new Uint8Array(readFileSync(romPath));
const cases = Array.from({ length: 36 * 4 }, (_, i) => ({
tileset: 1 + Math.floor(i / 4),
time: i % 4,
environment: 3,
group: 24,
}));
for (const environment of [1, 2, 4, 7])
for (const time of [0, 1, 2, 3])
for (const tileset of [1, 29, 30])
cases.push({ tileset, time, environment, group: 24 });
await page.goto("/reference.html");
const original = await page.evaluate(
async ({ rom, symbols, cases }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const emu = new BinjgbEmulator();
await emu.load(new Uint8Array(rom), document.createElement("canvas"));
const write = (name: string, value: number) =>
emu.writeMemory(symbols[name].address, value);
const target = symbols.LoadMapPals;
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
target.bank,
0xe0,
0x9d,
0xea,
0,
0x20,
0xcd,
target.address & 255,
target.address >> 8,
0x3e,
0x42,
0xea,
0xff,
0xc6,
0x18,
0xfe,
];
const output = cases.map((c) => {
emu.writeMemory(0xff70, 1);
write("wMapTileset", c.tileset);
write("wEnvironment", c.environment);
write("wTimeOfDayPal", c.time);
write("wMapGroup", c.group);
emu.writeMemory(0xc6ff, 0);
program.forEach((v, i) => emu.writeMemory(0xc000 + i, v));
emu.setProgramCounter(0xc000);
emu.advanceFrames(2);
if (emu.readMemory(0xc6ff) !== 0x42)
throw new Error("LoadMapPals did not return");
emu.writeMemory(0xff70, symbols.wBGPals1.bank);
return Array.from({ length: 32 }, (_, i) => {
const p = symbols.wBGPals1.address + i * 2;
const value = emu.readMemory(p) | (emu.readMemory(p + 1) << 8);
return [0, 5, 10].map((shift) =>
Math.round((((value >> shift) & 31) * 255) / 31),
);
});
});
emu.dispose();
return output;
},
{ rom: [...bytes], symbols, cases },
);
const assets = new CrystalAssets(bytes),
base = assets.map("24:6");
cases.forEach((c, i) =>
expect(
assets.palettes({ ...base, ...c }, c.time).flat(),
JSON.stringify(c),
).toEqual(original[i]),
);
});