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