Sound & Music
Game Boy audio architecture, hUGE music driver internals, sound effect playback, channel muting, and direct hardware register control for plugin development.
Game Boy Audio Architecture
The Game Boy has four dedicated sound channels, each with distinct synthesis capabilities. All channels share a 4-bit volume range (0–15) and a length counter. On the Game Boy Color, each channel can be individually panned left/right for stereo output.
| Channel | Type | Registers | Capabilities |
|---|---|---|---|
| CH1 | Pulse | NR10–NR14 | Frequency sweep, 4 duty cycles, volume envelope |
| CH2 | Pulse | NR21–NR24 | 4 duty cycles, volume envelope (no sweep) |
| CH3 | Wave | NR30–NR34 | Custom 32-sample 4-bit waveform, 4 volume levels |
| CH4 | Noise | NR41–NR44 | LFSR-based noise, adjustable frequency and width |
Music and sound effects share these four channels. When a sound effect plays on a channel, it temporarily overrides the music on that channel. Plan your music arrangements and SFX channel usage to avoid unwanted cutoffs.
Channel Register Reference
Pulse Channels (CH1 / CH2)
| Register | Address | Description |
|---|---|---|
NR10 | 0xFF10 | CH1 sweep (period, direction, shift) |
NR11 | 0xFF11 | CH1 duty cycle (bits 7–6) + length timer (bits 5–0) |
NR12 | 0xFF12 | CH1 volume envelope |
NR13 | 0xFF13 | CH1 frequency low 8 bits |
NR14 | 0xFF14 | CH1 trigger (bit 7), length enable (bit 6), frequency high 3 bits |
NR21–24 | 0xFF16–19 | CH2 (same layout, no sweep register) |
Wave Channel (CH3)
| Register | Address | Bits | Description |
|---|---|---|---|
NR30 | 0xFF1A | Bit 7 | DAC on/off. Must be OFF to write wave RAM. |
NR31 | 0xFF1B | 7–0 | Length timer (0–255). Duration = (256−t)/256 seconds. |
NR32 | 0xFF1C | 6–5 | Volume: 00=mute, 01=100%, 10=50%, 11=25% |
NR33 | 0xFF1D | 7–0 | Frequency low 8 bits |
NR34 | 0xFF1E | 7, 6, 2–0 | Trigger (bit 7), length enable (bit 6), frequency high 3 bits |
Wave RAM (0xFF30–0xFF3F): 16 bytes = 32 four-bit samples. Each byte holds two samples (high nibble first). Write only when NR30 bit 7 = 0.
Frequency formula: f = 65536 / (2048 − period) Hz, where period is the 11-bit value in NR33/NR34.
CH3 has no volume envelope — only 4 fixed volume levels. Once triggered, it plays at constant volume indefinitely (unless length enable is set). You must explicitly silence it by writing NR32 = 0 or NR30 = 0.
Noise Channel (CH4)
| Register | Address | Bits | Description |
|---|---|---|---|
NR41 | 0xFF20 | 5–0 | Length timer (0–63). Duration = (64−t)/256 seconds. |
NR42 | 0xFF21 | 7–0 | Volume envelope (see format) |
NR43 | 0xFF22 | 7–0 | Polynomial counter: shift (7–4), width (3), divisor (2–0) |
NR44 | 0xFF23 | 7, 6 | Trigger (bit 7), length enable (bit 6) |
NR43 polynomial counter: Bits 7–4 = clock shift (s), bit 3 = LFSR width (0 = 15-bit white noise, 1 = 7-bit metallic), bits 2–0 = divisor code (r). LFSR clock = 262144 / (r × 2s) Hz. If r=0, treat as r=0.5.
Key Hardware Behaviors
- Trigger (NRx4 bit 7): Restarts the channel. Reloads length counter from NRx1, restarts envelope from NR42 initial volume, resets LFSR.
- Writing NRx4 without trigger: Only updates length enable and frequency high bits. Does NOT restart envelope or LFSR.
- DAC control: CH1/CH2/CH4 DAC is enabled when NRx2 upper nibble ≠ 0 OR direction bit = 1. Writing
NRx2 = 0x00disables the DAC. CH3 DAC is controlled independently by NR30 bit 7. - Wave DAC off → on: Turning NR30 back on does NOT restart playback. A trigger (NR34 bit 7) is required afterward.
- Envelope timing: Volume changes every
pace × (1/64)seconds. Pace 1 = 15.6ms per step, pace 7 = 109ms per step. - Sound registers persist across scene changes. There is no automatic sound cut during scene transitions. A new song’s
hUGE_initcallsmusic_sound_cut()which zeroes all channels, but only if a song is explicitly loaded.
Volume Envelope Format
Registers NR12, NR22, and NR42 share the same envelope format:
NRx2 format: VVVV DNNN
V = initial volume (0-15)
D = direction (0 = decrease, 1 = increase)
N = envelope period (0 = disabled, 1-7 = speed)
| Value | Volume | Direction | Pace | Effect |
|---|---|---|---|---|
0xF0 | 15 | — | 0 | Constant full volume |
0xF1 | 15 | Decrease | 1 | Fast fade out (~234ms to silence) |
0xF7 | 15 | Decrease | 7 | Slow fade out (~1.6s to silence) |
0x91 | 9 | Decrease | 1 | Medium start, fast decay (~140ms) |
0x47 | 4 | Decrease | 7 | Quiet start, slow decay (~437ms) |
0x00 | 0 | — | 0 | Silence (also disables DAC) |
hUGE Music Driver
GB Studio 4.2 uses the hUGE tracker driver for music playback (.uge files). The driver is not VBlank-driven — it runs from the timer interrupt at a fixed rate independent of frame rendering.
Timing
- GB Studio configures the timer:
TAC_REG = 0x07(clock/256). TMA varies by hardware: CGB =0x80, DMG =0xC0. Both produce 256 Hz. music_play_isr()is called at 256 Hz, but only processes music every 4th call (++counter & 3), giving an effective 64 Hz forhUGE_dosound().- Song tempo = ticks per row. Tempo 4 = one row every 4 ticks = every 62.5ms.
- VBlank is ~59.7 Hz, hUGE ticks are 64 Hz. When replicating hUGE timing from VBlank-driven C code, use a fractional accumulator with 16/15 ratio:
60 × 16/15 ≈ 64.
A common misconception is that hUGE runs from VBlank. It runs from the timer ISR at 256 Hz (music processed every 4th call = 64 Hz). This means the music driver writes to sound registers between your VBlank code, not synchronized with it. Any direct register writes from your plugin can be overridden by the next ISR tick within milliseconds. See Hardware — Clock Architecture for why 64 Hz differs from VBlank’s ~59.7 Hz.
Pattern Row Format
DN(note, instrument, effect)
// note: 0-71 = C_3..B_8, 90 = ___ (no note)
// instrument: 0 = none, 1-15 = instrument index (1-indexed!)
// effect: 0xTVV where T = effect type, VV = value
Each row entry is 3 bytes (the DN macro splits the values across bytes). Note is in byte 0 bits 6–0, instrument is split across bytes 0–1, effect occupies bytes 1–2.
Instrument Structs
Wave Instrument (CH3)
typedef struct {
unsigned char length; // → NR31
unsigned char volume; // → NR32 (0x20=100%, 0x40=50%, 0x60=25%)
unsigned char waveform; // wave table index (0-15) into waves[] array
unsigned char *subpattern; // NULL = none
unsigned char highmask; // bit 7 = trigger, bit 6 = length enable
} hUGEWaveInstr_t;
Noise Instrument (CH4)
typedef struct {
unsigned char envelope; // → NR42 directly
unsigned char *subpattern; // NULL = none
unsigned char highmask; // bits 5-0 → NR41, bit 6 → length enable, bit 7 → LFSR width
unsigned char unused1, unused2;
} hUGENoiseInstr_t;
Noise highmask decoding:
NR41 = highmask & 0x3F(length timer value)NR44 = (highmask & 0x40) | 0x80(length enable + trigger)step_width = (highmask >> 7) << 3(bit 7 → NR43 bit 3 = LFSR width)
Channel Struct Layout
Each channel uses a 16-byte struct. Layout for CH1–CH3:
| Offset | Field | Size | Description |
|---|---|---|---|
| 0 | channel_period | 2 | Current period (CH1–3) or poly note (CH4) |
| 2 | toneporta_target | 2 | Tone portamento target period |
| 4 | channel_note | 1 | Stored note index (0–71) |
| 5 | highmask | 1 | Trigger/length enable bits for NRx4 |
| 6 | vibrato_tremolo_phase | 1 | LFO phase for vibrato/tremolo |
| 7 | envelope | 1 | Envelope value |
| 8 | table | 2 | Subpattern pointer |
| 10 | table_row | 1 | Current subpattern step (0–31) |
| 11–15 | padding | 5 | Unused |
CH4 has an extra step_width byte between highmask and vibrato_tremolo_phase (LFSR width for NR43 bit 3).
Processing Order
Tick 0 (Row Boundary) — when a note + instrument is present
For CH3 (wave):
- Look up note → store index in
channel_note3, period inchannel_period3 - Write NR31 (length), NR32 (volume) from instrument
- If waveform changed: turn off DAC, copy 16 bytes to wave RAM, turn DAC back on
- Store subpattern pointer, reset
table_row3to 0 - Process effect (portamento, etc.)
- Write NR33 (period low), NR34 = period_high | highmask (trigger)
- Subpattern runs last — can override NR33/NR34
For CH4 (noise):
- Store note, compute
channel_period4viaget_note_poly(note) - Write NR42 (envelope), NR41 (length from highmask)
- Store subpattern pointer, reset
table_row4 - Process effect
- Write NR43 =
channel_period4 | step_width4, NR44 = highmask (trigger) - Subpattern runs last — overrides NR43/NR44
Non-zero ticks (between rows)
- Process effect (e.g., portamento: modifies
channel_period, writes NRx3/NRx4) - Subpattern: writes frequency registers if the step has a valid note
Subpattern always has the last write — it runs after all effects.
Subpatterns — Notes are Relative
Subpatterns use the same DN(note, instrument, effect) encoding as pattern rows, but with different semantics:
- Note = 90 (
___): No register write for this step. - Instrument byte ≠ 0: JUMP command. Value = target step index (0-based).
- Note < 72: Relative offset from the base note. The driver computes:
Then looks up the note table:offset = subpattern_note - 36 // signed: range -36 to +35 effective_note = channel_noteX + offsetget_note_period(effective_note)for CH1–3, orget_note_poly(effective_note)for CH4.
This is the most critical detail for understanding hUGE subpatterns. Note 36 means “same as the base note.” Note 37 = +1 semitone, 38 = +2, 40 = +4, 35 = −1, etc. The base note (channel_noteX) is only updated when the pattern row plays a new note — it does NOT change when the subpattern writes.
32 steps per subpattern (indices 0–31). Step 31 typically contains a jump instruction (e.g., DN(___, 1, 0x000) = jump to step 1), creating a loop: 0→1→2→…→31→1→2→… Step 0 only plays on the first pass or when an instrument retrigger resets table_row to 0.
hUGE Note Table
The driver uses a 72-entry lookup table mapping note indices to 11-bit period values. Period → frequency: f = 65536 / (2048 − period) Hz.
| Index | Note | Period | ~Hz | Index | Note | Period | ~Hz | |
|---|---|---|---|---|---|---|---|---|
| 0 | C_3 | 44 | 32.7 | 36 | C_6 | 1798 | 262 | |
| 1 | Cs3 | 156 | 34.7 | 37 | Cs6 | 1812 | 278 | |
| 2 | D_3 | 262 | 36.7 | 38 | D_6 | 1825 | 294 | |
| 3 | Ds3 | 363 | 38.9 | 39 | Ds6 | 1837 | 311 | |
| 4 | E_3 | 457 | 41.2 | 40 | E_6 | 1849 | 329 | |
| 5 | F_3 | 547 | 43.7 | 41 | F_6 | 1860 | 349 | |
| 6 | Fs3 | 631 | 46.3 | 42 | Fs6 | 1871 | 370 | |
| 7 | G_3 | 710 | 49.0 | 43 | G_6 | 1881 | 392 | |
| 8 | Gs3 | 786 | 51.9 | 44 | Gs6 | 1890 | 415 | |
| 9 | A_3 | 854 | 54.9 | 45 | A_6 | 1899 | 440 | |
| 10 | As3 | 923 | 58.2 | 46 | As6 | 1907 | 465 | |
| 11 | B_3 | 986 | 61.7 | 47 | B_6 | 1915 | 493 | |
| 12 | C_4 | 1046 | 65.4 | 48 | C_7 | 1923 | 524 | |
| 13 | Cs4 | 1102 | 69.3 | 49 | Cs7 | 1930 | 555 | |
| 14 | D_4 | 1155 | 73.4 | 50 | D_7 | 1936 | 585 | |
| 15 | Ds4 | 1205 | 77.8 | 51 | Ds7 | 1943 | 624 | |
| 16 | E_4 | 1253 | 82.5 | 52 | E_7 | 1949 | 662 | |
| 17 | F_4 | 1297 | 87.3 | 53 | F_7 | 1954 | 697 | |
| 18 | Fs4 | 1339 | 92.4 | 54 | Fs7 | 1959 | 736 | |
| 19 | G_4 | 1379 | 98.0 | 55 | G_7 | 1964 | 780 | |
| 20 | Gs4 | 1417 | 103.9 | 56 | Gs7 | 1969 | 829 | |
| 21 | A_4 | 1452 | 110.0 | 57 | A_7 | 1974 | 886 | |
| 22 | As4 | 1486 | 116.6 | 58 | As7 | 1978 | 936 | |
| 23 | B_4 | 1517 | 123.4 | 59 | B_7 | 1982 | 993 | |
| 24 | C_5 | 1546 | 130.6 | 60 | C_8 | 1985 | 1040 | |
| 25 | Cs5 | 1575 | 138.6 | 61 | Cs8 | 1988 | 1092 | |
| 26 | D_5 | 1602 | 147.0 | 62 | D_8 | 1992 | 1170 | |
| 27 | Ds5 | 1627 | 155.7 | 63 | Ds8 | 1995 | 1236 | |
| 28 | E_5 | 1650 | 164.7 | 64 | E_8 | 1998 | 1311 | |
| 29 | F_5 | 1673 | 174.7 | 65 | F_8 | 2001 | 1395 | |
| 30 | Fs5 | 1694 | 185.0 | 66 | Fs8 | 2004 | 1489 | |
| 31 | G_5 | 1714 | 196.2 | 67 | G_8 | 2006 | 1560 | |
| 32 | Gs5 | 1732 | 208 | 68 | Gs8 | 2009 | 1680 | |
| 33 | A_5 | 1750 | 220 | 69 | A_8 | 2011 | 1771 | |
| 34 | As5 | 1767 | 233 | 70 | As8 | 2013 | 1872 | |
| 35 | B_5 | 1783 | 247 | 71 | B_8 | 2015 | 1986 |
get_note_poly (NR43 from Note Index)
The hUGE driver converts a note index (0–71) to an NR43 polynomial counter value using this algorithm:
A = note + 192 // 8-bit add (wraps around)
A = ~A // bitwise complement
if A < 7:
NR43 = A // low notes: direct value
else:
upper = (A >> 2) - 1
lower = (A & 3) + 4
NR43 = (upper << 4) | lower
Key values used in this project:
| Note | Name | NR43 | Usage |
|---|---|---|---|
| 32 | Gs5 | 0x67 | Bike idle noise |
| 36 | C_6 | 0x57 | Bike riding noise |
| 40 | E_6 | 0x47 | — |
Pattern: every 4 notes, the upper nibble decreases by 1 and the lower nibble cycles 7, 6, 5, 4.
GB Studio Music Manager
The music manager (music_manager.c) mediates between GBVM scripts, the hUGE driver, and the SFX player. Understanding its ISR flow is essential for plugins that control sound channels directly.
Key Variables
| Variable | Type | Description |
|---|---|---|
music_current_track_bank | volatile uint8_t | ROM bank of current track. 0xFF (MUSIC_STOP_BANK) = stopped. |
music_next_track | const TRACK_T * | Queued track pointer. Non-NULL triggers hUGE_init on next ISR. |
music_mute_mask | uint8_t | SFX-driven channel mute (temporary, cleared when SFX ends). |
music_global_mute_mask | uint8_t | Persistent mute set by plugins/scripts. Survives SFX completion. |
music_effective_mute | uint8_t | What the manager believes hUGE_mute_mask currently is. |
music_play_isr_pause | uint8_t | When TRUE, ISR returns before processing any music. |
music_play_isr Flow (Timer ISR, 256 Hz)
void music_play_isr(void) {
// 1. SFX processing (only if SFX is active)
if (sfx_play_bank != SFX_STOP_BANK) {
// Re-sync mute mask if changed
if (music_effective_mute != (global_mask | sfx_mask))
driver_set_mute_mask(combined);
// Process SFX sample
if (!sfx_play_isr()) {
// SFX finished: restore mutes, reset wave, clear SFX state
driver_set_mute_mask(music_global_mute_mask);
driver_reset_wave(); // reloads wave RAM from hUGE instrument!
}
}
// 2. Early exits
if (music_play_isr_pause) return;
if (music_current_track_bank == MUSIC_STOP_BANK) return;
if (++counter & 3) return; // only process every 4th call (64 Hz)
// 3. Song init or update
if (music_next_track) {
music_sound_cut(); // zeroes all channel volumes
hUGE_init(music_next_track); // initializes driver, ZEROES MUTE MASK
music_next_track = NULL;
} else {
hUGE_dosound(); // processes one tick: effects, subpatterns, register writes
}
}
music_stop / music_load
// Stop: sets bank to STOP_BANK, cuts all channel sound.
inline void music_stop(void) {
music_current_track_bank = MUSIC_STOP_BANK;
music_sound_cut(); // NR12=NR22=NR32=NR42=0, retrigger
}
// Load: queues a track. Actual init happens in next music_play_isr call.
inline void music_load(uint8_t bank, const TRACK_T *data) {
if (bank == music_current_track_bank && data == music_current_track) return;
music_current_track_bank = MUSIC_STOP_BANK; // safe: ISR won't process
music_current_track = data;
music_next_track = data;
music_current_track_bank = bank; // go: ISR will init on next tick
}
music_pause(uint8_t pause) is defined in music_manager.c (bank 255) without the BANKED qualifier. Calling it from a plugin in a different ROM bank will crash — the linker generates a near call to a garbage address. Instead, declare extern uint8_t music_play_isr_pause; and set it directly, or use music_current_track_bank = MUSIC_STOP_BANK to block the ISR.
hUGE_init Zeroes the Mute Mask
hUGE_init zero-fills a memory region that includes hUGE_mute_mask (= mute_channels). This means every call to hUGE_init clears all channel mutes.
Combined with a second bug — the mute re-sync check in the ISR is gated behind sfx_play_bank != SFX_STOP_BANK (only runs when SFX is playing) — this creates a permanent mute loss scenario:
- Plugin sets
music_global_mute_mask |= CH3|CH4, callsdriver_set_mute_mask→ works - Script starts a new song →
music_loadqueues it - ISR calls
hUGE_init→hUGE_mute_mask = 0 - No SFX playing → re-sync check never fires → mute permanently lost
Workaround: Instead of relying on mute masks, set music_current_track_bank = MUSIC_STOP_BANK to prevent the ISR from calling hUGE_dosound() entirely. This is simpler and immune to the mute reset bug.
Direct Channel Control from Plugins
For plugins that need precise control over audio (engine sounds, ambient effects), write directly to the Game Boy’s sound registers. But you must prevent the music ISR from overriding your writes.
Preventing ISR Interference
The safest approach is to stop the ISR from processing music entirely:
// In your state_init (runs BEFORE On Init script):
music_current_track_bank = MUSIC_STOP_BANK;
// ISR sees STOP_BANK → returns before calling hUGE_dosound()
// Your direct register writes are now safe from being overridden
This is necessary because state_init() runs before the On Init script. Even if the script has a “Stop Music” event, the persistent song’s ISR is still actively writing to all 4 channels during the window between state_init and the script executing.
Hardware sound registers are not cleared during scene transitions. If your plugin writes to CH3/CH4 and the player transitions to a non-plugin scene, those channels keep playing. Ensure the destination scene either plays music (which calls music_sound_cut() before hUGE_init) or explicitly stops the channels.
Loading Wave RAM
static void load_wave(const UBYTE *wave) {
NR30_REG = 0x00; // DAC off (required to write wave RAM)
for (UBYTE i = 0; i < 16; i++) {
*((volatile UBYTE *)(0xFF30 + i)) = wave[i];
}
NR30_REG = 0x80; // DAC on (does NOT restart playback)
// Must trigger (NR34 bit 7) after this to hear sound
}
Triggering a Wave Note
NR31_REG = 0x00; // no length
NR32_REG = 0x20; // 100% volume
NR33_REG = period & 0xFF; // frequency low
NR34_REG = 0x80 | ((period >> 8) & 0x07); // trigger + frequency high
Updating Frequency Without Retriggering
// Smooth portamento: just update frequency, no trigger bit
NR33_REG = new_period & 0xFF;
NR34_REG = (new_period >> 8) & 0x07; // no bit 7 = no trigger
Silencing Channels
// Silence CH3 (wave) - set volume to 0
NR32_REG = 0x00;
// Silence CH4 (noise) - zero envelope + retrigger to apply
NR42_REG = 0x00;
NR44_REG = 0x80;
// Or use the engine helper for specific channels:
sfx_sound_cut_mask(SFX_CH_3 | SFX_CH_4);
Sound Effects System
Sound effects are played via a VGM-style sample player that runs inside the music ISR. An SFX temporarily takes over one or more channels; when the effect finishes, the channels return to the music driver automatically.
SFX Playback
// GBVM:
VM_SFX_PLAY bank, pointer, mask, priority
// C API:
music_play_sfx(bank, sample, mute_mask, priority);
- mask: Which channels the SFX uses (same bitmask format)
- priority:
MUSIC_SFX_PRIORITY_MINIMAL(0),NORMAL(4),HIGH(8). Higher priority won’t be interrupted by lower.
When an SFX finishes, the ISR calls driver_reset_wave() which reloads wave RAM from the current hUGE instrument. If your plugin has loaded custom wave data into wave RAM, an SFX ending will corrupt it. Guard against this by reloading your wave data on the next frame.
Channel Mute Masks
| Bit | Channel | Constant | Value |
|---|---|---|---|
| 0 | CH1 (Pulse 1) | SFX_CH_1 / MUSIC_CH_1 | 0x01 |
| 1 | CH2 (Pulse 2) | SFX_CH_2 / MUSIC_CH_2 | 0x02 |
| 2 | CH3 (Wave) | SFX_CH_3 / MUSIC_CH_3 | 0x04 |
| 3 | CH4 (Noise) | SFX_CH_4 / MUSIC_CH_4 | 0x08 |
FX Hammer Sound Effects (.sav Files)
GB Studio uses FX Hammer .sav files as sound effect banks. Each .sav contains multiple numbered effects (designed in the FX Hammer tool or GB Studio’s Sound Effects editor). A single .sav file can hold dozens of effects, each identified by a zero-based index.
Asset Structure
Each .sav file in assets/sounds/ has a companion .gbsres JSON that tells the compiler how to process it:
{
"_resourceType": "sound",
"id": "a1b2c3d4-...",
"name": "Game SFX.sav",
"symbol": "sound_game_sfx_sav",
"type": "fxhammer",
"numEffects": 20,
"filename": "Game SFX.sav"
}
The symbol field is the base name. The compiler generates per-effect symbols by appending _NN (zero-padded to 2 digits). For example, a file named Game SFX.sav with symbol sound_game_sfx_sav produces symbols like sound_game_sfx_sav_00, sound_game_sfx_sav_01, etc.
Generated Symbols
For each effect index NN, the compiler generates (using sound_game_sfx_sav as an example base symbol):
| Symbol | Type | Description |
|---|---|---|
sound_game_sfx_sav_NN | const uint8_t[] | SFX sample data array |
BANK(sound_game_sfx_sav_NN) | uint8_t | ROM bank containing the data |
MUTE_MASK_sound_game_sfx_sav_NN | #define | Channel bitmask (which channels the effect uses) |
__mute_mask_sound_game_sfx_sav_NN | linker symbol | Address-as-value encoding of the mask (for GBVM assembly) |
All symbols are declared in a generated header at build/src/include/data/, named after the base symbol (e.g., sound_game_sfx_sav.h).
Each effect has its own mute mask based on which channels it writes to. For example, a noise-only SFX has mask 0b00001000 (CH4), while a dual-channel effect might be 0b00001010 (CH2+CH4). Always use the generated MUTE_MASK_ define — don’t hardcode the mask.
Playing FX Hammer SFX from C Code
Include the required headers:
#include "music_manager.h"
#include "data/sound_game_sfx_sav.h"
Call music_play_sfx() with the generated symbols:
// Play effect 03 (e.g., a jump sound)
music_play_sfx(
BANK(sound_game_sfx_sav_03), // ROM bank
sound_game_sfx_sav_03, // data pointer
MUTE_MASK_sound_game_sfx_sav_03, // channel mute mask
MUSIC_SFX_PRIORITY_NORMAL // priority
);
// Play effect 10 (e.g., an impact sound)
music_play_sfx(
BANK(sound_game_sfx_sav_10),
sound_game_sfx_sav_10,
MUTE_MASK_sound_game_sfx_sav_10,
MUSIC_SFX_PRIORITY_NORMAL
);
The SFX player runs inside the timer ISR. It temporarily mutes the music on the SFX’s channels, plays the effect, then unmutes automatically when it finishes.
Playing FX Hammer SFX from GBVM Assembly
In GBVM scripts (.s files), use VM_SFX_PLAY with the linker symbol variants (prefixed with underscores):
; Play effect 03
VM_SFX_PLAY ___bank_sound_game_sfx_sav_03, _sound_game_sfx_sav_03, ___mute_mask_sound_game_sfx_sav_03, .SFX_PRIORITY_NORMAL
Note the naming convention differences between C and GBVM assembly:
| Purpose | C Code | GBVM Assembly |
|---|---|---|
| Bank | BANK(sound_game_sfx_sav_NN) | ___bank_sound_game_sfx_sav_NN |
| Data | sound_game_sfx_sav_NN | _sound_game_sfx_sav_NN |
| Mask | MUTE_MASK_sound_game_sfx_sav_NN | ___mute_mask_sound_game_sfx_sav_NN |
| Priority | MUSIC_SFX_PRIORITY_NORMAL | .SFX_PRIORITY_NORMAL |
Priority Levels
Higher-priority effects cancel lower-priority ones currently playing:
| C Constant | GBVM Constant | Value | Use Case |
|---|---|---|---|
MUSIC_SFX_PRIORITY_MINIMAL | .SFX_PRIORITY_MINIMAL | 0 | Ambient, low-importance sounds |
MUSIC_SFX_PRIORITY_NORMAL | .SFX_PRIORITY_NORMAL | 4 | Standard gameplay SFX |
MUSIC_SFX_PRIORITY_HIGH | .SFX_PRIORITY_HIGH | 8 | Critical feedback (damage, menu confirm) |
If your plugin writes directly to sound registers (like the biking engine sound), playing an FX Hammer SFX on the same channel will interrupt your writes. When the SFX finishes, driver_reset_wave() reloads the hUGE wave instrument — not your custom wave data. Either avoid SFX on channels you control directly, or reload your wave/register state on the next frame after detecting the SFX has ended (sfx_play_bank == SFX_STOP_BANK).
Common Pitfalls
- Music ISR overrides direct register writes. The ISR fires at 256 Hz (music at 64 Hz). Any values you write to NRxx registers will be overwritten within milliseconds unless you stop the ISR from processing music (
music_current_track_bank = MUSIC_STOP_BANK). music_pause()is not BANKED. Calling it from a different ROM bank crashes. Setmusic_play_isr_pausedirectly via extern, or just use STOP_BANK.hUGE_initzeroes the mute mask. Loading a new song clears all mutes. The re-sync check only runs during SFX playback, so without SFX the mute is permanently lost.- Wave channel has no envelope. CH3 plays at constant volume forever once triggered. You must explicitly silence it (NR32=0 or NR30=0) when done.
- Subpattern notes are relative, not absolute. Note 36 in a subpattern = +0 offset from the base note. This is the source of many “wrong frequency” bugs when translating hUGE songs to C.
state_init()runs before On Init script. If your plugin needs to own sound channels, block the ISR instate_init. Don’t rely on the On Init script to stop music first.- Sound registers persist across scene transitions. There is no automatic sound cut. If your plugin writes to channels and the player leaves, those channels keep playing until something else writes to them.
driver_reset_wave()on SFX completion. Reloads wave RAM from hUGE instrument data, corrupting any custom wave. Reload your wave on the next frame if SFX might play during your scene.