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:
kawaandClaude Opus 5.5 committed 2026-10-15 15:54:02 +02:00
1 parent aff8391e55
commit 7f30c3f450
34 files changed
+3961 -74

No files matched your search

+90 -7
View File
@@ -135,6 +135,14 @@ export type BattleEffect =
| { kind: "party"; text: string; menu: boolean; cancel: boolean }
/** MoveSelectionScreen for an item: a move slot (0-3), or -1 to go back. */
| { kind: "move"; text: string; moves: number[]; pp: number[] };
/** PlayStereoCry from a side, sound effects one after another (WaitSFX
* between them), PlayVictoryMusic or the capture music, and
* StopDangerSound. */
export type BattleSound =
| { cry: number; side: "player" | "enemy" }
| { sfx: number[] }
| { music: "victory" | "capture" }
| { stopDanger: true };
/** A name, level and max HP, as DisplayCaughtContestMonStats prints them. */
export type ContestMonSummary = { name: string; level: number; maxHp: number };
/** The Bug-Catching Contest's wParkBallsRemaining and wContestMon. */
@@ -214,9 +222,15 @@ export interface BattleTrainer {
}
type Gen<T = void> = Generator<BattleEffect, T, BattleInput>;
// Sound effect ids (constants/sfx_constants.asm; audio/constants.ts).
const SFX_KINESIS = 0x2f,
SFX_FAINT = 0x2a,
SFX_RUN = 0x2b;
export interface BattleOptions {
data: PokemonData;
/** Where the battle's sounds go (cries, sound effects, music). */
sound?: (sound: BattleSound) => void;
party: PartyMon[];
pokedex: { seen: Set<number>; caught: Set<number> };
playerId: number;
@@ -708,12 +722,34 @@ export class Battle {
/** StdBattleTextbox / BattleTextbox with a ROM text. `button` waits for
* a button afterwards even without a prompt (WaitPressAorB_BlinkCursor). */
private *say(address: number, button = false): Gen {
const { text, prompt } = readTextEnd(this.reader, address, this.context());
const sfx: number[] = [];
const { text, prompt } = readTextEnd(this.reader, address, {
...this.context(),
onSound: (id) => sfx.push(id),
});
// Sounds of the text itself (a fanfare) and of its text_asm routines.
const sounds = this.textSounds.splice(0);
if (sfx.length) sounds.push({ sfx });
for (const sound of sounds) this.playSound(sound);
// The tutorial's auto input presses A at every prompt.
const waits = (prompt || button) && this.battleType !== C.BATTLETYPE_TUTORIAL;
for (const page of text.split("\n\n").filter(Boolean))
yield { kind: "text", text: page, prompt: waits };
}
/** Sounds text_asm routines play, given out by the text that ran them. */
private textSounds: BattleSound[] = [];
/** A sound for the presentation; the battle never waits for it. */
private playSound(sound: BattleSound) {
this.options.sound?.(sound);
}
/** wBattleLowHealthAlarm: set once the battle is won, which keeps the
* danger tone off for the rest of it. */
lowHealthAlarmOff = false;
/** StopDangerSound, and wBattleLowHealthAlarm for the rest of the battle. */
private winSounds() {
this.lowHealthAlarmOff = true;
this.playSound({ stopDanger: true });
}
/** text_asm routines that battle texts run between fragments. */
private textAsm(offset: number): number {
const at = (label: string, fragment = 4) => BATTLE[label as keyof typeof BATTLE] + fragment;
@@ -773,7 +809,8 @@ export class Battle {
return BATTLE[(text ?? "BattleCommand_Charge.BattleDugText") as keyof typeof BATTLE];
}
case at("Text_GotchaMonWasCaught"):
// Capture music (not ported), then WaitButtonText.
// WaitSFX, MUSIC_CAPTURE, then WaitButtonText.
this.textSounds.push({ music: "capture" });
return BATTLE.WaitButtonText;
default:
throw new UnsupportedBattle(`Battle text routine at 0x${offset.toString(16)} is not ported.`);
@@ -1118,7 +1155,10 @@ export class Battle {
if (trainer) {
yield* this.say(BATTLE.WantsToBattleText);
yield* this.say(BATTLE.BattleText_EnemySentOut);
} else
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
} else {
// The wild Pokémon's picture animates with its cry before the text.
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
yield* this.say(
this.battleType === C.BATTLETYPE_FISH
? BATTLE.HookedPokemonAttackedText
@@ -1128,6 +1168,7 @@ export class Battle {
? BATTLE.WildCelebiAppearedText
: BATTLE.WildPokemonAppearedText,
);
}
this.resetBattleParticipants();
this.initBattleMon();
this.checkAmuletCoin();
@@ -1141,12 +1182,19 @@ export class Battle {
// WinTrainerBattle and LostBattle's .battle_tower: BattleTowerText
// instead of the prize money or the whiteout.
const won = (this.battleResult & 0x3f) === C.WIN;
if (won) yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
if (won) {
this.winSounds();
this.playSound({ music: "victory" });
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
}
const text = won ? trainer.winText : trainer.lossText;
if (text) yield* this.say(text);
return { result: this.battleResult, evolvable: this.evolvable };
}
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
// WinTrainerBattle: StopDangerSound, PlayVictoryMusic.
this.winSounds();
this.playSound({ music: "victory" });
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
if (this.options.canLose) {
for (const mon of this.options.party) {
@@ -2350,7 +2398,11 @@ export class Battle {
// The contest always lets the player go; the event battles never do.
if (this.battleType === C.BATTLETYPE_CONTEST) {
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
yield* this.say(BATTLE.BattleText_GotAwaySafely);
this.playSound({ stopDanger: true });
this.playSound({ sfx: [SFX_RUN] });
this.playSound({ stopDanger: true });
this.playSound({ sfx: [SFX_RUN] });
yield* this.say(BATTLE.BattleText_GotAwaySafely);
return true;
}
if (this.cannotFlee()) {
@@ -2370,7 +2422,11 @@ export class Battle {
this.strings[RAM.stringBuffer1] = this.options.data.itemName(this.player.mon.item);
yield* this.say(BATTLE.BattleText_UserFledUsingAStringBuffer1);
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
yield* this.say(BATTLE.BattleText_GotAwaySafely);
this.playSound({ stopDanger: true });
this.playSound({ sfx: [SFX_RUN] });
this.playSound({ stopDanger: true });
this.playSound({ sfx: [SFX_RUN] });
yield* this.say(BATTLE.BattleText_GotAwaySafely);
return true;
}
this.numFleeAttempts = (this.numFleeAttempts + 1) & 0xff;
@@ -2401,6 +2457,8 @@ export class Battle {
return false;
}
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
this.playSound({ stopDanger: true });
this.playSound({ sfx: [SFX_RUN] });
yield* this.say(BATTLE.BattleText_GotAwaySafely);
return true;
}
@@ -5450,8 +5508,20 @@ export class Battle {
}
private *handleEnemyMonFaint(): Gen {
this.playSound({ sfx: [SFX_KINESIS, SFX_FAINT] });
yield* this.say(BATTLE.BattleText_EnemyMonFainted);
if (!this.player.mon.hp) yield* this.faintYourPokemon();
if (!this.options.trainer) this.winSounds();
// PlayVictoryMusic: a wild battle's, once someone gets the experience
// or a Pay Day coin.
if (
!this.options.trainer &&
this.options.party.some((mon) => mon.hp > 0) &&
(this.player.mon.hp ||
this.payDayMoney ||
this.options.party.some((mon) => mon.hp > 0 && mon.item === C.EXP_SHARE))
)
this.playSound({ music: "victory" });
yield* this.updateStateAndExperienceAfterEnemyFaint(false);
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
yield this.hud();
@@ -5497,6 +5567,7 @@ export class Battle {
this.loadEnemyMonToSwitchTo();
const switching = yield* this.offerSwitch();
yield* this.say(BATTLE.BattleText_EnemySentOut);
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
yield this.hud();
if (!switching) return;
this.participants = this.participantsAll = 0;
@@ -5508,6 +5579,7 @@ export class Battle {
this.loadEnemyMonToSwitchTo();
this.enemyIsSwitching = 1;
yield* this.say(BATTLE.BattleText_EnemySentOut);
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
yield this.hud();
}
/** ResetEnemyBattleVars, CheckWhetherSwitchmonIsPredetermined (else the
@@ -5638,6 +5710,7 @@ export class Battle {
this.curBattleMon = current;
if (turn === 0) {
if (this.enemy.mon.hp) return false;
this.playSound({ sfx: [SFX_KINESIS, SFX_FAINT] });
yield* this.say(BATTLE.BattleText_EnemyMonFainted);
return true;
}
@@ -5648,6 +5721,7 @@ export class Battle {
outgoing.hp = this.player.mon.hp;
if (this.player.mon.hp) return false;
this.participants &= ~(1 << this.lastPlayerMon);
this.playSound({ cry: this.player.mon.species, side: "player" });
yield* this.say(BATTLE.BattleText_MonFainted);
return true;
}
@@ -5677,6 +5751,9 @@ export class Battle {
const text = sendOutText(hp, maxHp);
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
yield* this.say(BATTLE[text]);
const mon = this.player.mon;
if (mon.hp && !(mon.status & (C.SLP_MASK | (1 << C.FRZ))))
this.playSound({ cry: mon.species, side: "player" });
}
/** AskUseNextPokemon: only wild battles ask. NO tries to run, measured
* with the first party Pokémon's Speed. Returns true if the player fled. */
@@ -6227,12 +6304,18 @@ export class Battle {
else if (c >= 100) score++;
return score & 0xff;
}
/** FaintYourPokemon: StopDangerSound, then the cry. */
private *faintYourPokemon(): Gen {
this.playSound({ stopDanger: true });
this.playSound({ cry: this.player.mon.species, side: "player" });
yield* this.say(BATTLE.BattleText_MonFainted);
}
private *handlePlayerMonFaint(): Gen {
yield* this.faintYourPokemon();
if (!this.enemy.mon.hp) yield* this.say(BATTLE.BattleText_EnemyMonFainted);
if (!this.enemy.mon.hp) {
this.playSound({ sfx: [SFX_KINESIS, SFX_FAINT] });
yield* this.say(BATTLE.BattleText_EnemyMonFainted);
}
this.updateFaintedPlayerMon();
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
if (!this.enemy.mon.hp) {