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>
219 lines
7.2 KiB
TypeScript
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;
|
|
}
|