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