Play the music and sound effects from the ROM's sound engine (Closes #34)
The sound engine (audio/engine.asm, home/audio.asm) is ported on a byte copy of its audio WRAM and a sound register model. A reference test runs the cartridge's routines beside it and matches the WRAM and registers after every UpdateSound for all songs, effects and cries, fades, the danger tone and effect priority. apu.ts synthesizes the four channels in an AudioWorklet; its output matches the reference emulator's APU by spectrum. The world runs the engine twice per tick; Play or the first press unlocks sound, pausing suspends it, Settings sets the volume. Wired in: map music by map setup and special map music, script audio commands, text fanfares, WaitSFX and cries that hold the script frozen (the last page stays shown), battle music, cries, fainting, victory, capture and the low HP tone, Pokémon Center healing, evolution, the intro, the Pokédex's cries, PCs, marts, healing items, talk and menu clicks, bumps, ledges, warps, boulders, the Bicycle and Surf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aff8391e55
commit
7f30c3f450
34 files changed
+3961
-74
No files matched your search
@@ -0,0 +1,250 @@
|
||||
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([]);
|
||||
});
|
||||
Reference in new issue
Block a user