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

+218
View File
@@ -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;
}