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/reference.", ); test("Hall of Fame PC reads SRAM and matches the resting tilemap, picture and palette", async ({ page, }) => { const { at, routine } = loadSymbols(); const flat = (name: string) => { const { bank, address } = routine(name); return address < 0x4000 ? address : bank * 0x4000 + address - 0x4000; }; const addresses = { romBank: at("hROMBank"), cgb: at("hCGB"), joy: at("hJoyLast"), tilemap: at("wTilemap"), attrs: at("wAttrmap"), palettes: at("wBGPals1"), frame: at("wTextboxFrame"), hof: at("sHallOfFame"), }; const pc = routine("_HallOfFamePC"); const font = routine("_LoadStandardFont"); // Only bypass frame waits and frontpic animation: the native viewer // presents the resting image. All SRAM decoding, layout, text, gender, // frontpic decompression and palette selection execute cartridge code. const skip = [ "WaitBGMap", "DelayFrame", "HOF_AnimateFrontpic", "JoyTextDelay", ].map(flat); await openReference(page); const result = await page.evaluate( async ({ addresses: a, pc, font, skip }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const { CrystalAssets } = await import("/src/game/rom/assets.ts"); const { hallOfFameScreen } = await import("/src/game/scripts/hall-of-fame.ts"); const { textTiles } = await import("/src/game/rom/font.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); for (const p of skip) rom[p] = 0xc9; const reference = new BinjgbEmulator(); await reference.load(rom, document.createElement("canvas")); const write = (p: number, n: number) => reference.writeMemory(p, n & 255); const read = (p: number) => reference.readMemory(p); const cases = [ { species: 25, dvs: 0xaaaa, level: 42, nickname: "SPARK", otId: 12345, win: 1, }, { species: 201, dvs: 0xffff, level: 100, nickname: "UNOWN", otId: 0, win: 200, }, { species: 29, dvs: 0, level: 5, nickname: "NIDORAN♀", otId: 65535, win: 99, }, { species: 157, dvs: 0xffff, level: 99, nickname: "FIRE", otId: 42, win: 100, }, ...Array.from({ length: 251 }, (_, i) => ({ species: i + 1, dvs: i % 2 ? 0x2aaa : 0xffff, level: 1 + (i % 100), nickname: "RECORDED", otId: i * 251, win: 1 + (i % 200), })), ]; const failures: unknown[] = []; for (const [index, input] of cases.entries()) { const { win, ...mon } = input; const team = { win, mons: [mon] }; // SRAM bank 1: one team, a terminator after its first Pokémon, and // the absent next team. The PC must load this itself. write(0, 0x0a); write(0x4000, 1); for (let i = 0; i < 196; i++) write(a.hof + i, 0); const record = [ win, mon.species, mon.otId >> 8, mon.otId & 255, mon.dvs >> 8, mon.dvs & 255, mon.level, ...textTiles(mon.nickname), ...Array(10 - mon.nickname.length).fill(0x50), 0xff, ]; record.forEach((value, offset) => write(a.hof + offset, value)); write(0, 0); write(0xff70, 1); write(0xff40, 0); write(a.cgb, 1); write(a.frame, index % 8); write(a.joy, 2); // B after the first page. // JoyTextDelay is stubbed to preserve the fixed B input above. const program = [ 0xf3, 0x31, 0xfe, 0xc7, 0x3e, font.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, 0xcd, font.address & 255, font.address >> 8, 0x3e, pc.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, 0xcd, pc.address & 255, pc.address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe, ]; write(0xc0f0, 0); program.forEach((value, offset) => write(0xc000 + offset, value)); reference.setProgramCounter(0xc000); for (let frame = 0; frame < 100 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1); const native = hallOfFameScreen(assets.pokemon, team, 0, index % 8); const original = Array.from({ length: 360 }, (_, i) => read(a.tilemap + i), ); const mismatches = original.flatMap((tile, i) => tile === native.map[i] ? [] : [{ i, original: tile, native: native.map[i] }], ); const attrDiff = Array.from({ length: 360 }, (_, i) => read(a.attrs + i), ).filter((v, i) => v !== native.attrs[i]).length; // CGB palette work RAM is bank 5; graphics at $9000 are VRAM bank 0. write(0xff70, 5); const palette = Array.from({ length: 4 }, (_, i) => rgb(read(a.palettes + i * 2) | (read(a.palettes + i * 2 + 1) << 8)), ); write(0xff70, 1); write(0xff4f, 0); const picture = Array.from({ length: 49 * 16 }, (_, i) => read(0x9000 + i), ); const picDiff = picture.filter((v, i) => v !== native.tiles[i]).length; const tileDiff = [...new Set(native.map)] .flatMap((tile) => Array.from({ length: 16 }, (_, byte) => ({ tile, byte, expected: native.tiles[tile * 16 + byte], actual: read( (tile < 128 ? 0x9000 : 0x8800) + (tile % 128) * 16 + byte, ), })), ) .filter(({ expected, actual }) => expected !== actual); if ( read(0xc0f0) !== 0x42 || mismatches.length || attrDiff || picDiff || tileDiff.length || JSON.stringify(palette) !== JSON.stringify(native.palettes[0]) ) failures.push({ input, returned: read(0xc0f0) === 0x42, mismatches: mismatches.slice(0, 12), attrDiff, picDiff, tileDiff: tileDiff.slice(0, 5), palette, nativePalette: native.palettes[0], }); } reference.dispose(); return { cases: cases.length, failures }; }, { addresses, pc, font, skip }, ); expect(result.failures, JSON.stringify(result)).toEqual([]); });