Files
Crystal/tests/reference-magnet-train.spec.ts
kawaandClaude Opus 5.5 d3be732118
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Failing after 25m40s
Open Kanto: the S.S. Aqua, the Kanto gyms, the Magnet Train, Mt. Silver and Red (Closes #33)
All 146 Kanto maps, Route 22, Route 28 and Silver Cave are open. The
Magnet Train, Snorlax and the Poké Flute channel, the Diploma, the Trainer
House, Bill's grandpa, Oak's Pokédex rating in Pallet and Goldenrod's
postgame move tutor are ported; big objects cover 2x2 tiles. The Magnet
Train and the Diploma are compared with the cartridge frame by frame.

The playthrough continues from the Hall of Fame through the S.S. Aqua,
the eight Kanto badges, Oak opening Mt. Silver and Red's defeat and
credits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:53:57 +02:00

187 lines
9.1 KiB
TypeScript

import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
/** The cartridge's MagnetTrain runs with its interrupts after a boot, in
* Saffron's station at day; each frame (sampled in VBlank, after the
* cutscene's handler) its OAM, hSCX and wLYOverrides' bands, the BG map
* and attributes, the icon's tiles and the palettes in CRAM are compared
* with MagnetTrainMachine's, both ways and for both players. */
for (const toGoldenrod of [false, true])
for (const female of [false, true])
test(`MagnetTrain matches the cartridge frame by frame ${toGoldenrod ? "to Goldenrod" : "to Saffron"}${female ? " as Kris" : ""}`, async ({ page }) => {
test.setTimeout(240_000);
const { at, routine } = loadSymbols();
const a = {
romBank: at("hROMBank"),
program: at("wOverworldMapBlocks"),
stackTop: at("wStackTop"),
scriptVar: at("wScriptVar"),
gender: at("wPlayerGender"),
timeOfDay: at("wTimeOfDay"),
mapGroup: at("wMapGroup"),
mapNumber: at("wMapNumber"),
tileset: at("wMapTileset"),
jumptable: at("wJumptableIndex"),
position: at("wMagnetTrainPosition"),
lyOverrides: at("wLYOverrides"),
scx: at("hSCX"),
};
const train = routine("MagnetTrain");
const TEARDOWN = 8;
await openReference(page);
const result = await page.evaluate(
async ({ a, train, toGoldenrod, female }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { MagnetTrainMachine } = await import("/src/game/scripts/magnet-train.ts");
const { rgb } = await import("/src/game/scripts/screen.ts");
const rom = new Uint8Array(
await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(),
);
const assets = new CrystalAssets(rom);
const station = assets.map("25:9");
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
reference.setMuted(true);
const read = (p: number) => reference.readMemory(p);
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
reference.advanceFrames(600);
write(0xff70, 1);
write(a.gender, female ? 1 : 0);
write(a.timeOfDay, 1);
write(a.mapGroup, 25);
write(a.mapNumber, 9);
write(a.tileset, station.tileset);
write(a.scriptVar, toGoldenrod ? 1 : 0);
write(a.jumptable, 0xff);
const sentinel = a.program + 0x80;
const program = [
0xf3, 0x31, a.stackTop & 255, a.stackTop >> 8, 0xfb,
0x3e, train.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
0xcd, train.address & 255, train.address >> 8,
0x3e, 0x42, 0xea, sentinel & 255, sentinel >> 8,
0x18, 0xfe,
];
write(sentinel, 0);
program.forEach((v, i) => write(a.program + i, v));
reference.setProgramCounter(a.program);
const toLine = (line: number) => {
for (let guard = 0; read(0xff44) !== line; guard++) {
if (guard > 20000) throw new Error("The LCD does not reach the line.");
reference.advanceCycles(16);
}
};
const cram = (index: number, obj: boolean) => {
write(obj ? 0xff6a : 0xff68, index);
return read(obj ? 0xff6b : 0xff69);
};
type State = { oam: number[]; bands: number[]; map: number[]; attrs: number[]; tiles: number[]; bg: number[]; obj: number[] };
const sample = (): State => {
toLine(152);
write(0xff4f, 0);
const map = Array.from({ length: 18 * 32 }, (_, i) => read(0x9800 + i));
write(0xff4f, 1);
const attrs = Array.from({ length: 18 * 32 }, (_, i) => read(0x9800 + i) & 7);
write(0xff4f, 0);
const state = {
oam: Array.from({ length: 16 }, (_, i) => read(0xfe00 + i)),
// Lines 1-143 take wLYOverrides (pushed at VBlank); line 0
// takes hSCX, which already holds the next frame's here.
bands: [read(a.lyOverrides), read(a.lyOverrides + 47), read(a.lyOverrides + 95)],
map,
attrs,
tiles: Array.from({ length: 128 }, (_, i) => read(0x8000 + i)),
bg: Array.from({ length: 64 }, (_, i) => cram(i, false)),
obj: Array.from({ length: 16 }, (_, i) => cram(i, true)),
};
reference.advanceCycles(456 * 140);
return state;
};
// Wait for the first run through: its jumptable entry leaves 1.
let guard = 0;
while (read(a.jumptable) !== 1 && guard++ < 300) reference.advanceFrames(1);
const original: State[] = [];
for (let frame = 0; frame < 600 && read(sentinel) !== 0x42; frame++) original.push(sample());
const time = 1;
const host = {
reader: assets.reader,
female,
tiles: () => assets.tileset(station).graphics,
palettes: () => ({
bg: assets.palettes({ ...station, environment: 1 }, time),
obj: Array.from({ length: 8 }, (_, i) => assets.objectPalette(i, time)),
}),
};
const machine = new MagnetTrainMachine(host, toGoldenrod);
const tiles = machine.tiles();
const word = (c: number[]) => Math.round((c[0] * 31) / 255) | (Math.round((c[1] * 31) / 255) << 5) | (Math.round((c[2] * 31) / 255) << 10);
const native: State[] = [];
const present = () => {
const screen = machine.screen(tiles, "");
const oam = screen.sprites.flatMap((s) => [
(s.y + 16) & 255,
(s.x + 8) & 255,
s.tile - 0x100,
(s.behind ? 0x80 : 0) | (s.flipY ? 0x40 : 0) | (s.flipX ? 0x20 : 0) | s.palette,
]);
const d = machine.debug();
const pal = (list: number[][][]) => list.flatMap((p) => p.flatMap((c) => { const w = word(c); return [w & 255, w >> 8]; }));
native.push({
oam: [...oam, ...Array(16 - oam.length).fill(-1)].slice(0, 16),
bands: [d.bands.bushes, d.bands.train, d.bands.bushes],
map: machine.bg.flat(),
attrs: machine.attrs.flat(),
tiles: Array.from(tiles.subarray(0x100 * 16, 0x108 * 16)),
bg: pal(screen.palettes as number[][][]),
obj: pal(screen.objPalettes.slice(0, 2) as number[][][]),
});
};
machine.step();
present();
machine.setPalettes();
present();
while (!machine.done) {
machine.step();
present();
}
// The emulator's OAM keeps cleared sprites' bytes; compare the
// four the icon uses, or nothing before it appears.
const same = (o: State, n: State) => {
const oam = n.oam[0] === -1 ? true : JSON.stringify(o.oam) === JSON.stringify(n.oam);
return (
oam &&
JSON.stringify([o.bands, o.map, o.attrs, o.tiles, o.bg, o.obj]) ===
JSON.stringify([n.bands, n.map, n.attrs, n.tiles, n.bg, n.obj])
);
};
// The first sample can fall a frame either side of the first run
// through: align on the offset where the frames agree.
const offset = [0, 1, -1].find((off) => native.slice(2, 40).every((n, i) => original[i + 2 + off] && same(original[i + 2 + off], n))) ?? 0;
const mismatches: unknown[] = [];
// The first sample falls in the VBlank of the first run through,
// before its overrides are pushed: compare from the next frame.
native.forEach((n, i) => {
if (i === 0) return;
const o = original[i + offset];
if (mismatches.length >= 4) return;
if (!o) return void mismatches.push({ frame: i, original: "missing" });
if (same(o, n)) return;
const diff: Record<string, unknown> = { frame: i };
for (const key of ["oam", "bands", "map", "attrs", "tiles", "bg", "obj"] as const) {
const cells = o[key].flatMap((value, index) => (value === n[key][index] ? [] : [`${index}:${value}/${n[key][index]}`]));
if (cells.length) diff[key] = cells.slice(0, 10).join(" ");
}
mismatches.push(diff);
});
return { original: original.length - offset, native: native.length, offset, mismatches };
},
{ a, train, toGoldenrod, female },
);
expect(result.mismatches, JSON.stringify(result)).toEqual([]);
// The cartridge's teardown frames follow the arrival.
expect(result.original).toBe(result.native + TEARDOWN);
});