Files
Crystal/tests/audio-world.test.ts
T
kawaandClaude Opus 5.5 7f30c3f450 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>
2026-10-15 15:54:02 +02:00

156 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { generatePartyMon } from "../src/game/party/pokemon";
import { SOUND } from "../src/game/audio/constants";
import { battleMusic, hpBarPixels, victoryMusic, MUSIC_TRAINERS as T } from "../src/game/audio/battle-music";
import { battleFixtureWorld, ENCOUNTER_WALK } from "./battle-fixture";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe("battle music choices (PlayBattleMusic, PlayVictoryMusic)", () => {
it("picks the wild, trainer, leader, rival and Rocket songs", () => {
expect(battleMusic({ night: false, kanto: false })).toBe(SOUND.MUSIC_JOHTO_WILD_BATTLE);
expect(battleMusic({ night: true, kanto: false })).toBe(SOUND.MUSIC_JOHTO_WILD_BATTLE_NIGHT);
expect(battleMusic({ night: true, kanto: true })).toBe(SOUND.MUSIC_KANTO_WILD_BATTLE);
expect(battleMusic({ battleType: T.BATTLETYPE_ROAMING, night: false, kanto: false })).toBe(SOUND.MUSIC_SUICUNE_BATTLE);
expect(battleMusic({ trainerClass: T.FALKNER, night: false, kanto: false })).toBe(SOUND.MUSIC_JOHTO_GYM_LEADER_BATTLE);
expect(battleMusic({ trainerClass: T.BROCK, night: false, kanto: true })).toBe(SOUND.MUSIC_KANTO_GYM_LEADER_BATTLE);
expect(battleMusic({ trainerClass: T.GRUNTM, night: false, kanto: false })).toBe(SOUND.MUSIC_ROCKET_BATTLE);
expect(battleMusic({ trainerClass: T.RIVAL1, night: false, kanto: false })).toBe(SOUND.MUSIC_RIVAL_BATTLE);
expect(battleMusic({ trainerClass: T.RIVAL2, trainerId: 4, night: false, kanto: false })).toBe(SOUND.MUSIC_CHAMPION_BATTLE);
expect(battleMusic({ trainerClass: T.RED, night: false, kanto: true })).toBe(SOUND.MUSIC_CHAMPION_BATTLE);
// Youngsters and the like: by region.
expect(battleMusic({ trainerClass: 30, night: false, kanto: false })).toBe(SOUND.MUSIC_JOHTO_TRAINER_BATTLE);
expect(battleMusic({ trainerClass: 30, night: false, kanto: true })).toBe(SOUND.MUSIC_KANTO_TRAINER_BATTLE);
expect(victoryMusic(undefined)).toBe(SOUND.MUSIC_WILD_VICTORY);
expect(victoryMusic(T.MORTY)).toBe(SOUND.MUSIC_GYM_VICTORY);
// IsGymLeader's list runs on into the Kanto leaders.
expect(victoryMusic(T.BLUE)).toBe(SOUND.MUSIC_GYM_VICTORY);
expect(victoryMusic(30)).toBe(SOUND.MUSIC_TRAINER_VICTORY);
});
it("measures the HP bar as ComputeHPBarPixels does", () => {
expect(hpBarPixels(0, 20)).toBe(0);
expect(hpBarPixels(1, 200)).toBe(1);
expect(hpBarPixels(20, 20)).toBe(48);
expect(hpBarPixels(4, 20)).toBe(9);
expect(hpBarPixels(5, 20)).toBe(12);
// Above 255 HP both sides are divided by four first.
expect(hpBarPixels(100, 400)).toBe(12);
});
});
describe.skipIf(!existsSync(romPath))("sound in the world with the local ROM", () => {
const load = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
/** Every song and sound effect the engine is asked to start. */
const record = (world: GameWorld) => {
const music: number[] = [],
sfx: number[] = [],
cries: number[] = [];
const engine = world.sound.engine;
const playMusic = engine.playMusic.bind(engine),
playSFX = engine.playSFX.bind(engine),
playCry = engine.playCryWith.bind(engine);
engine.playMusic = (id) => {
if (id) music.push(id);
playMusic(id);
};
engine.playSFX = (id) => {
sfx.push(id);
playSFX(id);
};
engine.playCryWith = (id, pitch, length) => {
cries.push(id);
playCry(id, pitch, length);
};
return { music, sfx, cries };
};
it("plays a wild battle's music, cries and victory, then the map's music again", () => {
const world = battleFixtureWorld(load(), "win");
world.restore(world.save());
const heard = record(world);
const mapMusic = world.sound.engine.mapMusic;
expect(mapMusic).toBe(world.mapMusicId());
for (const direction of ENCOUNTER_WALK) world.move(direction);
expect(world.battle).toBeDefined();
// PlayBattleMusic (noon: day music), then the wild Pokémon's cry.
expect(heard.music).toContain(SOUND.MUSIC_JOHTO_WILD_BATTLE);
const pidgey = world.sound.engine.cryEntry(16).id;
expect(heard.cries[0]).toBe(pidgey);
for (let tick = 0; tick < 10000 && (world.battle || world.busy); tick++) {
const view = world.battle;
if (view?.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 });
else if (view?.prompt?.kind === "yesno") world.battleChoose(1);
else if (view?.prompt?.kind === "forget") world.battleChoose(3);
else if (view && (view.prompt?.kind === "stats" || view.message?.prompt)) world.interact();
else if (world.dialogue) world.advanceDialogue();
else world.tick();
}
expect(world.battle).toBeUndefined();
// The enemy fainted (SFX_KINESIS, SFX_FAINT), the wild victory played,
// and reloadmapafterbattle restarted the map's music.
expect(heard.music).toContain(SOUND.MUSIC_WILD_VICTORY);
for (let tick = 0; tick < 200; tick++) world.tick();
expect(heard.sfx).toContain(SOUND.SFX_KINESIS);
expect(heard.sfx).toContain(SOUND.SFX_FAINT);
expect(heard.music.at(-1)).toBe(mapMusic);
expect(world.sound.engine.channelMusicId(0)).toBe(mapMusic);
expect(world.sound.engine.lowHealthAlarm).toBe(false);
});
it("sounds the low HP tone while the player's HP bar is red", () => {
const world = battleFixtureWorld(load(), "loss");
for (const direction of ENCOUNTER_WALK) world.move(direction);
for (let tick = 0; tick < 400 && world.battle && world.battle.prompt?.kind !== "action"; tick++)
if (world.battle.message?.prompt) world.interact();
else world.tick();
expect(world.battle?.prompt?.kind).toBe("action");
expect(world.sound.engine.lowHealthAlarm).toBe(true);
});
it("heals with the Pokémon Center's jingle, then the map's music returns", () => {
const assets = load();
const world = new GameWorld(assets, () => new Date("2026-09-22T13:00:00"));
const mon = generatePartyMon(assets.pokemon, {
species: 155, level: 10, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x823b, caughtData: 0,
});
mon.hp = 1;
world.state.party.push(mon, structuredClone(mon));
world.restore({ ...world.save(), map: "26:5", x: 3, y: 3, direction: "up" });
const heard = record(world);
world.interact();
// PlayTalkObject
expect(heard.sfx).toEqual([SOUND.SFX_READ_TEXT_2]);
const ticksBefore = world.ticks;
const pages = settle(world, [1]);
expect(pages).toContain("Your POKéMON are\nfully healed.");
// A ball sound for each Pokémon, the heal jingle, and the map's music.
expect(heard.sfx.filter((id) => id === SOUND.SFX_SECOND_PART_OF_ITEMFINDER)).toHaveLength(2);
expect(heard.music).toContain(SOUND.MUSIC_HEAL);
expect(heard.music.at(-1)).toBe(SOUND.MUSIC_POKEMON_CENTER);
// HealMachineAnim's 2 × 30 + 80 frames, then pause 30.
expect(world.ticks - ticksBefore).toBeGreaterThan(70 + 15);
});
it("plays the warp's sound and fades to the next map's music", () => {
const world = new GameWorld(load(), () => new Date("2026-09-22T13:00:00"));
// New Bark Town, below Elm's lab door.
world.restore({ ...world.save(), map: "24:4", x: 6, y: 4, direction: "up" });
const heard = record(world);
expect(world.sound.engine.mapMusic).toBe(SOUND.MUSIC_NEW_BARK_TOWN);
world.move("up");
for (let tick = 0; tick < 40; tick++) world.tick();
expect(world.mapId).toBe("24:5");
expect(heard.sfx[0]).toBe(SOUND.SFX_ENTER_DOOR);
// FadeToMapMusic: Elm's lab plays MUSIC_PROF_ELM once the fade ends.
for (let tick = 0; tick < 200; tick++) world.tick();
expect(world.sound.engine.mapMusic).toBe(SOUND.MUSIC_PROF_ELM);
expect(world.sound.engine.channelMusicId(0)).toBe(SOUND.MUSIC_PROF_ELM);
});
});