Files
Crystal/tests/reference-overworld.ts
T
Sébastien QUEROLandClaude Opus 5.5 451464f7c6
Checks / web (push) Canceled after 0s
Complete the overworld foundation
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>
2026-09-25 16:47:29 +02:00

219 lines
7.2 KiB
TypeScript

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;
}