Play the music and sound effects from the ROM's sound engine (Closes #34)

The sound engine (audio/engine.asm, home/audio.asm) is ported on a byte
copy of its audio WRAM and a sound register model. A reference test runs
the cartridge's routines beside it and matches the WRAM and registers
after every UpdateSound for all songs, effects and cries, fades, the
danger tone and effect priority. apu.ts synthesizes the four channels in
an AudioWorklet; its output matches the reference emulator's APU by
spectrum. The world runs the engine twice per tick; Play or the first
press unlocks sound, pausing suspends it, Settings sets the volume.

Wired in: map music by map setup and special map music, script audio
commands, text fanfares, WaitSFX and cries that hold the script frozen
(the last page stays shown), battle music, cries, fainting, victory,
capture and the low HP tone, Pokémon Center healing, evolution, the
intro, the Pokédex's cries, PCs, marts, healing items, talk and menu
clicks, bumps, ledges, warps, boulders, the Bicycle and Surf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-15 15:54:02 +02:00
1 parent aff8391e55
commit 7f30c3f450
34 files changed
+3961 -74

No files matched your search

+5 -3
View File
@@ -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.
+2 -1
View File
@@ -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
+6 -3
View File
@@ -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. |
+15 -2
View File
@@ -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.
+9 -3
View File
@@ -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")};`))
+374
View File
@@ -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;
}
}
+105
View File
@@ -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;
}
+315
View File
@@ -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;
File diff suppressed because it is too large. Load diff
+77
View File
@@ -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);
}
}
+202
View File
@@ -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;
}
}
+67
View File
@@ -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<AudioMessage>) => {
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);
+90 -7
View File
@@ -135,6 +135,14 @@ export type BattleEffect =
| { kind: "party"; text: string; menu: boolean; cancel: boolean }
/** MoveSelectionScreen for an item: a move slot (0-3), or -1 to go back. */
| { kind: "move"; text: string; moves: number[]; pp: number[] };
/** PlayStereoCry from a side, sound effects one after another (WaitSFX
* between them), PlayVictoryMusic or the capture music, and
* StopDangerSound. */
export type BattleSound =
| { cry: number; side: "player" | "enemy" }
| { sfx: number[] }
| { music: "victory" | "capture" }
| { stopDanger: true };
/** A name, level and max HP, as DisplayCaughtContestMonStats prints them. */
export type ContestMonSummary = { name: string; level: number; maxHp: number };
/** The Bug-Catching Contest's wParkBallsRemaining and wContestMon. */
@@ -214,9 +222,15 @@ export interface BattleTrainer {
}
type Gen<T = void> = Generator<BattleEffect, T, BattleInput>;
// Sound effect ids (constants/sfx_constants.asm; audio/constants.ts).
const SFX_KINESIS = 0x2f,
SFX_FAINT = 0x2a,
SFX_RUN = 0x2b;
export interface BattleOptions {
data: PokemonData;
/** Where the battle's sounds go (cries, sound effects, music). */
sound?: (sound: BattleSound) => void;
party: PartyMon[];
pokedex: { seen: Set<number>; caught: Set<number> };
playerId: number;
@@ -708,12 +722,34 @@ export class Battle {
/** StdBattleTextbox / BattleTextbox with a ROM text. `button` waits for
* a button afterwards even without a prompt (WaitPressAorB_BlinkCursor). */
private *say(address: number, button = false): Gen {
const { text, prompt } = readTextEnd(this.reader, address, this.context());
const sfx: number[] = [];
const { text, prompt } = readTextEnd(this.reader, address, {
...this.context(),
onSound: (id) => sfx.push(id),
});
// Sounds of the text itself (a fanfare) and of its text_asm routines.
const sounds = this.textSounds.splice(0);
if (sfx.length) sounds.push({ sfx });
for (const sound of sounds) this.playSound(sound);
// The tutorial's auto input presses A at every prompt.
const waits = (prompt || button) && this.battleType !== C.BATTLETYPE_TUTORIAL;
for (const page of text.split("\n\n").filter(Boolean))
yield { kind: "text", text: page, prompt: waits };
}
/** Sounds text_asm routines play, given out by the text that ran them. */
private textSounds: BattleSound[] = [];
/** A sound for the presentation; the battle never waits for it. */
private playSound(sound: BattleSound) {
this.options.sound?.(sound);
}
/** wBattleLowHealthAlarm: set once the battle is won, which keeps the
* danger tone off for the rest of it. */
lowHealthAlarmOff = false;
/** StopDangerSound, and wBattleLowHealthAlarm for the rest of the battle. */
private winSounds() {
this.lowHealthAlarmOff = true;
this.playSound({ stopDanger: true });
}
/** text_asm routines that battle texts run between fragments. */
private textAsm(offset: number): number {
const at = (label: string, fragment = 4) => BATTLE[label as keyof typeof BATTLE] + fragment;
@@ -773,7 +809,8 @@ export class Battle {
return BATTLE[(text ?? "BattleCommand_Charge.BattleDugText") as keyof typeof BATTLE];
}
case at("Text_GotchaMonWasCaught"):
// Capture music (not ported), then WaitButtonText.
// WaitSFX, MUSIC_CAPTURE, then WaitButtonText.
this.textSounds.push({ music: "capture" });
return BATTLE.WaitButtonText;
default:
throw new UnsupportedBattle(`Battle text routine at 0x${offset.toString(16)} is not ported.`);
@@ -1118,7 +1155,10 @@ export class Battle {
if (trainer) {
yield* this.say(BATTLE.WantsToBattleText);
yield* this.say(BATTLE.BattleText_EnemySentOut);
} else
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
} else {
// The wild Pokémon's picture animates with its cry before the text.
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
yield* this.say(
this.battleType === C.BATTLETYPE_FISH
? BATTLE.HookedPokemonAttackedText
@@ -1128,6 +1168,7 @@ export class Battle {
? BATTLE.WildCelebiAppearedText
: BATTLE.WildPokemonAppearedText,
);
}
this.resetBattleParticipants();
this.initBattleMon();
this.checkAmuletCoin();
@@ -1141,12 +1182,19 @@ export class Battle {
// WinTrainerBattle and LostBattle's .battle_tower: BattleTowerText
// instead of the prize money or the whiteout.
const won = (this.battleResult & 0x3f) === C.WIN;
if (won) yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
if (won) {
this.winSounds();
this.playSound({ music: "victory" });
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
}
const text = won ? trainer.winText : trainer.lossText;
if (text) yield* this.say(text);
return { result: this.battleResult, evolvable: this.evolvable };
}
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
// WinTrainerBattle: StopDangerSound, PlayVictoryMusic.
this.winSounds();
this.playSound({ music: "victory" });
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
if (this.options.canLose) {
for (const mon of this.options.party) {
@@ -2350,7 +2398,11 @@ export class Battle {
// The contest always lets the player go; the event battles never do.
if (this.battleType === C.BATTLETYPE_CONTEST) {
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
yield* this.say(BATTLE.BattleText_GotAwaySafely);
this.playSound({ stopDanger: true });
this.playSound({ sfx: [SFX_RUN] });
this.playSound({ stopDanger: true });
this.playSound({ sfx: [SFX_RUN] });
yield* this.say(BATTLE.BattleText_GotAwaySafely);
return true;
}
if (this.cannotFlee()) {
@@ -2370,7 +2422,11 @@ export class Battle {
this.strings[RAM.stringBuffer1] = this.options.data.itemName(this.player.mon.item);
yield* this.say(BATTLE.BattleText_UserFledUsingAStringBuffer1);
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
yield* this.say(BATTLE.BattleText_GotAwaySafely);
this.playSound({ stopDanger: true });
this.playSound({ sfx: [SFX_RUN] });
this.playSound({ stopDanger: true });
this.playSound({ sfx: [SFX_RUN] });
yield* this.say(BATTLE.BattleText_GotAwaySafely);
return true;
}
this.numFleeAttempts = (this.numFleeAttempts + 1) & 0xff;
@@ -2401,6 +2457,8 @@ export class Battle {
return false;
}
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
this.playSound({ stopDanger: true });
this.playSound({ sfx: [SFX_RUN] });
yield* this.say(BATTLE.BattleText_GotAwaySafely);
return true;
}
@@ -5450,8 +5508,20 @@ export class Battle {
}
private *handleEnemyMonFaint(): Gen {
this.playSound({ sfx: [SFX_KINESIS, SFX_FAINT] });
yield* this.say(BATTLE.BattleText_EnemyMonFainted);
if (!this.player.mon.hp) yield* this.faintYourPokemon();
if (!this.options.trainer) this.winSounds();
// PlayVictoryMusic: a wild battle's, once someone gets the experience
// or a Pay Day coin.
if (
!this.options.trainer &&
this.options.party.some((mon) => mon.hp > 0) &&
(this.player.mon.hp ||
this.payDayMoney ||
this.options.party.some((mon) => mon.hp > 0 && mon.item === C.EXP_SHARE))
)
this.playSound({ music: "victory" });
yield* this.updateStateAndExperienceAfterEnemyFaint(false);
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
yield this.hud();
@@ -5497,6 +5567,7 @@ export class Battle {
this.loadEnemyMonToSwitchTo();
const switching = yield* this.offerSwitch();
yield* this.say(BATTLE.BattleText_EnemySentOut);
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
yield this.hud();
if (!switching) return;
this.participants = this.participantsAll = 0;
@@ -5508,6 +5579,7 @@ export class Battle {
this.loadEnemyMonToSwitchTo();
this.enemyIsSwitching = 1;
yield* this.say(BATTLE.BattleText_EnemySentOut);
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
yield this.hud();
}
/** ResetEnemyBattleVars, CheckWhetherSwitchmonIsPredetermined (else the
@@ -5638,6 +5710,7 @@ export class Battle {
this.curBattleMon = current;
if (turn === 0) {
if (this.enemy.mon.hp) return false;
this.playSound({ sfx: [SFX_KINESIS, SFX_FAINT] });
yield* this.say(BATTLE.BattleText_EnemyMonFainted);
return true;
}
@@ -5648,6 +5721,7 @@ export class Battle {
outgoing.hp = this.player.mon.hp;
if (this.player.mon.hp) return false;
this.participants &= ~(1 << this.lastPlayerMon);
this.playSound({ cry: this.player.mon.species, side: "player" });
yield* this.say(BATTLE.BattleText_MonFainted);
return true;
}
@@ -5677,6 +5751,9 @@ export class Battle {
const text = sendOutText(hp, maxHp);
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
yield* this.say(BATTLE[text]);
const mon = this.player.mon;
if (mon.hp && !(mon.status & (C.SLP_MASK | (1 << C.FRZ))))
this.playSound({ cry: mon.species, side: "player" });
}
/** AskUseNextPokemon: only wild battles ask. NO tries to run, measured
* with the first party Pokémon's Speed. Returns true if the player fled. */
@@ -6227,12 +6304,18 @@ export class Battle {
else if (c >= 100) score++;
return score & 0xff;
}
/** FaintYourPokemon: StopDangerSound, then the cry. */
private *faintYourPokemon(): Gen {
this.playSound({ stopDanger: true });
this.playSound({ cry: this.player.mon.species, side: "player" });
yield* this.say(BATTLE.BattleText_MonFainted);
}
private *handlePlayerMonFaint(): Gen {
yield* this.faintYourPokemon();
if (!this.enemy.mon.hp) yield* this.say(BATTLE.BattleText_EnemyMonFainted);
if (!this.enemy.mon.hp) {
this.playSound({ sfx: [SFX_KINESIS, SFX_FAINT] });
yield* this.say(BATTLE.BattleText_EnemyMonFainted);
}
this.updateFaintedPlayerMon();
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
if (!this.enemy.mon.hp) {
+1
View File
@@ -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),
+16
View File
@@ -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 = {
+24
View File
@@ -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;
}
+170 -23
View File
@@ -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<ScriptEffect, void, Input>;
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<ScriptEffect, void, Input> = 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<ScriptEffect, void, Input> {
@@ -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<ScriptEffect, void, Input> {
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:
+13 -1
View File
@@ -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 `<PLAYER>` 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 } };
+11
View File
@@ -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<number, number>;
/** _PlayerDecorationMenu: true if a decoration changed. */
decorationMenu(): Gen<boolean>;
/** 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<boolean> {
this.host.sound?.playSFX(SOUND.SFX_BOOT_PC);
yield* this.print(PC.PlayersPCTurnOnText);
return yield* this.playersPC(true);
}
+20 -6
View File
@@ -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<number>;
/** 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());
}
}
}
+10
View File
@@ -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;
+20 -2
View File
@@ -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<Record<number, number>> = {
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;
+1
View File
@@ -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,
+2
View File
@@ -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;
+251 -20
View File
@@ -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<string>([
"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<number, Actor>;
map: GameMap;
stoneTables: number[];
}) {
private applyMap(
entry: {
id: string;
state: ScriptState;
actors: Map<number, Actor>;
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,
+18 -1
View File
@@ -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<HTMLCanvasElement>("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<HTMLInputElement>('input[name="text-scale"]'))
radio.checked = Number(radio.value) === preferences.textScale;
for (const radio of document.querySelectorAll<HTMLInputElement>('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<HTMLInputElement>('input[name="tex
if (TEXT_SCALES.includes(scale))
setPreferences({ ...preferences, textScale: scale }, `Text size: ${radio.parentElement?.textContent?.trim()}.`);
});
for (const radio of document.querySelectorAll<HTMLInputElement>('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."),
);
+6
View File
@@ -7,9 +7,13 @@ export interface Preferences {
textScale: TextScale;
/** The keyboard codes (KeyboardEvent.code) that press each button. */
keys: Record<Button, string[]>;
/** 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<Record<Button, readonly string[]>> = {
@@ -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<Button, string[]>,
@@ -44,6 +49,7 @@ function parse(value: unknown): Preferences {
const stored = value as Partial<Preferences>;
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<string>();
+1 -1
View File
@@ -82,5 +82,5 @@ export function renderShell(root: HTMLElement) {
</div>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
<dialog id="help-dialog" aria-labelledby="help-title"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2 id="help-title">Playing Crystal in your browser</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl id="help-keys"></dl><p>On a touch screen, use the on-screen pad and buttons. Settings changes the keys and the text size.</p><p>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.</p></dialog>
<dialog id="settings-dialog" aria-labelledby="settings-title"><button id="close-settings" class="dialog-close" aria-label="Close settings">×</button><p class="menu-title">SETTINGS</p><h2 id="settings-title">Display and controls</h2><fieldset class="text-size"><legend>Text size</legend><label><input type="radio" name="text-scale" value="1"> Standard</label><label><input type="radio" name="text-scale" value="1.25"> Large</label><label><input type="radio" name="text-scale" value="1.5"> Largest</label></fieldset><section class="key-settings" aria-labelledby="keys-title"><h3 id="keys-title">Keyboard controls</h3><p>Choose Change, then press the key to use. Tab, Escape and Space keep their usual roles.</p><table><thead><tr><th scope="col">Button</th><th scope="col">Key</th><th scope="col"><span class="visually-hidden">Change</span></th></tr></thead><tbody id="key-table"></tbody></table><button id="reset-keys" class="menu-button">Reset controls</button><p id="settings-status" class="settings-status" role="status" aria-live="polite"></p></section></dialog>`;
<dialog id="settings-dialog" aria-labelledby="settings-title"><button id="close-settings" class="dialog-close" aria-label="Close settings">×</button><p class="menu-title">SETTINGS</p><h2 id="settings-title">Display, sound and controls</h2><fieldset class="text-size"><legend>Text size</legend><label><input type="radio" name="text-scale" value="1"> Standard</label><label><input type="radio" name="text-scale" value="1.25"> Large</label><label><input type="radio" name="text-scale" value="1.5"> Largest</label></fieldset><fieldset class="text-size"><legend>Sound</legend><label><input type="radio" name="volume" value="0"> Off</label><label><input type="radio" name="volume" value="0.3"> Quiet</label><label><input type="radio" name="volume" value="0.6"> Medium</label><label><input type="radio" name="volume" value="1"> Full</label></fieldset><section class="key-settings" aria-labelledby="keys-title"><h3 id="keys-title">Keyboard controls</h3><p>Choose Change, then press the key to use. Tab, Escape and Space keep their usual roles.</p><table><thead><tr><th scope="col">Button</th><th scope="col">Key</th><th scope="col"><span class="visually-hidden">Change</span></th></tr></thead><tbody id="key-table"></tbody></table><button id="reset-keys" class="menu-button">Reset controls</button><p id="settings-status" class="settings-status" role="status" aria-live="polite"></p></section></dialog>`;
}
+89
View File
@@ -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);
});
+155
View File
@@ -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);
});
});
+152
View File
@@ -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;
}
+6 -1
View File
@@ -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();
+206
View File
@@ -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<string, (...args: number[]) => 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<number>, 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<number>, 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);
}
});
+250
View File
@@ -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([]);
});