From 7f30c3f45084c642ba7722f361bea5cad76f6c9b Mon Sep 17 00:00:00 2001 From: kawa Date: Thu, 15 Oct 2026 15:54:02 +0200 Subject: [PATCH] Play the music and sound effects from the ROM's sound engine (Closes #34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 8 +- docs/ARCHITECTURE.md | 3 +- docs/NATIVE_PORT.md | 9 +- docs/PLAN.md | 17 +- scripts/check-reference.mjs | 12 +- src/game/audio/apu.ts | 374 ++++++++ src/game/audio/battle-music.ts | 105 +++ src/game/audio/constants.ts | 315 +++++++ src/game/audio/engine.ts | 1272 +++++++++++++++++++++++++++ src/game/audio/output.ts | 77 ++ src/game/audio/sound.ts | 202 +++++ src/game/audio/worklet.ts | 67 ++ src/game/battle/battle.ts | 97 +- src/game/rom/assets.ts | 1 + src/game/rom/profile.ts | 16 + src/game/runtime.ts | 24 + src/game/scripts/interpreter.ts | 193 +++- src/game/scripts/intro.ts | 14 +- src/game/scripts/pc.ts | 11 + src/game/scripts/pokedex.ts | 26 +- src/game/scripts/start-menu.ts | 10 + src/game/scripts/text.ts | 22 +- src/game/world/constants.ts | 1 + src/game/world/types.ts | 2 + src/game/world/world.ts | 271 +++++- src/main.ts | 19 +- src/ui/preferences.ts | 6 + src/ui/shell.ts | 2 +- tests/audio-browser.spec.ts | 89 ++ tests/audio-world.test.ts | 155 ++++ tests/audio.test.ts | 152 ++++ tests/native.test.ts | 7 +- tests/reference-audio-synth.spec.ts | 206 +++++ tests/reference-audio.spec.ts | 250 ++++++ 34 files changed, 3961 insertions(+), 74 deletions(-) create mode 100644 src/game/audio/apu.ts create mode 100644 src/game/audio/battle-music.ts create mode 100644 src/game/audio/constants.ts create mode 100644 src/game/audio/engine.ts create mode 100644 src/game/audio/output.ts create mode 100644 src/game/audio/sound.ts create mode 100644 src/game/audio/worklet.ts create mode 100644 tests/audio-browser.spec.ts create mode 100644 tests/audio-world.test.ts create mode 100644 tests/audio.test.ts create mode 100644 tests/reference-audio-synth.spec.ts create mode 100644 tests/reference-audio.spec.ts diff --git a/README.md b/README.md index 42675dc..99679bb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls. -Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Field moves work from the party menu too: Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent, and Softboiled or Milk Drink to share HP. Fly opens the Fly map to choose a town you have visited, Teleport returns to the town of the last Pokémon Center you entered, and Dig or an Escape Rope leads out of a cave through the entrance you last used. In PACK, SEL registers a key item such as the Bicycle, and Select then uses it. POKéGEAR shows the clock, the Town Map card with a cursor over every landmark, and the phone: call Mom or Professor Elm. Call Mom after the catching lesson and she offers to save some of your trainer prize money. Once the Mystery Egg is delivered, talk to her at home to save or take money, or to switch Daylight Saving Time; with enough saved, she buys items and dolls for you and calls to say so. POKéDEX lists Pokémon in Johto order up to the last one you have seen. Open a seen Pokémon to read its number, category and footprint; once caught, its height, weight and two pages of text appear. Your name in the start menu opens the trainer card: your ID, money, Pokédex count and play time, then the Johto leaders with the badges you have earned. OPTION sets the battle style (SET skips the offer to switch when a trainer sends out the next Pokémon) and the menu account (the start menu's descriptions); text speed, battle scene, sound, print and frame are kept for later. Bill's PC can move Pokémon between the party and any box. The poster opens the Town Map. +Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Field moves work from the party menu too: Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent, and Softboiled or Milk Drink to share HP. Fly opens the Fly map to choose a town you have visited, Teleport returns to the town of the last Pokémon Center you entered, and Dig or an Escape Rope leads out of a cave through the entrance you last used. In PACK, SEL registers a key item such as the Bicycle, and Select then uses it. POKéGEAR shows the clock, the Town Map card with a cursor over every landmark, and the phone: call Mom or Professor Elm. Call Mom after the catching lesson and she offers to save some of your trainer prize money. Once the Mystery Egg is delivered, talk to her at home to save or take money, or to switch Daylight Saving Time; with enough saved, she buys items and dolls for you and calls to say so. POKéDEX lists Pokémon in Johto order up to the last one you have seen. Open a seen Pokémon to read its number, category and footprint and hear its cry (CRY plays it again); once caught, its height, weight and two pages of text appear. Your name in the start menu opens the trainer card: your ID, money, Pokédex count and play time, then the Johto leaders with the badges you have earned. OPTION sets the battle style (SET skips the offer to switch when a trainer sends out the next Pokémon), the sound (mono or stereo) and the menu account (the start menu's descriptions); text speed, battle scene, print and frame are kept for later. Bill's PC can move Pokémon between the party and any box. The poster opens the Town Map. The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations. @@ -32,7 +32,9 @@ Routes 45 and 46 lead south from Blackthorn back to Route 29. In Ecruteak, the C On Route 40, the Battle Tower takes a party of three different Pokémon holding different items into a Battle Room of the level you choose, against seven trainers in a row. Win them all for five of a random vitamin. Between battles you can save and quit, and the receptionist picks up where you left off. -Kanto, the League, Olivine's port and the S.S. Aqua, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet. +The music and sound effects play from your ROM's own sound engine: map music, battles and their cries, fanfares, jingles and fades. Sound starts with your first press (browsers require it) and pauses with the game; Settings turns it down or off. Move animations and their sounds are not ported. + +Kanto, the League, Olivine's port and the S.S. Aqua, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet. ## Run @@ -45,7 +47,7 @@ npm run dev Open the printed local URL and select your uncompressed **English US/Europe revision 1.1** ROM (`.gbc`, 2 MiB). Its checksum is verified locally. ROMs are never uploaded or stored in browser storage; select yours again after reloading to resume. A new game starts with Professor Oak's introduction, where you choose your character, set the clock and pick a name. -Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. **Settings** changes these keys and the text size for this browser. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Sessions save every 10 seconds and when paused. Switching apps pauses play. Download a backup before clearing browser data. +Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. **Settings** changes these keys, the text size and the sound volume for this browser. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Sessions save every 10 seconds and when paused. Switching apps pauses play. Download a backup before clearing browser data. Native saves are separate from emulator snapshots and cartridge `.sav` files. Saving during a scene keeps the progress from before it, so the scene replays after loading. The time you give Oak is kept as an offset from the device clock (the steppers start at the device's time), and the weekday you give Mom as an offset from that clock's weekday. The tile counter measures in-game movement, not real-world steps. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a508075..0ac8952 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,6 +25,7 @@ flowchart LR | `src/game/scripts` | The game's event, movement and text formats and translated specials, no GB instructions | | `src/game/rendering` | Planar pixels, metatiles, connection strips, adaptive camera, sprites, emotes and minimap | | `src/game/party` | Party Pokémon generation, stat formulas, bag pockets and their validation | +| `src/game/audio` | The sound engine on its original WRAM, the home sound routines, and the Web Audio synthesizer | | `src/game/runtime.ts` | 30 Hz tick scheduling from real time, input routing and presentation updates | | `src/input` | Abstract buttons, multiple sources and browser bindings | | `src/app` | Runtime-independent load/play/pause/backup lifecycle | @@ -48,7 +49,7 @@ Asset caches and the ROM reader remain in memory for lazy extraction. Neither RO The native viewport follows its available CSS dimensions and draws additional world tiles at a bounded pixel resolution. Beyond a map's edge it draws the connected map with the current tileset, as the original connection strips do, and the border block elsewhere. Resizing also redraws paused sessions. The local minimap caches terrain from the loaded map and updates its marker from the same world position used by the player; the Map tab shares that view. Below it, the Map tab draws the region atlas (`world/atlas.ts`): the ROM's Town Map with the player's landmark, the visited flypoints and the areas open in this port. Leaving Game pauses the session and releases held input; returning requires explicit play. The reference player retains its own fixed-screen styling. -`game/battle` now holds the native battle core and ROM-backed battle tables. Browser tests cover the overworld-to-battle flow using a test-only encounter fixture; Route 29 uses ROM encounter tables. Trainer battles share that core, with ROM trainer decoders, the trainer's send-out and switch AI, and explicit AI support checks. Map trainers (sight and talk) are started by `game/world` and run the ROM's own trainer scripts. Battles receive the script's draft party, bag and Pokédex, so a catch, a thrown ball or a nickname commits only with the script that started the battle. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state. +`game/battle` now holds the native battle core and ROM-backed battle tables. Browser tests cover the overworld-to-battle flow using a test-only encounter fixture; Route 29 uses ROM encounter tables. Trainer battles share that core, with ROM trainer decoders, the trainer's send-out and switch AI, and explicit AI support checks. Map trainers (sight and talk) are started by `game/world` and run the ROM's own trainer scripts. Battles receive the script's draft party, bag and Pokédex, so a catch, a thrown ball or a nickname commits only with the script that started the battle. The world runs the sound engine twice per tick, as VBlank does; the runtime sends each frame's sound register writes to an AudioWorklet that synthesizes them (`game/audio/worklet.ts`), unlocked by the first user gesture and suspended while paused. Sound never changes game state; `waitsfx` and cries hold the script, frozen, while sound effects play, as the original's busy loops do. Introduce future modules with working features: `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state. The overworld extension keeps player travel mode, backup-warp data and Flash diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index a8292ce..ba4048b 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -30,14 +30,17 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta | `game/world/world.ts` | `engine/overworld/events.asm`, `player_movement.asm`, `warp_connection.asm`, `data/collision/collision_permissions.asm` | 30 Hz overworld ticks, 8-tick walks, 16-tick ledge jumps, directional mats, animated door/stair exits, connection coordinates, events, scenes and callbacks. NPC random walks and spins follow `HandleNPCStep`. Forced movement, Surf, Cut, Strength, cycling rules, pits and elevators implemented. Fourteen reference traces compare turn timing, actors and event flags; map-entry boundaries verify the completed destination. The Bicycle is used from the pack or with Select. Field moves chosen from the party menu are checked here (`menuFieldMove`). | | Event interpreter | `macros/scripts/events.asm`, `engine/overworld/scripting.asm`, `engine/events/std_scripts.asm` | Resumable; yields text, choices, movement, emotes and pauses. Scenes, phone contacts, string buffers and object commands. Unknown commands and specials abort without committing. | | Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` takes one tile toward the leader's previous square. All movement opcodes decode, including teleport, fishing, skyfall, shake and flicker poses; Controlled reference scenes check spin, teleport, skyfall, fishing and flicker direction, visibility and lift. | -| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), room decoration toggles, `PokemonCenterPC` and `PlayersHousePC` (Bill's, the player's and Oak's PCs), the Town Map, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. | +| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic`, room decoration toggles, `PokemonCenterPC` and `PlayersHousePC` (Bill's, the player's and Oak's PCs), the Town Map, `HealParty`, `HealMachineAnim` (its sounds and waits; the machine's sprites are not drawn), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. | | `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes, MOVE PKMN W/O MAIL, stats), item storage, the MAIL BOX (`MailboxPC`) and Oak's rating. Menu strings and texts come from the ROM; lists are native. Printing is not ported. | | `game/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu (checked by the world), Softboiled and Milk Drink, SEL and the Select button (`SelectMenu`), MAIL (`MonMailAction`, `ComposeMailMessage`) and `SwitchItemsInBag`. `party/mail.ts` holds the `mailmsg` struct. | | `game/scripts/pokegear.ts` | `engine/pokegear/pokegear.asm`, `engine/phone/phone.asm`, `engine/phone/scripts/mom.asm`, `elm.asm`, `engine/rtc/print_hours_mins.asm` | `PokeGear` with each card's joypad handling: the clock (`Pokegear_UpdateClock`, `PrintHoursMins`), the map card's landmark cursor (`TownMap_InitCursorAndPlayerIconPositions`, `TownMap_GetKantoLandmarkLimits`), the phone list (`PokegearPhone_UpdateDisplayList`, `GetCallerClassAndName`, `PokegearPhoneContactSubmenu`, `CheckCanDeletePhoneNumber`) and calls (`MakePhoneCallFromPokegear`, `CheckPhoneContactTimeOfDay`, `GetMapPhoneService`, `HangUp`). Callee scripts run in the event interpreter on the start menu's draft. Screens are native; the radio's stations are not ported. | | `game/scripts/trainer-card.ts` | `engine/menus/trainer_card.asm`, `engine/gfx/player_gfx.asm` (`GetCardPic`), `engine/gfx/cgb_layouts.asm` (`_CGB_TrainerCard`), `home/game_time.asm` | `TrainerCard`'s two reachable pages with their joypad rules, strings from `.Name_Money`, `.Dex_PlayTime` and `.Badges`, the caught count, and the badges from `TrainerCard_JohtoBadgesOAM` with their eight-frame steps. `rendering/trainer-card.ts` draws the card picture, leader faces and badges from the ROM with the card's palettes and attribute map. `world/game-time.ts` is `GameTimer` (capped at 999:59:59). Text is native; the blinking colon's and badges' phase start at the page load. | -| `game/scripts/pokedex.ts`, `game/rom/pokedex.ts` | `engine/pokedex/pokedex.asm`, `pokedex_2.asm` (`DisplayDexEntry`, `GetDexEntryPointer`), `pokedex_3.asm` (`LoadQuestionMarkPic`), `data/pokemon/dex_order_new.asm`, `dex_entry_pointers.asm`, `dex_entries/`, `gfx/footprints.asm`, `engine/gfx/cgb_layouts.asm` (`_CGB_Pokedex`) | New mode's main screen and entry screen with their joypad rules: `Pokedex_ListingHandleDPadInput`, `Pokedex_InitCursorPosition`, `Pokedex_NextOrPreviousDexEntry`, `Pokedex_MoveArrowCursor` and `Pokedex_Page`. Strings come from the ROM; screens are native. Tests compare the order, all 251 entries and every footprint with the PRET source. AREA, CRY, PRNT, the option and search screens, Old and A to Z modes and Unown mode are not ported. `ui/pokedex.ts` draws the screen and the Pokédex tab. | +| `game/scripts/pokedex.ts`, `game/rom/pokedex.ts` | `engine/pokedex/pokedex.asm`, `pokedex_2.asm` (`DisplayDexEntry`, `GetDexEntryPointer`), `pokedex_3.asm` (`LoadQuestionMarkPic`), `data/pokemon/dex_order_new.asm`, `dex_entry_pointers.asm`, `dex_entries/`, `gfx/footprints.asm`, `engine/gfx/cgb_layouts.asm` (`_CGB_Pokedex`) | New mode's main screen and entry screen with their joypad rules: `Pokedex_ListingHandleDPadInput`, `Pokedex_InitCursorPosition`, `Pokedex_NextOrPreviousDexEntry`, `Pokedex_MoveArrowCursor` and `Pokedex_Page`. Strings come from the ROM; screens are native. Tests compare the order, all 251 entries and every footprint with the PRET source. CRY and the entries' cries play; AREA, PRNT, the option and search screens, Old and A to Z modes and Unown mode are not ported. `ui/pokedex.ts` draws the screen and the Pokédex tab. | | `game/world/atlas.ts` | `data/maps/landmarks.asm`, `data/maps/flypoints.asm`, `engine/pokegear/pokegear.asm` (`_FlyMap`, `CheckIfVisitedFlypoint`), `home/map.asm` (`GetWorldMapLocation`) | The Map tab's atlas: each region's landmarks with their Town Map positions and ROM names, the player's landmark, the visited flypoints and the landmarks with ported maps. Kanto follows the Fly map's Indigo Plateau rule. `rendering/town-map.ts` draws it. Tests compare positions and flypoints with the PRET source. | -| `game/scripts/options.ts` | `engine/menus/options_menu.asm`, `data/default_options.asm`, `engine/battle/core.asm` (`CheckWhetherToAskSwitch`), `engine/menus/start_menu.asm` | `_Option` with `OptionsControl` and each option's routine on the four option bytes, `DefaultOptions`, and the two settings consumed so far: `BATTLE_SHIFT` and `MENU_ACCOUNT`. Strings come from the ROM; the screen is native. | +| `game/scripts/options.ts` | `engine/menus/options_menu.asm`, `data/default_options.asm`, `engine/battle/core.asm` (`CheckWhetherToAskSwitch`), `engine/menus/start_menu.asm` | `_Option` with `OptionsControl` and each option's routine on the four option bytes, `DefaultOptions`, and the three settings consumed so far: `BATTLE_SHIFT`, `MENU_ACCOUNT` and `STEREO` (the sound engine's panning). Strings come from the ROM; the screen is native. | +| `game/audio/engine.ts` | `audio/engine.asm`, `home/audio.asm`, `audio/notes.asm`, `wave_samples.asm`, `drumkits.asm`, `music_pointers.asm`, `sfx_pointers.asm`, `cry_pointers.asm`, `data/pokemon/cries.asm` | The sound engine routine by routine, on a byte-for-byte copy of the audio WRAM ($c100-$c2c1) and a sound register model with read masks. `tests/reference-audio.spec.ts` compares the WRAM and readable registers after every `UpdateSound` with the cartridge's own routines: all 102 songs for 1200 frames, all 207 sound effects through `PlaySFX` and `PlayStereoSFX`, all 251 cries over music, fades, the danger tone and SFX priority. Mutation checks fail it. One branch is unreached by the data: a vibrato step that would carry exactly past $ff. | +| `game/audio/apu.ts`, `worklet.ts`, `output.ts` | Game Boy Color sound hardware (pandocs) | Pulse, sweep, wave, noise, envelopes, length counters and NR50/NR51, averaged per output sample, in an AudioWorklet fed one video frame of register writes per 70224 cycles. `tests/reference-audio-synth.spec.ts` compares the output with binjgb's APU by spectrum (median similarity 0.89-0.998 over five songs, a sound effect and two cries). Absolute loudness and the console's analog filtering are not matched. Unlocked by Play or the first press; suspended while paused. | +| `game/audio/sound.ts`, `battle-music.ts` | `home/audio.asm`, `engine/overworld/map_setup.asm`, `data/maps/setup_scripts.asm`, `home/map.asm` (`GetMapMusic`), `engine/battle/start_battle.asm` (`PlayBattleMusic`), `engine/battle/core.asm` (`PlayVictoryMusic`, `CheckDanger`, cries, fainting), `engine/pokemon/health.asm` (`ComputeHPBarPixels`), `data/trainers/leaders.asm`, `engine/overworld/landmarks.asm` (`RegionCheck`) | Map music by map setup, special map music, script and text sounds, battle music and sounds, the evolution's, the Pokédex's, the PCs', the marts' and healing items'. `tests/audio-world.test.ts` checks a wild battle's music, cries, victory and return to the map's music, the low HP tone, the Pokémon Center's jingle and waits, and a door's sound and fade. | | `game/scripts/fly-map.ts` | `engine/pokegear/pokegear.asm` (`_FlyMap`, `FlyMap`, `TownMapBubble`, `CheckIfVisitedFlypoint`), `data/maps/flypoints.asm`, `engine/events/field_moves.asm` (`FlyFromAnim`, `FlyToAnim`), `engine/sprite_anims/functions.asm` | The Fly map's region choice (Kanto only after Indigo Plateau, without the player's icon otherwise), the default flypoint and up/down scrolling over visited flypoints (`wVisitedSpawns` through the ROM's `EngineFlags`). The flights follow `SpriteAnimFunc_FlyFrom` and `SpriteAnimFunc_FlyTo` frame by frame with the party icon. The Town Map, bubble and icons are drawn natively; the leaves are not presented. | | `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. | | `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, evolution lists, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes; all 251 evolution lists match the pinned source. | diff --git a/docs/PLAN.md b/docs/PLAN.md index 25f101f..e2f1c12 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -24,6 +24,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati | Tin Tower, Suicune, Ho-Oh, Whirl Islands and Lugia | Done | | The Battle Tower | Done | | Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open | +| Music and sound effects (Web Audio) | Done (no move animation sounds) | | 1. Complete the overworld foundation | Done | | 2. Opening story and first battle slice | Mostly done | | 3. Complete Crystal | In progress (Johto done) | @@ -458,11 +459,23 @@ The numbered sections below keep their full scope. Each one opens with a checkli - The native game saves itself, so a reload during a challenge replays the room's script from its start instead of reaching the original's "left without saving" state, and `Reset` (save and quit) returns to where the quick save was made. The original also rolls back money gained by Pay Day there; the port keeps it. - Tests: `tests/battle-tower.test.ts` (the gate, every rule's text, the level and uber checks, the L:40 menu, a full streak and its reward, save and quit and the resume, a saved-and-left state after a reload, cancelling and losing) and `tests/battle-tower-engine.test.ts` (no experience, prize or items; obedience; a loss without a whiteout). +## Implemented: music and sound (board #34) + +- `game/audio/engine.ts` ports the whole sound engine (`audio/engine.asm` and `home/audio.asm`): `_UpdateSound` and its channel updates, `ParseMusic` and every music command, notes, rests, drums and noise samples, cries and their pitch and length, sound effects and `PlaySFX`'s priority, `PlayStereoSFX`, vibrato, pitch slides, duty patterns, `FadeMusic` (with the Bicycle's fade-in) and the low HP tone. Its state is the original's audio WRAM byte for byte, and it drives a model of the sound registers, including their read masks (`.ch1_vibrato_override` reads NR11 back). +- `tests/reference-audio.spec.ts` runs the cartridge's own routines in the reference emulator next to the port and compares all of the audio WRAM and the readable sound registers after every `UpdateSound`: every song for 20 seconds, every sound effect through `PlaySFX` and `PlayStereoSFX`, every Pokémon's cry over music and from a battle side, fades in and out, the danger tone and sound effects over music with priority. All match. Deliberate mutations of the port (vibrato rounding, pitch slide steps, an envelope bit) make it fail. +- `game/audio/apu.ts` synthesizes the four channels from those register writes: both pulse channels (the first with its sweep), the wave channel, the noise channel's LFSR, envelopes and length counters on the 512 Hz frame sequencer, NR50/NR51 mixing and the output capacitor. Each sample averages the channels over its CPU cycles, against aliasing. `tests/reference-audio-synth.spec.ts` records the reference emulator's own APU output for five songs, a sound effect and two cries and compares it with the port's, window by window, by spectrum: the median similarity is 0.89 to 0.998. +- `game/audio/worklet.ts` runs the synthesizer in an AudioWorklet. The world runs the engine twice per tick, as VBlank does, and sends each frame's writes; the worklet plays one frame per 70224 cycles with a few frames of queue. Browsers start audio only from a user gesture: Play and the first button press unlock it. Pausing (another tab, the app in the background) suspends it; playing again restores the registers. Settings has Off, Quiet, Medium and Full, kept for this browser. OPTION's SOUND sets mono or stereo (`Music_StereoPanning`, the cries' battle side). +- `game/audio/sound.ts` holds the home routines the game calls: `PlayMapMusic`, `PlayMapMusicBike`, `FadeToMapMusic`, `RestartMapMusic`, `TryRestartMapMusic`, `ForceMapMusic`, `PlayTrainerEncounterMusic`, `PlayBattleMusic`, `PlayMonCry2`, `PlayStereoCry`. The map setup scripts' music follows the entry: warps fade in with `PlayMapMusicBike`, doors skip six frames and fade to the new map's song, connections fade, Continue plays at once. `GetMapMusic_MaybeSpecial`: Surf's music, the contest gates' ranking music, the Mahogany shop and the Radio Tower while the Rockets hold them. +- Scripts: `playmusic`, `musicfadeout`, `playmapmusic`, `dontrestartmapmusic`, `cry` and `waitsfx` (which wait, frozen, as the original's busy loops do), `playsound`, `warpsound`, `specialsound` and `encountermusic`; the specials `RestartMapMusic`, `FadeOutMusic`, `PlayMapMusic`, `WaitSFX`, `PlaySlowCry`, `PlayCurMonCry` and `HealMachineAnim` (a sound per Poké Ball, MUSIC_HEAL, and its 30-frame and 80-frame waits); the text commands' fanfares (`TextSFX`). Text is shown a page at a time, so a sound started after text plays when its page appears. +- Battles: `PlayBattleMusic`'s choice (wild by time and region, Rockets, gym leaders, rivals, the Champion, the roaming beasts), the wild Pokémon's and each sent-out Pokémon's cry from its side, fainting (`SFX_KINESIS`, `SFX_FAINT`, the player's cry), `PlayVictoryMusic`, the capture music, `SFX_RUN`, the low HP tone (`CheckDanger` with `ComputeHPBarPixels`) and the map's music again after `reloadmapafterbattle`. +- Elsewhere: the intro's music and Wooper's cry, the talk and menu clicks (`PlayTalkObject`, `PromptButton`, `MenuClickSound`), `SFX_MENU`, bumping into walls, ledges, warps, Strength's boulders, the Bicycle's and Surf's music, the evolution music, cries and jingles, the Pokédex's cries and CRY, the PCs' boot, choice and shutdown sounds, the marts' transactions and the healing items' sounds. +- Not presented yet: move animations' sounds (there are no move animations), the experience bar, Poké Ball throws and wobbles, the Game Corner's and Unown puzzle's sounds, the battle transition, the radio and phone ringing (board #30), and the main menu and title music (there is no title screen). Physical phones are not tested (manual card). + ## Native battles: current state The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains. -This is still not a complete new game. The Pokégear's radio, the Pokédex's area view, search and other modes, the League, Kanto and the postgame, and audio remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; Kanto's maps remain gated. Save slots, online Link, friends and real-world steps come later. +This is still not a complete new game. The Pokégear's radio, the Pokédex's area view, search and other modes, the League, Kanto and the postgame remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; Kanto's maps remain gated. Save slots, online Link, friends and real-world steps come later. ## 1. Complete the overworld foundation @@ -555,7 +568,7 @@ Acceptance: finish the opening quest, including encounters and the rival battle, - [ ] Field moves, gyms, Rocket events, the phone and Pokégear, puzzles, minigames, daily events, roaming Pokémon and the postgame - [x] Johto beyond Route 30: every city, gym, Rocket event, puzzle and minigame to the Rising Badge, Tin Tower, Whirl Islands and the Battle Tower - [ ] The League, Kanto and the final battle -- [ ] Music and sound through Web Audio +- [x] Music and sound through Web Audio (board #34; move animations and minigame sounds remain) - [ ] Acceptance: main-game and postgame playthroughs, battle regression vectors, long sessions and save recovery Port remaining battle commands, move/item effects, AI, special battles, evolutions, breeding, happiness, eggs and PC storage. Gen II behavior is the baseline; list intentional changes separately. diff --git a/scripts/check-reference.mjs b/scripts/check-reference.mjs index 9bf88ce..02a56c9 100644 --- a/scripts/check-reference.mjs +++ b/scripts/check-reference.mjs @@ -226,8 +226,8 @@ for (const table of ["SAVED_WRAM", "WRAM"]) { throw new Error(`${table} RAM mismatch: ${label}`); } } -// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX, OPTIONS and JOHTO entries are keyed by their PRET labels. -for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS", "JOHTO"]) { +// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX, OPTIONS, JOHTO and AUDIO entries are keyed by their PRET labels. +for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS", "JOHTO", "AUDIO"]) { const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1]; if (block === undefined) throw new Error(`${table} symbol table is missing.`); for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) { @@ -359,7 +359,7 @@ const pretConstants = new Map(); } } } -const battleConstants = ["src/game/battle/constants.ts", "src/game/world/constants.ts"] +const battleConstants = ["src/game/battle/constants.ts", "src/game/world/constants.ts", "src/game/audio/constants.ts", "src/game/audio/battle-music.ts"] .map((file) => readFileSync(file, "utf8")) .join("\n"); for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?)\} as const;/g)) { @@ -377,6 +377,12 @@ if (!profile.includes(`allFruitTrees: ${pretConstants.get("ENGINE_ALL_FRUIT_TREE throw new Error("ENGINE_ALL_FRUIT_TREES does not match the reference."); if (!profile.includes(`bugContestTimer: ${pretConstants.get("ENGINE_BUG_CONTEST_TIMER")},`)) throw new Error("ENGINE_BUG_CONTEST_TIMER does not match the reference."); +for (const [key, name] of [ + ["rocketsInRadioTower", "ENGINE_ROCKETS_IN_RADIO_TOWER"], + ["rocketsInMahogany", "ENGINE_ROCKETS_IN_MAHOGANY"], +]) + if (!profile.includes(`${key}: ${pretConstants.get(name)},`)) + throw new Error(`${name} does not match the reference.`); if (!profile.includes(`NUM_SPRITEMOVEDATA = ${pretConstants.get("NUM_SPRITEMOVEDATA")};`)) throw new Error("NUM_SPRITEMOVEDATA does not match the reference."); if (!profile.includes(`NUM_ENGINE_FLAGS = ${pretConstants.get("NUM_ENGINE_FLAGS")};`)) diff --git a/src/game/audio/apu.ts b/src/game/audio/apu.ts new file mode 100644 index 0000000..aa3a90b --- /dev/null +++ b/src/game/audio/apu.ts @@ -0,0 +1,374 @@ +/** The Game Boy Color's four sound channels, as the sound engine's register + * writes drive them: two pulse channels (the first with a frequency + * sweep), the wave channel and the noise channel, their envelopes and + * length counters on the 512 Hz frame sequencer, and the NR50/NR51 mixer. + * Each output sample averages the channels over its span of CPU cycles, + * which keeps high notes and noise from aliasing. No ROM data or CPU. */ + +export const CPU_HZ = 4194304; +/** Cycles per video frame: the engine updates once per frame. */ +export const FRAME_CYCLES = 70224; + +const DUTY = [ + [0, 0, 0, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1, 0], +]; +const NOISE_DIVISORS = [8, 16, 32, 48, 64, 80, 96, 112]; + +class Envelope { + initial = 0; + increase = false; + period = 0; + volume = 0; + timer = 0; + write(value: number) { + this.initial = value >> 4; + this.increase = (value & 8) !== 0; + this.period = value & 7; + } + get dac() { + return this.initial !== 0 || this.increase; + } + trigger() { + this.volume = this.initial; + this.timer = this.period || 8; + } + clock() { + if (!this.period) return; + if (--this.timer > 0) return; + this.timer = this.period; + if (this.increase && this.volume < 15) this.volume++; + else if (!this.increase && this.volume > 0) this.volume--; + } +} + +abstract class Channel { + enabled = false; + length = 0; + lengthEnabled = false; + timer = 0; + abstract get dac(): boolean; + abstract get maxLength(): number; + abstract period(): number; + /** The channel's digital output, 0-15. */ + abstract level(): number; + abstract step(): void; + clockLength() { + if (this.lengthEnabled && this.length > 0 && --this.length === 0) this.enabled = false; + } + trigger() { + this.enabled = this.dac; + if (!this.length) this.length = this.maxLength; + this.timer = this.period(); + } + /** Average analog output (-1..1) over the next `cycles`. */ + render(cycles: number) { + if (!this.dac) return 0; + if (!this.enabled) return -1; + let sum = 0; + let left = cycles; + while (left > 0) { + const span = Math.min(left, this.timer); + sum += span * this.level(); + left -= span; + this.timer -= span; + if (this.timer <= 0) { + this.timer += this.period(); + this.step(); + } + } + return sum / cycles / 7.5 - 1; + } +} + +class Pulse extends Channel { + readonly envelope = new Envelope(); + duty = 0; + position = 0; + frequency = 0; + // The first channel's sweep. + sweepPeriod = 0; + sweepNegate = false; + sweepShift = 0; + sweepTimer = 0; + sweepEnabled = false; + shadow = 0; + get dac() { + return this.envelope.dac; + } + get maxLength() { + return 64; + } + period() { + return (2048 - this.frequency) * 4; + } + level() { + return DUTY[this.duty][this.position] ? this.envelope.volume : 0; + } + step() { + this.position = (this.position + 1) & 7; + } + override trigger() { + super.trigger(); + this.envelope.trigger(); + this.shadow = this.frequency; + this.sweepTimer = this.sweepPeriod || 8; + this.sweepEnabled = this.sweepPeriod !== 0 || this.sweepShift !== 0; + if (this.sweepShift) this.sweepCalculate(); + } + private sweepCalculate() { + const delta = this.shadow >> this.sweepShift; + const next = this.sweepNegate ? this.shadow - delta : this.shadow + delta; + if (next > 2047) this.enabled = false; + return next; + } + clockSweep() { + if (--this.sweepTimer > 0) return; + this.sweepTimer = this.sweepPeriod || 8; + if (!this.sweepEnabled || !this.sweepPeriod) return; + const next = this.sweepCalculate(); + if (next <= 2047 && this.sweepShift) { + this.shadow = next; + this.frequency = next; + this.sweepCalculate(); + } + } +} + +class Wave extends Channel { + on = false; + shift = 4; + frequency = 0; + position = 0; + readonly ram = new Uint8Array(16); + get dac() { + return this.on; + } + get maxLength() { + return 256; + } + period() { + return (2048 - this.frequency) * 2; + } + level() { + const byte = this.ram[this.position >> 1]; + const sample = this.position & 1 ? byte & 0xf : byte >> 4; + return sample >> this.shift; + } + step() { + this.position = (this.position + 1) & 31; + } + override trigger() { + super.trigger(); + this.position = 0; + } +} + +class Noise extends Channel { + readonly envelope = new Envelope(); + divisor = 0; + shift = 0; + narrow = false; + lfsr = 0x7fff; + get dac() { + return this.envelope.dac; + } + get maxLength() { + return 64; + } + period() { + return NOISE_DIVISORS[this.divisor] << this.shift; + } + level() { + return this.lfsr & 1 ? 0 : this.envelope.volume; + } + step() { + const feedback = (this.lfsr ^ (this.lfsr >> 1)) & 1; + this.lfsr = (this.lfsr >> 1) | (feedback << 14); + if (this.narrow) this.lfsr = (this.lfsr & ~0x40) | (feedback << 6); + } + override trigger() { + super.trigger(); + this.envelope.trigger(); + this.lfsr = 0x7fff; + } +} + +export class Apu { + private readonly pulse1 = new Pulse(); + private readonly pulse2 = new Pulse(); + private readonly wave = new Wave(); + private readonly noise = new Noise(); + private readonly channels: Channel[] = [this.pulse1, this.pulse2, this.wave, this.noise]; + private nr50 = 0; + private nr51 = 0; + private sequencer = 0; + private sequencerStep = 0; + /** Fractional cycles carried between samples. */ + private carry = 0; + /** High-pass filter state per side (the console's output capacitor). */ + private capacitor = [0, 0]; + private readonly charge: number; + readonly cyclesPerSample: number; + /** Master gain, 0-1. */ + gain = 1; + + constructor(readonly sampleRate: number) { + this.cyclesPerSample = CPU_HZ / sampleRate; + this.charge = Math.pow(0.999958, this.cyclesPerSample); + } + + /** A write to $ff10 + register. */ + write(register: number, value: number) { + const [p1, p2, wave, noise] = [this.pulse1, this.pulse2, this.wave, this.noise]; + switch (register) { + case 0x00: + p1.sweepPeriod = (value >> 4) & 7; + p1.sweepNegate = (value & 8) !== 0; + p1.sweepShift = value & 7; + break; + case 0x01: + case 0x06: { + const pulse = register === 0x01 ? p1 : p2; + pulse.duty = value >> 6; + pulse.length = 64 - (value & 0x3f); + break; + } + case 0x02: + case 0x07: { + const pulse = register === 0x02 ? p1 : p2; + pulse.envelope.write(value); + if (!pulse.dac) pulse.enabled = false; + break; + } + case 0x03: + case 0x08: { + const pulse = register === 0x03 ? p1 : p2; + pulse.frequency = (pulse.frequency & 0x700) | value; + break; + } + case 0x04: + case 0x09: { + const pulse = register === 0x04 ? p1 : p2; + pulse.frequency = (pulse.frequency & 0xff) | ((value & 7) << 8); + pulse.lengthEnabled = (value & 0x40) !== 0; + if (value & 0x80) pulse.trigger(); + break; + } + case 0x0a: + wave.on = (value & 0x80) !== 0; + if (!wave.on) wave.enabled = false; + break; + case 0x0b: + wave.length = 256 - value; + break; + case 0x0c: + wave.shift = [4, 0, 1, 2][(value >> 5) & 3]; + break; + case 0x0d: + wave.frequency = (wave.frequency & 0x700) | value; + break; + case 0x0e: + wave.frequency = (wave.frequency & 0xff) | ((value & 7) << 8); + wave.lengthEnabled = (value & 0x40) !== 0; + if (value & 0x80) wave.trigger(); + break; + case 0x10: + noise.length = 64 - (value & 0x3f); + break; + case 0x11: + noise.envelope.write(value); + if (!noise.dac) noise.enabled = false; + break; + case 0x12: + noise.shift = value >> 4; + noise.narrow = (value & 8) !== 0; + noise.divisor = value & 7; + break; + case 0x13: + noise.lengthEnabled = (value & 0x40) !== 0; + if (value & 0x80) noise.trigger(); + break; + case 0x14: + this.nr50 = value; + break; + case 0x15: + this.nr51 = value; + break; + case 0x16: + if (!(value & 0x80)) this.reset(); + break; + default: + if (register >= 0x20 && register < 0x30) wave.ram[register - 0x20] = value; + } + } + + /** Power off: every channel and register cleared. */ + reset() { + for (const channel of this.channels) { + channel.enabled = false; + channel.length = 0; + channel.lengthEnabled = false; + } + this.pulse1.envelope.write(0); + this.pulse2.envelope.write(0); + this.noise.envelope.write(0); + this.wave.on = false; + this.nr50 = 0; + this.nr51 = 0; + } + + /** Whether a channel (0-3) is sounding, as NR52's low bits report. */ + active(channel: number) { + return this.channels[channel].enabled; + } + + private clockSequencer() { + const step = this.sequencerStep; + this.sequencerStep = (step + 1) & 7; + if (!(step & 1)) for (const channel of this.channels) channel.clockLength(); + if (step === 2 || step === 6) this.pulse1.clockSweep(); + if (step === 7) { + this.pulse1.envelope.clock(); + this.pulse2.envelope.clock(); + this.noise.envelope.clock(); + } + } + + /** Fill `count` samples of both sides from `offset`. */ + render(left: Float32Array, right: Float32Array, offset: number, count: number) { + const leftVolume = (((this.nr50 >> 4) & 7) + 1) / 8; + const rightVolume = ((this.nr50 & 7) + 1) / 8; + for (let i = offset; i < offset + count; i++) { + this.carry += this.cyclesPerSample; + const cycles = Math.floor(this.carry); + this.carry -= cycles; + if (cycles <= 0) { + left[i] = right[i] = 0; + continue; + } + this.sequencer += cycles; + while (this.sequencer >= 8192) { + this.sequencer -= 8192; + this.clockSequencer(); + } + let l = 0, + r = 0; + for (let n = 0; n < 4; n++) { + const output = this.channels[n].render(cycles); + if (this.nr51 & (0x10 << n)) l += output; + if (this.nr51 & (1 << n)) r += output; + } + left[i] = this.filter(0, (l / 4) * leftVolume) * this.gain; + right[i] = this.filter(1, (r / 4) * rightVolume) * this.gain; + } + } + + private filter(side: number, input: number) { + const output = input - this.capacitor[side]; + this.capacitor[side] = input - output * this.charge; + return output; + } +} diff --git a/src/game/audio/battle-music.ts b/src/game/audio/battle-music.ts new file mode 100644 index 0000000..c1fab0e --- /dev/null +++ b/src/game/audio/battle-music.ts @@ -0,0 +1,105 @@ +import { SOUND } from "./constants"; + +/** Trainer classes and ids PlayBattleMusic and PlayVictoryMusic name + * (constants/trainer_constants.asm), verified by `npm run check:reference`. */ +export const MUSIC_TRAINERS = { + FALKNER: 1, + WHITNEY: 2, + BUGSY: 3, + MORTY: 4, + PRYCE: 5, + JASMINE: 6, + CHUCK: 7, + CLAIR: 8, + RIVAL1: 9, + WILL: 11, + BRUNO: 13, + KAREN: 14, + KOGA: 15, + CHAMPION: 16, + BROCK: 17, + MISTY: 18, + LT_SURGE: 19, + ERIKA: 21, + JANINE: 26, + GRUNTM: 31, + SABRINA: 35, + RIVAL2: 42, + BLAINE: 46, + RED: 63, + BLUE: 64, + GRUNTF: 66, + RIVAL2_2_CHIKORITA: 4, + BATTLETYPE_ROAMING: 5, + BATTLETYPE_SUICUNE: 12, + KANTO_LANDMARK: 47, + LANDMARK_VICTORY_ROAD: 88, + LANDMARK_FAST_SHIP: 95, +} as const; +const T = MUSIC_TRAINERS; + +/** data/trainers/leaders.asm: KantoGymLeaders, and IsGymLeader's list, + * which runs on into it up to the one -1. */ +const KANTO_GYM_LEADERS: readonly number[] = [ + T.BROCK, T.MISTY, T.LT_SURGE, T.ERIKA, T.JANINE, T.SABRINA, T.BLAINE, T.BLUE, +]; +const GYM_LEADERS: readonly number[] = [ + T.FALKNER, T.WHITNEY, T.BUGSY, T.MORTY, T.PRYCE, T.JASMINE, T.CHUCK, T.CLAIR, + T.WILL, T.BRUNO, T.KAREN, T.KOGA, T.CHAMPION, T.RED, ...KANTO_GYM_LEADERS, +]; + +/** RegionCheck for a landmark: Kanto's, except the S.S. Aqua and the + * Victory Road area. */ +export const inKanto = (landmark: number) => + landmark !== T.LANDMARK_FAST_SHIP && + landmark >= T.KANTO_LANDMARK && + landmark < T.LANDMARK_VICTORY_ROAD; + +/** PlayBattleMusic's song. */ +export function battleMusic(battle: { + battleType?: number; + trainerClass?: number; + trainerId?: number; + night: boolean; + kanto: boolean; +}) { + if (battle.battleType === T.BATTLETYPE_SUICUNE || battle.battleType === T.BATTLETYPE_ROAMING) + return SOUND.MUSIC_SUICUNE_BATTLE; + const trainerClass = battle.trainerClass ?? 0; + if (!trainerClass) { + if (battle.kanto) return SOUND.MUSIC_KANTO_WILD_BATTLE; + return battle.night ? SOUND.MUSIC_JOHTO_WILD_BATTLE_NIGHT : SOUND.MUSIC_JOHTO_WILD_BATTLE; + } + if (trainerClass === T.CHAMPION || trainerClass === T.RED) return SOUND.MUSIC_CHAMPION_BATTLE; + // Executives and Scientists keep the trainer music, as in the original. + if (trainerClass === T.GRUNTM || trainerClass === T.GRUNTF) return SOUND.MUSIC_ROCKET_BATTLE; + if (KANTO_GYM_LEADERS.includes(trainerClass)) return SOUND.MUSIC_KANTO_GYM_LEADER_BATTLE; + if (GYM_LEADERS.includes(trainerClass)) return SOUND.MUSIC_JOHTO_GYM_LEADER_BATTLE; + if (trainerClass === T.RIVAL1) return SOUND.MUSIC_RIVAL_BATTLE; + if (trainerClass === T.RIVAL2) + return (battle.trainerId ?? 0) < T.RIVAL2_2_CHIKORITA + ? SOUND.MUSIC_RIVAL_BATTLE + : SOUND.MUSIC_CHAMPION_BATTLE; + return battle.kanto ? SOUND.MUSIC_KANTO_TRAINER_BATTLE : SOUND.MUSIC_JOHTO_TRAINER_BATTLE; +} + +/** PlayVictoryMusic's song: a gym leader's, a trainer's or the wild one. */ +export function victoryMusic(trainerClass: number | undefined) { + if (!trainerClass) return SOUND.MUSIC_WILD_VICTORY; + return GYM_LEADERS.includes(trainerClass) ? SOUND.MUSIC_GYM_VICTORY : SOUND.MUSIC_TRAINER_VICTORY; +} + +/** ComputeHPBarPixels: the HP bar's length out of 48 pixels, at least 1 + * while any HP is left. Bars under 10 pixels are red (GetHPPal). */ +export function hpBarPixels(hp: number, maxHp: number) { + if (!hp) return 0; + let dividend = hp * 48, + divisor = maxHp; + if (maxHp > 0xff) { + // hDivisor is one byte: both sides are divided by 4 first. + divisor = (maxHp >> 2) & 0xff; + dividend = (dividend & 0xffff) >> 2; + } + const quotient = divisor ? Math.floor(dividend / divisor) & 0xff : 0; + return quotient || 1; +} diff --git a/src/game/audio/constants.ts b/src/game/audio/constants.ts new file mode 100644 index 0000000..5e8bf04 --- /dev/null +++ b/src/game/audio/constants.ts @@ -0,0 +1,315 @@ +/** Music and sound effect ids (constants/music_constants.asm and + * sfx_constants.asm), verified by name by `npm run check:reference`. */ +export const SOUND = { + MUSIC_NONE: 0x00, + MUSIC_TITLE: 0x01, + MUSIC_ROUTE_1: 0x02, + MUSIC_ROUTE_3: 0x03, + MUSIC_ROUTE_12: 0x04, + MUSIC_MAGNET_TRAIN: 0x05, + MUSIC_KANTO_GYM_LEADER_BATTLE: 0x06, + MUSIC_KANTO_TRAINER_BATTLE: 0x07, + MUSIC_KANTO_WILD_BATTLE: 0x08, + MUSIC_POKEMON_CENTER: 0x09, + MUSIC_HIKER_ENCOUNTER: 0x0a, + MUSIC_LASS_ENCOUNTER: 0x0b, + MUSIC_OFFICER_ENCOUNTER: 0x0c, + MUSIC_HEAL: 0x0d, + MUSIC_LAVENDER_TOWN: 0x0e, + MUSIC_ROUTE_2: 0x0f, + MUSIC_MT_MOON: 0x10, + MUSIC_SHOW_ME_AROUND: 0x11, + MUSIC_GAME_CORNER: 0x12, + MUSIC_BICYCLE: 0x13, + MUSIC_HALL_OF_FAME: 0x14, + MUSIC_VIRIDIAN_CITY: 0x15, + MUSIC_CELADON_CITY: 0x16, + MUSIC_TRAINER_VICTORY: 0x17, + MUSIC_WILD_VICTORY: 0x18, + MUSIC_GYM_VICTORY: 0x19, + MUSIC_MT_MOON_SQUARE: 0x1a, + MUSIC_GYM: 0x1b, + MUSIC_PALLET_TOWN: 0x1c, + MUSIC_POKEMON_TALK: 0x1d, + MUSIC_PROF_OAK: 0x1e, + MUSIC_RIVAL_ENCOUNTER: 0x1f, + MUSIC_RIVAL_AFTER: 0x20, + MUSIC_SURF: 0x21, + MUSIC_EVOLUTION: 0x22, + MUSIC_NATIONAL_PARK: 0x23, + MUSIC_CREDITS: 0x24, + MUSIC_AZALEA_TOWN: 0x25, + MUSIC_CHERRYGROVE_CITY: 0x26, + MUSIC_KIMONO_ENCOUNTER: 0x27, + MUSIC_UNION_CAVE: 0x28, + MUSIC_JOHTO_WILD_BATTLE: 0x29, + MUSIC_JOHTO_TRAINER_BATTLE: 0x2a, + MUSIC_ROUTE_30: 0x2b, + MUSIC_ECRUTEAK_CITY: 0x2c, + MUSIC_VIOLET_CITY: 0x2d, + MUSIC_JOHTO_GYM_LEADER_BATTLE: 0x2e, + MUSIC_CHAMPION_BATTLE: 0x2f, + MUSIC_RIVAL_BATTLE: 0x30, + MUSIC_ROCKET_BATTLE: 0x31, + MUSIC_PROF_ELM: 0x32, + MUSIC_DARK_CAVE: 0x33, + MUSIC_ROUTE_29: 0x34, + MUSIC_ROUTE_36: 0x35, + MUSIC_SS_AQUA: 0x36, + MUSIC_YOUNGSTER_ENCOUNTER: 0x37, + MUSIC_BEAUTY_ENCOUNTER: 0x38, + MUSIC_ROCKET_ENCOUNTER: 0x39, + MUSIC_POKEMANIAC_ENCOUNTER: 0x3a, + MUSIC_SAGE_ENCOUNTER: 0x3b, + MUSIC_NEW_BARK_TOWN: 0x3c, + MUSIC_GOLDENROD_CITY: 0x3d, + MUSIC_VERMILION_CITY: 0x3e, + MUSIC_POKEMON_CHANNEL: 0x3f, + MUSIC_POKE_FLUTE_CHANNEL: 0x40, + MUSIC_TIN_TOWER: 0x41, + MUSIC_SPROUT_TOWER: 0x42, + MUSIC_BURNED_TOWER: 0x43, + MUSIC_LIGHTHOUSE: 0x44, + MUSIC_LAKE_OF_RAGE: 0x45, + MUSIC_INDIGO_PLATEAU: 0x46, + MUSIC_ROUTE_37: 0x47, + MUSIC_ROCKET_HIDEOUT: 0x48, + MUSIC_DRAGONS_DEN: 0x49, + MUSIC_JOHTO_WILD_BATTLE_NIGHT: 0x4a, + MUSIC_RUINS_OF_ALPH_RADIO: 0x4b, + MUSIC_CAPTURE: 0x4c, + MUSIC_ROUTE_26: 0x4d, + MUSIC_MOM: 0x4e, + MUSIC_VICTORY_ROAD: 0x4f, + MUSIC_POKEMON_LULLABY: 0x50, + MUSIC_POKEMON_MARCH: 0x51, + MUSIC_GS_OPENING: 0x52, + MUSIC_GS_OPENING_2: 0x53, + MUSIC_MAIN_MENU: 0x54, + MUSIC_RUINS_OF_ALPH_INTERIOR: 0x55, + MUSIC_ROCKET_OVERTURE: 0x56, + MUSIC_DANCING_HALL: 0x57, + MUSIC_BUG_CATCHING_CONTEST_RANKING: 0x58, + MUSIC_BUG_CATCHING_CONTEST: 0x59, + MUSIC_LAKE_OF_RAGE_ROCKET_RADIO: 0x5a, + MUSIC_PRINTER: 0x5b, + MUSIC_POST_CREDITS: 0x5c, + MUSIC_CLAIR: 0x5d, + MUSIC_MOBILE_ADAPTER_MENU: 0x5e, + MUSIC_MOBILE_ADAPTER: 0x5f, + MUSIC_BUENAS_PASSWORD: 0x60, + MUSIC_MYSTICALMAN_ENCOUNTER: 0x61, + MUSIC_CRYSTAL_OPENING: 0x62, + MUSIC_BATTLE_TOWER_THEME: 0x63, + MUSIC_SUICUNE_BATTLE: 0x64, + MUSIC_BATTLE_TOWER_LOBBY: 0x65, + MUSIC_MOBILE_CENTER: 0x66, + SFX_DEX_FANFARE_50_79: 0x00, + SFX_ITEM: 0x01, + SFX_CAUGHT_MON: 0x02, + SFX_POKEBALLS_PLACED_ON_TABLE: 0x03, + SFX_POTION: 0x04, + SFX_FULL_HEAL: 0x05, + SFX_MENU: 0x06, + SFX_READ_TEXT: 0x07, + SFX_READ_TEXT_2: 0x08, + SFX_DEX_FANFARE_20_49: 0x09, + SFX_DEX_FANFARE_80_109: 0x0a, + SFX_POISON: 0x0b, + SFX_GOT_SAFARI_BALLS: 0x0c, + SFX_BOOT_PC: 0x0d, + SFX_SHUT_DOWN_PC: 0x0e, + SFX_CHOOSE_PC_OPTION: 0x0f, + SFX_ESCAPE_ROPE: 0x10, + SFX_PUSH_BUTTON: 0x11, + SFX_SECOND_PART_OF_ITEMFINDER: 0x12, + SFX_WARP_TO: 0x13, + SFX_WARP_FROM: 0x14, + SFX_CHANGE_DEX_MODE: 0x15, + SFX_JUMP_OVER_LEDGE: 0x16, + SFX_GRASS_RUSTLE: 0x17, + SFX_FLY: 0x18, + SFX_WRONG: 0x19, + SFX_SQUEAK: 0x1a, + SFX_STRENGTH: 0x1b, + SFX_BOAT: 0x1c, + SFX_WALL_OPEN: 0x1d, + SFX_PLACE_PUZZLE_PIECE_DOWN: 0x1e, + SFX_ENTER_DOOR: 0x1f, + SFX_SWITCH_POKEMON: 0x20, + SFX_TALLY: 0x21, + SFX_TRANSACTION: 0x22, + SFX_EXIT_BUILDING: 0x23, + SFX_BUMP: 0x24, + SFX_SAVE: 0x25, + SFX_POKEFLUTE: 0x26, + SFX_ELEVATOR_END: 0x27, + SFX_THROW_BALL: 0x28, + SFX_BALL_POOF: 0x29, + SFX_FAINT: 0x2a, + SFX_RUN: 0x2b, + SFX_SLOT_MACHINE_START: 0x2c, + SFX_FANFARE: 0x2d, + SFX_PECK: 0x2e, + SFX_KINESIS: 0x2f, + SFX_LICK: 0x30, + SFX_POUND: 0x31, + SFX_MOVE_PUZZLE_PIECE: 0x32, + SFX_COMET_PUNCH: 0x33, + SFX_MEGA_PUNCH: 0x34, + SFX_SCRATCH: 0x35, + SFX_VICEGRIP: 0x36, + SFX_RAZOR_WIND: 0x37, + SFX_CUT: 0x38, + SFX_WING_ATTACK: 0x39, + SFX_WHIRLWIND: 0x3a, + SFX_BIND: 0x3b, + SFX_VINE_WHIP: 0x3c, + SFX_DOUBLE_KICK: 0x3d, + SFX_MEGA_KICK: 0x3e, + SFX_HEADBUTT: 0x3f, + SFX_HORN_ATTACK: 0x40, + SFX_TACKLE: 0x41, + SFX_POISON_STING: 0x42, + SFX_POWDER: 0x43, + SFX_DOUBLESLAP: 0x44, + SFX_BITE: 0x45, + SFX_JUMP_KICK: 0x46, + SFX_STOMP: 0x47, + SFX_TAIL_WHIP: 0x48, + SFX_KARATE_CHOP: 0x49, + SFX_SUBMISSION: 0x4a, + SFX_WATER_GUN: 0x4b, + SFX_SWORDS_DANCE: 0x4c, + SFX_THUNDER: 0x4d, + SFX_SUPERSONIC: 0x4e, + SFX_LEER: 0x4f, + SFX_EMBER: 0x50, + SFX_BUBBLEBEAM: 0x51, + SFX_HYDRO_PUMP: 0x52, + SFX_SURF: 0x53, + SFX_PSYBEAM: 0x54, + SFX_CHARGE: 0x55, + SFX_THUNDERSHOCK: 0x56, + SFX_PSYCHIC: 0x57, + SFX_SCREECH: 0x58, + SFX_BONE_CLUB: 0x59, + SFX_SHARPEN: 0x5a, + SFX_EGG_BOMB: 0x5b, + SFX_SING: 0x5c, + SFX_HYPER_BEAM: 0x5d, + SFX_SHINE: 0x5e, + SFX_UNKNOWN_5F: 0x5f, + SFX_UNKNOWN_60: 0x60, + SFX_UNKNOWN_61: 0x61, + SFX_SWITCH_POCKETS: 0x62, + SFX_UNKNOWN_63: 0x63, + SFX_BURN: 0x64, + SFX_TITLE_SCREEN_ENTRANCE: 0x65, + SFX_UNKNOWN_66: 0x66, + SFX_GET_COIN_FROM_SLOTS: 0x67, + SFX_PAY_DAY: 0x68, + SFX_METRONOME: 0x69, + SFX_CALL: 0x6a, + SFX_HANG_UP: 0x6b, + SFX_NO_SIGNAL: 0x6c, + SFX_SANDSTORM: 0x6d, + SFX_ELEVATOR: 0x6e, + SFX_PROTECT: 0x6f, + SFX_SKETCH: 0x70, + SFX_RAIN_DANCE: 0x71, + SFX_AEROBLAST: 0x72, + SFX_SPARK: 0x73, + SFX_CURSE: 0x74, + SFX_RAGE: 0x75, + SFX_THIEF: 0x76, + SFX_THIEF_2: 0x77, + SFX_SPIDER_WEB: 0x78, + SFX_MIND_READER: 0x79, + SFX_NIGHTMARE: 0x7a, + SFX_SNORE: 0x7b, + SFX_SWEET_KISS: 0x7c, + SFX_SWEET_KISS_2: 0x7d, + SFX_BELLY_DRUM: 0x7e, + SFX_TOXIC: 0x7f, + SFX_SLUDGE_BOMB: 0x80, + SFX_FORESIGHT: 0x81, + SFX_SPITE: 0x82, + SFX_OUTRAGE: 0x83, + SFX_PERISH_SONG: 0x84, + SFX_GIGA_DRAIN: 0x85, + SFX_ATTRACT: 0x86, + SFX_KINESIS_2: 0x87, + SFX_ZAP_CANNON: 0x88, + SFX_MEAN_LOOK: 0x89, + SFX_HEAL_BELL: 0x8a, + SFX_RETURN: 0x8b, + SFX_EXP_BAR: 0x8c, + SFX_MILK_DRINK: 0x8d, + SFX_PRESENT: 0x8e, + SFX_MORNING_SUN: 0x8f, + SFX_LEVEL_UP: 0x90, + SFX_KEY_ITEM: 0x91, + SFX_FANFARE_2: 0x92, + SFX_REGISTER_PHONE_NUMBER: 0x93, + SFX_3RD_PLACE: 0x94, + SFX_GET_EGG_UNUSED: 0x95, + SFX_GET_EGG: 0x96, + SFX_MOVE_DELETED: 0x97, + SFX_2ND_PLACE: 0x98, + SFX_1ST_PLACE: 0x99, + SFX_CHOOSE_A_CARD: 0x9a, + SFX_GET_TM: 0x9b, + SFX_GET_BADGE: 0x9c, + SFX_QUIT_SLOTS: 0x9d, + SFX_EGG_CRACK: 0x9e, + SFX_DEX_FANFARE_LESS_THAN_20: 0x9f, + SFX_DEX_FANFARE_140_169: 0xa0, + SFX_DEX_FANFARE_170_199: 0xa1, + SFX_DEX_FANFARE_200_229: 0xa2, + SFX_DEX_FANFARE_230_PLUS: 0xa3, + SFX_EVOLVED: 0xa4, + SFX_MASTER_BALL: 0xa5, + SFX_EGG_HATCH: 0xa6, + SFX_GS_INTRO_CHARIZARD_FIREBALL: 0xa7, + SFX_GS_INTRO_POKEMON_APPEARS: 0xa8, + SFX_FLASH: 0xa9, + SFX_GAME_FREAK_LOGO_GS: 0xaa, + SFX_NOT_VERY_EFFECTIVE: 0xab, + SFX_DAMAGE: 0xac, + SFX_SUPER_EFFECTIVE: 0xad, + SFX_BALL_BOUNCE: 0xae, + SFX_MOONLIGHT: 0xaf, + SFX_ENCORE: 0xb0, + SFX_BEAT_UP: 0xb1, + SFX_BATON_PASS: 0xb2, + SFX_BALL_WOBBLE: 0xb3, + SFX_SWEET_SCENT: 0xb4, + SFX_SWEET_SCENT_2: 0xb5, + SFX_HIT_END_OF_EXP_BAR: 0xb6, + SFX_GIVE_TRADEMON: 0xb7, + SFX_GET_TRADEMON: 0xb8, + SFX_TRAIN_ARRIVED: 0xb9, + SFX_STOP_SLOT: 0xba, + SFX_2_BOOPS: 0xbb, + SFX_GLASS_TING: 0xbc, + SFX_GLASS_TING_2: 0xbd, + SFX_INTRO_UNOWN_1: 0xbe, + SFX_INTRO_UNOWN_2: 0xbf, + SFX_INTRO_UNOWN_3: 0xc0, + SFX_DITTO_POP_UP: 0xc1, + SFX_DITTO_TRANSFORM: 0xc2, + SFX_INTRO_SUICUNE_1: 0xc3, + SFX_INTRO_PICHU: 0xc4, + SFX_INTRO_SUICUNE_2: 0xc5, + SFX_INTRO_SUICUNE_3: 0xc6, + SFX_DITTO_BOUNCE: 0xc7, + SFX_INTRO_SUICUNE_4: 0xc8, + SFX_GAME_FREAK_PRESENTS: 0xc9, + SFX_TINGLE: 0xca, + SFX_INTRO_WHOOSH: 0xcb, + SFX_TWO_PC_BEEPS: 0xcc, + SFX_4_NOTE_DITTY: 0xcd, + SFX_TWINKLE: 0xce, + MUSIC_MAHOGANY_MART: 0x64, +} as const; diff --git a/src/game/audio/engine.ts b/src/game/audio/engine.ts new file mode 100644 index 0000000..bb28a39 --- /dev/null +++ b/src/game/audio/engine.ts @@ -0,0 +1,1272 @@ +import type { RomReader } from "../rom/reader"; +import { AUDIO } from "../rom/profile"; + +/** Crystal's sound engine (audio/engine.asm and home/audio.asm), ported + * routine by routine. Its state is the original's audio WRAM, byte for + * byte ($c100-$c2c1), so it can be compared with the cartridge running in + * the reference emulator. The engine drives a model of the sound + * registers; the synthesizer (apu.ts) turns their writes into samples. */ + +export const AUDIO_RAM = 0xc100; +const AUDIO_RAM_SIZE = 0x1c2; + +// Audio WRAM labels (ram/wram.asm). +const wMusicPlaying = 0xc100; +const wAudio = 0xc101; +const wChannel1 = 0xc101; +const wCurTrackDuty = 0xc292; +const wCurTrackVolumeEnvelope = 0xc293; +const wCurTrackFrequency = 0xc294; +const wCurNoteDuration = 0xc297; +const wCurMusicByte = 0xc298; +const wCurChannel = 0xc299; +const wVolume = 0xc29a; +const wSoundOutput = 0xc29b; +const wPitchSweep = 0xc29c; +const wMusicID = 0xc29d; +const wMusicBank = 0xc29f; +const wNoiseSampleAddress = 0xc2a0; +const wNoiseSampleDelay = 0xc2a2; +const wMusicNoiseSampleSet = 0xc2a4; +const wSFXNoiseSampleSet = 0xc2a5; +const wLowHealthAlarm = 0xc2a6; +const wMusicFade = 0xc2a7; +const wMusicFadeCount = 0xc2a8; +const wMusicFadeID = 0xc2a9; +const wCryPitch = 0xc2b0; +const wCryLength = 0xc2b2; +const wLastVolume = 0xc2b4; +const wUnusedMusicF9Flag = 0xc2b5; +const wSFXPriority = 0xc2b6; +const wChannel1JumpCondition = 0xc2b8; +const wStereoPanningMask = 0xc2bc; +const wCryTracks = 0xc2bd; +const wSFXDuration = 0xc2be; +const wCurSFX = 0xc2bf; +const wAudioEnd = 0xc2c0; +const wMapMusic = 0xc2c0; +const wDontPlayMapMusicOnReload = 0xc2c1; + +// channel_struct (macros/ram.asm). +const CHANNEL_MUSIC_ID = 0x00; +const CHANNEL_MUSIC_BANK = 0x02; +const CHANNEL_FLAGS1 = 0x03; +const CHANNEL_FLAGS2 = 0x04; +const CHANNEL_FLAGS3 = 0x05; +const CHANNEL_MUSIC_ADDRESS = 0x06; +const CHANNEL_LAST_MUSIC_ADDRESS = 0x08; +const CHANNEL_NOTE_FLAGS = 0x0c; +const CHANNEL_CONDITION = 0x0d; +const CHANNEL_DUTY_CYCLE = 0x0e; +const CHANNEL_VOLUME_ENVELOPE = 0x0f; +const CHANNEL_FREQUENCY = 0x10; +const CHANNEL_PITCH = 0x12; +const CHANNEL_OCTAVE = 0x13; +const CHANNEL_TRANSPOSITION = 0x14; +const CHANNEL_NOTE_DURATION = 0x15; +const CHANNEL_NOTE_DURATION_MODIFIER = 0x16; +const CHANNEL_LOOP_COUNT = 0x18; +const CHANNEL_TEMPO = 0x19; +const CHANNEL_TRACKS = 0x1b; +const CHANNEL_DUTY_CYCLE_PATTERN = 0x1c; +const CHANNEL_VIBRATO_DELAY_COUNT = 0x1d; +const CHANNEL_VIBRATO_DELAY = 0x1e; +const CHANNEL_VIBRATO_EXTENT = 0x1f; +const CHANNEL_VIBRATO_RATE = 0x20; +const CHANNEL_PITCH_SLIDE_TARGET = 0x21; +const CHANNEL_PITCH_SLIDE_AMOUNT = 0x23; +const CHANNEL_PITCH_SLIDE_AMOUNT_FRACTION = 0x24; +const CHANNEL_FIELD25 = 0x25; +const CHANNEL_PITCH_OFFSET = 0x27; +const CHANNEL_FIELD29 = 0x29; +const CHANNEL_FIELD2A = 0x2a; +const CHANNEL_FIELD2C = 0x2c; +const CHANNEL_NOTE_LENGTH = 0x2d; +const CHANNEL_FIELD2E = 0x2e; +const CHANNEL_FIELD2F = 0x2f; +const CHANNEL_FIELD30 = 0x30; +const CHANNEL_STRUCT_LENGTH = 0x32; + +const NUM_MUSIC_CHANS = 4; +const NUM_CHANNELS = 8; +const CHAN4 = 3; +const CHAN5 = 4; +const NOISE_CHAN_F = 2; + +// Flags1 +const SOUND_CHANNEL_ON = 0; +const SOUND_SUBROUTINE = 1; +const SOUND_LOOPING = 2; +const SOUND_SFX = 3; +const SOUND_NOISE = 4; +const SOUND_CRY = 5; +// Flags2 +const SOUND_VIBRATO = 0; +const SOUND_PITCH_SLIDE = 1; +const SOUND_DUTY_LOOP = 2; +const SOUND_UNKN_0B = 3; +const SOUND_PITCH_OFFSET = 4; +const SOUND_UNKN_0D = 5; +const SOUND_UNKN_0E = 6; +const SOUND_UNKN_0F = 7; +// Flags3 +const SOUND_VIBRATO_DIR = 0; +const SOUND_PITCH_SLIDE_DIR = 1; +// NoteFlags +const NOTE_DUTY_OVERRIDE = 0; +const NOTE_FREQ_OVERRIDE = 1; +const NOTE_PITCH_SWEEP = 3; +const NOTE_NOISE_SAMPLING = 4; +const NOTE_REST = 5; +const NOTE_VIBRATO_OVERRIDE = 6; + +const DANGER_ON_F = 7; +const MUSIC_FADE_IN_F = 7; +const MAX_VOLUME = 0x77; +const FIRST_MUSIC_CMD = 0xd0; +const SOUND_RET_CMD = 0xff; +export const MUSIC_NONE = 0; +const PLAYER_BIKE = 1; + +// Sound registers, as offsets from rAUD1SWEEP ($ff10). +export const NR10 = 0x00; +export const NR11 = 0x01; +export const NR12 = 0x02; +export const NR13 = 0x03; +export const NR14 = 0x04; +export const NR21 = 0x06; +export const NR22 = 0x07; +export const NR23 = 0x08; +export const NR24 = 0x09; +export const NR30 = 0x0a; +export const NR31 = 0x0b; +export const NR32 = 0x0c; +export const NR33 = 0x0d; +export const NR34 = 0x0e; +export const NR41 = 0x10; +export const NR42 = 0x11; +export const NR43 = 0x12; +export const NR44 = 0x13; +export const NR50 = 0x14; +export const NR51 = 0x15; +export const NR52 = 0x16; +export const WAVE_RAM = 0x20; +const AUD1RAM = NR10; +const AUD2RAM = 0x05; +const AUD3RAM = NR30; +const AUD4RAM = 0x0f; + +/** Bits that read back as 1 (pandocs' sound register read masks). */ +const READ_MASKS = [ + 0x80, 0x3f, 0x00, 0xff, 0xbf, 0xff, 0x3f, 0x00, 0xff, 0xbf, 0x7f, 0xff, + 0x9f, 0xff, 0xbf, 0xff, 0xff, 0x00, 0x00, 0xbf, 0x00, 0x00, 0x70, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, +]; + +/** One sound register write: its offset from $ff10 and value. */ +export type RegisterWrite = number; +export const packWrite = (register: number, value: number): RegisterWrite => + (register << 8) | value; + +const bit = (value: number, n: number) => (value >> n) & 1; + +export class AudioEngine { + /** The original's audio WRAM, $c100-$c2c1. */ + readonly ram = new Uint8Array(AUDIO_RAM_SIZE); + /** The sound registers $ff10-$ff3f as last written. */ + readonly registers = new Uint8Array(0x30); + /** Writes since the last `drain`, packed by `packWrite`. */ + private writes: RegisterWrite[] = []; + /** wOptions' STEREO bit. */ + stereo = false; + /** wPlayerState, which FadeMusic checks for the Bicycle. */ + playerState = 0; + private readonly engineBank: number; + + constructor(private readonly rom: RomReader) { + this.engineBank = Math.floor(AUDIO._UpdateSound / 0x4000); + this.registers[NR52] = 0x80; + } + + // ---- memory ----------------------------------------------------------- + r(address: number) { + return this.ram[address - AUDIO_RAM]; + } + w(address: number, value: number) { + this.ram[address - AUDIO_RAM] = value & 0xff; + } + private r16(address: number) { + return this.r(address) | (this.r(address + 1) << 8); + } + private w16(address: number, value: number) { + this.w(address, value); + this.w(address + 1, value >> 8); + } + private set(address: number, n: number) { + this.w(address, this.r(address) | (1 << n)); + } + private res(address: number, n: number) { + this.w(address, this.r(address) & ~(1 << n)); + } + private test(address: number, n: number) { + return bit(this.r(address), n) === 1; + } + /** ldh [rX], a */ + private out(register: number, value: number) { + value &= 0xff; + if (register === NR52) value = (value & 0x80) | (this.registers[NR52] & 0x7f); + this.registers[register] = value; + this.writes.push(packWrite(register, value)); + } + /** ldh a, [rX] */ + private in(register: number) { + return (this.registers[register] | READ_MASKS[register]) & 0xff; + } + /** Register writes since the last call. */ + drain(): RegisterWrite[] { + const writes = this.writes; + this.writes = []; + return writes; + } + /** A byte from ROM at a banked address, as `ld a, [de]` sees it. */ + private romByte(bank: number, address: number) { + address &= 0xffff; + if (address >= 0x8000) return SOUND_RET_CMD; + if (address < 0x4000) return this.rom.bytes[address]; + const offset = bank * 0x4000 + address - 0x4000; + return offset < this.rom.bytes.length ? this.rom.bytes[offset] : 0xff; + } + /** A byte in the engine's own bank, where its tables and drums live. */ + private engineByte(address: number) { + return this.romByte(this.engineBank, address); + } + private engineWord(address: number) { + return this.engineByte(address) | (this.engineByte(address + 1) << 8); + } + private near(offset: number) { + return offset < 0x4000 ? offset : (offset % 0x4000) + 0x4000; + } + + // ---- home/audio.asm ---------------------------------------------------- + /** PlayMusic: music id de; MUSIC_NONE restarts the sound instead. */ + playMusic(id: number) { + if ((id & 0xff) === 0) this.initSound(); + else this.playMusicRaw(id); + } + /** PlayCry: the species' cry (index species - 1 in PokemonCries). */ + playCry(species: number) { + const { id, pitch, length } = this.cryEntry(species); + this.playCryWith(id, pitch, length); + } + /** _PlayCry with explicit pitch and length (the slow cry, the Pokédex). */ + playCryWith(id: number, pitch: number, length: number) { + this.w16(wCryPitch, pitch); + this.w16(wCryLength, length); + this.playCryRaw(id); + } + /** The species' PokemonCries entry: cry id, pitch and length. */ + cryEntry(species: number) { + const entry = AUDIO.PokemonCries + (species - 1) * 6; + const word = (offset: number) => this.flatByte(offset) | (this.flatByte(offset + 1) << 8); + return { id: word(entry), pitch: word(entry + 2), length: word(entry + 4) }; + } + private flatByte(offset: number) { + return offset < this.rom.bytes.length ? this.rom.bytes[offset] : 0xff; + } + /** PlaySFX: sound effects are ordered by priority, highest first. */ + playSFX(id: number) { + if (this.checkSFX() && this.r(wCurSFX) < (id & 0xff)) return; + this.w(wCurSFX, id); + this.playSFXRaw(id); + } + /** CheckSFX: carry if any sound effect channel is active. */ + checkSFX() { + for (let channel = 4; channel < 8; channel++) + if (this.test(this.channel(channel) + CHANNEL_FLAGS1, SOUND_CHANNEL_ON)) return true; + return false; + } + maxVolume() { + this.w(wVolume, MAX_VOLUME); + } + lowVolume() { + this.w(wVolume, 0x33); + } + minVolume() { + this.w(wVolume, 0); + } + fadeInToMusic() { + this.w(wMusicFade, 4 | (1 << MUSIC_FADE_IN_F)); + } + /** wMusicFade and wMusicFadeID, as the scripts' musicfadeout and + * FadeToMapMusic set them. */ + fadeTo(id: number, rate: number) { + this.w(wMusicFade, rate); + this.w16(wMusicFadeID, id); + } + set musicFade(value: number) { + this.w(wMusicFade, value); + } + get musicFading() { + return this.r(wMusicFade) !== 0; + } + get volume() { + return this.r(wVolume); + } + get mapMusic() { + return this.r(wMapMusic); + } + set mapMusic(id: number) { + this.w(wMapMusic, id); + } + get dontPlayMapMusicOnReload() { + return this.r(wDontPlayMapMusicOnReload) !== 0; + } + set dontPlayMapMusicOnReload(value: boolean) { + this.w(wDontPlayMapMusicOnReload, value ? 1 : 0); + } + /** wLowHealthAlarm's danger tone (battle). */ + setLowHealthAlarm(on: boolean) { + this.w(wLowHealthAlarm, on ? 1 << DANGER_ON_F : 0); + } + get lowHealthAlarm() { + return this.test(wLowHealthAlarm, DANGER_ON_F); + } + /** wStereoPanningMask and wCryTracks: a battle cry from its side. */ + setCryTracks(mask: number, tracks: number) { + this.w(wStereoPanningMask, mask); + this.w(wCryTracks, tracks); + } + /** ChannelsOff: music channels off at once. */ + channelsOff() { + for (let channel = 0; channel < 4; channel++) this.w(this.channel(channel) + CHANNEL_FLAGS1, 0); + this.w(wPitchSweep, 0); + } + /** SFXChannelsOff */ + sfxChannelsOff() { + for (let channel = 4; channel < 8; channel++) this.w(this.channel(channel) + CHANNEL_FLAGS1, 0); + this.w(wPitchSweep, 0); + } + /** TerminateExpBarSound */ + terminateExpBarSound() { + this.w(this.channel(4) + CHANNEL_FLAGS1, 0); + this.w(wPitchSweep, 0); + for (const register of [NR10, NR11, NR12, NR13, NR14]) this.out(register, 0); + } + /** Whether a channel (0-7) is on. */ + channelOn(channel: number) { + return this.test(this.channel(channel) + CHANNEL_FLAGS1, SOUND_CHANNEL_ON); + } + /** The id a channel (0-7) is playing, 0 when off. */ + channelMusicId(channel: number) { + return this.r16(this.channel(channel) + CHANNEL_MUSIC_ID); + } + private channel(n: number) { + return wChannel1 + n * CHANNEL_STRUCT_LENGTH; + } + + // ---- audio/engine.asm -------------------------------------------------- + /** _InitSound: restart sound operation. */ + initSound() { + this.w(wMusicPlaying, 0); + this.out(NR50, 0); + this.out(NR51, 0); + this.out(NR52, 0x80); + let register = AUD1RAM; + for (let e = 0; e < NUM_MUSIC_CHANS; e++) { + this.out(register++, 0); + this.out(register++, 0); + this.out(register++, 0x08); + this.out(register++, 0); + this.out(register++, 0x80); + } + for (let address = wAudio; address < wAudioEnd; address++) this.w(address, 0); + this.w(wVolume, MAX_VOLUME); + this.w(wMusicPlaying, 1); + } + + /** MusicFadeRestart: restart but keep the music id to fade in to. */ + private musicFadeRestart() { + const id = this.r16(wMusicFadeID); + this.initSound(); + this.w16(wMusicFadeID, id); + } + + /** _UpdateSound: called once per frame. */ + updateSound() { + if (!this.r(wMusicPlaying)) return; + this.w(wCurChannel, 0); + this.w(wSoundOutput, 0); + let bc = wChannel1; + for (;;) { + if (this.test(bc + CHANNEL_FLAGS1, SOUND_CHANNEL_ON)) { + const duration = this.r(bc + CHANNEL_NOTE_DURATION); + if (duration < 2) { + this.w(bc + CHANNEL_VIBRATO_DELAY_COUNT, this.r(bc + CHANNEL_VIBRATO_DELAY)); + this.res(bc + CHANNEL_FLAGS2, SOUND_PITCH_SLIDE); + this.parseMusic(bc); + } else this.w(bc + CHANNEL_NOTE_DURATION, duration - 1); + this.applyPitchSlide(bc); + this.w(wCurTrackDuty, this.r(bc + CHANNEL_DUTY_CYCLE)); + this.w(wCurTrackVolumeEnvelope, this.r(bc + CHANNEL_VOLUME_ENVELOPE)); + this.w(wCurTrackFrequency, this.r(bc + CHANNEL_FREQUENCY)); + this.w(wCurTrackFrequency + 1, this.r(bc + CHANNEL_FREQUENCY + 1)); + this.handleTrackVibrato(bc); + this.handleNoise(bc); + if ( + this.r(wSFXPriority) && + this.r(wCurChannel) < NUM_MUSIC_CHANS && + [4, 5, 6, 7].some((n) => this.channelOn(n)) + ) + this.set(bc + CHANNEL_NOTE_FLAGS, NOTE_REST); + if ( + this.r(wCurChannel) >= NUM_MUSIC_CHANS || + !this.test(bc + CHANNEL_STRUCT_LENGTH * NUM_MUSIC_CHANS + CHANNEL_FLAGS1, SOUND_CHANNEL_ON) + ) { + this.updateChannels(bc); + this.w(wSoundOutput, this.r(wSoundOutput) | this.r(bc + CHANNEL_TRACKS)); + } + this.w(bc + CHANNEL_NOTE_FLAGS, 0); + } + bc = (bc + CHANNEL_STRUCT_LENGTH) & 0xffff; + const next = (this.r(wCurChannel) + 1) & 0xff; + this.w(wCurChannel, next); + if (next === NUM_CHANNELS) break; + // A restarted channel can move wCurChannel past the last channel; + // the original would then run on through unrelated WRAM. + if (bc >= wCurTrackDuty) break; + } + this.playDanger(); + this.fadeMusic(); + this.out(NR50, this.r(wVolume)); + this.out(NR51, this.r(wSoundOutput)); + } + + private updateChannels(bc: number) { + const channel = this.r(wCurChannel) & 7; + const flags = () => this.r(bc + CHANNEL_NOTE_FLAGS); + const durationOverride = (len: number) => + this.out(len, (this.in(len) & 0x3f) | this.r(wCurTrackDuty)); + switch (channel) { + case 0: + case 4: { + if (channel === 0 && this.test(wLowHealthAlarm, DANGER_ON_F)) return; + const f = flags(); + if (bit(f, NOTE_PITCH_SWEEP)) this.out(NR10, this.r(wPitchSweep)); + if (bit(f, NOTE_REST)) { + this.out(NR52, this.in(NR52) & 0b10001110); + this.clearChannel(AUD1RAM); + } else if (bit(f, NOTE_NOISE_SAMPLING)) { + this.out(NR11, 0x3f | this.r(wCurTrackDuty)); + this.out(NR12, this.r(wCurTrackVolumeEnvelope)); + this.out(NR13, this.r(wCurTrackFrequency)); + this.out(NR14, this.r(wCurTrackFrequency + 1) | 0x80); + } else if (bit(f, NOTE_FREQ_OVERRIDE)) { + this.out(NR13, this.r(wCurTrackFrequency)); + this.out(NR14, this.r(wCurTrackFrequency + 1)); + if (bit(f, NOTE_DUTY_OVERRIDE)) durationOverride(NR11); + } else if (bit(f, NOTE_VIBRATO_OVERRIDE)) { + durationOverride(NR11); + this.out(NR13, this.r(wCurTrackFrequency)); + } else if (bit(f, NOTE_DUTY_OVERRIDE)) durationOverride(NR11); + return; + } + case 1: + case 5: { + const f = flags(); + if (bit(f, NOTE_REST)) { + this.out(NR52, this.in(NR52) & 0b10001101); + this.clearChannel(AUD2RAM); + } else if (bit(f, NOTE_NOISE_SAMPLING)) { + this.out(NR21, 0x3f | this.r(wCurTrackDuty)); + this.out(NR22, this.r(wCurTrackVolumeEnvelope)); + this.out(NR23, this.r(wCurTrackFrequency)); + this.out(NR24, this.r(wCurTrackFrequency + 1) | 0x80); + } else if (bit(f, NOTE_VIBRATO_OVERRIDE)) { + durationOverride(NR21); + this.out(NR23, this.r(wCurTrackFrequency)); + } else if (bit(f, NOTE_DUTY_OVERRIDE)) durationOverride(NR21); + return; + } + case 2: + case 6: { + const f = flags(); + if (bit(f, NOTE_REST)) { + this.out(NR52, this.in(NR52) & 0b10001011); + this.clearChannel(AUD3RAM); + } else if (bit(f, NOTE_NOISE_SAMPLING)) { + this.out(NR31, 0x3f); + this.out(NR30, 0); + this.loadWavePattern(); + this.out(NR30, 0x80); + this.out(NR33, this.r(wCurTrackFrequency)); + this.out(NR34, this.r(wCurTrackFrequency + 1) | 0x80); + } else if (bit(f, NOTE_VIBRATO_OVERRIDE)) this.out(NR33, this.r(wCurTrackFrequency)); + return; + } + default: { + const f = flags(); + if (bit(f, NOTE_REST)) { + this.out(NR52, this.in(NR52) & 0b10000111); + this.clearChannel(AUD4RAM); + } else if (bit(f, NOTE_NOISE_SAMPLING)) { + this.out(NR41, 0x3f); + this.out(NR42, this.r(wCurTrackVolumeEnvelope)); + this.out(NR43, this.r(wCurTrackFrequency)); + this.out(NR44, 0x80); + } + } + } + } + + /** .load_wave_pattern: wave samples 0-9 (higher ids read past them). */ + private loadWavePattern() { + const envelope = this.r(wCurTrackVolumeEnvelope); + const source = this.near(AUDIO.WaveSamples) + ((envelope & 0xf) << 4); + for (let i = 0; i < 16; i++) this.out(WAVE_RAM + i, this.engineByte(source + i)); + this.out(NR32, ((envelope & 0xf0) << 1) & 0xff); + } + + /** ClearChannel: 00 00 08 00 80 from one channel's first register. */ + private clearChannel(register: number) { + this.out(register, 0); + this.out(register + 1, 0); + this.out(register + 2, 0x08); + this.out(register + 3, 0); + this.out(register + 4, 0x80); + } + + /** _CheckSFX */ + private anySFX() { + return this.checkSFX(); + } + + private playDanger() { + let a = this.r(wLowHealthAlarm); + if (!bit(a, DANGER_ON_F)) return; + a &= ~(1 << DANGER_ON_F); + const d = a; + if (!this.anySFX()) { + const tone = + a === 0 ? [0x80, 0xe2, 0x50, 0x87] : a === 16 ? [0x80, 0xe2, 0xee, 0x86] : undefined; + if (tone) { + this.out(NR10, 0); + this.out(NR11, tone[0]); + this.out(NR12, tone[1]); + this.out(NR13, tone[2]); + this.out(NR14, tone[3]); + } + } + let next = (d + 1) & 0xff; + if (next >= 30) next = 0; + this.w(wLowHealthAlarm, next | (1 << DANGER_ON_F)); + if (this.r(wSoundOutput) & 0x11) return; + this.w(wSoundOutput, this.r(wSoundOutput) | 0x11); + } + + private fadeMusic() { + const fade = this.r(wMusicFade); + if (!fade) return; + const count = this.r(wMusicFadeCount); + if (count) { + this.w(wMusicFadeCount, count - 1); + return; + } + this.w(wMusicFadeCount, fade & 0x3f); + let level = this.r(wVolume) & 0x07; + if (bit(fade, MUSIC_FADE_IN_F)) { + if (level >= (MAX_VOLUME & 0xf)) { + this.w(wMusicFade, 0); + return; + } + level++; + } else { + if (level === 0) { + this.w(wVolume, 0); + if (this.playerState === PLAYER_BIKE) { + this.musicFadeRestart(); + this.w(wVolume, 0); + this.playMusicRaw(this.r16(wMusicFadeID)); + this.set(wMusicFade, MUSIC_FADE_IN_F); + return; + } + this.musicFadeRestart(); + if (this.r(wMusicFadeID)) this.playMusicRaw(this.r16(wMusicFadeID)); + this.w(wMusicFade, 0); + return; + } + level--; + } + this.w(wVolume, (level << 4) | level); + } + + private loadNote(bc: number) { + if (!this.test(bc + CHANNEL_FLAGS2, SOUND_PITCH_SLIDE)) return; + let a = this.r(bc + CHANNEL_NOTE_DURATION) - this.r(wCurNoteDuration); + if (a < 0) a = 1; + this.w(wCurNoteDuration, a); + const fLo = this.r(bc + CHANNEL_FREQUENCY), + fHi = this.r(bc + CHANNEL_FREQUENCY + 1), + tLo = this.r(bc + CHANNEL_PITCH_SLIDE_TARGET), + tHi = this.r(bc + CHANNEL_PITCH_SLIDE_TARGET + 1); + // ld a, e / sub [target] / ld a, d / sbc 0 / sub [target + 1] + const borrow = fLo - tLo < 0 ? 1 : 0; + const hi = (fHi - borrow) & 0xff; + let e: number, d: number; + if (hi - tHi < 0) { + this.set(bc + CHANNEL_FLAGS3, SOUND_PITCH_SLIDE_DIR); + const b1 = tLo - fLo < 0 ? 1 : 0; + e = (tLo - fLo) & 0xff; + d = (tHi - ((fHi - b1) & 0xff)) & 0xff; + } else { + this.res(bc + CHANNEL_FLAGS3, SOUND_PITCH_SLIDE_DIR); + e = (fLo - tLo) & 0xff; + d = (hi - tHi) & 0xff; + } + const divisor = this.r(wCurNoteDuration); + let b = 0; + for (let guard = 0; ; guard++) { + b = (b + 1) & 0xff; + const borrowed = e - divisor < 0; + e = (e - divisor) & 0xff; + if (!borrowed) { + if (guard > 0x10000) break; + continue; + } + if (d === 0) break; + d--; + } + const remainder = (e + divisor) & 0xff; + this.w(bc + CHANNEL_PITCH_SLIDE_AMOUNT, b); + this.w(bc + CHANNEL_PITCH_SLIDE_AMOUNT_FRACTION, remainder); + this.w(bc + CHANNEL_FIELD25, 0); + } + + private handleTrackVibrato(bc: number) { + if (this.test(bc + CHANNEL_FLAGS2, SOUND_DUTY_LOOP)) { + let pattern = this.r(bc + CHANNEL_DUTY_CYCLE_PATTERN); + pattern = ((pattern << 2) | (pattern >> 6)) & 0xff; + this.w(bc + CHANNEL_DUTY_CYCLE_PATTERN, pattern); + this.w(wCurTrackDuty, pattern & 0xc0); + this.set(bc + CHANNEL_NOTE_FLAGS, NOTE_DUTY_OVERRIDE); + } + if (this.test(bc + CHANNEL_FLAGS2, SOUND_PITCH_OFFSET)) { + const sum = this.r16(wCurTrackFrequency) + this.r16(bc + CHANNEL_PITCH_OFFSET); + this.w16(wCurTrackFrequency, sum); + } + if (!this.test(bc + CHANNEL_FLAGS2, SOUND_VIBRATO)) return; + const delay = bc + CHANNEL_VIBRATO_DELAY_COUNT; + if (this.r(delay)) { + this.w(delay, this.r(delay) - 1); + return; + } + const extent = this.r(bc + CHANNEL_VIBRATO_EXTENT); + if (!extent) return; + const rate = bc + CHANNEL_VIBRATO_RATE; + if (this.r(rate) & 0xf) { + this.w(rate, this.r(rate) - 1); + return; + } + const old = this.r(rate); + const swapped = ((old << 4) | (old >> 4)) & 0xff; + this.w(rate, old | swapped); + const e = this.r(wCurTrackFrequency); + let a: number; + if (this.test(bc + CHANNEL_FLAGS3, SOUND_VIBRATO_DIR)) { + this.res(bc + CHANNEL_FLAGS3, SOUND_VIBRATO_DIR); + a = e - (extent & 0xf); + if (a < 0) a = 0; + } else { + this.set(bc + CHANNEL_FLAGS3, SOUND_VIBRATO_DIR); + a = (extent >> 4) + e; + if (a > 0xff) a = 0xff; + } + this.w(wCurTrackFrequency, a); + this.set(bc + CHANNEL_NOTE_FLAGS, NOTE_VIBRATO_OVERRIDE); + } + + private applyPitchSlide(bc: number) { + if (!this.test(bc + CHANNEL_FLAGS2, SOUND_PITCH_SLIDE)) return; + let de = this.r16(bc + CHANNEL_FREQUENCY); + const tLo = this.r(bc + CHANNEL_PITCH_SLIDE_TARGET), + tHi = this.r(bc + CHANNEL_PITCH_SLIDE_TARGET + 1); + let finished: boolean; + if (this.test(bc + CHANNEL_FLAGS3, SOUND_PITCH_SLIDE_DIR)) { + de = (de + this.r(bc + CHANNEL_PITCH_SLIDE_AMOUNT)) & 0xffff; + const sum = this.r(bc + CHANNEL_PITCH_SLIDE_AMOUNT_FRACTION) + this.r(bc + CHANNEL_FIELD25); + this.w(bc + CHANNEL_FIELD25, sum); + if (sum > 0xff) de = (de + 1) & 0xffff; + const d = de >> 8, + e = de & 0xff; + finished = tHi < d || (tHi === d && tLo < e); + } else { + de = (de - this.r(bc + CHANNEL_PITCH_SLIDE_AMOUNT)) & 0xffff; + const doubled = this.r(bc + CHANNEL_PITCH_SLIDE_AMOUNT_FRACTION) * 2; + this.w(bc + CHANNEL_PITCH_SLIDE_AMOUNT_FRACTION, doubled); + if (doubled > 0xff) de = (de - 1) & 0xffff; + const d = de >> 8, + e = de & 0xff; + finished = d < tHi || (d === tHi && e < tLo); + } + if (finished) { + this.res(bc + CHANNEL_FLAGS2, SOUND_PITCH_SLIDE); + this.res(bc + CHANNEL_FLAGS3, SOUND_PITCH_SLIDE_DIR); + return; + } + this.w16(bc + CHANNEL_FREQUENCY, de); + this.set(bc + CHANNEL_NOTE_FLAGS, NOTE_FREQ_OVERRIDE); + this.set(bc + CHANNEL_NOTE_FLAGS, NOTE_DUTY_OVERRIDE); + } + + private handleNoise(bc: number) { + if (!this.test(bc + CHANNEL_FLAGS1, SOUND_NOISE)) return; + if (!bit(this.r(wCurChannel), NOISE_CHAN_F)) { + const ch8 = this.channel(7) + CHANNEL_FLAGS1; + if (this.test(ch8, SOUND_CHANNEL_ON) && this.test(ch8, SOUND_NOISE)) return; + } + const delay = this.r(wNoiseSampleDelay); + if (delay) { + this.w(wNoiseSampleDelay, delay - 1); + return; + } + this.readNoiseSample(bc); + } + + private readNoiseSample(bc: number) { + let de = this.r16(wNoiseSampleAddress); + if (!de) return; + const head = this.engineByte(de); + de++; + if (head === SOUND_RET_CMD) return; + this.w(wNoiseSampleDelay, (head & 0xf) + 1); + this.w(wCurTrackVolumeEnvelope, this.engineByte(de++)); + this.w(wCurTrackFrequency, this.engineByte(de++)); + this.w(wCurTrackFrequency + 1, 0); + this.w16(wNoiseSampleAddress, de); + this.set(bc + CHANNEL_NOTE_FLAGS, NOTE_NOISE_SAMPLING); + } + + private parseMusic(bc: number) { + for (let guard = 0; guard < 0x10000; guard++) { + const a = this.getMusicByte(bc); + if (a === SOUND_RET_CMD && !this.test(bc + CHANNEL_FLAGS1, SOUND_SUBROUTINE)) { + const channel = this.r(wCurChannel); + if ( + channel >= NUM_MUSIC_CHANS || + !this.test(bc + CHANNEL_STRUCT_LENGTH * NUM_MUSIC_CHANS + CHANNEL_FLAGS1, SOUND_CHANNEL_ON) + ) { + if (this.test(bc + CHANNEL_FLAGS1, SOUND_CRY)) this.restoreVolume(); + if (this.r(wCurChannel) === CHAN5) this.out(NR10, 0); + } + this.res(bc + CHANNEL_FLAGS1, SOUND_CHANNEL_ON); + this.set(bc + CHANNEL_NOTE_FLAGS, NOTE_REST); + this.w(bc + CHANNEL_MUSIC_ID, 0); + this.w(bc + CHANNEL_MUSIC_ID + 1, 0); + this.w(bc + CHANNEL_MUSIC_BANK, 0); + return; + } + if (a >= FIRST_MUSIC_CMD) { + this.parseMusicCommand(bc); + continue; + } + const flags1 = this.r(bc + CHANNEL_FLAGS1); + if (bit(flags1, SOUND_SFX) || bit(flags1, SOUND_CRY)) return this.parseSFXOrCry(bc); + if (bit(flags1, SOUND_NOISE)) return this.getNoiseSample(bc); + this.setNoteDuration(bc, this.r(wCurMusicByte) & 0xf); + const pitch = this.r(wCurMusicByte) >> 4; + if (!pitch) { + this.set(bc + CHANNEL_NOTE_FLAGS, NOTE_REST); + return; + } + this.w(bc + CHANNEL_PITCH, pitch); + this.w16(bc + CHANNEL_FREQUENCY, this.getFrequency(bc, this.r(bc + CHANNEL_OCTAVE), pitch)); + this.set(bc + CHANNEL_NOTE_FLAGS, NOTE_NOISE_SAMPLING); + this.loadNote(bc); + return; + } + throw new Error("Sound data never reached a note."); + } + + private restoreVolume() { + if (this.r(wCurChannel) !== CHAN5) return; + this.w16(this.channel(5) + CHANNEL_PITCH_OFFSET, 0); + this.w16(this.channel(7) + CHANNEL_PITCH_OFFSET, 0); + this.w(wVolume, this.r(wLastVolume)); + this.w(wLastVolume, 0); + this.w(wSFXPriority, 0); + } + + private parseSFXOrCry(bc: number) { + this.set(bc + CHANNEL_NOTE_FLAGS, NOTE_NOISE_SAMPLING); + this.setNoteDuration(bc, this.r(wCurMusicByte)); + this.w(bc + CHANNEL_VOLUME_ENVELOPE, this.getMusicByte(bc)); + this.w(bc + CHANNEL_FREQUENCY, this.getMusicByte(bc)); + if ((this.r(wCurChannel) & 3) === CHAN4) return; + this.w(bc + CHANNEL_FREQUENCY + 1, this.getMusicByte(bc)); + } + + private getNoiseSample(bc: number) { + if ((this.r(wCurChannel) & 3) !== CHAN4) return; + this.setNoteDuration(bc, this.r(wCurMusicByte) & 0xf); + let set: number; + if (bit(this.r(wCurChannel), NOISE_CHAN_F)) set = this.r(wSFXNoiseSampleSet); + else { + if (this.channelOn(7)) return; + set = this.r(wMusicNoiseSampleSet); + } + const kit = this.engineWord(this.near(AUDIO.Drumkits) + set * 2); + const pitch = this.r(wCurMusicByte) >> 4; + if (!pitch) return; + this.w16(wNoiseSampleAddress, this.engineWord((kit + pitch * 2) & 0xffff)); + this.w(wNoiseSampleDelay, 0); + } + + private parseMusicCommand(bc: number) { + const command = this.r(wCurMusicByte); + const byte = () => this.getMusicByte(bc); + const word = () => byte() | (byte() << 8); + const address = bc + CHANNEL_MUSIC_ADDRESS; + const flags1 = bc + CHANNEL_FLAGS1; + const flags2 = bc + CHANNEL_FLAGS2; + if (command < 0xd8) { + // Music_Octave8 - Music_Octave1 + this.w(bc + CHANNEL_OCTAVE, command & 7); + return; + } + switch (command) { + case 0xd8: // Music_NoteType + this.w(bc + CHANNEL_NOTE_LENGTH, byte()); + if ((this.r(wCurChannel) & 3) === CHAN4) return; + this.w(bc + CHANNEL_VOLUME_ENVELOPE, byte()); + return; + case 0xd9: // Music_Transpose + this.w(bc + CHANNEL_TRANSPOSITION, byte()); + return; + case 0xda: { + // Music_Tempo + const d = byte(), + e = byte(); + this.setGlobalTempo((d << 8) | e); + return; + } + case 0xdb: // Music_DutyCycle + this.w(bc + CHANNEL_DUTY_CYCLE, (byte() & 3) << 6); + return; + case 0xdc: // Music_VolumeEnvelope + this.w(bc + CHANNEL_VOLUME_ENVELOPE, byte()); + return; + case 0xdd: // Music_PitchSweep + this.w(wPitchSweep, byte()); + this.set(bc + CHANNEL_NOTE_FLAGS, NOTE_PITCH_SWEEP); + return; + case 0xde: { + // Music_DutyCyclePattern + this.set(flags2, SOUND_DUTY_LOOP); + const value = byte(); + const rotated = ((value >> 2) | (value << 6)) & 0xff; + this.w(bc + CHANNEL_DUTY_CYCLE_PATTERN, rotated); + this.w(bc + CHANNEL_DUTY_CYCLE, rotated & 0xc0); + return; + } + case 0xdf: // Music_ToggleSFX + if (this.test(flags1, SOUND_SFX)) this.res(flags1, SOUND_SFX); + else this.set(flags1, SOUND_SFX); + return; + case 0xe0: { + // Music_PitchSlide + this.w(wCurNoteDuration, byte()); + const value = byte(); + const target = this.getFrequency(bc, value >> 4, value & 0xf); + this.w16(bc + CHANNEL_PITCH_SLIDE_TARGET, target); + this.set(flags2, SOUND_PITCH_SLIDE); + return; + } + case 0xe1: { + // Music_Vibrato + this.set(flags2, SOUND_VIBRATO); + this.res(bc + CHANNEL_FLAGS3, SOUND_VIBRATO_DIR); + const delay = byte(); + this.w(bc + CHANNEL_VIBRATO_DELAY, delay); + this.w(bc + CHANNEL_VIBRATO_DELAY_COUNT, delay); + const d = byte(); + const top = d >> 4; + const e = top >> 1; + const rounded = e + (top & 1); + this.w(bc + CHANNEL_VIBRATO_EXTENT, ((rounded << 4) | e) & 0xff); + const rate = d & 0xf; + this.w(bc + CHANNEL_VIBRATO_RATE, (rate << 4) | rate); + return; + } + case 0xe2: // MusicE2 + this.w(bc + CHANNEL_FIELD2C, byte()); + this.set(flags2, SOUND_UNKN_0B); + return; + case 0xe3: // Music_ToggleNoise + if (this.test(flags1, SOUND_NOISE)) this.res(flags1, SOUND_NOISE); + else { + this.set(flags1, SOUND_NOISE); + this.w(wMusicNoiseSampleSet, byte()); + } + return; + case 0xe4: // Music_ForceStereoPanning + this.forceStereoPanning(bc); + return; + case 0xe5: // Music_Volume + byte(); + if (this.r(wMusicFade)) return; + this.w(wVolume, this.r(wCurMusicByte)); + return; + case 0xe6: // Music_PitchOffset + this.set(flags2, SOUND_PITCH_OFFSET); + this.w(bc + CHANNEL_PITCH_OFFSET + 1, byte()); + this.w(bc + CHANNEL_PITCH_OFFSET, byte()); + return; + case 0xe7: // MusicE7 + this.set(flags2, SOUND_UNKN_0E); + this.w(bc + CHANNEL_FIELD29, byte()); + return; + case 0xe8: // MusicE8 + this.set(flags2, SOUND_UNKN_0D); + this.w(bc + CHANNEL_FIELD2A, byte()); + return; + case 0xe9: { + // Music_TempoRelative + const e = byte(); + const de = e >= 0x80 ? 0xff00 | e : e; + this.setGlobalTempo((this.r16(bc + CHANNEL_TEMPO) + de) & 0xffff); + return; + } + case 0xea: { + // Music_RestartChannel: the header pointer is read in the engine's + // bank, the header itself in the channel's. + this.w(wMusicID, this.r(bc + CHANNEL_MUSIC_ID)); + this.w(wMusicID + 1, this.r(bc + CHANNEL_MUSIC_ID + 1)); + this.w(wMusicBank, this.r(bc + CHANNEL_MUSIC_BANK)); + const hl = word(); + const de = this.engineWord(hl); + const channel = this.loadChannel(de); + this.startChannel(channel.bc); + return; + } + case 0xeb: // Music_NewSong + this.playMusicRaw(word()); + return; + case 0xec: // Music_SFXPriorityOn + this.w(wSFXPriority, 1); + return; + case 0xed: // Music_SFXPriorityOff + this.w(wSFXPriority, 0); + return; + case 0xee: { + // MusicEE + const condition = wChannel1JumpCondition + (this.r(wCurChannel) & 3); + if (this.r(condition)) { + this.w(condition, 0); + this.w16(address, word()); + } else this.w16(address, this.r16(address) + 2); + return; + } + case 0xef: // Music_StereoPanning + if (this.stereo) this.forceStereoPanning(bc); + else byte(); + return; + case 0xf0: // Music_SFXToggleNoise + if (this.test(flags1, SOUND_NOISE)) this.res(flags1, SOUND_NOISE); + else { + this.set(flags1, SOUND_NOISE); + this.w(wSFXNoiseSampleSet, byte()); + } + return; + case 0xf9: // MusicF9 + this.w(wUnusedMusicF9Flag, 1); + return; + case 0xfa: // Music_SetCondition + this.w(bc + CHANNEL_CONDITION, byte()); + return; + case 0xfb: { + // Music_JumpIf + const condition = byte(); + if (condition === this.r(bc + CHANNEL_CONDITION)) this.w16(address, word()); + else this.w16(address, this.r16(address) + 2); + return; + } + case 0xfc: // Music_Jump + this.w16(address, word()); + return; + case 0xfd: { + // Music_Loop + let count = byte(); + let jump = false; + if (this.test(flags1, SOUND_LOOPING)) jump = this.loopCountdown(bc); + else if (count === 0) jump = true; + else { + count = (count - 1) & 0xff; + this.set(flags1, SOUND_LOOPING); + this.w(bc + CHANNEL_LOOP_COUNT, count); + jump = this.loopCountdown(bc); + } + if (jump) this.w16(address, word()); + else { + this.res(flags1, SOUND_LOOPING); + this.w16(address, this.r16(address) + 2); + } + return; + } + case 0xfe: { + // Music_Call + const target = word(); + this.w16(bc + CHANNEL_LAST_MUSIC_ADDRESS, this.r16(address)); + this.w16(address, target); + this.set(flags1, SOUND_SUBROUTINE); + return; + } + case 0xff: // Music_Ret + this.res(flags1, SOUND_SUBROUTINE); + this.w16(address, this.r16(bc + CHANNEL_LAST_MUSIC_ADDRESS)); + return; + default: + // MusicF1 - MusicF8: nothing. + return; + } + } + + /** Music_Loop's .checkloop: true to jump, false when the count ran out. */ + private loopCountdown(bc: number) { + const count = this.r(bc + CHANNEL_LOOP_COUNT); + if (!count) return false; + this.w(bc + CHANNEL_LOOP_COUNT, count - 1); + return true; + } + + private forceStereoPanning(bc: number) { + this.setLRTracks(bc); + const value = this.getMusicByte(bc); + this.w(bc + CHANNEL_TRACKS, value & this.r(bc + CHANNEL_TRACKS)); + } + + private getMusicByte(bc: number) { + const address = this.r16(bc + CHANNEL_MUSIC_ADDRESS); + const value = this.romByte(this.r(bc + CHANNEL_MUSIC_BANK), address); + this.w(wCurMusicByte, value); + this.w16(bc + CHANNEL_MUSIC_ADDRESS, address + 1); + return value; + } + + /** GetFrequency: d octave, e pitch. */ + private getFrequency(bc: number, octave: number, pitch: number) { + const transposition = this.r(bc + CHANNEL_TRANSPOSITION); + let a = ((transposition >> 4) + octave) & 0xff; + const index = ((transposition & 0xf) + pitch) * 2; + let de = this.engineWord((this.near(AUDIO.FrequencyTable) + index) & 0xffff); + while (a < 7) { + de = ((de >> 1) | (de & 0x8000)) & 0xffff; + a++; + } + return de & 0x7ff; + } + + private setNoteDuration(bc: number, a: number) { + const units = (a + 1) & 0xff; + let hl = this.multiply(this.r(bc + CHANNEL_NOTE_LENGTH), units, 0); + hl = this.multiply( + hl & 0xff, + this.r16(bc + CHANNEL_TEMPO), + this.r(bc + CHANNEL_NOTE_DURATION_MODIFIER), + ); + this.w(bc + CHANNEL_NOTE_DURATION_MODIFIER, hl & 0xff); + this.w(bc + CHANNEL_NOTE_DURATION, hl >> 8); + } + + /** SetNoteDuration.Multiply: hl = l + a * de, all in 16 bits. */ + private multiply(a: number, de: number, l: number) { + let hl = l & 0xff; + do { + const carry = a & 1; + a >>= 1; + if (carry) hl = (hl + de) & 0xffff; + de = (de << 1) & 0xffff; + } while (a); + return hl; + } + + private setGlobalTempo(de: number) { + const first = this.r(wCurChannel) >= NUM_MUSIC_CHANS ? 4 : 0; + for (let n = first; n < first + 4; n++) { + const bc = this.channel(n); + this.w16(bc + CHANNEL_TEMPO, de); + this.w(bc + CHANNEL_NOTE_DURATION_MODIFIER, 0); + } + } + + private startChannel(bc: number) { + this.setLRTracks(bc); + this.set(bc + CHANNEL_FLAGS1, SOUND_CHANNEL_ON); + } + + /** SetLRTracks: MonoTracks and StereoTracks are the same table. */ + private setLRTracks(bc: number) { + this.w(bc + CHANNEL_TRACKS, [0x11, 0x22, 0x44, 0x88][this.r(wCurChannel) & 3]); + } + + /** The 3-byte pointer table entry (bank, address) for an id. */ + private header(table: number, id: number) { + const entry = table + id * 3; + const at = (offset: number) => this.flatByte(offset); + return { bank: at(entry), address: at(entry + 1) | (at(entry + 2) << 8) }; + } + + /** _PlayMusic */ + playMusicRaw(id: number) { + this.w(wMusicPlaying, 0); + this.w16(wMusicID, id); + const { bank, address } = this.header(AUDIO.Music, id & 0xffff); + this.w(wMusicBank, bank); + let de = address; + let count = ((this.loadMusicByte(de) >> 6) & 3) + 1; + while (count--) { + const channel = this.loadChannel(de); + de = channel.de; + this.startChannel(channel.bc); + } + this.w(wUnusedMusicF9Flag, 0); + for (let n = 0; n < 4; n++) this.w(wChannel1JumpCondition + n, 0); + this.w16(wNoiseSampleAddress, 0); + this.w(wNoiseSampleDelay, 0); + this.w(wMusicNoiseSampleSet, 0); + this.w(wMusicPlaying, 1); + } + + /** _PlayCry with wCryPitch and wCryLength already set. */ + playCryRaw(id: number) { + this.w(wMusicPlaying, 0); + this.w16(wMusicID, id); + const { bank, address } = this.header(AUDIO.Cries, id & 0xffff); + this.w(wMusicBank, bank); + let de = address; + let count = ((this.loadMusicByte(de) >> 6) & 3) + 1; + while (count--) { + const channel = this.loadChannel(de); + de = channel.de; + const bc = channel.bc; + this.set(bc + CHANNEL_FLAGS1, SOUND_CRY); + this.set(bc + CHANNEL_FLAGS2, SOUND_PITCH_OFFSET); + this.w16(bc + CHANNEL_PITCH_OFFSET, this.r16(wCryPitch)); + if ((this.r(wCurChannel) & 3) < CHAN4) + this.w16(bc + CHANNEL_TEMPO, this.r16(wCryLength)); + this.startChannel(bc); + if (this.r(wStereoPanningMask) && this.stereo) + this.w(bc + CHANNEL_TRACKS, this.r(bc + CHANNEL_TRACKS) & this.r(wCryTracks)); + } + if (!this.r(wLastVolume)) { + this.w(wLastVolume, this.r(wVolume)); + this.w(wVolume, MAX_VOLUME); + } + this.w(wSFXPriority, 1); + this.w(wMusicPlaying, 1); + } + + /** _PlaySFX */ + playSFXRaw(id: number) { + this.w(wMusicPlaying, 0); + const flags = (n: number) => this.channel(n) + CHANNEL_FLAGS1; + if (this.test(flags(4), SOUND_CHANNEL_ON)) { + this.res(flags(4), SOUND_CHANNEL_ON); + this.out(NR11, 0); + this.out(NR12, 0x08); + this.out(NR13, 0); + this.out(NR14, 0x80); + this.w(wPitchSweep, 0); + this.out(NR10, 0); + } + if (this.test(flags(5), SOUND_CHANNEL_ON)) { + this.res(flags(5), SOUND_CHANNEL_ON); + this.out(NR21, 0); + this.out(NR22, 0x08); + this.out(NR23, 0); + this.out(NR24, 0x80); + } + if (this.test(flags(6), SOUND_CHANNEL_ON)) { + this.res(flags(6), SOUND_CHANNEL_ON); + this.out(NR30, 0); + this.out(NR31, 0); + this.out(NR32, 0x08); + this.out(NR33, 0); + this.out(NR34, 0x80); + } + if (this.test(flags(7), SOUND_CHANNEL_ON)) { + this.res(flags(7), SOUND_CHANNEL_ON); + this.out(NR41, 0); + this.out(NR42, 0x08); + this.out(NR43, 0); + this.out(NR44, 0x80); + this.w16(wNoiseSampleAddress, 0); + } + this.w16(wMusicID, id); + const { bank, address } = this.header(AUDIO.SFX, id & 0xffff); + this.w(wMusicBank, bank); + let de = address; + let count = ((this.loadMusicByte(de) >> 6) & 3) + 1; + while (count--) { + const channel = this.loadChannel(de); + de = channel.de; + this.set(channel.bc + CHANNEL_FLAGS1, SOUND_SFX); + this.startChannel(channel.bc); + } + this.w(wMusicPlaying, 1); + this.w(wSFXPriority, 0); + } + + /** PlayStereoSFX */ + playStereoSFX(id: number) { + this.w(wMusicPlaying, 0); + if (!this.stereo) return this.playSFXRaw(id); + this.w16(wMusicID, id); + const { bank, address } = this.header(AUDIO.SFX, id & 0xffff); + this.w(wMusicBank, bank); + let de = address; + let count = ((this.loadMusicByte(de) >> 6) & 3) + 1; + while (count--) { + const channel = this.loadChannel(de); + de = channel.de; + const bc = channel.bc; + this.set(bc + CHANNEL_FLAGS1, SOUND_SFX); + const tracks = [0x11, 0x22, 0x44, 0x88][this.r(wCurChannel) & 3] & this.r(wStereoPanningMask); + this.w(bc + CHANNEL_TRACKS, tracks); + this.w(bc + CHANNEL_FIELD30, tracks); + if (this.r(wCryTracks) >= 2) { + this.w(bc + CHANNEL_FIELD2E, this.r(wSFXDuration)); + this.w(bc + CHANNEL_FIELD2F, this.r(wSFXDuration)); + this.set(bc + CHANNEL_FLAGS2, SOUND_UNKN_0F); + } + this.set(bc + CHANNEL_FLAGS1, SOUND_CHANNEL_ON); + } + this.w(wMusicPlaying, 1); + } + + /** LoadMusicByte: [wMusicBank:de] into wCurMusicByte. */ + private loadMusicByte(de: number) { + const value = this.romByte(this.r(wMusicBank), de); + this.w(wCurMusicByte, value); + return value; + } + + /** LoadChannel: de points at a channel header; returns the channel + * struct and the next header. */ + private loadChannel(de: number) { + const bank = this.r(wMusicBank); + const id = this.loadMusicByte(de) & 7; + de++; + this.w(wCurChannel, id); + const bc = this.channel(id); + this.res(bc + CHANNEL_FLAGS1, SOUND_CHANNEL_ON); + // ChannelInit + for (let i = 0; i < CHANNEL_STRUCT_LENGTH; i++) this.w(bc + i, 0); + this.w16(bc + CHANNEL_TEMPO, 0x100); + this.w(bc + CHANNEL_NOTE_LENGTH, 1); + this.w(bc + CHANNEL_MUSIC_ADDRESS, this.loadMusicByte(de++)); + this.w(bc + CHANNEL_MUSIC_ADDRESS + 1, this.loadMusicByte(de++)); + this.w(bc + CHANNEL_MUSIC_ID, this.r(wMusicID)); + this.w(bc + CHANNEL_MUSIC_ID + 1, this.r(wMusicID + 1)); + this.w(bc + CHANNEL_MUSIC_BANK, bank); + return { bc, de: de & 0xffff }; + } +} diff --git a/src/game/audio/output.ts b/src/game/audio/output.ts new file mode 100644 index 0000000..195755b --- /dev/null +++ b/src/game/audio/output.ts @@ -0,0 +1,77 @@ +import workletUrl from "./worklet.ts?worker&url"; +import type { AudioMessage } from "./worklet"; + +/** The browser side of the sound: an AudioContext running the synthesizer + * worklet, fed the engine's register writes frame by frame. Browsers start + * audio only from a user gesture, so `unlock` runs from the first press. */ +export class AudioOutput { + private context?: AudioContext; + private node?: AudioWorkletNode; + private gain = 1; + private failed = false; + + /** Create or resume the context; call from a user gesture. */ + unlock() { + if (this.failed) return; + try { + if (!this.context) { + const Context = + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Context || !("audioWorklet" in Context.prototype)) { + this.failed = true; + return; + } + const context = new Context({ latencyHint: "interactive" }); + this.context = context; + void context.audioWorklet + .addModule(workletUrl) + .then(() => { + const node = new AudioWorkletNode(context, "crystal-audio", { + numberOfInputs: 0, + outputChannelCount: [2], + }); + node.connect(context.destination); + this.node = node; + this.post({ kind: "gain", value: this.gain }); + }) + .catch(() => { + this.failed = true; + }); + } + if (this.context.state !== "running") void this.context.resume().catch(() => {}); + } catch { + this.failed = true; + } + } + /** Whether sound is playing (unlocked, loaded and running). */ + get running() { + return !!this.node && this.context?.state === "running"; + } + /** Frames of register writes, in order. */ + send(frames: number[][]) { + if (!frames.length || !this.running) return; + this.post({ kind: "frames", frames }); + } + setGain(value: number) { + this.gain = value; + this.post({ kind: "gain", value }); + } + /** Pause: silence at once and drop the queued frames. */ + suspend() { + this.post({ kind: "reset" }); + if (this.context?.state === "running") void this.context.suspend().catch(() => {}); + } + resume() { + if (this.context && this.context.state !== "running") void this.context.resume().catch(() => {}); + } + dispose() { + this.node?.disconnect(); + void this.context?.close().catch(() => {}); + this.node = undefined; + this.context = undefined; + } + private post(message: AudioMessage) { + this.node?.port.postMessage(message); + } +} diff --git a/src/game/audio/sound.ts b/src/game/audio/sound.ts new file mode 100644 index 0000000..779072b --- /dev/null +++ b/src/game/audio/sound.ts @@ -0,0 +1,202 @@ +import type { RomReader } from "../rom/reader"; +import { AudioEngine, NR14, NR24, NR34, NR44, NR50, NR51, NR52, WAVE_RAM, packWrite } from "./engine"; +import { SOUND } from "./constants"; +import { AUDIO } from "../rom/profile"; + +const PLAYER_BIKE = 1; +/** Frames of register writes kept for the output; older ones are dropped + * when nobody listens (tests, or before the first press unlocks sound). */ +const MAX_FRAMES = 32; + +/** The game's side of the sound: the engine, run once per video frame as + * VBlank's UpdateSound does, and the home/audio.asm routines the game + * calls (map music, fades, cries). The register writes of each frame are + * kept for the synthesizer. */ +export class GameSound { + readonly engine: AudioEngine; + private frames: number[][] = []; + + constructor(private readonly reader: RomReader) { + this.engine = new AudioEngine(reader); + this.engine.initSound(); + } + + /** One video frame: UpdateSound. */ + frame() { + this.playQueued(); + this.engine.updateSound(); + this.frames.push(this.engine.drain()); + if (this.frames.length > MAX_FRAMES) this.frames.shift(); + } + /** The frames of register writes since the last call. */ + takeFrames() { + const frames = this.frames; + this.frames = []; + return frames; + } + + /** A frame that restores the sound registers after the output was + * reset (a pause): the last values written, each channel retriggered. */ + snapshot() { + const registers = this.engine.registers; + const frame: number[] = [packWrite(NR52, 0x80), packWrite(NR50, registers[NR50]), packWrite(NR51, registers[NR51])]; + for (let i = 0; i < 16; i++) frame.push(packWrite(WAVE_RAM + i, registers[WAVE_RAM + i])); + for (let register = 0; register < NR50; register++) { + const trigger = register === NR14 || register === NR24 || register === NR34 || register === NR44; + frame.push(packWrite(register, registers[register] | (trigger ? 0x80 : 0))); + } + return frame; + } + + /** PlayMusic */ + playMusic(id: number) { + this.engine.playMusic(id); + } + /** Script_playmusic and PlayMusic2: stop, wait a frame, then play. */ + playMusicAfterFrame(id: number) { + this.engine.playMusic(SOUND.MUSIC_NONE); + this.frame(); + this.engine.playMusic(id); + } + /** Script_playmusic: the fade stops and the volume is full. */ + scriptPlayMusic(id: number) { + this.engine.playMusic(SOUND.MUSIC_NONE); + this.engine.musicFade = 0; + this.engine.maxVolume(); + this.engine.playMusic(id); + } + /** PlayTrainerEncounterMusic: the trainer class's encounter music. */ + trainerEncounterMusic(trainerClass: number) { + this.engine.musicFade = 0; + this.engine.playMusic(SOUND.MUSIC_NONE); + this.frame(); + this.engine.maxVolume(); + const entry = AUDIO.TrainerEncounterMusic + (trainerClass & 0xff); + this.engine.playMusic(entry < this.reader.bytes.length ? this.reader.bytes[entry] : 0); + } + /** PlayMonCry2: a cry from both sides, without waiting. */ + playMonCry2(species: number) { + this.engine.setCryTracks(0, 0); + this.playCry(species); + } + /** PlayStereoCry: from the side `tracks` names ($f0 the player's, $0f + * the enemy's) when stereo is on. */ + playStereoCry(species: number, tracks: number) { + this.engine.setCryTracks(1, tracks); + this.playCry(species); + } + /** Sound effects to play one after another, each once the one before it + * has ended, as PlaySFX and WaitSFX in turn. */ + queueSFX(ids: readonly number[]) { + this.afterSFX(...ids.map((id) => () => this.engine.playSFX(id))); + } + /** Sounds to start one after another, each once the sound effects (or + * cry) before it have ended: a sequence with WaitSFX between its steps + * that the game itself does not wait for. */ + afterSFX(...actions: (() => void)[]) { + this.queued.push(...actions); + this.playQueued(); + } + private queued: (() => void)[] = []; + private playQueued() { + // Steps that start no sound effect (music) run on at once. + while (this.queued.length && !this.engine.checkSFX()) this.queued.shift()!(); + } + /** Drop the queued steps (a new scene replaces them). */ + clearQueue() { + this.queued = []; + } + /** PlayBattleMusic: no fade, full volume, a frame of silence first. */ + playBattleMusic(id: number) { + this.engine.musicFade = 0; + this.engine.playMusic(SOUND.MUSIC_NONE); + this.frame(); + this.engine.maxVolume(); + this.engine.playMusic(id); + } + /** wLowHealthAlarm's DANGER_ON_F, changed only when it differs. */ + setDanger(on: boolean) { + if (on !== this.engine.lowHealthAlarm) this.engine.setLowHealthAlarm(on); + } + /** PlaySFX */ + playSFX(id: number) { + this.engine.playSFX(id); + } + /** WaitSFX's condition: whether a sound effect still plays. */ + get sfxPlaying() { + return this.engine.checkSFX(); + } + /** PlayCry for a species. */ + playCry(species: number) { + if (species >= 1 && species <= 251) this.engine.playCry(species); + } + /** Script_musicfadeout: fade out at `rate`, then play `id`. */ + fadeOutTo(id: number, rate: number) { + this.engine.fadeTo(id, rate & 0x7f); + } + /** SkipMusic: run the engine `frames` times at once. */ + skipMusic(frames: number) { + for (let n = 0; n < frames; n++) this.engine.updateSound(); + } + + /** PlayMapMusic: switch to the map's music unless it already plays. */ + playMapMusic(id: number) { + if (this.engine.mapMusic === id) return; + this.engine.playMusic(SOUND.MUSIC_NONE); + this.frame(); + this.engine.mapMusic = id; + this.engine.playMusic(id); + } + /** PlayMapMusicBike: the Bicycle's music while riding. */ + playMapMusicBike(id: number, playerState: number) { + this.engine.dontPlayMapMusicOnReload = false; + const music = playerState === PLAYER_BIKE ? SOUND.MUSIC_BICYCLE : id; + this.engine.playMusic(SOUND.MUSIC_NONE); + this.frame(); + this.engine.mapMusic = music; + this.engine.playMusic(music); + } + /** FadeToMapMusic: fade out to the map's music unless it already plays. */ + fadeToMapMusic(id: number) { + if (this.engine.mapMusic === id) return; + this.engine.fadeTo(id, 8); + this.engine.mapMusic = id; + } + /** FadeInToMusic */ + fadeInToMusic() { + this.engine.fadeInToMusic(); + } + /** RestartMapMusic */ + restartMapMusic() { + this.engine.playMusic(SOUND.MUSIC_NONE); + this.frame(); + this.engine.playMusic(this.engine.mapMusic); + } + /** TryRestartMapMusic: unless a script asked otherwise. */ + tryRestartMapMusic() { + if (!this.engine.dontPlayMapMusicOnReload) return this.restartMapMusic(); + this.engine.mapMusic = 0; + this.engine.playMusic(SOUND.MUSIC_NONE); + this.frame(); + this.engine.dontPlayMapMusicOnReload = false; + } + /** ForceMapMusic (MAPSETUP_RELOADMAP): the Bicycle fades back in. */ + forceMapMusic(playerState: number) { + if (playerState === PLAYER_BIKE) { + this.engine.minVolume(); + this.engine.musicFade = 0x88; + } + this.tryRestartMapMusic(); + } + /** Script_dontrestartmapmusic */ + dontRestartMapMusic() { + this.engine.dontPlayMapMusicOnReload = true; + } + /** wPlayerState, which FadeMusic reads for the Bicycle. */ + setPlayerState(state: number) { + this.engine.playerState = state; + } + setStereo(stereo: boolean) { + this.engine.stereo = stereo; + } +} diff --git a/src/game/audio/worklet.ts b/src/game/audio/worklet.ts new file mode 100644 index 0000000..f9f090d --- /dev/null +++ b/src/game/audio/worklet.ts @@ -0,0 +1,67 @@ +import { Apu, FRAME_CYCLES } from "./apu"; + +/** The audio thread: plays the sound engine's register writes, one video + * frame of writes per 70224 cycles of synthesized sound. The game sends + * frames as it runs; a few frames of queue absorb its timing jitter. */ + +declare const sampleRate: number; +declare function registerProcessor(name: string, processor: unknown): void; +declare class AudioWorkletProcessor { + readonly port: MessagePort; +} + +/** Frames queued beyond this are applied at once to catch up. */ +const MAX_QUEUE = 8; +/** After running dry, wait for this many frames before playing again. */ +const REFILL = 3; + +export type AudioMessage = + | { kind: "frames"; frames: number[][] } + | { kind: "reset" } + | { kind: "gain"; value: number }; + +class CrystalAudioProcessor extends AudioWorkletProcessor { + private readonly apu = new Apu(sampleRate); + private queue: number[][] = []; + private cycles = 0; + private starving = true; + constructor() { + super(); + this.port.onmessage = ({ data }: MessageEvent) => { + if (data.kind === "frames") this.queue.push(...data.frames); + else if (data.kind === "reset") { + this.queue = []; + this.apu.reset(); + this.starving = true; + } else this.apu.gain = data.value; + }; + } + private apply(frame: number[]) { + for (const write of frame) this.apu.write(write >> 8, write & 0xff); + } + process(_inputs: Float32Array[][], outputs: Float32Array[][]) { + const [left, right = left] = outputs[0]; + if (!left) return true; + while (this.queue.length > MAX_QUEUE) this.apply(this.queue.shift()!); + if (this.starving && this.queue.length >= REFILL) this.starving = false; + let offset = 0; + while (offset < left.length) { + const until = Math.ceil((FRAME_CYCLES - this.cycles) / this.apu.cyclesPerSample); + const count = Math.max(1, Math.min(left.length - offset, until)); + this.apu.render(left, right, offset, count); + offset += count; + this.cycles += count * this.apu.cyclesPerSample; + if (this.cycles >= FRAME_CYCLES) { + this.cycles -= FRAME_CYCLES; + if (!this.starving) { + const frame = this.queue.shift(); + if (frame) this.apply(frame); + else this.starving = true; + } + } + } + return true; + } +} + +registerProcessor("crystal-audio", CrystalAudioProcessor); diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts index a59e2aa..13de969 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -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 = Generator; +// 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; caught: Set }; 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) { diff --git a/src/game/rom/assets.ts b/src/game/rom/assets.ts index ade1b7a..ddb1b64 100644 --- a/src/game/rom/assets.ts +++ b/src/game/rom/assets.ts @@ -130,6 +130,7 @@ export class CrystalAssets { environment: r.byte(header + 2), paletteMode: r.byte(header + 7) & 15, phoneService: r.byte(header + 7) >> 4, + music: r.byte(header + 6), fishGroup: r.byte(header + 8), attributes, blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight), diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index da94bd5..7d9b9f0 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -209,6 +209,8 @@ export const ENGINE_FLAGS = { allFruitTrees: 84, /** STATUSFLAGS2_BUG_CONTEST_TIMER_F is the same WRAM bit. */ bugContestTimer: 17, + rocketsInRadioTower: 19, + rocketsInMahogany: 23, } as const; /** Overworld scripts, routines and tables, keyed by their PRET labels so @@ -1063,6 +1065,20 @@ export const OPTIONS = { "Options_MenuAccount.On": 0xe44f6, } as const; +/** The sound engine (audio/engine.asm) and its tables, keyed by their + * PRET labels. */ +export const AUDIO = { + _UpdateSound: 0xe805c, + FrequencyTable: 0xe8d80, + WaveSamples: 0xe8db2, + Drumkits: 0xe8e52, + TrainerEncounterMusic: 0xe9027, + Music: 0xe906e, + Cries: 0xe91b0, + SFX: 0xe927c, + PokemonCries: 0xf2787, +} as const; + /** Johto's scripts, routines and tables from Route 32 on, keyed by their * PRET labels for `npm run check:reference`. */ export const JOHTO = { diff --git a/src/game/runtime.ts b/src/game/runtime.ts index 955e538..b05b6ca 100644 --- a/src/game/runtime.ts +++ b/src/game/runtime.ts @@ -5,6 +5,7 @@ import { GameWorld, TICKS_PER_SECOND } from "./world/world"; import type { BattleAction } from "./battle/battle"; import type { Direction } from "./world/types"; import { WorldRenderer } from "./rendering/renderer"; +import { AudioOutput } from "./audio/output"; export class NativeRuntime implements GameRuntime { readonly id = "crystal-native-world-v1"; @@ -17,6 +18,9 @@ export class NativeRuntime implements GameRuntime { private pending = 0; private published = ""; private resizeObserver?: ResizeObserver; + /** The synthesizer the world's sound engine feeds. */ + readonly audio = new AudioOutput(); + private volume = 1; constructor( private onUpdate: (world: GameWorld) => void, private onError: (error: unknown) => void, @@ -39,6 +43,12 @@ export class NativeRuntime implements GameRuntime { } play() { if (!this.world || this.raf) return; + // Starting play is a user gesture in the app; browsers allow sound then. + if (this.volume > 0) { + this.audio.unlock(); + this.audio.resume(); + this.audio.send([this.world.sound.snapshot()]); + } this.lastTime = 0; this.raf = requestAnimationFrame(this.tick); } @@ -48,8 +58,16 @@ export class NativeRuntime implements GameRuntime { this.lastTime = 0; this.pressed.clear(); this.pending = 0; + this.audio.suspend(); + this.world?.sound.takeFrames(); if (this.world && this.renderer) this.renderer.render(this.world); } + /** Sound volume, 0 (off) to 1. */ + setVolume(volume: number) { + this.volume = volume; + this.audio.setGain(volume); + if (volume > 0 && this.raf) this.audio.unlock(); + } setButton(button: Button, pressed: boolean) { if (!pressed) { this.pressed.delete(button); @@ -57,6 +75,8 @@ export class NativeRuntime implements GameRuntime { } if (this.pressed.has(button) || !this.world) return; this.pressed.add(button); + // Browsers start sound only from a user gesture such as this press. + if (this.volume > 0) this.audio.unlock(); const world = this.world; if (this.battleInput?.(button)) { this.publish(); @@ -137,6 +157,8 @@ export class NativeRuntime implements GameRuntime { ticks++; } if (ticks === 6) this.pending = 0; + const frames = world.sound.takeFrames(); + if (this.volume > 0) this.audio.send(frames); if (ticks) { this.renderer!.render(world); this.publishIfChanged(); @@ -180,6 +202,7 @@ export class NativeRuntime implements GameRuntime { dialogue?.choice, world.liveState.money, dialogue?.holdTicks === undefined, + world.waitingPage, ]); if (signature === this.published) return; this.published = signature; @@ -262,6 +285,7 @@ export class NativeRuntime implements GameRuntime { dispose() { this.disconnectResizeObserver(); this.pause(); + this.audio.dispose(); this.world = undefined; this.renderer = undefined; } diff --git a/src/game/scripts/interpreter.ts b/src/game/scripts/interpreter.ts index 440d9b5..349dc7e 100644 --- a/src/game/scripts/interpreter.ts +++ b/src/game/scripts/interpreter.ts @@ -4,6 +4,8 @@ import { type Decorations, } from "../world/decorations"; import { RomReader } from "../rom/reader"; +import type { GameSound } from "../audio/sound"; +import { SOUND } from "../audio/constants"; import { giveShuckle, returnShuckie, pokeSeer, photoStudio } from "./cianwood"; import { EMPTY_ROUTINES, @@ -544,6 +546,10 @@ export type ScriptEffect = | { kind: "moveobject"; object: number; x: number; y: number } | { kind: "emote"; object: number; emote: number; ticks: number } | { kind: "pause"; ticks: number } + /** WaitSFX: wait, frozen, until the sound effect channels are off. */ + | { kind: "waitsfx" } + /** DelayFrames inside a special: the map waits, frozen. */ + | { kind: "delay"; ticks: number } /** A front picture in a box (pokepic); species 0 closes it. */ | { kind: "picture"; species: number } /** The introduction's trainer or player picture; none closes it. */ @@ -754,8 +760,19 @@ export interface ScriptHost { /** The map's tileset graphics and background palettes as shown now, * for specials that draw over the map. */ tileset?(): { graphics: Uint8Array; palettes: Palette[] }; + /** The sound engine, and GetMapMusic_MaybeSpecial for this map. */ + sound?: GameSound; + mapMusic?(state: ScriptState): number; } +/** A sound a script started while its text was still to be shown: it + * plays when that page appears, as the original prints, then plays. */ +export type SoundCue = { page: number; play: () => void }; +/** The cues for a batch of pages, carried with the pages a text effect + * shows (a hidden property, so the pages still compare as strings). */ +export const pageCues = (pages: readonly string[]): SoundCue[] => + (pages as { cues?: SoundCue[] }).cues ?? []; + export class UnsupportedScript extends Error { constructor( readonly offset: number, @@ -893,6 +910,10 @@ export class ScriptRun { private strengthSpecies = 0; private iterator: Generator; private instructions = 0; + /** The run's sound helpers, for specials: play now or with the pending + * page, and WaitSFX. */ + private cue: (play: () => void) => void = () => {}; + private waitSFX: () => Generator = function* () {}; constructor( private reader: RomReader, @@ -1028,12 +1049,36 @@ export class ScriptRun { return result; }; const object = (id: number) => (id === LAST_TALKED ? this.lastTalked : id); + // Sounds waiting for their page, and sounds a text command plays. + const cues: SoundCue[] = []; + const sound = this.host.sound; + const cue = (play: () => void) => { + if (!sound) return; + if (pages.length) cues.push({ page: pages.length - 1, play }); + else play(); + }; const write = (address: number) => { - const content = readText(r, address, this.text()); - if (content) pages.push(...content.split("\n\n")); + const sounds: { sfx: number; at: number }[] = []; + const content = readText(r, address, { + ...this.text(), + onSound: (sfx, at) => sounds.push({ sfx, at }), + }); + if (content) { + const base = pages.length; + pages.push(...content.split("\n\n")); + for (const { sfx, at } of sounds) { + const page = base + content.slice(0, at).split("\n\n").length - 1; + if (sound) cues.push({ page: Math.min(page, pages.length - 1), play: () => sound.playSFX(sfx) }); + } + } if (pages.length > 64) throw new Error("Too many dialogue pages."); }; - const take = () => pages.splice(0, pages.length); + const take = () => { + const taken = pages.splice(0, pages.length); + const mine = cues.splice(0, cues.length).filter((entry) => entry.page < taken.length); + if (mine.length) Object.defineProperty(taken, "cues", { value: mine }); + return taken; + }; let picture = false; // Waits for the player when text is waiting; the DOM shows whole pages. const flush = function* (): Generator { @@ -1059,6 +1104,17 @@ export class ScriptRun { throw new Error("Map callbacks cannot present scenes."); return effect; }; + // WaitSFX: the script holds until the sound effects end. With text + // still to show, the sound plays with its page and the reader waits. + const waitSFX = function* (): Generator { + if (!sound || pages.length || options.callback) return; + if (sound.sfxPlaying) yield { kind: "waitsfx" }; + }; + const options = this.options; + this.cue = cue; + this.waitSFX = waitSFX; + // MAPSETUP_RELOADMAP's ForceMapMusic. + const reloadMapMusic = () => cue(() => sound!.forceMapMusic(this.state.playerState)); const sceneOwner = (mapId: string) => this.host.sceneOwner(mapId); // GetWhiteoutSpawn/IsSpawnPoint: the table index of the last recorded // spawn map, or SPAWN_HOME when unset or no longer a listed spawn point. @@ -1571,12 +1627,14 @@ export class ScriptRun { throw new UnsupportedScript(offset, opcode, " (battle type)"); yield* flush(); yield synchronous({ kind: "battle", wild, tutorial: true }); + reloadMapMusic(); wild = undefined; canLose = false; break; } case 0x60: // Script_reloadmapafterbattle: a loss continues with the whiteout. + if ((battleResult & 0x3f) !== 1) reloadMapMusic(); if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript; // BATTLESCRIPT_WILD_F is set by trainer battles, which run // MomTriesToBuySomething. Without a purchase it changes nothing. @@ -1600,6 +1658,7 @@ export class ScriptRun { // MAPSETUP_RELOADMAP restores graphics/audio after battle without // re-running callbacks or rebuilding objects. Native presentation // already retains the map; clear wBattleScriptFlags equivalents. + reloadMapMusic(); wild = undefined; trainer = undefined; canLose = false; @@ -2351,23 +2410,68 @@ export class ScriptRun { }); break; } - // Audio commands are consumed; native sound sequencing is pending. - case 0x7f: - case 0x84: - case 0x85: - word(); + // Audio: played now, or when the page printed before them shows. + case 0x7f: { + // playmusic + const id = word(); + cue(() => sound!.scriptPlayMusic(id)); break; - case 0x81: - word(); - byte(); + } + case 0x80: { + // encountermusic: wOtherTrainerClass's TrainerEncounterMusic. + const trainerClass = trainer?.classId ?? this.tempTrainer?.classId ?? 0; + cue(() => sound!.trainerEncounterMusic(trainerClass)); break; - case 0x80: // encountermusic - case 0x82: - case 0x83: - case 0x86: - case 0x87: - case 0x88: + } + case 0x81: { + // musicfadeout: the next song, then the fade's frames per step. + const id = word(), + rate = byte(); + cue(() => sound!.fadeOutTo(id, rate)); break; + } + case 0x82: // playmapmusic + cue(() => sound!.playMapMusic(this.host.mapMusic?.(this.state) ?? 0)); + break; + case 0x83: // dontrestartmapmusic + cue(() => sound!.dontRestartMapMusic()); + break; + case 0x84: { + // cry: a species, or wScriptVar's for 0; PlayMonCry waits. + const species = word() & 0xff || value & 0xff; + cue(() => sound!.playMonCry2(species)); + yield* waitSFX(); + break; + } + case 0x85: { + // playsound + const id = word(); + cue(() => sound!.playSFX(id)); + break; + } + case 0x86: // waitsfx + yield* waitSFX(); + break; + case 0x87: { + // warpsound: GetWarpSFX by the tile the player stands on. + const collision = this.host.playerCollision(); + const id = + collision === OW.COLL_DOOR + ? SOUND.SFX_ENTER_DOOR + : collision === OW.COLL_WARP_PANEL + ? SOUND.SFX_WARP_TO + : SOUND.SFX_EXIT_BUILDING; + cue(() => sound!.playSFX(id)); + break; + } + case 0x88: { + // specialsound: SFX_GET_TM for a TM or HM, else SFX_ITEM; waits. + const id = + data.itemPocket(this.currentItem) === "tm" ? SOUND.SFX_GET_TM : SOUND.SFX_ITEM; + cue(() => sound!.playSFX(id)); + yield* waitSFX(); + break; + } case 0x8b: { const ticks = byte() || 256; if (pages.length) { @@ -2588,6 +2692,8 @@ export class ScriptRun { soldOut.add(index); bought = true; this.state.money -= total; + // PlayTransactionSound + this.host.sound?.playSFX(SOUND.SFX_TRANSACTION); write(texts.thanks); yield* flush(); } @@ -2648,6 +2754,8 @@ export class ScriptRun { if (!confirmed) continue; tossItem(this.state.bag, slot.item, chosen); this.state.money = Math.min(C.MAX_MONEY, this.state.money + total); + // SellMenu's PlayTransactionSound + this.host.sound?.playSFX(SOUND.SFX_TRANSACTION); write(SYMBOLS.martBoughtText); yield* flush(); } @@ -2663,6 +2771,7 @@ export class ScriptRun { strings: this.strings, numbers: this.numbers, decorationMenu: () => this.decorationMenu(), + sound: this.host.sound, }); } @@ -3301,7 +3410,26 @@ export class ScriptRun { return value; } case SPECIAL_ROUTINES.restartMapMusic: + this.cue(() => this.host.sound!.restartMapMusic()); + return value; case SPECIAL_ROUTINES.fadeOutMusic: + // FadeOutMusic: to MUSIC_NONE, two frames a volume step. + this.cue(() => this.host.sound!.fadeOutTo(0, 2)); + return value; + case SPECIAL_ROUTINES.healMachineAnim: { + // HealMachineAnim: SFX_SECOND_PART_OF_ITEMFINDER and 30 frames for + // each Poké Ball placed, then MUSIC_HEAL over eight 10-frame + // flashes. The map waits, frozen. No Pokémon, no animation. + const sound = this.host.sound; + if (!this.state.party.length || !sound || pages.length || this.options.callback) return value; + for (let n = 0; n < this.state.party.length; n++) { + sound.playSFX(SOUND.SFX_SECOND_PART_OF_ITEMFINDER); + yield { kind: "delay", ticks: 15 }; + } + sound.playMusic(SOUND.MUSIC_HEAL); + yield { kind: "delay", ticks: 40 }; + return value; + } case SPECIAL_ROUTINES.fadeOutToWhite: case SPECIAL_ROUTINES.warpToSpawnPoint: case SPECIAL_ROUTINES.stubbedTrainerRankingsHealings: @@ -3309,10 +3437,14 @@ export class ScriptRun { // StubbedTrainerRankings_Healings begins with `ret`: the Trainer // Rankings feature (Pokémon News) was cut, leaving a dead stub. return value; - case OVERWORLD.UpdatePlayerSprite: case OVERWORLD.PlayMapMusic: - case OVERWORLD.UpdateTimePals: + this.cue(() => this.host.sound!.playMapMusic(this.host.mapMusic?.(this.state) ?? 0)); + return value; case OVERWORLD.WaitSFX: + yield* this.waitSFX(); + return value; + case OVERWORLD.UpdatePlayerSprite: + case OVERWORLD.UpdateTimePals: case OVERWORLD.FadeInFromWhite: case OVERWORLD.LoadUsedSpritesGFX: case OVERWORLD.RefreshSprites: @@ -3324,7 +3456,6 @@ export class ScriptRun { // slow_step in the facing direction, then step_end. this.movementBuffer = [0x08 | this.host.facing(), 0x47]; return value; - case SPECIAL_ROUTINES.healMachineAnim: case SPECIAL_ROUTINES.fadeOutToBlack: case SPECIAL_ROUTINES.fadeInFromBlack: case SPECIAL_ROUTINES.reloadSpritesNoPalettes: @@ -3469,10 +3600,25 @@ export class ScriptRun { case JOHTO.PhotoStudio: yield* photoStudio(this.specialContext(pages, take)); return value; - case JOHTO.PlaySlowCry: - case JOHTO.PlayCurMonCry: - // Cry pitch/duration is audio, like the ordinary cry opcode. + case JOHTO.PlaySlowCry: { + // PlaySlowCry: wScriptVar's cry, pitch - $140 and length + $60. + const species = value & 0xff; + this.cue(() => { + const sound = this.host.sound!; + const { id, pitch, length } = sound.engine.cryEntry(species); + if (species >= 1 && species <= 251) + sound.engine.playCryWith(id, (pitch - 0x140) & 0xffff, (length + 0x60) & 0xffff); + }); + yield* this.waitSFX(); return value; + } + case JOHTO.PlayCurMonCry: { + // PlayMonCry for wCurPartySpecies, the Pokémon just chosen. + const species = this.state.party[this.curPartyMon]?.species ?? 0; + this.cue(() => this.host.sound!.playMonCry2(species)); + yield* this.waitSFX(); + return value; + } case JOHTO.FindPartyMonThatSpecies: return findPartyMonThatSpecies(this.specialContext(pages, take), value); case JOHTO.CheckMagikarpLength: @@ -3503,6 +3649,7 @@ export class ScriptRun { dex: new PokedexData(r), ...this.state.pokedex, firstUnownSeen: this.state.firstUnownSeen, + sound: this.host.sound, }).newEntry(value); return value; case JOHTO.OlderHaircutBrother: diff --git a/src/game/scripts/intro.ts b/src/game/scripts/intro.ts index 037ea8f..5f0a7cb 100644 --- a/src/game/scripts/intro.ts +++ b/src/game/scripts/intro.ts @@ -1,3 +1,5 @@ +import type { GameSound } from "../audio/sound"; +import { SOUND } from "../audio/constants"; import type { RomReader } from "../rom/reader"; import { INTRO } from "../rom/profile"; import { @@ -27,6 +29,8 @@ export interface IntroHost { now(): Date; /** Text values, with `` read as `player`. */ text(player: string): TextContext; + /** The sound engine: OakSpeech's music and Wooper's cry. */ + sound?: GameSound; } /** A new game's introduction: NewGame's PlayerProfileSetup (InitGender) @@ -100,12 +104,20 @@ export class IntroRun { ); this.avatar = gender === 1 ? "kris" : "chris"; yield* this.initClock(); + // OakSpeech: MUSIC_ROUTE_30 after InitClock. + const sound = this.host.sound; + sound?.playMusic(SOUND.MUSIC_ROUTE_30); yield { kind: "portrait", portrait: { trainer: POKEMON_PROF } }; yield { kind: "text", pages: this.pages(INTRO._OakText1) }; // OakText2's text_asm plays Wooper's cry; OakText3 is a button wait. yield { kind: "portrait" }; yield { kind: "picture", species: WOOPER }; - yield { kind: "text", pages: this.pages(INTRO._OakText2) }; + const oakText2 = this.pages(INTRO._OakText2); + if (sound) + Object.defineProperty(oakText2, "cues", { + value: [{ page: oakText2.length - 1, play: () => sound.playMonCry2(WOOPER) }], + }); + yield { kind: "text", pages: oakText2 }; yield { kind: "text", pages: this.pages(INTRO._OakText4) }; yield { kind: "picture", species: 0 }; yield { kind: "portrait", portrait: { trainer: POKEMON_PROF } }; diff --git a/src/game/scripts/pc.ts b/src/game/scripts/pc.ts index 1fc0ef4..bcb441a 100644 --- a/src/game/scripts/pc.ts +++ b/src/game/scripts/pc.ts @@ -1,3 +1,5 @@ +import type { GameSound } from "../audio/sound"; +import { SOUND } from "../audio/constants"; import type { RomReader } from "../rom/reader"; import type { PokemonData } from "../rom/pokemon"; import { ENGINE_FLAGS, MENU, PC, RAM } from "../rom/profile"; @@ -37,6 +39,8 @@ export interface PcHost { numbers: Record; /** _PlayerDecorationMenu: true if a decoration changed. */ decorationMenu(): Gen; + /** The sound engine, for the PC's boot, choice and shutdown sounds. */ + sound?: GameSound; } /** PokemonCenterPC's menu items (PCPCITEM_*). */ @@ -144,6 +148,8 @@ export class PcSession { yield* this.print(PC["PC_CheckPartyForPokemon.PokecenterPCCantUseText"]); return; } + // PC_PlayBootSound + this.host.sound?.playSFX(SOUND.SFX_BOOT_PC); yield* this.print(PC.PokecenterPCTurnOnText); const whose = this.pages(PC.PokecenterPCWhoseText); let cursor = 0; @@ -160,6 +166,10 @@ export class PcSession { undefined, cursor, ); + // PC_PlayShutdownSound when leaving; PC_PlayChoosePCSound otherwise. + const sound = this.host.sound; + if (choice < 0 || items[choice] === PCPCITEM_TURN_OFF) sound?.playSFX(SOUND.SFX_SHUT_DOWN_PC); + else sound?.playSFX(SOUND.SFX_CHOOSE_PC_OPTION); if (choice < 0) return; cursor = choice; switch (items[choice]) { @@ -187,6 +197,7 @@ export class PcSession { /** _PlayersHousePC: returns true when a decoration changed, which makes * the script reload the room. */ *playersHousePC(): Gen { + this.host.sound?.playSFX(SOUND.SFX_BOOT_PC); yield* this.print(PC.PlayersPCTurnOnText); return yield* this.playersPC(true); } diff --git a/src/game/scripts/pokedex.ts b/src/game/scripts/pokedex.ts index 4681c00..b6d2827 100644 --- a/src/game/scripts/pokedex.ts +++ b/src/game/scripts/pokedex.ts @@ -1,3 +1,5 @@ +import type { GameSound } from "../audio/sound"; +import { SOUND } from "../audio/constants"; import { NUM_POKEMON } from "../rom/profile"; import type { PokedexData } from "../rom/pokedex"; import { dexHeight, dexWeight } from "../rom/pokedex"; @@ -86,13 +88,15 @@ export interface PokedexHost { caught: ReadonlySet; /** wFirstUnownSeen, the letter Unown's picture shows (0 before any). */ firstUnownSeen?: number; + /** The sound engine, for the entries' cries and the closing click. */ + sound?: GameSound; } /** Pokedex (engine/pokedex/pokedex.asm) in New mode: the main list and * the entry screen. B leaves the list; A opens a seen Pokémon's entry, * where left and right move the arrow cursor, up and down step through - * seen Pokémon, A runs the action under the cursor and B returns. Only - * PAGE acts: AREA, CRY and PRNT, the option (SELECT) and search (START) + * seen Pokémon, A runs the action under the cursor and B returns. PAGE + * and CRY act: AREA and PRNT, the option (SELECT) and search (START) * screens and the Unown mode are not ported. */ export class Pokedex { /** wPokedexOrder, in DEXMODE_NEW. */ @@ -118,7 +122,11 @@ export class Pokedex { *run(): Gen { for (;;) { const button = yield* this.button(this.listView()); - if (button === "b") break; + if (button === "b") { + // Pokedex's .exit: SFX_READ_TEXT_2. + this.host.sound?.playSFX(SOUND.SFX_READ_TEXT_2); + break; + } if (button === "a") { if (!this.host.seen.has(this.selected())) continue; yield* this.entry(); @@ -137,6 +145,8 @@ export class Pokedex { for (const page of [1, 2] as const) { const view = this.entryView(page, 0, species); view.entry!.actions = []; + // NewPokedexEntry's PlayMonCry, with the first page. + if (page === 1) this.host.sound?.playMonCry2(species); for (;;) { const button = yield* this.button(view); if (button === "a" || button === "b") break; @@ -157,6 +167,8 @@ export class Pokedex { let page: 1 | 2 = 1; let action = 0; this.prevEntry = this.selected(); + // Pokedex_InitDexEntryScreen's PlayMonCry. + this.host.sound?.playMonCry2(this.selected()); for (;;) { const button = yield* this.button(this.entryView(page, action)); // Pokedex_MoveArrowCursor reads left and right only. @@ -164,21 +176,23 @@ export class Pokedex { else if (button === "right" && action < ENTRY_ACTIONS - 1) action++; if (button === "b") return; if (button === "a") { - // Pokedex_Page; .Area, .Cry and .Print are not ported. + // Pokedex_Page and .Cry; .Area and .Print are not ported. if (action === 0) { page = page === 1 ? 2 : 1; this.prevEntry = this.selected(); - } + } else if (action === 2) this.host.sound?.playCry(this.selected()); continue; } if ( (button === "up" || button === "down") && this.nextOrPrevious(button) ) { - // Pokedex_ReinitDexEntryScreen: page 1 and the cursor on PAGE. + // Pokedex_ReinitDexEntryScreen: page 1, the cursor on PAGE, and + // the new Pokémon's cry. page = 1; action = 0; this.prevEntry = this.selected(); + this.host.sound?.playMonCry2(this.selected()); } } } diff --git a/src/game/scripts/start-menu.ts b/src/game/scripts/start-menu.ts index 0094ef9..d173982 100644 --- a/src/game/scripts/start-menu.ts +++ b/src/game/scripts/start-menu.ts @@ -1,3 +1,4 @@ +import { SOUND } from "../audio/constants"; import type { RomReader } from "../rom/reader"; import type { PokemonData } from "../rom/pokemon"; import type { PokedexData } from "../rom/pokedex"; @@ -363,6 +364,7 @@ export class StartMenuRun { seen: this.state.pokedex.seen, caught: this.state.pokedex.caught, firstUnownSeen: this.state.firstUnownSeen, + sound: this.host.scriptHost().sound, }, this.cursors.dex, ); @@ -1455,6 +1457,10 @@ export class StartMenuRun { this.reader.byte(BATTLE.HappinessChanges + (action - 1) * 3 + tier), ); } + /** A sound effect, when the game has sound. */ + private sfx(id: number) { + this.host.scriptHost().sound?.playSFX(id); + } /** The new HP, with HealHP_SFX_GFX's delta for the action text. */ private setHP(mon: PartyMon, hp: number) { this.numbers[RAM.curHPAnimDeltaHP] = hp - mon.hp; @@ -1476,6 +1482,7 @@ export class StartMenuRun { } if (amount === undefined) throw new Error("Missing healing amount."); this.setHP(mon, restoreHealth(mon.hp, mon.stats[0], amount)); + this.sfx(SOUND.SFX_POTION); // HealHP_SFX_GFX yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP); this.useDisposableItem(use); return 0; @@ -1497,6 +1504,7 @@ export class StartMenuRun { const { text, mask } = this.healingAction(use.item); if (!(mon.status & mask)) return 1; mon.status = 0; + this.sfx(SOUND.SFX_FULL_HEAL); // Play_SFX_FULL_HEAL yield* this.itemActionText(target, text); this.useDisposableItem(use); return 0; @@ -1513,6 +1521,7 @@ export class StartMenuRun { ); this.setHP(mon, mon.stats[0]); mon.status = 0; + this.sfx(SOUND.SFX_POTION); yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP); this.useDisposableItem(use); return true; @@ -1523,6 +1532,7 @@ export class StartMenuRun { const mon = this.state.party[target]; if (mon.hp) return 1; this.setHP(mon, use.item === C.REVIVE ? mon.stats[0] >> 1 : mon.stats[0]); + this.sfx(SOUND.SFX_POTION); yield* this.itemActionText(target, C.PARTYMENUTEXT_REVIVE); this.useDisposableItem(use); return 0; diff --git a/src/game/scripts/text.ts b/src/game/scripts/text.ts index d5a5956..9a9b454 100644 --- a/src/game/scripts/text.ts +++ b/src/game/scripts/text.ts @@ -86,8 +86,22 @@ export interface TextContext { /** Native handlers for text_asm, keyed by the command's ROM offset. They * return where text continues, as the original routine returns in hl. */ asm?: (offset: number) => number | undefined; + /** TextCommand_SOUND's sound effect (TextSFX), with the length of the + * text printed before it. */ + onSound?: (sfx: number, at: number) => void; } +/** TextSFX: the text commands that play a sound effect, and its id. */ +const TEXT_SFX: Readonly> = { + 0x0b: 0x00, // SFX_DEX_FANFARE_50_79 + 0x0e: 0x09, // SFX_DEX_FANFARE_20_49 + 0x0f: 0x01, // SFX_ITEM + 0x10: 0x02, // SFX_CAUGHT_MON + 0x11: 0x0a, // SFX_DEX_FANFARE_80_109 + 0x12: 0x2d, // SFX_FANFARE + 0x13: 0x2c, // SFX_SLOT_MACHINE_START +}; + export const defaultTextContext = (player: string): TextContext => ({ player, // New games initialize these names before the player can choose them. @@ -252,6 +266,7 @@ export function readTextEnd( break; } case 0x0a: + break; // Timed pauses belong to presentation. case 0x0b: case 0x0e: case 0x0f: @@ -259,7 +274,8 @@ export function readTextEnd( case 0x11: case 0x12: case 0x13: - break; // Timed pauses and sound cues belong to presentation/audio. + context.onSound?.(TEXT_SFX[command], output.length); + break; case 0x0c: output += "…".repeat(reader.byte(p++)); break; @@ -275,10 +291,12 @@ export function readTextEnd( output += context.weekday; break; case 0x16: { + const before = output.length, + onSound = context.onSound; const nested = readTextEnd( reader, reader.near(p, reader.byte(p + 2)), - context, + onSound ? { ...context, onSound: (sfx, at) => onSound(sfx, before + at) } : context, depth + 1, ); output += nested.text; diff --git a/src/game/world/constants.ts b/src/game/world/constants.ts index 32ac2c7..716421a 100644 --- a/src/game/world/constants.ts +++ b/src/game/world/constants.ts @@ -26,6 +26,7 @@ export const OW = { COLL_DOOR_79: 0x79, COLL_STAIRCASE: 0x7a, COLL_CAVE: 0x7b, + COLL_WARP_PANEL: 0x7c, COLL_WARP_CARPET_RIGHT: 0x7e, COLL_COUNTER: 0x90, HI_NYBBLE_CURRENT: 0x30, diff --git a/src/game/world/types.ts b/src/game/world/types.ts index bd05abc..0819c4e 100644 --- a/src/game/world/types.ts +++ b/src/game/world/types.ts @@ -60,6 +60,8 @@ export interface GameMap { /** The palette byte's high nybble: nonzero where the phone has no * signal (GetMapPhoneService). */ phoneService: number; + /** MAP_MUSIC: the song id, MUSIC_MAHOGANY_MART or RADIO_TOWER_MUSIC | id. */ + music: number; /** MAP_FISHGROUP: the FishGroups entry the rods fish from, or * FISHGROUP_NONE. */ fishGroup: number; diff --git a/src/game/world/world.ts b/src/game/world/world.ts index 97f21d3..080a1ce 100644 --- a/src/game/world/world.ts +++ b/src/game/world/world.ts @@ -4,6 +4,9 @@ import { type Decorations, } from "./decorations"; import { CrystalAssets } from "../rom/assets"; +import { GameSound } from "../audio/sound"; +import { SOUND } from "../audio/constants"; +import { battleMusic, hpBarPixels, inKanto, victoryMusic } from "../audio/battle-music"; import { TrainerData } from "../rom/trainers"; import { ENGINE_FLAGS, @@ -57,6 +60,8 @@ import { type Portrait, type ScriptState, type DayTimerName, + pageCues, + type SoundCue, } from "../scripts/interpreter"; import { initialMagikarpRecord, type MagikarpRecord } from "../scripts/lake-of-rage"; import { @@ -117,6 +122,7 @@ import { type BattleInput, type BattleOutcome, type RoamMon, + type BattleSound, } from "../battle/battle"; import { C } from "../battle/constants"; import { @@ -240,6 +246,21 @@ function randomByte(): number { } /** One overworld tick is two video frames (MaxOverworldDelay). */ +/** Choices answered through the menu routines that click (MenuClickSound). */ +const CLICKING_CHOICES = new Set([ + "yesno", + "weekday", + "menu", + "martmenu", + "martlist", + "martquantity", + "list", + "pack", +]); + +/** Which map setup script loads a map, for its music. */ +type MapSetup = "warp" | "door" | "connection" | "continue" | "none"; + export const TICKS_PER_SECOND = 59.7275 / 2; /** STEP_WALK: 8 ticks at 2 pixels per tick. */ export const WALK_TICKS = 8; @@ -443,6 +464,8 @@ export interface BattleView { >; /** Yes/No cursor, as on the original boxes. */ selected: 0 | 1; + /** wOtherTrainerClass, for PlayVictoryMusic; absent in a wild battle. */ + trainerClass?: number; } /** EvolutionAnimation in overworld ticks (two frames each): 80 frames on * the old species, then .AnimationSequence's seven rounds of a B-checking @@ -793,12 +816,13 @@ export class GameWorld { /** A source of random bytes, like the game's Random routine. */ private random: () => number = randomByte, ) { + this.sound = new GameSound(assets.reader); this.state.decorations = initialDecorations(); this.playerId = (this.random() << 8) | this.random(); // A new game warps into the bedroom. Its NEWMAP callback runs the // original InitializeEventsScript, which supplies initial NPC flags. const entry = this.prepareMap(INITIAL_POSITION.map, "warp"); - this.applyMap(entry); + this.applyMap(entry, "none"); } get x() { @@ -1253,7 +1277,11 @@ export class GameWorld { // .NotMoving: bump into the obstacle (not into an edge warp), or stand. this.turning = 0; this.bumping = Boolean(held) && WARP_DIRECTIONS[tile] !== held; - if (this.bumping) player.stepFrame = (player.stepFrame + 1) & 0xff; + if (this.bumping) { + player.stepFrame = (player.stepFrame + 1) & 0xff; + // .BumpSound, unless a sound effect already plays. + if (!this.sound.sfxPlaying) this.sound.playSFX(SOUND.SFX_BUMP); + } return false; } @@ -1386,7 +1414,11 @@ export class GameWorld { ) return false; if (!this.enterable(x, y)) return false; - if (permission === OW.LAND_TILE) this.state.playerState = OW.PLAYER_NORMAL; + if (permission === OW.LAND_TILE) { + // .ExitWater: GetOutOfWater, then PlayMapMusic. + this.state.playerState = OW.PLAYER_NORMAL; + this.sound.playMapMusic(this.mapMusicId()); + } if (!player.fixedFacing) player.direction = direction; this.turning = 0x80 | DIRECTION_ORDER.indexOf(direction); this.startMotion(player, x, y, WALK_TICKS, true); @@ -1416,6 +1448,7 @@ export class GameWorld { if (!player.fixedFacing) player.direction = direction; this.turning = 0x80 | DIRECTION_ORDER.indexOf(direction); // SetMinTwoStepWildEncounterCooldown is a no-op in Crystal. + this.sound.playSFX(SOUND.SFX_JUMP_OVER_LEDGE); this.startMotion(player, landingX, landingY, WALK_TICKS * 2, true, true); return true; } @@ -1507,12 +1540,23 @@ export class GameWorld { private enterWarp(warp: Warp): boolean { const destination = this.prepareWarp(warp); if (!destination) return false; + // WarpToNewMapScript's warpsound: GetWarpSFX for the tile left. + const left = this.assets.collision(this.map, this.player.x, this.player.y); + // A pit's FallIntoMapScript plays its own sounds on arrival. + if (!isPit(left)) + this.sound.playSFX( + left === OW.COLL_DOOR + ? SOUND.SFX_ENTER_DOOR + : left === OW.COLL_WARP_PANEL + ? SOUND.SFX_WARP_TO + : SOUND.SFX_EXIT_BUILDING, + ); this.player.x = destination.arrival.x; this.player.y = destination.arrival.y; this.player.motion = undefined; if (FACE_DOWN_ON_WARP.has(destination.collision)) this.player.direction = "down"; - this.applyMap(destination.entry); + this.applyMap(destination.entry, "door"); // WarpToNewMapScript's MAPSETUP_DOOR and FallIntoMapScript's // MAPSETUP_FALL both fall through into MAPSETUP_TRAIN's own // mapsetup UpdateRoamMons. @@ -1543,7 +1587,7 @@ export class GameWorld { direction: "down", motion: undefined, }; - this.applyMap(entry); + this.applyMap(entry, "warp"); this.turning = 0; this.checkUpdatePlayerSprite(this.state); } @@ -1601,7 +1645,7 @@ export class GameWorld { const entry = this.prepareMap(target.map.id, "connection"); player.x = target.x; player.y = target.y; - this.applyMap(entry); + this.applyMap(entry, "connection"); // MapSetupScript_Connection's own mapsetup UpdateRoamMons. this.applyRoamMons(this.state, target.map.id, false); return; @@ -2144,13 +2188,16 @@ export class GameWorld { } } } - private applyMap(entry: { - id: string; - state: ScriptState; - actors: Map; - map: GameMap; - stoneTables: number[]; - }) { + private applyMap( + entry: { + id: string; + state: ScriptState; + actors: Map; + map: GameMap; + stoneTables: number[]; + }, + setup: MapSetup, + ) { this.mapId = entry.id; this.currentMap = entry.map; this.stoneTables = entry.stoneTables; @@ -2163,6 +2210,61 @@ export class GameWorld { ); this.sceneCheck = true; this.encounterCooldown = 5; // SetUpFiveStepWildEncounterCooldown + this.mapSetupMusic(setup); + } + + /** Sounds the script started after the text it is showing. */ + private soundCues: SoundCue[] = []; + /** The last page a script showed, kept on screen during sound waits. */ + private lastPage?: string; + /** The page left in the text box while a script waits for its sound + * (WaitSFX) or a delay: presentation only, it takes no buttons. */ + waitingPage: string | undefined = undefined; + /** Play the sounds whose page has come up. */ + private playCues(page: number) { + const due = this.soundCues.filter((entry) => entry.page <= page); + if (!due.length) return; + this.soundCues = this.soundCues.filter((entry) => entry.page > page); + for (const entry of due) entry.play(); + } + + /** The map setup scripts' music (data/maps/setup_scripts.asm). */ + private mapSetupMusic(setup: MapSetup) { + const sound = this.sound, + music = this.mapMusicId(); + sound.setPlayerState(this.state.playerState); + if (setup === "warp") { + // MAPSETUP_WARP, _TELEPORT and _FLY: PlayMapMusicBike, FadeInToMusic. + sound.playMapMusicBike(music, this.state.playerState); + sound.fadeInToMusic(); + } else if (setup === "door") { + // MAPSETUP_DOOR, _FALL and _TRAIN: FadeOutMapMusic, FadeToMapMusic. + sound.skipMusic(6); + sound.fadeToMapMusic(music); + } else if (setup === "connection") sound.fadeToMapMusic(music); + else if (setup === "continue") sound.playMapMusicBike(music, this.state.playerState); + } + + /** GetMapMusic_MaybeSpecial: Surf's music, the contest gates' ranking + * music, then the map's own, which the Rockets can change. */ + mapMusicId(state = this.state, map = this.map) { + if (state.playerState === OW.PLAYER_SURF || state.playerState === OW.PLAYER_SURF_PIKA) + return SOUND.MUSIC_SURF; + if ( + state.flags.has(ENGINE_FLAGS.bugContestTimer) && + (map.id === "10:15" || map.id === "10:17") + ) + return SOUND.MUSIC_BUG_CATCHING_CONTEST_RANKING; + const music = map.music; + if (music === SOUND.MUSIC_MAHOGANY_MART) + return state.flags.has(ENGINE_FLAGS.rocketsInMahogany) + ? SOUND.MUSIC_ROCKET_HIDEOUT + : SOUND.MUSIC_CHERRYGROVE_CITY; + if (music & 0x80) + return state.flags.has(ENGINE_FLAGS.rocketsInRadioTower) + ? SOUND.MUSIC_ROCKET_OVERTURE + : music & 0x7f; + return music; } // -------------------------------------------------------------- scripts @@ -2188,6 +2290,8 @@ export class GameWorld { gender: this.avatar === "kris" ? 1 : 0, }), random: () => this.random() & 0xff, + sound: this.sound, + mapMusic: (state) => this.mapMusicId(state), landmark: () => this.assets.map(mapId).landmark, facing: () => DIRECTION_ORDER.indexOf(this.player.direction), position: () => ({ x: this.player.x, y: this.player.y }), @@ -2392,6 +2496,8 @@ export class GameWorld { } if (this.busy || this.introPending) return false; this.clearNotice(); + // StartMenu's SFX_MENU; SelectMenu's PlayTalkObject. + this.sound.playSFX(select ? SOUND.SFX_READ_TEXT_2 : SOUND.SFX_MENU); this.script = { run: new StartMenuRun( this.state, @@ -2451,6 +2557,7 @@ export class GameWorld { this.script = { run: new IntroRun(this.state, { reader: this.assets.reader, + sound: this.sound, now: () => this.now(), text: (player) => ({ ...this.host(PLAYER).text(), player }), }), @@ -2510,7 +2617,21 @@ export class GameWorld { // Commands after a warp would run in the new map in the original. if (script.warp) throw new Error("Script commands after a warp are not ported."); + const pages = (effect as { pages?: string[] }).pages; + if (pages) { + this.soundCues = pageCues(pages); + this.playCues(0); + this.lastPage = pages.at(-1); + } else if (effect.kind !== "waitsfx" && effect.kind !== "delay") this.lastPage = undefined; switch (effect.kind) { + case "waitsfx": + case "delay": { + script.waitTicks = effect.kind === "delay" ? effect.ticks : 0; + if (effect.kind === "waitsfx" && !this.sound.sfxPlaying) return false; + // The text box keeps its last page meanwhile, as in the original. + this.waitingPage = this.lastPage; + return true; + } case "text": if (!effect.pages.length) return false; this.dialogue = { @@ -2688,6 +2809,7 @@ export class GameWorld { }); const engine = new Battle({ data: this.assets.pokemon, + sound: (sound) => this.battleSound(sound), party: draft.party, pokedex: draft.pokedex, playerId: this.playerId, @@ -2745,8 +2867,19 @@ export class GameWorld { draft.flags.has(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K + bit) ? 1 << bit : 0, ).reduce((bits, bit) => bits | bit, 0), }); - this.battle = { engine, selected: 1 }; + const trainerClass = tower ? tower.trainerClass : effect.trainer?.classId; + this.battle = { engine, selected: 1, trainerClass }; this.battleRun = engine.run(); + // PlayBattleMusic, before the battle transition. + this.sound.playBattleMusic( + battleMusic({ + battleType: effect.battleType, + trainerClass, + trainerId: effect.trainer?.id, + night: this.clockTimeOfDay === 2, + kanto: inKanto(this.assets.map(this.mapId).landmark), + }), + ); this.pumpBattle(); return true; } @@ -2927,7 +3060,15 @@ export class GameWorld { }, }; return true; - case "evolve": + case "evolve": { + // EvolutionAnimation: silence, the old species' cry (not when it + // is asleep or frozen), then MUSIC_EVOLUTION. + const mon = script.run.state.party.find((candidate) => candidate.species === effect.from); + const sound = this.sound; + sound.clearQueue(); + sound.playMusic(SOUND.MUSIC_NONE); + if (!mon || !(mon.status & (C.SLP_MASK | (1 << C.FRZ)))) sound.playMonCry2(effect.from); + sound.afterSFX(() => sound.playMusic(SOUND.MUSIC_EVOLUTION)); this.picture = effect.from; this.evolution = { from: effect.from, @@ -2937,6 +3078,7 @@ export class GameWorld { schedule: evolutionSchedule(effect.from, effect.to), }; return true; + } case "pack": this.dialogue = { pages: [""], @@ -3268,6 +3410,8 @@ export class GameWorld { private finishScript() { const script = this.script!; + this.soundCues = []; + this.waitingPage = undefined; if (script.warp) { const { map, x, y, facing, door, roam } = script.warp; const target = this.assets.isPorted(map) @@ -3289,7 +3433,7 @@ export class GameWorld { direction: facing ?? (keep ? this.player.direction : "down"), motion: undefined, }; - this.applyMap(entry); + this.applyMap(entry, door ? "door" : "warp"); if (roam) this.applyRoamMons(this.state, map, roam === "jump"); this.turning = 0; this.checkUpdatePlayerSprite(this.state); @@ -3317,6 +3461,9 @@ export class GameWorld { this.name = script.run.name; this.avatar = script.run.avatar; this.introPending = false; + // ShrinkPlayer's SFX_ESCAPE_ROPE, then the new game's MAPSETUP_WARP. + this.sound.playSFX(SOUND.SFX_ESCAPE_ROPE); + this.mapSetupMusic("warp"); } if (!script.scene || script.run.deferred !== undefined) this.sceneCheck = true; @@ -3347,6 +3494,8 @@ export class GameWorld { } private abortScript(error: unknown) { const script = this.script!; + this.soundCues = []; + this.waitingPage = undefined; this.battle = undefined; this.battleRun = undefined; this.state = script.rollback.state; @@ -3413,6 +3562,7 @@ export class GameWorld { } this.battle = undefined; this.battleRun = undefined; + this.sound.setDanger(false); // ExitBattle: EvolveAfterBattle after a win, then CleanUpBattleRAM. const { result, evolvable } = next.value; this.menuCursors = { @@ -3434,7 +3584,10 @@ export class GameWorld { return; } const effect = next.value; - if (effect.kind === "hud") continue; + if (effect.kind === "hud") { + this.checkDanger(); + continue; + } if (effect.kind === "text") { view.message = { text: effect.text, prompt: effect.prompt }; this.battleHold = effect.prompt ? 0 : BATTLE_MESSAGE_TICKS; @@ -3452,6 +3605,25 @@ export class GameWorld { "This battle needs game logic that has not been ported yet. Your progress is unchanged."; } } + /** A battle's sound: PlayStereoCry, PlaySFX, PlayVictoryMusic. */ + private battleSound(sound: BattleSound) { + const view = this.battle; + if (!view) return; + if ("cry" in sound) this.sound.playStereoCry(sound.cry, sound.side === "player" ? 0xf0 : 0x0f); + else if ("sfx" in sound) this.sound.queueSFX(sound.sfx); + else if ("stopDanger" in sound) this.sound.setDanger(false); + else if (sound.music === "capture") this.sound.playMusicAfterFrame(SOUND.MUSIC_CAPTURE); + else this.sound.playMusicAfterFrame(victoryMusic(view.trainerClass)); + } + /** CheckDanger: the low HP tone while the player's HP bar is red. */ + private checkDanger() { + const view = this.battle; + if (!view) return; + const mon = view.engine.player.mon; + const red = + !!mon?.hp && hpBarPixels(mon.hp, mon.maxHp) < 10 && !view.engine.lowHealthAlarmOff; + this.sound.setDanger(red); + } /** FIGHT with a move slot, or RUN, from the battle menu. */ battleAction(action: BattleAction) { if (this.battle?.prompt?.kind === "action") this.pumpBattle(action); @@ -3693,6 +3865,7 @@ export class GameWorld { false, ); npc.step = "boulder"; + this.sound.playSFX(SOUND.SFX_STRENGTH); this.animate(actor); return; } @@ -3866,9 +4039,16 @@ export class GameWorld { trainerCard: TrainerCardView | undefined; private cardFrames = 0; ticks = 0; + /** The sound engine and the home routines that drive it. */ + readonly sound: GameSound; tick(held?: Direction) { this.ticks++; + // VBlank's UpdateSound, once per video frame. + this.sound.setPlayerState(this.state.playerState); + this.sound.setStereo(((this.state.options.options >> OW.STEREO) & 1) === 1); + this.sound.frame(); + this.sound.frame(); // GameTimer: two frames per overworld tick, once the game has started. if (!this.introPending) { advanceGameTime(this.gameTime); @@ -3906,6 +4086,18 @@ export class GameWorld { this.cardFrames += 2; refreshTrainerCard(card, this.cardFrames, this.gameTime); } + if (script.effect?.kind === "delay" || script.effect?.kind === "waitsfx") { + // WaitSFX holds everything but the sound; a long wait gives up. + const done = + script.effect.kind === "delay" + ? --script.waitTicks <= 0 + : !this.sound.sfxPlaying || ++script.waitTicks > 900; + if (done) { + this.waitingPage = undefined; + this.pump(); + } + return; + } const evolution = this.evolution; if (evolution) { const frame = evolution.schedule[++evolution.step]; @@ -4065,7 +4257,14 @@ export class GameWorld { (object) => object.x === x && object.y === y, ); let script: number | undefined; + // PlayTalkObject (and PlayClickSFX for tile scripts) as an event starts. + const talk = () => this.sound.playSFX(SOUND.SFX_READ_TEXT_2); if (object) { + if (object.kind !== 0 && object.kind !== 1 && object.kind !== OBJECTTYPE_TRAINER) { + this.notice = "This interaction is not ported yet."; + return; + } + talk(); if (object.kind === 1) { // OBJECTTYPE_ITEMBALL: the script field names raw data (the itemball // macro's item, quantity), not a script; FindItemInBallScript always @@ -4115,6 +4314,7 @@ export class GameWorld { item: r.byte(event.script + 2), }; if (this.state.events.has(hidden.event)) return; + talk(); this.startScript(SYMBOLS.hiddenItemScript, PLAYER, { hiddenItem: hidden, }); @@ -4137,11 +4337,14 @@ export class GameWorld { } if (script === undefined) { const [fx, fy] = VECTORS[this.player.direction]; - if (this.tryTileCollisionEvent(this.x + fx, this.y + fy, collision)) + if (this.tryTileCollisionEvent(this.x + fx, this.y + fy, collision)) { + if (this.script) talk(); return; + } this.notice = "Nothing to interact with here."; return; } + talk(); this.startScript(script, PLAYER); } @@ -4290,10 +4493,16 @@ export class GameWorld { OW.LAND_TILE; if (!allowed) return undefined; // .CheckIfRegistered: Select uses the scripts without a message. - if (state.playerState === OW.PLAYER_NORMAL) + if (state.playerState === OW.PLAYER_NORMAL) { + // .GetOnBike: MUSIC_BICYCLE at once, as wMapMusic. + this.sound.engine.musicFade = 0; + this.sound.playMusicAfterFrame(SOUND.MUSIC_BICYCLE); + this.sound.engine.maxVolume(); + this.sound.engine.mapMusic = SOUND.MUSIC_BICYCLE; return select ? OVERWORLD.Script_GetOnBike_Register : OVERWORLD.Script_GetOnBike; + } if (state.playerState === OW.PLAYER_BIKE) return state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE) ? OVERWORLD.Script_CantGetOffBike @@ -4347,6 +4556,23 @@ export class GameWorld { private finishEvolution(stopped: boolean) { const evolution = this.evolution!; this.evolution = undefined; + // .PlayEvolvedSFX and the new picture's cry, or the old one's when + // stopped; then EvolvePokemon's jingle under EvolvedIntoText. Outside + // a battle's aftermath, ReturnToMap's RestartMapMusic follows. + const sound = this.sound, + afterBattle = Boolean(this.script?.interlude); + sound.clearQueue(); + if (stopped) sound.playMonCry2(evolution.from); + else + sound.afterSFX( + () => sound.playSFX(SOUND.SFX_EVOLVED), + () => sound.playMonCry2(evolution.to), + () => { + sound.playMusic(SOUND.MUSIC_NONE); + sound.playSFX(SOUND.SFX_CAUGHT_MON); + }, + ); + if (!afterBattle) sound.afterSFX(() => sound.restartMapMusic()); this.picture = stopped ? evolution.from : evolution.to; this.pump(stopped ? 1 : 0); } @@ -4383,7 +4609,10 @@ export class GameWorld { return; } if (!last) { + // PromptButton's SFX_READ_TEXT_2 after the button. + this.sound.playSFX(SOUND.SFX_READ_TEXT_2); dialogue.index++; + this.playCues(dialogue.index); return; } this.townMap = false; @@ -4510,6 +4739,8 @@ export class GameWorld { !(dialogue.choice.realtime && answer === "")) ) return; + // MenuClickSound: menus click on A or B. + if (CLICKING_CHOICES.has(dialogue.choice.kind)) this.sound.playSFX(SOUND.SFX_READ_TEXT_2); this.townMap = false; this.statsScreen = undefined; this.mailView = undefined; @@ -4913,7 +5144,7 @@ export class GameWorld { throw error; } this.script = undefined; - this.applyMap(entry); + this.applyMap(entry, continued ? "continue" : "none"); for (const object of save.objects) { const source = map.objects.find( (candidate) => candidate.id === object.id, diff --git a/src/main.ts b/src/main.ts index 8ab07d0..90d187b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,8 @@ import { renderPokedexScreen, renderPokedexTab } from "./ui/pokedex"; import { RESERVED_KEYS, TEXT_SCALES, + VOLUMES, + type Volume, applyTextScale, buttonKeys, defaultPreferences, @@ -93,7 +95,12 @@ function showWorld(world: GameWorld) { get("town-map-view").hidden = !world.townMap; if (world.townMap) drawTownMap(get("town-map-canvas"), world); - const dialogue = world.dialogue; + // While a script waits for its sound, the last page stays in the box. + const dialogue = + world.dialogue ?? + (world.waitingPage !== undefined + ? { pages: [world.waitingPage], index: 0, holdTicks: 0 } + : undefined); showPicture(world); // A page without text is a button wait while a picture is shown. const pictureWait = Boolean( @@ -1696,6 +1703,7 @@ const input = new InputController((button, pressed) => let preferences = loadPreferences(); let keys = keyMap(preferences); applyTextScale(preferences.textScale); +runtime.setVolume(preferences.volume); const controls = bindControls( root, input, @@ -2021,6 +2029,7 @@ function setPreferences(next: Preferences, status = "") { keys = keyMap(preferences); savePreferences(preferences); applyTextScale(preferences.textScale); + runtime.setVolume(preferences.volume); get("game-screen").setAttribute("aria-label", `Game screen. ${controlsSummary()}`); showSettings(); get("settings-status").textContent = status; @@ -2028,6 +2037,8 @@ function setPreferences(next: Preferences, status = "") { function showSettings() { for (const radio of document.querySelectorAll('input[name="text-scale"]')) radio.checked = Number(radio.value) === preferences.textScale; + for (const radio of document.querySelectorAll('input[name="volume"]')) + radio.checked = Number(radio.value) === preferences.volume; get("key-table").replaceChildren( ...BUTTON_ORDER.map((button) => { const row = document.createElement("tr"), @@ -2112,6 +2123,12 @@ for (const radio of document.querySelectorAll('input[name="tex if (TEXT_SCALES.includes(scale)) setPreferences({ ...preferences, textScale: scale }, `Text size: ${radio.parentElement?.textContent?.trim()}.`); }); +for (const radio of document.querySelectorAll('input[name="volume"]')) + radio.addEventListener("change", () => { + const volume = Number(radio.value) as Volume; + if (VOLUMES.includes(volume)) + setPreferences({ ...preferences, volume }, `Sound: ${radio.parentElement?.textContent?.trim()}.`); + }); get("reset-keys").addEventListener("click", () => setPreferences({ ...preferences, keys: defaultPreferences().keys }, "Controls reset to the defaults."), ); diff --git a/src/ui/preferences.ts b/src/ui/preferences.ts index c87d3f6..70747ab 100644 --- a/src/ui/preferences.ts +++ b/src/ui/preferences.ts @@ -7,9 +7,13 @@ export interface Preferences { textScale: TextScale; /** The keyboard codes (KeyboardEvent.code) that press each button. */ keys: Record; + /** Sound volume: 0 is off. */ + volume: Volume; } export const TEXT_SCALES = [1, 1.25, 1.5] as const; +export const VOLUMES = [0, 0.3, 0.6, 1] as const; +export type Volume = (typeof VOLUMES)[number]; export type TextScale = (typeof TEXT_SCALES)[number]; export const DEFAULT_KEYS: Readonly> = { @@ -31,6 +35,7 @@ const STORAGE_KEY = "crystal-preferences-v1"; export const defaultPreferences = (): Preferences => ({ textScale: 1, + volume: 0.6, keys: Object.fromEntries( BUTTONS.map((button) => [button, [...DEFAULT_KEYS[button]]]), ) as Record, @@ -44,6 +49,7 @@ function parse(value: unknown): Preferences { const stored = value as Partial; if (TEXT_SCALES.includes(stored.textScale as TextScale)) preferences.textScale = stored.textScale as TextScale; + if (VOLUMES.includes(stored.volume as Volume)) preferences.volume = stored.volume as Volume; const keys = stored.keys; if (keys && typeof keys === "object") { const seen = new Set(); diff --git a/src/ui/shell.ts b/src/ui/shell.ts index 762a327..3af3d1d 100644 --- a/src/ui/shell.ts +++ b/src/ui/shell.ts @@ -82,5 +82,5 @@ export function renderShell(root: HTMLElement) {

Playing Crystal in your browser

Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.

On a touch screen, use the on-screen pad and buttons. Settings changes the keys and the text size.

Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play. The Map tab lists the areas open in this port; the others appear at the edges but cannot be entered yet.

-

Display and controls

Text size

Keyboard controls

Choose Change, then press the key to use. Tab, Escape and Space keep their usual roles.

ButtonKeyChange

`; +

Display, sound and controls

Text size
Sound

Keyboard controls

Choose Change, then press the key to use. Tab, Escape and Space keep their usual roles.

ButtonKeyChange

`; } diff --git a/tests/audio-browser.spec.ts b/tests/audio-browser.spec.ts new file mode 100644 index 0000000..37814da --- /dev/null +++ b/tests/audio-browser.spec.ts @@ -0,0 +1,89 @@ +import { test, expect, type Page } from "@playwright/test"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { completeIntro } from "./intro-driver"; + +const romPath = resolve( + process.env.CRYSTAL_ROM_PATH ?? + "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc", +); + +/** Tap everything the page sends to the speakers with an analyser, so the + * test can measure what is actually being played. */ +async function listen(page: Page) { + await page.addInitScript(() => { + const connect = AudioNode.prototype.connect as (...args: unknown[]) => unknown; + const tapped = window as unknown as { __analysers: AnalyserNode[] }; + tapped.__analysers = []; + AudioNode.prototype.connect = function (this: AudioNode, ...args: unknown[]) { + if (args[0] instanceof AudioDestinationNode) { + const analyser = this.context.createAnalyser(); + analyser.fftSize = 2048; + connect.call(this, analyser); + tapped.__analysers.push(analyser); + } + return connect.apply(this, args); + } as typeof AudioNode.prototype.connect; + }); +} +/** The loudest RMS level heard over `ms`. */ +async function level(page: Page, ms: number) { + return page.evaluate(async (ms) => { + const analysers = (window as unknown as { __analysers: AnalyserNode[] }).__analysers; + let loudest = 0; + const end = performance.now() + ms; + while (performance.now() < end) { + for (const analyser of analysers) { + // A suspended context sends nothing; its analyser keeps old data. + if (analyser.context.state !== "running") continue; + const data = new Float32Array(analyser.fftSize); + analyser.getFloatTimeDomainData(data); + let sum = 0; + for (const value of data) sum += value * value; + loudest = Math.max(loudest, Math.sqrt(sum / data.length)); + } + await new Promise((done) => setTimeout(done, 50)); + } + return loudest; + }, ms); +} + +test("music plays after the first press and stops while paused", async ({ page }) => { + test.skip(!existsSync(romPath), "Requires a local user-supplied ROM."); + test.setTimeout(120_000); + await listen(page); + await page.goto("/"); + await page.locator("#rom-file").setInputFiles(romPath); + await expect(page.locator("#status")).toContainText("verified"); + await page.locator("#play").click(); + await completeIntro(page); + // The bedroom's music plays once the introduction hands over. + await expect.poll(() => level(page, 500), { timeout: 15_000 }).toBeGreaterThan(0.01); + // Leaving the game tab pauses play, and the sound with it. + await page.locator("#tab-saves").click(); + await expect.poll(() => level(page, 500), { timeout: 10_000 }).toBeLessThan(0.001); + // Returning and playing again resumes the music. + await page.locator("#tab-game").click(); + await page.locator("#play").click(); + await expect.poll(() => level(page, 500), { timeout: 15_000 }).toBeGreaterThan(0.01); +}); + +test("Settings turns the sound off and on", async ({ page }) => { + test.skip(!existsSync(romPath), "Requires a local user-supplied ROM."); + test.setTimeout(120_000); + await listen(page); + await page.goto("/"); + await page.locator("#rom-file").setInputFiles(romPath); + await expect(page.locator("#status")).toContainText("verified"); + await page.locator("#settings").click(); + await page.locator('input[name="volume"][value="0"]').check(); + await page.locator("#close-settings").click(); + await page.locator("#play").click(); + await completeIntro(page); + expect(await level(page, 1500)).toBeLessThan(0.001); + await page.locator("#settings").click(); + await page.locator('input[name="volume"][value="1"]').check(); + await page.locator("#close-settings").click(); + await page.locator("#play").click(); + await expect.poll(() => level(page, 500), { timeout: 15_000 }).toBeGreaterThan(0.01); +}); diff --git a/tests/audio-world.test.ts b/tests/audio-world.test.ts new file mode 100644 index 0000000..85b9529 --- /dev/null +++ b/tests/audio-world.test.ts @@ -0,0 +1,155 @@ +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); + }); +}); diff --git a/tests/audio.test.ts b/tests/audio.test.ts new file mode 100644 index 0000000..2b3ea14 --- /dev/null +++ b/tests/audio.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { Apu, CPU_HZ, FRAME_CYCLES } from "../src/game/audio/apu"; +import { AudioEngine } from "../src/game/audio/engine"; +import { SOUND } from "../src/game/audio/constants"; +import { RomReader } from "../src/game/rom/reader"; +import { CrystalAssets } from "../src/game/rom/assets"; +import { GameWorld } from "../src/game/world/world"; + +const RATE = 48000; +const romPath = + process.env.CRYSTAL_ROM_PATH ?? + "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; + +function render(apu: Apu, seconds: number) { + const count = Math.round(RATE * seconds); + const left = new Float32Array(count), + right = new Float32Array(count); + apu.render(left, right, 0, count); + return { left, right }; +} +const rms = (samples: Float32Array, from = 0, to = samples.length) => { + let sum = 0; + for (let i = from; i < to; i++) sum += samples[i] * samples[i]; + return Math.sqrt(sum / Math.max(1, to - from)); +}; +/** Upward zero crossings per second. */ +const pitch = (samples: Float32Array) => { + let crossings = 0; + for (let i = 1; i < samples.length; i++) if (samples[i - 1] < 0 && samples[i] >= 0) crossings++; + return crossings / (samples.length / RATE); +}; +const on = (apu: Apu) => { + apu.write(0x16, 0x80); // NR52: power + apu.write(0x14, 0x77); // NR50: full volume + apu.write(0x15, 0xff); // NR51: every channel to both sides +}; + +describe("sound channels (apu.ts)", () => { + it("plays a pulse note at 131072 / (2048 - frequency) Hz", () => { + const apu = new Apu(RATE); + on(apu); + apu.write(0x01, 0x80); // 50% duty + apu.write(0x02, 0xf0); // volume 15, no envelope + apu.write(0x03, 1750 & 0xff); + apu.write(0x04, 0x80 | (1750 >> 8)); + const { left } = render(apu, 1); + expect(pitch(left)).toBeGreaterThan(436); + expect(pitch(left)).toBeLessThan(444); + expect(rms(left)).toBeGreaterThan(0.05); + }); + it("fades a decreasing envelope out in 15 steps of 1/64 s", () => { + const apu = new Apu(RATE); + on(apu); + apu.write(0x06, 0x80); + apu.write(0x07, 0xf1); // volume 15, decrease every 1/64 s + apu.write(0x08, 0x00); + apu.write(0x09, 0x87); + const { left } = render(apu, 0.5); + expect(rms(left, 0, 2400)).toBeGreaterThan(0.05); + // After 15/64 s the channel is silent but for the filter's settling. + expect(rms(left, Math.round(RATE * 0.3))).toBeLessThan(0.01); + }); + it("routes channels by NR51 and mutes the wave channel at level 0", () => { + const apu = new Apu(RATE); + on(apu); + apu.write(0x15, 0x04); // wave channel to the right side only + for (let i = 0; i < 16; i++) apu.write(0x20 + i, i < 8 ? 0xff : 0x00); + apu.write(0x0a, 0x80); + apu.write(0x0c, 0x20); // full level + apu.write(0x0d, 0x00); + apu.write(0x0e, 0x87); + const { left, right } = render(apu, 0.25); + expect(rms(left)).toBe(0); + expect(rms(right)).toBeGreaterThan(0.05); + apu.write(0x0c, 0x00); + const quiet = render(apu, 0.25); + expect(rms(quiet.right, 2400)).toBeLessThan(0.01); + }); + it("plays noise without a steady pitch", () => { + const apu = new Apu(RATE); + on(apu); + apu.write(0x11, 0xf0); + apu.write(0x12, 0x32); + apu.write(0x13, 0x80); + const { left } = render(apu, 0.25); + expect(rms(left)).toBeGreaterThan(0.05); + }); + it("has 70224 cycles in a video frame of the 4 MiHz clock", () => { + expect(CPU_HZ / FRAME_CYCLES).toBeCloseTo(59.7275, 3); + }); +}); + +describe.skipIf(!existsSync(romPath))("music from the local ROM", () => { + const rom = () => new Uint8Array(readFileSync(romPath)); + it("renders Route 29's music through the engine and the channels", () => { + const engine = new AudioEngine(new RomReader(rom())); + const apu = new Apu(RATE); + engine.initSound(); + engine.playMusic(SOUND.MUSIC_ROUTE_29); + const seconds = 4; + const samplesPerFrame = (RATE * FRAME_CYCLES) / CPU_HZ; + const total = Math.round(RATE * seconds); + const left = new Float32Array(total), + right = new Float32Array(total); + let done = 0, + due = 0; + while (done < total) { + engine.updateSound(); + for (const write of engine.drain()) apu.write(write >> 8, write & 0xff); + due += samplesPerFrame; + const count = Math.min(total - done, Math.floor(due) - done); + if (count > 0) apu.render(left, right, done, count); + done += Math.max(0, count); + } + expect(rms(left)).toBeGreaterThan(0.03); + expect(rms(right)).toBeGreaterThan(0.03); + // A listening copy, for comparing with the original by ear. + if (process.env.AUDIO_WAV) writeFileSync(process.env.AUDIO_WAV, wav(left, right)); + }); + it("plays each map's music on entry and fades between maps", () => { + const world = new GameWorld(new CrystalAssets(rom())); + world.restore({ ...world.save(), map: "24:3", x: 50, y: 10 }); + for (let tick = 0; tick < 4; tick++) world.tick(); + expect(world.sound.engine.mapMusic).toBe(SOUND.MUSIC_ROUTE_29); + expect(world.sound.engine.channelMusicId(0)).toBe(SOUND.MUSIC_ROUTE_29); + expect(world.mapMusicId()).toBe(SOUND.MUSIC_ROUTE_29); + }); +}); + +/** 16-bit stereo WAV. */ +function wav(left: Float32Array, right: Float32Array) { + const frames = left.length; + const buffer = Buffer.alloc(44 + frames * 4); + buffer.write("RIFF", 0); + buffer.writeUInt32LE(36 + frames * 4, 4); + buffer.write("WAVEfmt ", 8); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(2, 22); + buffer.writeUInt32LE(RATE, 24); + buffer.writeUInt32LE(RATE * 4, 28); + buffer.writeUInt16LE(4, 32); + buffer.writeUInt16LE(16, 34); + buffer.write("data", 36); + buffer.writeUInt32LE(frames * 4, 40); + for (let i = 0; i < frames; i++) { + buffer.writeInt16LE(Math.max(-1, Math.min(1, left[i])) * 32767, 44 + i * 4); + buffer.writeInt16LE(Math.max(-1, Math.min(1, right[i])) * 32767, 46 + i * 4); + } + return buffer; +} diff --git a/tests/native.test.ts b/tests/native.test.ts index 8eef7fb..86ed56b 100644 --- a/tests/native.test.ts +++ b/tests/native.test.ts @@ -747,8 +747,13 @@ describe.skipIf(!existsSync(romPath))( walkTo(world, 6, 4); expect(world.move("up")).toBe(false); world.interact(); - // pokepic shows the ROM's front picture and waits for a button. + // pokepic shows the ROM's front picture; the cry plays (PlayMonCry + // waits for it) and waitbutton then waits for a button. expect(world.picture).toBe(155); + expect(world.dialogue).toBeUndefined(); + expect(world.sound.sfxPlaying).toBe(true); + for (let tick = 0; tick < 300 && !world.dialogue; tick++) world.tick(); + expect(world.sound.sfxPlaying).toBe(false); expect(world.dialogue?.pages).toEqual([""]); world.advanceDialogue(); expect(world.picture).toBeUndefined(); diff --git a/tests/reference-audio-synth.spec.ts b/tests/reference-audio-synth.spec.ts new file mode 100644 index 0000000..8839331 --- /dev/null +++ b/tests/reference-audio-synth.spec.ts @@ -0,0 +1,206 @@ +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."); + +/** The synthesizer against the reference emulator's own sound output: the + * cartridge's UpdateSound drives binjgb's APU once per frame, the port's + * engine drives apu.ts with the same songs, and the two recordings are + * compared window by window by their spectra (pitches and loudness), which + * the consoles' different output filters leave alone. */ +test("apu.ts sounds like the reference emulator's APU for songs, effects and cries", async ({ page }) => { + test.setTimeout(300_000); + const { at } = loadSymbols(); + const labels = { + options: at("wOptions"), + initSound: at("InitSound"), + updateSound: at("UpdateSound"), + playMusic: at("PlayMusic"), + playSFX: at("PlaySFX"), + playCry: at("PlayCry"), + }; + await openReference(page); + const results = await page.evaluate(async (labels) => { + const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); + const { AudioEngine } = await import("/src/game/audio/engine.ts"); + const { Apu, FRAME_CYCLES } = await import("/src/game/audio/apu.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 inner = reference as unknown as { + module: { HEAPU8: Uint8Array } & Record number>; + handle: number; + audio?: AudioContext; + }; + const m = inner.module, + h = inner.handle; + const rate = inner.audio?.sampleRate ?? 44100; + const write = (p: number, v: number) => reference.writeMemory(p, v & 255); + const read = (p: number) => reference.readMemory(p); + // Recorded reference sound, mixed to mono. + let recorded: number[] = []; + const run = (cycles: number) => { + const target = m._emulator_get_ticks_f64(h) + cycles; + for (let n = 0; n < 1000; n++) { + const event = m._emulator_run_until_f64(h, target); + if (event & 2) { + const pointer = m._get_audio_buffer_ptr(h); + const bytes = m.HEAPU8.subarray(pointer, pointer + 2048 * 2); + for (let i = 0; i < 2048; i++) recorded.push((bytes[i * 2] + bytes[i * 2 + 1]) / 510 - 0.5); + } + if (event & 4) return; + } + throw Error("Reference cycle budget exceeded."); + }; + // DI; SP; ld de, nn; call; sentinel; then spin. Returns the cycles the + // call took, in 256-cycle steps. + const call = (address: number, de = 0) => { + const program = [0xf3, 0x31, 0xfe, 0xc7, 0x11, de & 255, de >> 8, 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); + let cycles = 0; + for (; cycles < 65536 && read(0xc0f0) !== 0x42; cycles += 256) run(256); + if (read(0xc0f0) !== 0x42) throw Error(`Routine $${address.toString(16)} did not return`); + return cycles; + }; + const fft = (re: Float64Array) => { + const n = re.length, + im = new Float64Array(n); + for (let i = 1, j = 0; i < n; i++) { + let bit = n >> 1; + for (; j & bit; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) [re[i], re[j]] = [re[j], re[i]]; + } + for (let len = 2; len <= n; len <<= 1) { + const angle = (-2 * Math.PI) / len; + for (let i = 0; i < n; i += len) + for (let k = 0; k < len / 2; k++) { + const wr = Math.cos(angle * k), + wi = Math.sin(angle * k); + const ur = re[i + k], + ui = im[i + k]; + const vr = re[i + k + len / 2] * wr - im[i + k + len / 2] * wi; + const vi = re[i + k + len / 2] * wi + im[i + k + len / 2] * wr; + re[i + k] = ur + vr; + im[i + k] = ui + vi; + re[i + k + len / 2] = ur - vr; + im[i + k + len / 2] = ui - vi; + } + } + // Magnitudes up to 4 kHz, where the music's fundamentals and most + // harmonics are. + const bins = Math.floor((4000 / rate) * n); + const out = new Float64Array(bins); + for (let i = 1; i < bins; i++) out[i] = Math.hypot(re[i], im[i]); + return out; + }; + const SIZE = 4096; + const spectra = (samples: ArrayLike, start: number, count: number) => { + const result: Float64Array[] = []; + for (let w = 0; w < count; w++) { + const frame = new Float64Array(SIZE); + let mean = 0; + for (let i = 0; i < SIZE; i++) mean += samples[start + w * SIZE + i] ?? 0; + mean /= SIZE; + for (let i = 0; i < SIZE; i++) + frame[i] = ((samples[start + w * SIZE + i] ?? 0) - mean) * (0.5 - 0.5 * Math.cos((2 * Math.PI * i) / SIZE)); + result.push(fft(frame)); + } + return result; + }; + const cosine = (a: Float64Array, b: Float64Array) => { + let dot = 0, + na = 0, + nb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + return na && nb ? dot / Math.sqrt(na * nb) : na === nb ? 1 : 0; + }; + const cases = [ + { name: "Route 29", routine: labels.playMusic, de: 0x34, seconds: 8 }, + { name: "Johto wild battle", routine: labels.playMusic, de: 0x29, seconds: 8 }, + { name: "Pokémon Center", routine: labels.playMusic, de: 0x09, seconds: 6 }, + { name: "Goldenrod City", routine: labels.playMusic, de: 0x3d, seconds: 6 }, + { name: "Dragon's Den (noise, vibrato)", routine: labels.playMusic, de: 0x49, seconds: 6 }, + { name: "SFX_SAVE", routine: labels.playSFX, de: 0x25, seconds: 2 }, + { name: "Pikachu's cry", routine: labels.playCry, de: 24, seconds: 1.5 }, + { name: "Lugia's cry", routine: labels.playCry, de: 248, seconds: 2 }, + ]; + const results = []; + for (const testCase of cases) { + const engine = new AudioEngine(new RomReader(rom)); + const apu = new Apu(rate); + write(labels.options, 0); + call(labels.initSound); + engine.initSound(); + call(testCase.routine, testCase.de); + if (testCase.routine === labels.playMusic) engine.playMusic(testCase.de); + else if (testCase.routine === labels.playSFX) engine.playSFX(testCase.de); + else engine.playCry(testCase.de + 1); + // Start both recordings at the first UpdateSound. + recorded = []; + const frames = Math.round(testCase.seconds * 59.7275); + const native = new Float32Array(Math.ceil(((frames + 1) * FRAME_CYCLES * rate) / 4194304) + 4096); + const left = new Float32Array(native.length), + right = new Float32Array(native.length); + let rendered = 0, + due = 0; + for (let frame = 0; frame < frames; frame++) { + // One UpdateSound per video frame, as VBlank calls it. + run(FRAME_CYCLES - call(labels.updateSound)); + engine.updateSound(); + for (const value of engine.drain()) apu.write(value >> 8, value & 0xff); + due += (FRAME_CYCLES * rate) / 4194304; + const count = Math.floor(due) - rendered; + apu.render(left, right, rendered, count); + rendered += count; + } + for (let i = 0; i < rendered; i++) native[i] = (left[i] + right[i]) / 2; + // binjgb's buffer runs ahead by up to one 2048-sample chunk; align by + // the best match of the loudness envelopes within that range. + const windows = Math.floor(Math.min(rendered, recorded.length - 2048) / SIZE) - 1; + let best = { score: -1, shift: 0 }; + for (let shift = -2048; shift <= 2048; shift += 64) { + const a = spectra(native, Math.max(0, shift), Math.min(windows, 3)), + b = spectra(recorded, Math.max(0, -shift), Math.min(windows, 3)); + const score = a.reduce((sum, spectrum, i) => sum + cosine(spectrum, b[i]), 0); + if (score > best.score) best = { score, shift }; + } + const a = spectra(native, Math.max(0, best.shift), windows), + b = spectra(recorded, Math.max(0, -best.shift), windows); + const scores = a.map((spectrum, i) => cosine(spectrum, b[i])).sort((x, y) => x - y); + const rms = (x: ArrayLike, n: number) => { + let sum = 0; + for (let i = 0; i < n; i++) sum += x[i] * x[i]; + return Math.sqrt(sum / Math.max(1, n)); + }; + results.push({ + name: testCase.name, + nativeRms: rms(native, rendered), + referenceRms: rms(recorded, recorded.length), + recorded: recorded.length, + rendered, + windows, + shift: best.shift, + median: scores[Math.floor(scores.length / 2)], + worst: scores[0], + }); + } + reference.dispose(); + return results; + }, labels); + console.log(JSON.stringify(results, null, 1)); + for (const result of results) { + expect(result.windows, result.name).toBeGreaterThan(0); + expect(result.median, result.name).toBeGreaterThan(0.85); + } +}); diff --git a/tests/reference-audio.spec.ts b/tests/reference-audio.spec.ts new file mode 100644 index 0000000..6063031 --- /dev/null +++ b/tests/reference-audio.spec.ts @@ -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([]); +});