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."); /** Each case starts both engines from InitSound, runs the same calls into * the sound engine and compares the audio WRAM ($c100-$c2c1) and the * readable sound registers after every UpdateSound. */ type Step = | { call: "PlayMusic" | "PlaySFX" | "PlayCry" | "PlayStereoSFX"; de: number } | { poke: string; value: number } | { frames: number }; type Case = { name: string; stereo?: boolean; steps: Step[] }; async function compare(page: import("@playwright/test").Page, cases: Case[]) { const { at, routine } = loadSymbols(); const labels = { romBank: at("hROMBank"), options: at("wOptions"), playerState: at("wPlayerState"), initSound: at("InitSound"), updateSound: at("UpdateSound"), PlayMusic: at("PlayMusic"), PlaySFX: at("PlaySFX"), PlayCry: at("PlayCry"), PlayStereoSFX: routine("PlayStereoSFX"), wMusicFade: at("wMusicFade"), wMusicFadeID: at("wMusicFadeID"), wLowHealthAlarm: at("wLowHealthAlarm"), wStereoPanningMask: at("wStereoPanningMask"), wCryTracks: at("wCryTracks"), }; await openReference(page); return page.evaluate( async ({ labels, cases }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const { AudioEngine, AUDIO_RAM } = await import("/src/game/audio/engine.ts"); const { RomReader } = await import("/src/game/rom/reader.ts"); const rom = new Uint8Array( await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(), ); const reference = new BinjgbEmulator(); await reference.load(rom, document.createElement("canvas")); const read = (p: number) => reference.readMemory(p); const write = (p: number, v: number) => reference.writeMemory(p, v & 255); // DI; SP=$c7fe; [ld de, nn]; [ld a, bank / ldh [hROMBank] / ld [$2000]]; // call routine; sentinel at $c0f0. const run = (address: number, de = 0, bank?: number) => { const program = [0xf3, 0x31, 0xfe, 0xc7, 0x11, de & 255, de >> 8]; if (bank !== undefined) program.push(0x3e, bank, 0xe0, labels.romBank & 255, 0xea, 0, 0x20); program.push(0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe); write(0xc0f0, 0); program.forEach((v, i) => write(0xc000 + i, v)); reference.setProgramCounter(0xc000); for (let n = 0; n < 64 && read(0xc0f0) !== 0x42; n++) reference.advanceCycles(2048); if (read(0xc0f0) !== 0x42) throw Error(`Routine $${address.toString(16)} did not return`); }; const size = 0x1c2; const results: unknown[] = []; for (const testCase of cases) { const engine = new AudioEngine(new RomReader(rom)); write(labels.options, testCase.stereo ? 1 << 5 : 0); write(labels.playerState, 0); engine.stereo = !!testCase.stereo; run(labels.initSound); engine.initSound(); for (let i = 0; i < size; i++) engine.ram[i] = read(AUDIO_RAM + i); let frame = 0; let failure: unknown; // Frames where the cartridge had a channel on, and register writes // the port made: proof the case exercised the engine. let active = 0; let writes = 0; const check = (where: string) => { for (let i = 0; i < size; i++) { const original = read(AUDIO_RAM + i); if (original !== engine.ram[i]) { failure = { where, frame, address: (AUDIO_RAM + i).toString(16), original, native: engine.ram[i], }; return false; } } // Readable sound registers, except NR52's channel status bits. for (let r = 0; r < 0x16; r++) { const original = read(0xff10 + r); const masks = [ 0x80, 0x3f, 0x00, 0xff, 0xbf, 0xff, 0x3f, 0x00, 0xff, 0xbf, 0x7f, 0xff, 0x9f, 0xff, 0xbf, 0xff, 0xff, 0x00, 0x00, 0xbf, 0x00, 0x00, ]; const native = engine.registers[r] | masks[r]; if (original !== native) { failure = { where, frame, register: (0xff10 + r).toString(16), original, native }; return false; } } return true; }; for (const step of testCase.steps) { if (failure) break; if ("call" in step) { if (step.call === "PlayMusic") { run(labels.PlayMusic, step.de); engine.playMusic(step.de); } else if (step.call === "PlaySFX") { run(labels.PlaySFX, step.de); engine.playSFX(step.de); } else if (step.call === "PlayCry") { run(labels.PlayCry, step.de); engine.playCry(step.de + 1); } else { run(labels.PlayStereoSFX.address, step.de, labels.PlayStereoSFX.bank); engine.playStereoSFX(step.de); } check(step.call); } else if ("poke" in step) { const address = labels[step.poke as keyof typeof labels] as number; write(address, step.value); engine.w(address, step.value); } else { for (let n = 0; n < step.frames && !failure; n++) { run(labels.updateSound); engine.updateSound(); frame++; if ([0, 1, 2, 3, 4, 5, 6, 7].some((n) => read(AUDIO_RAM + 4 + n * 0x32) & 1)) active++; writes += engine.drain().length; check("UpdateSound"); } } } results.push({ name: testCase.name, frames: frame, active, writes, failure }); } reference.dispose(); return results as { name: string; frames: number; active: number; writes: number; failure?: unknown }[]; }, { labels, cases }, ); } const failures = (results: { name: string; failure?: unknown }[]) => results.filter((result) => result.failure); test("every song matches _UpdateSound for 20 seconds", async ({ page }) => { test.setTimeout(600_000); const cases: Case[] = []; for (let id = 1; id < 0x67; id++) cases.push({ name: `music ${id}`, stereo: id % 2 === 0, steps: [{ call: "PlayMusic", de: id }, { frames: 1200 }] }); const results = await compare(page, cases); expect(results.length).toBe(0x66); // Every song plays for at least two seconds (the jingles end) and // writes registers; nearly all loop for the whole 20 seconds. expect(results.filter((result) => result.active < 120 || result.writes < 100)).toEqual([]); expect(results.filter((result) => result.active === 1200).length).toBeGreaterThan(90); expect(failures(results), JSON.stringify(failures(results).slice(0, 3))).toEqual([]); }); test("every sound effect matches PlaySFX and PlayStereoSFX", async ({ page }) => { test.setTimeout(600_000); const cases: Case[] = []; for (let id = 0; id < 0xcf; id++) { cases.push({ name: `sfx ${id}`, steps: [{ call: "PlaySFX", de: id }, { frames: 300 }] }); cases.push({ name: `stereo sfx ${id}`, stereo: true, steps: [ { poke: "wStereoPanningMask", value: id & 1 ? 0xf0 : 0x0f }, { poke: "wCryTracks", value: id % 3 }, { call: "PlayStereoSFX", de: id }, { frames: 300 }, ], }); } const results = await compare(page, cases); expect(failures(results), JSON.stringify(failures(results).slice(0, 3))).toEqual([]); }); test("every cry matches PlayCry, over music and from a battle side", async ({ page }) => { test.setTimeout(600_000); const cases: Case[] = []; for (let species = 1; species <= 251; species++) cases.push({ name: `cry ${species}`, stereo: species % 2 === 0, steps: [ { call: "PlayMusic", de: 1 + (species % 0x66) }, { frames: 30 }, { poke: "wStereoPanningMask", value: species % 3 ? 1 : 0 }, { poke: "wCryTracks", value: species & 1 ? 0xf0 : 0x0f }, { call: "PlayCry", de: species - 1 }, { frames: 240 }, ], }); const results = await compare(page, cases); expect(failures(results), JSON.stringify(failures(results).slice(0, 3))).toEqual([]); }); test("fades, the danger tone and sound effects over music match", async ({ page }) => { test.setTimeout(300_000); const cases: Case[] = []; for (let id = 1; id < 0x67; id += 5) { cases.push({ name: `fade out ${id} to ${id + 1}`, steps: [ { call: "PlayMusic", de: id }, { frames: 60 }, { poke: "wMusicFadeID", value: (id % 0x66) + 1 }, { poke: "wMusicFade", value: 8 }, { frames: 600 }, ], }); cases.push({ name: `fade in ${id}`, steps: [ { call: "PlayMusic", de: id }, { poke: "wMusicFade", value: 4 | 0x80 }, { frames: 120 }, ], }); cases.push({ name: `danger over ${id}`, steps: [ { call: "PlayMusic", de: id }, { poke: "wLowHealthAlarm", value: 0x80 }, { frames: 90 }, { call: "PlaySFX", de: 0x10 }, { frames: 90 }, { poke: "wLowHealthAlarm", value: 0 }, { frames: 30 }, ], }); cases.push({ name: `sfx priority over ${id}`, steps: [ { call: "PlayMusic", de: id }, { frames: 45 }, { call: "PlaySFX", de: (id * 7) % 0xcf }, { frames: 5 }, { call: "PlaySFX", de: (id * 11) % 0xcf }, { frames: 200 }, ], }); } const results = await compare(page, cases); expect(failures(results), JSON.stringify(failures(results).slice(0, 3))).toEqual([]); });