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:
1 parent
9ac111f262
commit
451464f7c6
38 files changed
+5600
-490
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user