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."); /** The cartridge's AnimateHallOfFame.DisplayNewHallOfFamer and * HOF_AnimatePlayerPic run with the LCD off (so graphics load at once) and * only their frame waits, HOF_AnimateFrontpic and ProfOaksPCRating * stubbed; the tilemap, attributes, BG palette 0 and the tiles each screen * shows are compared with the native screens. */ test("the Hall of Fame ceremony's pages match the cartridge's tilemaps, tiles and palettes", async ({ page }) => { test.setTimeout(120_000); const { at, routine } = loadSymbols(); const flat = (name: string) => { const { bank, address } = routine(name); return address < 0x4000 ? address : bank * 0x4000 + address - 0x4000; }; const a = { romBank: at("hROMBank"), cgb: at("hCGB"), tilemap: at("wTilemap"), attrs: at("wAttrmap"), palettes: at("wBGPals1"), frame: at("wTextboxFrame"), temp: at("wHallOfFameTempMon1"), name: at("wPlayerName"), id: at("wPlayerID"), hours: at("wGameTimeHours"), minutes: at("wGameTimeMinutes"), gender: at("wPlayerGender"), layout: at("GetSGBLayout"), setupFlags: at("wPlayerSpriteSetupFlags"), species: at("wCurPartySpecies"), tempDVs: at("wTempMonDVs"), }; const routines = { font: routine("_LoadStandardFont"), extra: routine("LoadFontsBattleExtra"), famer: routine("AnimateHallOfFame.DisplayNewHallOfFamer"), player: routine("HOF_AnimatePlayerPic"), }; const skip = ["WaitBGMap", "WaitBGMap2", "DelayFrame", "DelayFrames", "HOF_AnimateFrontpic", "ProfOaksPCRating"].map(flat); await openReference(page); const result = await page.evaluate( async ({ a, routines, skip, get2bpp, request2bpp }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const { CrystalAssets } = await import("/src/game/rom/assets.ts"); const { newHallOfFamerScreen, hallOfFamePlayerCard } = await import("/src/game/scripts/league.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; // Request2bpp waits for VBlank even with the LCD off; Get2bpp copies // at once then, from the same arguments. rom.set([0xc3, get2bpp & 255, get2bpp >> 8], request2bpp); 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 call = (r: { bank: number; address: number }) => [ 0x3e, r.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, 0xcd, r.address & 255, r.address >> 8, ]; /** Run the font loaders, the routine (HL = `hl`), then a sentinel; * with `layout`, GetSGBLayout's SCGB_PLAYER_OR_MON_FRONTPIC_PALS * first, as AnimateHOFMonEntrance applies it for the same Pokémon * before DisplayNewHallOfFamer. */ const run = (target: { bank: number; address: number }, hl = 0, layout = false) => { write(0xff70, 1); write(0xff40, 0); write(a.cgb, 1); const program = [ 0xf3, 0x31, 0xfe, 0xc7, ...call(routines.font), ...call(routines.extra), ...(layout ? [0x06, 0x1a, 0xcd, a.layout & 255, a.layout >> 8] : []), 0x21, hl & 255, hl >> 8, ...call(target), 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 < 200 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1); return read(0xc0f0) === 0x42; }; type Screen = { map: number[]; attrs: number[]; palettes: number[][][]; tiles: Uint8Array }; const compare = (label: unknown, native: Screen, tiles: number[]) => { const map = Array.from({ length: 360 }, (_, i) => read(a.tilemap + i)); // The attributes shown: WaitBGMap copies tiles only, so the text // boxes' palette in wAttrmap stays off screen; GetSGBLayout's // ApplyAttrmap set VRAM bank 1. write(0xff4f, 1); const attrs = Array.from({ length: 360 }, (_, i) => read(0x9800 + Math.floor(i / 20) * 32 + (i % 20))); write(0xff4f, 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 tileDiff = tiles.flatMap((tile) => Array.from({ length: 16 }, (_, byte) => { const actual = read((tile < 128 ? 0x9000 : 0x8800) + (tile % 128) * 16 + byte); return actual === native.tiles[tile * 16 + byte] ? [] : [{ tile, byte, actual, expected: native.tiles[tile * 16 + byte] }]; }).flat(), ); const mapDiff = map.flatMap((tile, i) => (tile === native.map[i] ? [] : [{ i, original: tile, native: native.map[i] }])); const attrDiff = attrs.filter((v, i) => v !== native.attrs[i]).length; const paletteDiff = JSON.stringify(palette) !== JSON.stringify(native.palettes[0]); return mapDiff.length || attrDiff || paletteDiff || tileDiff.length ? [{ label, mapDiff: mapDiff.slice(0, 10), attrDiff, palette, nativePalette: native.palettes[0], tileDiff: tileDiff.slice(0, 5) }] : []; }; const failures: unknown[] = []; const mons = [ { species: 25, dvs: 0xaaaa, level: 42, nickname: "SPARK", otId: 12345 }, { species: 201, dvs: 0xffff, level: 100, nickname: "UNOWN", otId: 0 }, { species: 29, dvs: 0, level: 5, nickname: "NIDORAN♀", otId: 65535 }, { species: 157, dvs: 0xfaaa, level: 64, nickname: "TYPHLOSION", otId: 35209 }, ...Array.from({ length: 251 }, (_, i) => ({ species: i + 1, dvs: i % 3 ? 0x2aaa : 0xffff, level: 1 + (i % 100), nickname: "INDUCTED", otId: i * 257, })), ]; for (const [index, mon] of mons.entries()) { const record = [ 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), ]; record.forEach((value, offset) => write(a.temp + offset, value)); write(a.species, mon.species); write(a.tempDVs, mon.dvs >> 8); write(a.tempDVs + 1, mon.dvs & 255); write(a.frame, index % 8); const returned = run(routines.famer, a.temp, true); const native = newHallOfFamerScreen(assets.pokemon, mon, index % 8); const tiles = [...new Set(native.map)]; failures.push(...(returned ? compare(mon, native, tiles) : [{ mon, returned }])); } const players = [ { name: "KAWA", id: 35209, female: false, time: { hours: 1, minutes: 23 } }, { name: "KRIS", id: 0, female: true, time: { hours: 999, minutes: 59 } }, { name: "ABCDEFG", id: 65535, female: false, time: { hours: 0, minutes: 0 } }, { name: "ᴾᴷᴹᴺ", id: 42, female: true, time: { hours: 42, minutes: 7 } }, ]; for (const [index, player] of players.entries()) { write(0xff70, 1); const name = [...textTiles(player.name), 0x50]; name.forEach((value, offset) => write(a.name + offset, value)); write(a.id, player.id >> 8); write(a.id + 1, player.id & 255); write(a.hours, player.time.hours >> 8); write(a.hours + 1, player.time.hours & 255); write(a.minutes, player.time.minutes); write(a.gender, player.female ? 1 : 0); write(a.setupFlags, 0); write(a.frame, index % 8); const returned = run(routines.player); const native = hallOfFamePlayerCard( assets.pokemon, { ...player, time: { ...player.time, seconds: 0, frames: 0, capped: false } }, index % 8, ); // The card's tiles, and the back picture loaded from $31. const tiles = [...new Set([...native.map, ...Array.from({ length: 36 }, (_, i) => 0x31 + i)])]; failures.push(...(returned ? compare(player, native, tiles) : [{ player, returned }])); } reference.dispose(); return { mons: mons.length, failures }; }, { a, routines, skip, get2bpp: at("Get2bpp"), request2bpp: flat("Request2bpp") }, ); expect(result.failures, JSON.stringify(result.failures.slice(0, 3))).toEqual([]); });