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 own InitDisplayForRedCredits and Credits run with their * interrupts after the game has booted; each video frame the screen's * background map, the banner's 16 tiles, wBGPals2's three palettes, * wLYOverrides and the parser's position and timer are compared with * CreditsMachine's, from the first parse to well past THE END. Without B * and with B held (which only shortens credits seen before). */ for (const skip of [false, true]) test(`Credits match the cartridge frame by frame${skip ? " with B held" : ""}`, async ({ page }) => { test.setTimeout(300_000); const { at, routine } = loadSymbols(); const a = { romBank: at("hROMBank"), pos: at("wCreditsPos"), timer: at("wCreditsTimer"), bgPals2: at("wBGPals2"), lyOverrides: at("wLYOverrides"), program: at("wOverworldMapBlocks"), stackTop: at("wStackTop"), }; const init = routine("InitDisplayForRedCredits"), credits = routine("Credits"); await openReference(page); const result = await page.evaluate( async ({ a, init, credits, skip }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const { CrystalAssets } = await import("/src/game/rom/assets.ts"); const { CreditsMachine } = await import("/src/game/scripts/league.ts"); const { hallOfFameTiles } = await import("/src/game/scripts/hall-of-fame.ts"); const rom = new Uint8Array( await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(), ); const assets = new CrystalAssets(rom); 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); // Boot to the intro so the interrupt handlers and hardware are set up. reference.advanceFrames(600); // DI; SP=wStackTop; EI; InitDisplayForRedCredits; B = the status // flags (bit 6: the Hall of Fame); Credits; a sentinel. const bank = (n: number) => [0x3e, n, 0xe0, a.romBank & 255, 0xea, 0, 0x20]; const sentinel = a.program + 0x80; const program = [ 0xf3, 0x31, a.stackTop & 255, a.stackTop >> 8, 0xfb, ...bank(init.bank), 0xcd, init.address & 255, init.address >> 8, ...bank(credits.bank), 0x06, skip ? 0x40 : 0, 0xcd, credits.address & 255, credits.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); if (skip) reference.setButton("b", true); const machine = new CreditsMachine(assets.reader, hallOfFameTiles(assets.pokemon, 0), skip); type State = { map: number[]; tiles: number[]; pals: number[]; ly: number[]; pos: number; timer: number; lcd?: number[] }; // The parser's work RAM at line 143 (the frame's logic has run, // its VBlank has not), then what that VBlank presents at line 152: // the BG map, the banner's tiles and the palettes in CRAM. 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 sample = (): State => { toLine(143); const wram = { ly: [read(a.lyOverrides + 0x1f), read(a.lyOverrides + 0x87)], pos: read(a.pos) | (read(a.pos + 1) << 8), timer: read(a.timer), }; toLine(152); const cram = Array.from({ length: 12 }, (_, i) => { write(0xff68, i * 2); const low = read(0xff69); write(0xff68, i * 2 + 1); return low | (read(0xff69) << 8); }); const state = { map: Array.from({ length: 360 }, (_, i) => read(0x9800 + Math.floor(i / 20) * 32 + (i % 20))), tiles: Array.from({ length: 256 }, (_, i) => read(0x9000 + i)), pals: cram, ...wram, lcd: [read(0xff44), read(0xff41) & 3], }; reference.advanceCycles(456 * 140); return state; }; const native = (): State => { const d = machine.debug(); return { map: [...machine.shown], tiles: Array.from(machine.vram.subarray(0, 256)), pals: machine.paletteWords(), ly: [d.lyOverride, d.lyOverride], pos: d.pos, timer: d.timer, }; }; // Sample once a frame at the last visible line: the frame's logic // has run and its VBlank has not. let guard = 0; while (read(a.pos) === 0 && read(a.pos + 1) === 0 && guard++ < 300) reference.advanceFrames(1); while (read(0xff44) !== 143 && guard++ < 100000) reference.advanceCycles(16); const original: State[] = []; for (let frame = 0; frame < 12000 && read(sentinel) !== 0x42; frame++) original.push(sample()); const nativeFrames: State[] = []; const present = () => { const before = native(); machine.vblank(); const after = native(); nativeFrames.push({ ...after, ly: before.ly, pos: before.pos, timer: before.timer }); }; for (let frame = 0; frame < original.length + 8; frame++) { const logic = machine.step(skip ? "b" : ""); let next = logic.next(); while (!next.done) { present(); next = logic.next(); } present(); } // Positions between two waits are a parse in progress, whose own // progress the native parser does not model: compare the parser // where it rests, and the screen always. const { KANTO } = await import("/src/game/rom/profile.ts"); const boundaries = new Set([0]); for (let p = 0, guard = 0; guard < 1024; guard++) { const command = rom[KANTO.CreditsScript + p++]; if (command === 0xff) { boundaries.add(p); break; } if (command === 0xfe || command === 0xfa) boundaries.add(++p); else if (command === 0xfd || command < 0xf9) p++; } const rest = ({ lcd: _, ...x }: State) => (boundaries.has(x.pos) ? x : { ...x, pos: -1, timer: -1 }); const same = (x: State, y: State) => JSON.stringify(rest(x)) === JSON.stringify(boundaries.has(x.pos) ? y : rest(y)); // The first emulator sample falls after the first parse; find its // native frame, then every later frame must match in order. const offset = nativeFrames.findIndex((frame) => frame.pos === original[0].pos && same(original[0], frame)); const mismatches: unknown[] = []; if (offset >= 0) original.forEach((frame, i) => { const mine = nativeFrames[offset + i]; if (mismatches.length >= 5 || (mine && same(frame, mine))) return; if (!mine) return void mismatches.push({ frame: i, native: "missing" }); const diff: Record = { frame: i, pos: frame.pos, timer: [frame.timer, mine.timer], lcd: frame.lcd }; for (const key of ["map", "tiles", "pals", "ly"] as const) { const cells = frame[key].flatMap((value, index) => value === mine[key][index] ? [] : [`${index}:${value}/${mine[key][index]}`], ); if (cells.length) diff[key] = cells.slice(0, 12).join(" "); } if (frame.pos !== mine.pos) diff.nativePos = mine.pos; mismatches.push(diff); }); return { frames: original.length, offset, first: original[0] && { pos: original[0].pos, timer: original[0].timer }, debugFirst: offset < 0 ? [original[0], ...nativeFrames.slice(0, 4)].map((x) => ({ ...x, map: x.map.slice(80, 140).join(","), tiles: x.tiles.slice(0, 16).join(",") })) : undefined, endPos: original.at(-1)?.pos, mismatches, }; }, { a, init, credits, skip }, ); expect(result.offset, JSON.stringify(result.debugFirst)).toBeGreaterThanOrEqual(0); expect(result.frames).toBeGreaterThan(skip ? 3000 : 8000); expect(result.mismatches).toEqual([]); });