From ef4cc8548d68c6bb95202c24dbf1f08b9fd0af35 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 25 Aug 2026 18:27:52 +0700 Subject: [PATCH] master volume moves ahead of the limiter, and mute NPCs greet you again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the master volume was applied after the limiter, so turning the game down scaled the pumping with it — two full-scale copies at master 0.25 came out at half the level they should, the limiter ducking 6 dB on a mix that was never going to clip; the master is now the first effect in the chain - six creature displays with a body loop could never say hello: one `source` tag answered three questions (ownership, the greeting latch, the bark latch), so any looping sound on a unit held its greeting latch for life — the latch is its own marker now, and a server-pushed object sound silences that unit's own grunts and barks while it plays, the reference's AISOUNDDESC registry - the attacker's exertion grunt fires from the attack packet, not the animation tag (the tag drives the victim's injury vocal only), with the reference's per-class chance tables read out of the binary; the voice cap can no longer steal a latch-holding channel and turn a pet's aggro roar into a repeat generator - fmod.dll itself read end to end: there is no hidden headroom in its mixers (full-scale sum, one clip) and the reference never steals a voice — every WoW channel is a stream at the one priority the allocator refuses to take; what the two unvoiced melee legs are blocked on is named precisely Co-Authored-By: Claude Fable 5 --- crates/benilla-app/src/sound/combat.rs | 185 +++++++++++----- crates/benilla-app/src/sound/creature.rs | 51 +++-- crates/benilla-app/src/sound/greeting.rs | 6 +- crates/benilla-app/src/sound/kit.rs | 264 ++++++++++++++++++----- crates/benilla-app/src/sound/limiter.rs | 29 ++- crates/benilla-app/src/sound/mixer.rs | 191 +++++++++++++--- crates/benilla-app/src/sound/zone.rs | 23 +- 7 files changed, 585 insertions(+), 164 deletions(-) diff --git a/crates/benilla-app/src/sound/combat.rs b/crates/benilla-app/src/sound/combat.rs index b97ab92b..93645e3e 100644 --- a/crates/benilla-app/src/sound/combat.rs +++ b/crates/benilla-app/src/sound/combat.rs @@ -8,18 +8,25 @@ //! - **`$CSS`** — the swing whoosh, played only when nothing was contacted (miss/dodge/evade): //! kits 7080/7081 `Combat Miss 1H/2H` by weapon handedness — exactly the two ids the client //! caches by name at startup (wow-re `0x4575b0`, `_DONOTRENAME_` kits). -//! - **`$CAH`** — the attacker's exertion vocal (`CreatureSoundData.Exertion[Critical]`, the -//! same voice chain as everything else — characters resolve via the model fallback). **INTERIM, -//! and now pinned wrong** (decision 1555): the sliver 0075 left open — "where the exertion -//! columns actually fire is unpinned" — has been answered, and it is not here. Exertion is -//! **packet-driven at swing start**: `SMSG_ATTACKERSTATEUPDATE` → `0x6246a0` → `0x624786` → -//! `0x623b10`, class `([hitrec+0x10] >> 7) & 1` (the crit bit — so the column index benilla -//! already computes is right), gated on `victimState != 0` and the victim's health > 0. There -//! is no `call [reg+0x88]` anywhere in `0x6247d0`, so `$CAH` never reaches the exertion columns -//! at all; what `$CAH` actually drives is `0x6249bb call 0x624530` — the **victim's** injury -//! vocal, which benilla already fires off that crossing. Correcting the trigger moves *when* -//! a grunt is heard (swing start, not mid-clip) and *whether* a clean miss grunts at all, so it -//! is deliberately a separate change from 1555's bus routing and is not folded in here. +//! - **`$CAH`** — **not** the attacker's exertion, which is what this module used to think. +//! `$CAH` drives the **victim's** injury vocal (`0x624865 je 0x624902` → `0x6249bb call +//! 0x624530`), which benilla already fires off that crossing; there is no `call [reg+0x88]` +//! anywhere in `0x6247d0`, so the tag never reaches the exertion columns at all. +//! +//! The **attacker's exertion** is packet-driven at swing start, and is now wired that way +//! (`SMSG_ATTACKERSTATEUPDATE` → `0x6246a0` → `0x624786` → `0x623b10`). Read off the bytes: +//! +//! - `class = ([hitrec+0x10] >> 7) & 1` — the crit bit, so `Exertion` / `ExertionCritical`. +//! - `0x62476a`: gated on **`victimState != 0`**, and only on that. (The earlier note here also +//! claimed a victim-health gate; `0x6246a0` contains no such test. Its other two bails — +//! `[[attacker+0x110]+0x40] <= 0` at `0x6246b1` and `hitInfo & 0x10000` at `0x6246c2` — sit +//! above the swing *animation* select as well, so they belong to that subsystem, not here.) +//! - `force = 0` (`0x62477e`), so the **class chance roll applies**: threshold 70 for a creature +//! and 35 for a player on class 0, 100 on class 1. A crit always grunts; an ordinary swing +//! grunts ~70 % of the time for a creature and ~36 % for a player. +//! +//! This moves *when* a grunt is heard — swing start rather than mid-clip — and thins ordinary +//! swings out, which the tag-driven version never did. //! //! The **contact family** consumes [`SwingImpact`] — the victim dispatch `0x624530` + the //! `0x6247d0` weapon-sound block, fired at the swing clip's **`$AH0–3`/`$CAH`** crossing, or at @@ -46,12 +53,37 @@ //! A `text_only` flush (supersede/attack-stop) drops its sounds — only the floating number //! flushes (decision 0149's flush law, inherited from the shared dispatch). //! -//! Two legs of the reference's melee family benilla does not voice at all, both now pinned -//! (decision 1555): the **connecting swing** — a `$CSS` on any victimState outside {0, 2, 6} -//! plays `WeaponSwingSounds2.dbc` (kits 233–238, Light/Medium/Heavy × Normal/Critical, index -//! `critical + swingType*2`) on the capped bus 6, where benilla plays nothing and voices only the -//! miss whoosh; and the **material foley** that rides the same `$FSD` as the terrain footstep -//! (`0x6233d9 call [vt+0x8c]` → `0x4584e0`, bus 0, uncapped). +//! ## Two legs still unvoiced — and what each is actually blocked on +//! +//! Both were "pinned" by 1555 in the sense that the *route* is known. Neither is buildable from +//! that alone, and the missing piece in each case is a specific unread function, not a judgement +//! call. Named here so the next pass is a scoped question rather than a rediscovery. +//! +//! **1 · The connecting swing** — `$CSS` on any victimState outside {0, 2, 6} plays +//! `WeaponSwingSounds2.dbc` on the capped bus 6, where benilla plays nothing and voices only the +//! miss whoosh. So every landed melee swing is currently missing a sound the reference makes. +//! The play itself is fully read: `0x624c81` → `0x457f60`, which bails on `swingType >= 3` +//! (`0x457f63`), indexes the 6-slot cache `[0xb06bd4]` at `critical + swingType*2` +//! (`0x457f8d lea eax,[eax+ecx*2]`) — kits 233–238, Light/Medium/Heavy × Normal/Critical — and +//! plays on `ecx = 6` at volume **0.5 when `[attacker+0xd80] & 0x10` is set, else 1.0** +//! (`0x457f74`/`0x457f7d`). +//! +//! **Blocked on `0x623870`**, which is where `swingType` comes from: the call site fills it via +//! `0x624c55 call 0x623870(bool, &out)` and hands the result straight in. Its Light/Medium/Heavy +//! classification rule — presumably off the equipped weapon — is unread, and so is the meaning of +//! the two `[attacker+0xd80]` bits the site uses (`0x80` as the critical argument, `0x10` as the +//! half-volume flag). Guessing the classification would put a *wrong* swing sound on every melee +//! hit in the game, which is worse than the silence. `WeaponSwingSounds2.dbc` also has no loader +//! yet (6 rows × 4 fields × 16 B: `{id, swingType, critical, SoundEntriesId}`). +//! +//! **2 · The material foley** rides the same `$FSD` as the terrain footstep — a footfall makes +//! *two* sounds in the reference, the capped terrain step on bus 9 and an uncapped foley on bus 0 +//! (`0x6233d9 call [vt+0x8c]` → `0x623610` for CGUnit / `0x62fa30` for CGPlayer → `0x4584e0`). +//! **Blocked on the material lookup itself**: CGUnit reads `[[unit+0xb3c]+0x28]` (a +//! `CreatureModelData` column) and CGPlayer reads the equipped item's material, and `0x4584e0` +//! then takes the material row's `+0x8` foley kit — but which DBC that row belongs to, and which +//! column `+0x8` is, are unread. benilla's footstep chain resolves a different lookup +//! (`FootstepTerrainLookup`) and cannot answer it by analogy. //! //! INTERIM readings (flagged for a wow-re pass): victims' armor lands on the flesh slot (the //! chain/plate slots need the armor-material chain); @@ -73,10 +105,14 @@ use benilla_formats::{impact_slot, WeaponImpactCatalog}; use crate::creature_anim::{AnimSoundEvent, SwingImpact, SwingMessage, Wielded}; use crate::net::{Embodied, NetEntity}; use benilla_assets::{AssetSet, LockRecover, WorldAssets}; +use benilla_protocol::EntityKind; use benilla_world::schedule::WorldStage; use super::creature::CreatureVoices; -use super::kit::{play_kit_ext, Bus, KitRef, PlayExtras, SoundCategory, SoundKits}; +use super::kit::{ + bark_chance_pass, object_sound_playing, play_kit_ext, Bus, KitRef, PlayExtras, SoundCategory, + SoundKits, EXERTION_CHANCE_CREATURE, EXERTION_CHANCE_PLAYER, +}; use super::{AudioListener, SoundConfig, SoundOutput}; // vmangos `HitInfo` bits (UnitDefines.h, 1.12 wire). @@ -122,9 +158,11 @@ fn load_weapon_impacts(mut commands: Commands, assets: Option>) } } -/// The latest swing outcome per attacker — written on the packet, read as the `$CSS`/`$CAH` -/// events fire over the following frames, overwritten by the next swing. (The contact family -/// no longer reads it: [`SwingImpact`] carries its own consumed record, decision 0529.) +/// The latest swing outcome per attacker — written on the packet, read as the `$CSS` event +/// fires over the following frames, overwritten by the next swing. (The contact family does not +/// read it: [`SwingImpact`] carries its own consumed record, decision 0529. Neither does the +/// exertion vocal any more — it fires from the packet itself, so it never needs the record to +/// survive into a later frame.) #[derive(Default)] struct LastSwing(EntityHashMap); @@ -208,15 +246,26 @@ fn combat_sounds( config: Res, listener: Res, ) { + // The attacker's exertion vocal is **packet-driven**, not tag-driven (see the module note): + // `SMSG_ATTACKERSTATEUPDATE` -> `0x6246a0` -> `0x624786`. Collected here with the swing record + // so the vocal fires at swing start, which is where the reference puts it. + let mut exertions: Vec<(Entity, bool)> = Vec::new(); for s in swings.read() { last.0.insert(s.attacker, *s); + // `0x62476a`: the vocal leg is gated on victimState, and ONLY on victimState — read off + // the bytes rather than the second-hand note, which also claimed a victim-health gate + // that is not in the function. A swing that contacted nothing at all is silent; every + // other outcome, hit or parried or blocked, grunts. + if s.victim_state != 0 { + exertions.push((s.attacker, s.hit_info & HITINFO_CRITICAL != 0)); + } } // Bound the map: entries for despawned attackers die with the entity check below; a cheap // periodic sweep keeps a long session from accumulating dead keys. if last.0.len() > 128 { last.0.retain(|e, _| units.contains(*e)); } - if events.is_empty() && contacts.is_empty() { + if events.is_empty() && contacts.is_empty() && exertions.is_empty() { return; } let (Some(impacts), Some(voices), Some(mut kits), Some(assets)) = @@ -249,38 +298,70 @@ fn combat_sounds( } }; - // The tag-driven pair: the whoosh and the exertion vocal. + // The attacker's exertion vocal, at swing start. `force = 0` at `0x62477e`, so the class + // chance roll applies: class 0 is 70 for a creature and 35 for a player, class 1 + // (ExertionCritical) is 100 in both twins — a crit always grunts, an ordinary swing thins out, + // and a player grunts about half as often as a creature. + for (attacker, crit) in exertions { + let Ok((tr, _, net, _)) = units.get(attacker) else { + continue; + }; + // Same `AISOUNDDESC` gate, on the attacker this time — exertion is classes 0/1. + if net.kind != EntityKind::Player && object_sound_playing(&out, attacker) { + continue; + } + if !crit { + let threshold = if net.kind == EntityKind::Player { + EXERTION_CHANCE_PLAYER + } else { + EXERTION_CHANCE_CREATURE + }; + if !bark_chance_pass(threshold, kits.roll()) { + continue; + } + } + let vocal = net + .display_id + .and_then(|d| voices.0.for_display(d)) + .map(|v| v.exertion[usize::from(crit)]) + .unwrap_or(0); + play( + &mut kits, + &mut out, + vocal, + tr.translation, + Bus::EXERTION, + "exertion", + ); + } + + // The one tag this module still consumes: the swing whoosh. for ev in events.read() { - let is = |tag: &[u8; 4]| &ev.ident == tag; - if !(is(b"$CSS") || is(b"$CAH")) { + if ev.ident != *b"$CSS" { continue; } let Some(swing) = last.0.get(&ev.entity) else { continue; // an attack anim without a tracked swing (e.g. spawned mid-fight) }; - let Ok((attacker_tr, wielded, net, _)) = units.get(ev.entity) else { + let Ok((attacker_tr, wielded, _, _)) = units.get(ev.entity) else { continue; }; - let pos = attacker_tr.translation; - if is(b"$CSS") { - if no_contact(swing) { - let offhand = swing.hit_info & 0x4 != 0; - let (subclass, _) = swing_weapon(wielded, offhand); - let kit = if TWO_HANDED.contains(&subclass) { - COMBAT_MISS_2H - } else { - COMBAT_MISS_1H - }; - play(&mut kits, &mut out, kit, pos, Bus::DEFAULT, "miss whoosh"); - } - } else { - let crit = swing.hit_info & HITINFO_CRITICAL != 0; - let vocal = net - .display_id - .and_then(|d| voices.0.for_display(d)) - .map(|v| v.exertion[usize::from(crit)]) - .unwrap_or(0); - play(&mut kits, &mut out, vocal, pos, Bus::EXERTION, "exertion"); + if no_contact(swing) { + let offhand = swing.hit_info & 0x4 != 0; + let (subclass, _) = swing_weapon(wielded, offhand); + let kit = if TWO_HANDED.contains(&subclass) { + COMBAT_MISS_2H + } else { + COMBAT_MISS_1H + }; + play( + &mut kits, + &mut out, + kit, + attacker_tr.translation, + Bus::DEFAULT, + "miss whoosh", + ); } } @@ -374,7 +455,15 @@ fn combat_sounds( && swing.hit_info & 0x60 == 0 && !matches!(swing.victim_state, VICTIM_PARRY | VICTIM_BLOCK) { - if let Some((victim_tr, _, net, victim_is_you)) = victim { + // The `AISOUNDDESC` gate (`0x4591f0` from `0x6234cb`): a server-pushed object sound + // live on the victim suppresses its own vocal, classes 0-3 and 8. The CGPlayer twin + // `0x62f880` omits the gate, so a player is never suppressed. Filtered off the victim + // rather than `continue`d, because everything else in this iteration still stands. + let vocal_victim = victim.filter(|(_, _, net, _)| { + net.kind == EntityKind::Player + || !swing.victim.is_some_and(|v| object_sound_playing(&out, v)) + }); + if let Some((victim_tr, _, net, victim_is_you)) = vocal_victim { let crushing = swing.hit_info & HITINFO_CRUSHING != 0; let vocal = net .display_id diff --git a/crates/benilla-app/src/sound/creature.rs b/crates/benilla-app/src/sound/creature.rs index 8daacfc8..8abb2ba5 100644 --- a/crates/benilla-app/src/sound/creature.rs +++ b/crates/benilla-app/src/sound/creature.rs @@ -23,9 +23,9 @@ use benilla_assets::{AssetSet, LockRecover, WorldAssets}; use benilla_world::schedule::WorldStage; use super::kit::{ - bark_chance_pass, play_kit, play_kit_ext, source_kit_playing, stop_source_kit, - unit_voice_playing, voice_category, KitRef, PlayExtras, SoundCategory, SoundKits, STAND_CHANCE, - STAND_COOLDOWN, + bark_chance_pass, object_sound_playing, play_kit, play_kit_ext, source_kit_playing, + stop_source_kit, unit_voice_playing, KitRef, Latch, PlayExtras, SoundCategory, SoundKits, + STAND_CHANCE, STAND_COOLDOWN, }; use super::{AudioListener, SoundConfig, SoundOutput}; @@ -129,20 +129,21 @@ fn death_vocals( /// `SMSG_PLAY_OBJECT_SOUND` (opcode `0x278`) registry. ALERT stores no latch and stays off the /// slot. /// -/// **That gate IS owed, and the note that said otherwise was wrong when it was written.** 1401 -/// recorded the opcode as one "benilla does not implement at all"; benilla has parsed, decoded -/// and played it since well before that — `SMSG_PLAY_OBJECT_SOUND` → -/// `net::apply::world::play_object_sound` → `ServerSoundKind::ObjectSound` → -/// `sound::zone::server_sounds`, which even resolves the source entity to position the kit at it. -/// What is missing is not the opcode but the **per-GUID registry** the gates consult (`0x4591f0`, -/// tested by `0x623a40`'s gate (i) and by `0x623490`'s gate 3 for classes 0,1,2,3 and 8): the -/// pushed channel plays untagged, so nothing can ask "is an object sound live on this unit". +/// **That gate is now in.** 1401 recorded the opcode as one "benilla does not implement at all", +/// which was wrong when it was written — benilla has parsed, decoded and played +/// `SMSG_PLAY_OBJECT_SOUND` since well before it (`net::apply::world::play_object_sound` → +/// `ServerSoundKind::ObjectSound` → `sound::zone::server_sounds`, which even resolves the source +/// entity to position the kit at it). What was missing was the **per-GUID registry** the gates +/// consult (`0x4591f0`, tested by `0x623a40`'s gate (i) and by `0x623490`'s gate 3 for classes 0, +/// 1, 2, 3 and 8): the pushed channel played untagged, so nothing could ask "is an object sound +/// live on this unit". /// -/// Tagging it is one argument (`PlayExtras::source`) — but tagging it into the *shared* `source` -/// latch would deepen the conflation `kit::source_playing` already documents, where a unit's body -/// loop and water splash mask a greeting the reference would let through. The honest fix is the -/// per-latch marker decision 1399 raised, and this is a second caller waiting on it, not a reason -/// to slip a third meaning into one tag. +/// It could not be tagged with a bare `PlayExtras::source`, because that shared tag also answered +/// the greeting latch — a third meaning in one field. [`kit::Latch`] split those apart (decision +/// 1399), and `Latch::ObjectSound` is the registry: a resolved object sound registers on its unit +/// for as long as it sounds, and [`kit::object_sound_playing`] is `0x4591f0`. ALERT (class 8) and +/// HOSTILE (the priority route) consult it below; the combat vocals (classes 0–3) consult it in +/// `super::combat`. Players are exempt — the CGPlayer twin `0x62f880` omits the gate entirely. #[allow(clippy::too_many_arguments)] fn ai_reaction_vocals( mut reactions: MessageReader, @@ -185,6 +186,14 @@ fn ai_reaction_vocals( if kit == 0 { continue; } + // The `AISOUNDDESC` gate (`0x4591f0`, from `0x6234cb` for ALERT's class route and + // `0x623a59` for HOSTILE's priority route): a server-pushed object sound live on this + // unit suppresses its own vocals, so a scripted voice line is not talked over. Applies to + // classes 0-3 and 8 — ALERT is 8 and HOSTILE takes the priority route, so both are in. + // The CGPlayer twin omits this gate entirely, hence the player exemption. + if net.kind != EntityKind::Player && object_sound_playing(&out, r.unit) { + continue; + } // The `[unit+0xb20]` gate. Only HOSTILE latches the slot; ALERT neither tests nor stores // it here (see the doc comment) and stays on the plain play. if !r.hostile { @@ -216,7 +225,7 @@ fn ai_reaction_vocals( SoundCategory::Sfx, PlayExtras { source: Some(r.unit), - voice: Some(voice_category::HOSTILE), + latch: Latch::Voice, ..default() }, ) { @@ -528,16 +537,16 @@ mod tests { /// and that reap IS the handle release. fn barks_that_sound(arrivals: &[f32], clip: f32) -> Vec { let bear = Entity::from_raw_u32(1).expect("valid entity id"); - let mut slot: Option<(Option, Option, f32)> = None; + let mut slot: Option<(Option, Latch, f32)> = None; let mut sounded = Vec::new(); for &t in arrivals { if slot.is_some_and(|(_, _, ends)| ends <= t) { slot = None; } - if slot.is_some_and(|(src, v, _)| occupies_voice_slot(src, v, bear)) { + if slot.is_some_and(|(src, l, _)| occupies_voice_slot(src, l, bear)) { continue; // category 0 is the lowest — a live bark wins, this one is dropped } - slot = Some((Some(bear), Some(voice_category::HOSTILE), t + clip)); + slot = Some((Some(bear), Latch::Voice, t + clip)); sounded.push(t); } sounded @@ -576,7 +585,7 @@ mod tests { Entity::from_raw_u32(1).expect("valid entity id"), Entity::from_raw_u32(2).expect("valid entity id"), ); - let barking = (Some(a), Some(voice_category::HOSTILE)); + let barking = (Some(a), Latch::Voice); assert!(occupies_voice_slot(barking.0, barking.1, a)); assert!(!occupies_voice_slot(barking.0, barking.1, b)); } diff --git a/crates/benilla-app/src/sound/greeting.rs b/crates/benilla-app/src/sound/greeting.rs index c330caf5..2f9f4621 100644 --- a/crates/benilla-app/src/sound/greeting.rs +++ b/crates/benilla-app/src/sound/greeting.rs @@ -48,7 +48,7 @@ use benilla_assets::{AssetSet, LockRecover, WorldAssets}; use benilla_world::schedule::WorldStage; use benilla_world::view::WorldCamera; -use super::kit::{self, play_kit_ext, KitRef, PlayExtras, SoundCategory, SoundKits}; +use super::kit::{self, play_kit_ext, KitRef, Latch, PlayExtras, SoundCategory, SoundKits}; use super::{SoundConfig, SoundOutput}; /// The number of HELLO takes the select greeter plays before it steps into the pissed kit @@ -209,6 +209,10 @@ fn play_line( PlayExtras { variant, source: Some(npc), + // The one play that takes `[unit+0xb1c]`: while this channel lives, the NPC refuses + // another hello. Decision 1399's marker — before it, every source-tagged channel on + // the unit answered this question and a body loop could mute the greeting. + latch: Latch::Greeting, ..default() }, ) { diff --git a/crates/benilla-app/src/sound/kit.rs b/crates/benilla-app/src/sound/kit.rs index ec9ebf64..6a67ac60 100644 --- a/crates/benilla-app/src/sound/kit.rs +++ b/crates/benilla-app/src/sound/kit.rs @@ -116,31 +116,69 @@ pub(crate) struct ActiveChannel { /// Does this channel loop? A loop is a *bed* — an ambience, a tracked body loop — and the /// voice cap never steals one: cutting a bed leaves an audible hole that stays open, where /// stealing a one-shot costs at most the tail of a sound already being buried. (The reference - /// makes no such distinction, but it does not have to: its exhaustion behaviour lives inside - /// `fmod.dll` and is unreadable, so the policy is ours to choose — decision 1557.) + /// makes no such distinction — it never steals *anything*; see [`claim_voice`] and decision + /// 1563. The bed/one-shot split is ours, and only matters because we do steal.) looping: bool, /// This channel's **current effective amplitude** — `category · v · gain · rolloff · /// near_field`, refreshed by [`pump_channels`] every frame. Cached rather than recomputed /// because the voice cap needs to rank every live channel by audibility on the play path, /// which is the one place that must not walk the world. amp: f32, - /// The **voice category latched on this channel**, when this play is a unit's one-shot - /// creature bark — the reference's `[unit+0xb24]` beside the `[unit+0xb20]` handle - /// ([`unit_voice_playing`]). `None` for every other play, which is what keeps a unit's body - /// loop, greeting line and footsteps out of the voice slot. - voice: Option, + /// Which per-unit latch this channel's liveness represents ([`Latch`]). + latch: Latch, } -/// The **voice categories** of the reference's per-unit one-shot bark dispatch `0x623a40` (its -/// 5-way jump table at `0x623afc`; wow-re `object-layer/scratch/smsg-ai-reaction.md`). Only the -/// one the note pins byte-exactly is named here — the rest of the table, and the priority -/// ordering that decides which category may interrupt which, are an open wow-re question and are -/// deliberately NOT guessed at: today only [`voice_category::HOSTILE`] is routed through the slot. -pub(super) mod voice_category { - /// `0x623a40(0)` — the HOSTILE aggro bark, `CreatureSoundData` col 10. Byte-verified as the - /// **lowest** priority in the table: it never interrupts a playing bark, and it is dropped - /// outright while the unit's voice slot is live. - pub(in crate::sound) const HOSTILE: u8 = 0; +/// **Which per-unit latch a channel holds** — the marker decision 1399 asked for, and the fix for +/// the conflation [`source_playing`] used to document. +/// +/// The reference keeps two separate handles on a unit: `[unit+0xb1c]` for its greeting line and +/// `[unit+0xb20]` for its one-shot bark. benilla tags a channel with its `source` entity for +/// *three* unrelated reasons, though — a latch, a thing the pump must follow in flight, and a +/// thing a despawn must stop — and asking "is a channel tagged with this unit alive?" answered all +/// three at once. So a creature's body loop, a missile's travel loop or a water splash silently +/// held the greeting latch and muted a hello the reference would have played. It bit real data: 6 +/// of the 4 509 displays that carry an `NPCSounds` greeting also resolve a `CreatureSoundData` +/// row with a nonzero `loop_sound`. +/// +/// Splitting the *reason* out of the *tag* is what fixes it. `source` now means only "this channel +/// belongs to that entity"; this says what, if anything, it latches. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(super) enum Latch { + /// Tagged for ownership alone — the pump follows it, a despawn stops it, and it blocks + /// nothing. Body loops, missile travel loops, liquid loops, spell holds. + #[default] + None, + /// The NPC greeting line's `[unit+0xb1c]` (`0x60c28c`/`0x60c40a`): a unit with a greeting + /// line still sounding refuses a new one. + Greeting, + /// The one-shot creature bark's `[unit+0xb20]` — the per-unit slot of the reference's bark + /// dispatch `0x623a40` (5-way jump table `0x623afc`; wow-re + /// `object-layer/scratch/smsg-ai-reaction.md`). + /// + /// The reference keeps the latched **category** beside the handle in `[unit+0xb24]` and uses + /// it for an interrupt rule — only a *strictly higher* category stops what is playing + /// (`0x623a8d`, after `0x623a95 call 0x7a5700`). benilla does not carry the category, because + /// the ordering of that 5-way table is an open wow-re question and guessing it would invent + /// an interrupt the client may not have. Today only the HOSTILE aggro bark (`0x623a40(0)`, + /// `CreatureSoundData` col 10) is routed through the slot, and it is byte-verified as the + /// table's **lowest** category: it never interrupts a playing bark and is dropped outright + /// while this latch is held — which is exactly what plain liveness already gives. When the + /// ordering is pinned, the category becomes a payload here. + Voice, + /// A **server-pushed object sound** live on this unit — `SMSG_PLAY_OBJECT_SOUND` + /// (opcode `0x278`), the `AISOUNDDESC` pool at `[0xb05f38]`. + /// + /// The reference registers one of these per source GUID and then *queries* it from two + /// places: `0x4591f0`, reached from `0x6234cb` (the 13-way class route) and `0x623a59` (the + /// priority bark route). While an object sound is live on a unit, that unit's own vocals are + /// **suppressed** — a scripted voice line is not talked over by the creature's grunts. The + /// pool releases on the sound finishing or the emitter leaving `DistanceCutoff` + /// (`0x457a50`), which is what this channel's liveness represents. + /// + /// Two limits, both byte-derived: the suppression applies to classes **0–3 and 8 only** + /// (`0x6234bb`/`0x6234bf`/`0x6234c4`), and the **CGPlayer twin `0x62f880` omits the gate + /// entirely**, so a player's vocals are never suppressed by one. + ObjectSound, } /// The **class-bark chance roll** (wow-re `sound/scratch/creature-vocal-gates.md`, §5): the @@ -163,12 +201,25 @@ pub(super) fn bark_chance_pass(threshold: u32, roll: u32) -> bool { /// The rest of the creature table is `{70, 100, 60, 100, 100, 40, 100, …}` (player twin /// `{35, 100, 30, 100, 100, 40, 100, …}`), and only classes 0, 2 and 5 are ever rolled — every /// other class carries 100, i.e. `P = 1`, which is why `$WNG`/`$WGG` (classes 7 and 10) and the -/// ALERT bark (class 8) are faithfully unconditional. Classes 0 (exertion) and 2 (injury) are -/// **not** encoded here on purpose: their live trigger route is still unpinned (`$CAH`'s exertion -/// leg, `super::combat`'s own INTERIM), and a threshold is only safe to apply once you know the -/// call actually reaches this gate. +/// ALERT bark (class 8) are faithfully unconditional. Class 2 (injury) is **not** encoded here: +/// its live trigger route is still unpinned. Class 0 (exertion) now is — see +/// [`EXERTION_CHANCE_CREATURE`]. pub(super) const STAND_CHANCE: u32 = 40; +/// The class-0 (**exertion**) chance thresholds, and the one place in the vocal tables where the +/// creature and player twins actually disagree — a player grunts about **half as often** as a +/// creature on the same swing. +/// +/// `0x8626d4[0] = 70` (creature) and `0x86424c[0] = 35` (player), read as dwords straight out of +/// `WoW.exe`. With [`bark_chance_pass`]'s inclusive compare that is **P = 71/101 ≈ 70.3 %** for a +/// creature and **36/101 ≈ 35.6 %** for a player. +/// +/// Class **1** (ExertionCritical) carries 100 in both twins, so a *critical* swing always grunts. +/// That asymmetry is the audible shape of the pair: ordinary swings thin out, crits never do. +pub(super) const EXERTION_CHANCE_CREATURE: u32 = 70; +/// The player twin of [`EXERTION_CHANCE_CREATURE`] — `0x86424c[0] = 35`. +pub(super) const EXERTION_CHANCE_PLAYER: u32 = 35; + /// The class-5 cooldown: **10 000 ms on ONE global timestamp** (`0x623290`, /// `GetTickCount − [0xc4e0e4] − 0x2710`). Not per unit and not per class — a single window shared /// by every creature in the world, so one crocodile's croak silences every other creature's stand @@ -279,9 +330,16 @@ pub(crate) const SOFTWARE_CHANNELS: usize = 12; /// alternative (refuse whatever arrives last) silences the sword swinging in your face because /// twelve distant footsteps got there first. /// -/// The reference cannot settle this for us: it passes `FSOUND_FREE` at every play site and, in -/// wow-re's words, "never protects a voice" — so what FMOD does when all 12 are busy is inside -/// `fmod.dll`, unreadable, and explicitly ours to decide (decision 1557). +/// **This is a deliberate divergence, and decision 1563 read the bytes that make it one.** The +/// rule below — lowest first, ties broken by lowest amplitude — *is* FMOD's own allocator +/// (`fmod.dll 0x100268e6`–`0x1002690e`). What the reference does is switch that allocator off: +/// every WoW voice is an `FSOUND_Stream`, `FSOUND_Stream_Create` stamps its sample priority +/// **256** (`0x1002be47`), and the steal scan skips anything `>= 256` (`0x100268e9`). So the +/// reference's 13th concurrent sound is not stolen for — it is **silently dropped**. +/// +/// We let the allocator run instead. Dropping outright is faithful but worse to listen to, and +/// with 1560's copy cap doing the crowd control the steal is rare and lands on the quietest thing +/// in the mix. 1557 chose this when FMOD was still unread; 1563 kept it with the divergence named. fn claim_voice(out: &mut SoundOutput, candidate_amp: f32) -> bool { if out.live_voices() >= SOFTWARE_CHANNELS { // Reap first. [`pump_channels`] drops finished channels once a frame, but plays happen in @@ -296,7 +354,7 @@ fn claim_voice(out: &mut SoundOutput, candidate_amp: f32) -> bool { .channels .iter() .enumerate() - .filter(|(_, c)| !c.looping) + .filter(|(_, c)| stealable(c.looping, c.latch)) .map(|(i, c)| (i, c.amp)); match pick_voice_slot(stealable, out.live_voices(), candidate_amp) { VoiceSlot::Free => true, @@ -316,6 +374,33 @@ fn claim_voice(out: &mut SoundOutput, candidate_amp: f32) -> bool { } /// What [`claim_voice`] decided. +/// May the voice cap take this channel's slot? Two exclusions, for two different reasons. +/// +/// **Beds** (`looping`) — cutting an ambience or a body loop leaves an audible hole that stays +/// open, where stealing a one-shot costs at most the tail of a sound already being buried. +/// +/// **Latch holders** (`source`) — and this one is a correctness bug, not a taste call. A +/// source-tagged channel's *liveness is the latch*: the NPC greeting's `[unit+0xb1c]` and the +/// creature bark's `[unit+0xb20]` are held for exactly as long as the sound plays, and released +/// when the pump reaps it. Stealing one **releases the latch early**, so the very next packet is +/// free to re-fire — and the thing those latches exist to stop is precisely a burst of repeats +/// (creature.rs measured a bear's aggro roar firing 63 times in two minutes, up to thirty +/// overlapping copies, when it was played ungated). A voice cap that manufactures that under +/// load is worse than no voice cap: it breaks hardest in exactly the crowded moment it was added +/// for. +/// +/// This exposure is **ours alone** — decision 1563 read `fmod.dll` and the reference never steals +/// any voice at all (every WoW channel is a stream at priority 256, the one value the allocator +/// refuses), so nothing there can release a latch early. It arrived with 1557's steal and is +/// closed here. +/// +/// The cost is nil in practice: only five play paths tag a source at all (the greeting line, the +/// creature bark, the body loop, the missile travel loop and the liquid loop), and three of those +/// are already `looping`. +fn stealable(looping: bool, latch: Latch) -> bool { + !looping && latch == Latch::None +} + #[derive(Debug, PartialEq, Eq)] enum VoiceSlot { /// Under the ceiling — just play. @@ -375,9 +460,9 @@ pub(super) struct PlayExtras { /// Loop regardless of the kit's own 0x200 flag, for the drivers whose *column* is the loop /// authority (the creature body-loop; see [`play_kit_ext`]'s own note). pub(super) force_loop: bool, - /// The category latched on the unit's one-shot bark slot ([`ActiveChannel::voice`]); `Some` - /// only for the barks the reference stores in `[unit+0xb20]`. - pub(super) voice: Option, + /// Which per-unit latch this play takes, if any ([`Latch`]). Defaults to [`Latch::None`] — + /// tagging a `source` is about ownership, and taking a latch has to be asked for. + pub(super) latch: Latch, /// The **voice bus** this play competes on ([`Bus`]). Defaults to the uncapped bus 0. pub(super) bus: Bus, } @@ -443,7 +528,7 @@ pub(super) fn play_kit_ext( variant, source, force_loop, - voice, + latch, bus, } = extras; // The cover's audio hold ([`SoundConfig::world_hold`]): while the loading screen is up, no @@ -618,7 +703,7 @@ pub(super) fn play_kit_ext( looping, amp, bus, - voice, + latch, }); Ok(()) } @@ -635,7 +720,7 @@ pub(super) fn play_kit_ext( pub(super) fn unit_voice_playing(out: &SoundOutput, unit: Entity) -> bool { out.channels .iter() - .any(|c| occupies_voice_slot(c.source, c.voice, unit)) + .any(|c| occupies_voice_slot(c.source, c.latch, unit)) } /// Does one channel, described by its `(source, voice)` identity, occupy `unit`'s **voice** slot @@ -643,18 +728,14 @@ pub(super) fn unit_voice_playing(out: &SoundOutput, unit: Entity) -> bool { /// `[unit+0xb1c]`)? The two are disjoint by construction, which is the whole point of separating /// them: a bark and a greeting line are different handles in the reference and must not mute each /// other. -pub(super) fn occupies_voice_slot(source: Option, voice: Option, unit: Entity) -> bool { - source == Some(unit) && voice.is_some() +pub(super) fn occupies_voice_slot(source: Option, latch: Latch, unit: Entity) -> bool { + source == Some(unit) && latch == Latch::Voice } /// The complement — the greeting latch's own test (see [`source_playing`] for what else currently /// lands in it). -pub(super) fn occupies_greeting_latch( - source: Option, - voice: Option, - unit: Entity, -) -> bool { - source == Some(unit) && voice.is_none() +pub(super) fn occupies_greeting_latch(source: Option, latch: Latch, unit: Entity) -> bool { + source == Some(unit) && latch == Latch::Greeting } /// Is a channel tagged with `source` still live? The NPC-greeting per-unit latch (`[unit+0xb1c]` @@ -666,18 +747,27 @@ pub(super) fn occupies_greeting_latch( /// ([`unit_voice_playing`]). Conflating them would let a unit's aggro roar mute its own greeting /// line, which the client never does. /// -/// Everything else tagged with `source` is still counted, and that is a **known pre-existing -/// conflation, not a claim of fidelity**: the body loop (`0x623800`'s own latch), the water -/// splash and the spell hold all ride the same one `source` tag, so any of them currently masks -/// a greeting the reference would let through. It bites real data — 6 of the 4 509 displays that -/// carry an `NPCSounds` greeting also resolve a `CreatureSoundData` row with a nonzero -/// `loop_sound` (1303, 10006, 10045, 10699, 11912, 12769; counted at the DBCs this session). The -/// honest fix is a per-latch marker rather than one shared tag, which is wider than this change -/// and is raised as such in decision 1399 — deliberately not slipped in here. +/// **So is everything else tagged with `source`** — and that is what [`Latch`] fixed. This used +/// to ask "is any channel tagged with this unit alive?", which the body loop (`0x623800`'s own +/// latch), the missile travel loop, the water splash and the spell hold all answered yes to, so +/// any of them masked a greeting the reference would have played. It bit real data: 6 of the +/// 4 509 displays that carry an `NPCSounds` greeting also resolve a `CreatureSoundData` row with +/// a nonzero `loop_sound` (1303, 10006, 10045, 10699, 11912, 12769), and every one of those +/// creatures was permanently unable to say hello. Decision 1399 raised the per-latch marker as +/// the honest fix; it is in, and this now asks only about the channel that actually holds +/// `[unit+0xb1c]`. +/// Is a **server-pushed object sound** live on `unit` — the reference's `AISOUNDDESC` pool query +/// `0x4591f0`? See [`Latch::ObjectSound`] for what it gates and what it does not. +pub(super) fn object_sound_playing(out: &SoundOutput, unit: Entity) -> bool { + out.channels + .iter() + .any(|c| c.source == Some(unit) && c.latch == Latch::ObjectSound) +} + pub(super) fn source_playing(out: &SoundOutput, source: Entity) -> bool { out.channels .iter() - .any(|c| occupies_greeting_latch(c.source, c.voice, source)) + .any(|c| occupies_greeting_latch(c.source, c.latch, source)) } /// Is a channel tagged with `source` playing kit `kit_id`? The kit-scoped latch — the creature @@ -778,7 +868,7 @@ pub(crate) fn play_file( looping: false, amp, bus: Bus::DEFAULT, - voice: None, + latch: Latch::None, }); Ok(()) } @@ -1149,6 +1239,34 @@ mod tests { assert_eq!(admitted, 41, "P = 41/101 for the class-5 stand vocal"); } + /// The exertion pair's shape: **a crit always grunts, an ordinary swing does not, and a + /// player grunts about half as often as a creature.** These are the only vocal thresholds + /// where the creature and player twins disagree (`0x8626d4[0] = 70` vs `0x86424c[0] = 35`, + /// read as dwords out of `WoW.exe`), and class 1 carries 100 in both. + #[test] + fn a_crit_always_grunts_and_a_player_grunts_half_as_often() { + let bucket = |r: u32| ((101u64 * u64::from(r)) >> 32) as u32; + let admitted = |threshold: u32| { + (0..=100) + .filter(|&b| { + let r = ((u64::from(b) << 32) / 101) as u32 + 1; + bucket(r) == b && bark_chance_pass(threshold, r) + }) + .count() + }; + assert_eq!( + admitted(EXERTION_CHANCE_CREATURE), + 71, + "P = 71/101 ≈ 70.3 %" + ); + assert_eq!(admitted(EXERTION_CHANCE_PLAYER), 36, "P = 36/101 ≈ 35.6 %"); + // Roughly half, which is the audible point of the split. + assert!(admitted(EXERTION_CHANCE_PLAYER) * 2 <= admitted(EXERTION_CHANCE_CREATURE) + 2); + // Class 1 (ExertionCritical) is 100 in both twins — combat.rs skips the roll entirely on + // a crit, and this is why that shortcut is faithful rather than a convenience. + assert_eq!(admitted(100), 101, "a critical swing always grunts"); + } + /// **The two per-unit handles are disjoint** (decision 1399): the one-shot bark occupies /// `[unit+0xb20]`, the greeting line `[unit+0xb1c]`, and neither may answer the other's /// question. This is the regression guard for the way the voice slot was added — tagging the @@ -1158,21 +1276,33 @@ mod tests { fn the_voice_slot_and_the_greeting_latch_are_disjoint() { let bear = Entity::from_raw_u32(1).expect("valid entity id"); let other = Entity::from_raw_u32(2).expect("valid entity id"); - let bark = (Some(bear), Some(voice_category::HOSTILE)); - let greet = (Some(bear), None); + let bark = (Some(bear), Latch::Voice); + let greet = (Some(bear), Latch::Greeting); + // The case decision 1399 was actually about: a channel tagged with the unit for + // *ownership* — its body loop, a missile's travel loop, a water splash — takes no latch + // at all. Before the marker these were indistinguishable from the greeting line, so a + // creature with a body loop could never say hello. + let body_loop = (Some(bear), Latch::None); assert!(occupies_voice_slot(bark.0, bark.1, bear)); assert!(!occupies_greeting_latch(bark.0, bark.1, bear)); assert!(occupies_greeting_latch(greet.0, greet.1, bear)); assert!(!occupies_voice_slot(greet.0, greet.1, bear)); + assert!( + !occupies_greeting_latch(body_loop.0, body_loop.1, bear), + "a body loop must not hold the greeting latch — 6 of the 4509 greeting displays \ + also carry a nonzero loop_sound, and every one of them was mute" + ); + assert!(!occupies_voice_slot(body_loop.0, body_loop.1, bear)); + // Neither slot is world-global: a bark on one unit says nothing about another. assert!(!occupies_voice_slot(bark.0, bark.1, other)); assert!(!occupies_greeting_latch(greet.0, greet.1, other)); // An untagged channel (every ordinary one-shot) is in neither slot. - assert!(!occupies_voice_slot(None, None, bear)); - assert!(!occupies_greeting_latch(None, None, bear)); + assert!(!occupies_voice_slot(None, Latch::None, bear)); + assert!(!occupies_greeting_latch(None, Latch::None, bear)); } /// The ceiling is the reference's, not a number we liked: `FSOUND_Init(44100, 12, 0x82)`, @@ -1248,6 +1378,34 @@ mod tests { ); } + /// A channel that holds a per-unit latch is never a steal victim — and unlike the bed rule + /// above, this one is correctness, not taste. + /// + /// A source-tagged channel's liveness **is** the latch (`[unit+0xb1c]` for the greeting line, + /// `[unit+0xb20]` for the bark). Steal it and the latch releases early, so the next packet on + /// that unit is free to re-fire — turning the voice cap into a *repeat generator* under + /// exactly the load it exists to handle. `creature.rs` measured the ungated version of that: + /// a bear's aggro roar 63 times in two minutes, up to thirty overlapping copies. + #[test] + fn a_channel_holding_a_units_latch_is_never_stolen() { + assert!( + stealable(false, Latch::None), + "an ordinary one-shot is the whole point of the steal" + ); + assert!( + !stealable(false, Latch::Greeting), + "a greeting line holds [unit+0xb1c] — stealing it lets the unit re-greet at once" + ); + assert!( + !stealable(false, Latch::Voice), + "a bark holds [unit+0xb20] — stealing it lets the unit re-bark at once" + ); + assert!( + !stealable(true, Latch::None), + "beds are held for their own reason" + ); + } + /// **The director's reported case, as arithmetic.** Prayer of Fortitude lands `HolyProtection` /// on five party members inside one frame. A probe capture measured exactly that as the /// loudest moment of a whole session — 3.73x full scale with only 10 voices live, so the diff --git a/crates/benilla-app/src/sound/limiter.rs b/crates/benilla-app/src/sound/limiter.rs index ef3a0aef..826c303c 100644 --- a/crates/benilla-app/src/sound/limiter.rs +++ b/crates/benilla-app/src/sound/limiter.rs @@ -43,13 +43,28 @@ //! //! This is **below** the decision-0070 seam, not across it: WoW's owned parameter math upstream is //! untouched, and the thing being replaced is not a reference behaviour but *kira's* hard clamp. -//! The reference delegates its whole audible mix to FMOD 3 and its own headroom mechanism is a -//! different one — the SFX-bus auto-duck (`0x457960`, wow-re `benilla-pins.md` B15: the -//! SoundVolume category dips to 50 % while one-shots ring, restoring over 3 s), which benilla owes -//! separately and which is a 6 dB constant, not a bound. Modern engines put a limiter here for -//! exactly this reason — FMOD Studio ships one on the master bus by default, and Wwise's own -//! guidance is a peak limiter on the master output. `SoundOutputLimiter 0` turns it off to A/B -//! against what the director reported. +//! +//! **The reference has no headroom mechanism at all — on either side of its API boundary.** That +//! is now read, not assumed: wow-re's §6 proved no constant factor exists anywhere in `WoW.exe` +//! (a close-up 1.0 kit reaches `FSOUND_SetVolume(255)` with the SFX master also 255), and +//! decision 1563 opened `fmod.dll` itself and found the other half — a complete multiply census +//! over all three software mixers turns up per-channel volume, `1/255`, `1/256`, `2⁻³¹` and a +//! mix-rate-derived ramp constant, and **nothing that depends on how many voices are live**. The +//! FPU mixer sums into a float buffer and clamps once; the MMX mixers (what the reference +//! actually runs) accumulate with **saturating** `paddsw` and their output stage is a bare +//! `movq` copy. So the real client sums at full scale and clips, exactly as we did. +//! +//! Two things it does have are often mistaken for headroom and are neither: the 12-voice device +//! ceiling with the 13 per-bus caps (ported — 1555, 1557), which bounds the *count*; and the SFX +//! auto-duck (`0x457960`), which wow-re §10 corrected — it is a **sidechain for server-pushed +//! unit voice lines** (`SMSG_PLAY_OBJECT_SOUND`), armed only by that one packet's pool, with the +//! arming sound itself exempt. Footsteps, spell impacts, weapon hits, UI sounds, creature barks, +//! music and ambience never arm it. It is not a bus limiter and would not have saved the mass +//! buff. +//! +//! Modern engines put a limiter here for exactly this reason — FMOD Studio ships one on the +//! master bus by default, and Wwise's own guidance is a peak limiter on the master output. +//! `SoundOutputLimiter 0` turns it off to A/B against what the director reported. use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/benilla-app/src/sound/mixer.rs b/crates/benilla-app/src/sound/mixer.rs index 89c1532c..8580420d 100644 --- a/crates/benilla-app/src/sound/mixer.rs +++ b/crates/benilla-app/src/sound/mixer.rs @@ -24,6 +24,7 @@ use bevy::prelude::*; use cpal::traits::{DeviceTrait, HostTrait}; use kira::backend::cpal::CpalBackendSettings; use kira::effect::reverb::{ReverbBuilder, ReverbHandle}; +use kira::effect::volume_control::{VolumeControlBuilder, VolumeControlHandle}; use kira::listener::ListenerHandle; use kira::sound::streaming::StreamingSoundData; use kira::sound::FromFileError; @@ -138,6 +139,8 @@ pub(crate) struct Mixer { level: Arc, /// The `SoundOutputLimiter` CVar cell the limiter reads each block. limiter_on: Arc, + /// The master gain — first in the main chain, **upstream** of the limiter ([`main_track`]). + master: VolumeControlHandle, /// The pre-limiter tap's frame clock while a probing run records; `None` otherwise. audio_pos: Option>, /// The device's negotiated sample rate — what turns a count of over-scale samples into a @@ -152,8 +155,11 @@ impl Mixer { let (backend_settings, sample_rate) = backend_settings(); let level = Arc::new(MixLevel::default()); let limiter_on = Arc::new(AtomicBool::new(true)); - let (main_track_builder, audio_pos) = - main_track(&level, &limiter_on, sample_rate, probe_dir); + let MainChain { + builder: main_track_builder, + master, + audio_pos, + } = main_track(&level, &limiter_on, sample_rate, probe_dir); let settings = AudioManagerSettings:: { backend_settings, main_track_builder, @@ -202,6 +208,7 @@ impl Mixer { reverb, level, limiter_on, + master, sample_rate, audio_pos, }) @@ -261,12 +268,14 @@ impl Mixer { } /// Master volume as linear amplitude (the whole mix — every track routes to main). Fed every - /// frame, so it [`glide`]s: a step on the *main* track clicks the entire mix at once, and this - /// is the knob a slider drag and the mute toggle both move. + /// frame, so it [`glide`]s: a step clicks the entire mix at once, and this is the knob a + /// slider drag and the mute toggle both move. + /// + /// Drives the **first effect** in the main chain, not the main track's volume — see + /// [`main_track`] for why the difference is audible. Same `Parameter`, same interpolation; + /// only the position in the chain changes. pub(crate) fn set_master(&mut self, amp: f32) { - self.manager - .main_track() - .set_volume(amp_to_db(amp), glide()); + self.master.set_volume(amp_to_db(amp), glide()); } /// Arm or bypass the output limiter — the `SoundOutputLimiter` CVar (decision 1551). The @@ -405,8 +414,16 @@ fn main_track( limiter_on: &Arc, sample_rate: Option, probe_dir: Option<&Path>, -) -> (kira::track::MainTrackBuilder, Option>) { +) -> MainChain { let mut main = kira::track::MainTrackBuilder::new(); + // MASTER FIRST — and it is an *effect*, not the main track's own volume, for one reason: + // kira applies a track's volume **after** its effects (`track/main.rs`: the effect loop, then + // `*frame *= volume`). Left on the track, the master would sit downstream of the limiter, and + // the limiter would duck peaks the master was about to remove anyway. Turning the volume down + // would not stop the pumping — it would move the whole mix down *including* the pumping, + // which is the one thing a volume knob must never do. Here the limiter sees the signal that + // is actually going to the device, so it engages exactly when the output would have clipped. + let master = main.add_effect(VolumeControlBuilder::new(Decibels::IDENTITY)); meter::install(&mut main, level); // A probing run brackets the limiter with two taps (decision 1556). One tap can only ever // say what was heard; two say whether the limiter is the thing that changed it — which is @@ -419,7 +436,23 @@ fn main_track( if let Some((dir, rate)) = probe { super::mix_tap::install_at(&mut main, &dir.join("post.wav"), rate); } - (super::mix_tap::install(main, sample_rate), audio_pos) + MainChain { + builder: super::mix_tap::install(main, sample_rate), + master, + audio_pos, + } +} + +/// What [`main_track`] hands back: the built chain, plus the two handles a caller needs to reach +/// into it afterwards. +struct MainChain { + builder: kira::track::MainTrackBuilder, + /// The master gain, first in the chain (see [`main_track`]). The main track's *own* volume is + /// left at unity forever — writing to it would reintroduce the post-limiter stage this + /// exists to avoid. + master: VolumeControlHandle, + /// The pre-limiter tap's frame clock, when a probing run armed one. + audio_pos: Option>, } /// kira's cpal backend takes `device.default_output_config().config()` when handed no config of @@ -835,7 +868,7 @@ mod tests { let heard = Arc::new(MixLevel::default()); let limiter_on = Arc::new(AtomicBool::new(limiter)); // The client's own chain, plus one probe meter appended to read the limiter's output. - let (mut main, _) = main_track(&asked, &limiter_on, Some(RATE), None); + let mut main = main_track(&asked, &limiter_on, Some(RATE), None).builder; meter::install(&mut main, &heard); let mut manager = AudioManager::::new(AudioManagerSettings { backend_settings: MockBackendSettings { sample_rate: RATE }, @@ -869,6 +902,119 @@ mod tests { (asked, heard.peak, heard.over) } + /// A quarter-second 0 dBFS sine as a stereo float WAV — the shape every WoW SFX ships in + /// (mastered to full scale), built in memory so a test about the *chain* never needs the + /// gitignored install. + fn full_scale_tone(rate: u32) -> Vec { + let frames = rate as usize / 4; + let mut wav = Vec::new(); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(36 + frames as u32 * 8).to_le_bytes()); + wav.extend_from_slice(b"WAVEfmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&3u16.to_le_bytes()); + wav.extend_from_slice(&2u16.to_le_bytes()); + wav.extend_from_slice(&rate.to_le_bytes()); + wav.extend_from_slice(&(rate * 8).to_le_bytes()); + wav.extend_from_slice(&8u16.to_le_bytes()); + wav.extend_from_slice(&32u16.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&(frames as u32 * 8).to_le_bytes()); + for i in 0..frames { + let v = (i as f32 * 440.0 * std::f32::consts::TAU / rate as f32).sin(); + wav.extend_from_slice(&v.to_le_bytes()); + wav.extend_from_slice(&v.to_le_bytes()); + } + wav + } + + /// The master volume must sit **upstream** of the limiter — the ordering fix in + /// [`main_track`], and the one place a unit test can prove it. + /// + /// kira applies a track's own volume *after* its effects, so a master left on the main track + /// lands downstream of the limiter. The audible symptom is specific and nasty: turn the + /// slider down and the limiter keeps ducking peaks that the slider was already going to + /// remove, so the *pumping scales with the mix instead of going away*. A quiet mix would + /// breathe exactly as hard as a loud one. + /// + /// Two 0 dBFS copies at master 0.25 sum to 2.0 before the master and **0.5 after** it — + /// comfortably inside full scale, so a correctly-placed limiter never engages at all. + /// Wrongly placed it sees 2.0, clamps to the ceiling, and the reading shows deep gain + /// reduction on a mix that was never going to clip. + #[test] + fn the_master_volume_sits_upstream_of_the_limiter() { + use kira::backend::mock::{MockBackend, MockBackendSettings}; + + const RATE: u32 = 44_100; + const MASTER: f32 = 0.25; + const COPIES: usize = 2; + + let asked = Arc::new(MixLevel::default()); + let heard = Arc::new(MixLevel::default()); + let limiter_on = Arc::new(AtomicBool::new(true)); + let chain = main_track(&asked, &limiter_on, Some(RATE), None); + let (mut main, mut master) = (chain.builder, chain.master); + // Appended last, so it reads the chain's actual output. + meter::install(&mut main, &heard); + let mut manager = AudioManager::::new(AudioManagerSettings { + backend_settings: MockBackendSettings { sample_rate: RATE }, + main_track_builder: main, + ..Default::default() + }) + .expect("mock backend"); + // Snap, not glide: a 10 ms ramp would let the tone's own attack through at near unity and + // the peak would be the ramp, not the steady state under test. + master.set_volume(amp_to_db(MASTER), snap()); + // Even a zero-duration tween takes one block to land: kira interpolates a parameter from + // its previous value across the chunk in which the command is read. Settle over silence + // and reset the meters, so what follows measures the steady state and not that ramp. + { + let backend = manager.backend_mut(); + for _ in 0..4 { + backend.on_start_processing(); + backend.process(); + } + } + asked.take(); + heard.take(); + + let data = sfx_from_bytes(full_scale_tone(RATE)).expect("tone decodes"); + for _ in 0..COPIES { + manager.play(data.clone()).expect("play"); + } + let blocks = (data.duration().as_secs_f64() + 0.25) * f64::from(RATE) / 128.0; + let backend = manager.backend_mut(); + for _ in 0..(blocks.ceil() as usize) { + backend.on_start_processing(); + backend.process(); + } + let heard = heard.take(); + // The limiter reports its gain into the *shared* level cell it was installed with, not + // into the meter appended after it — read it from the one that can actually see it. + let inner = asked.take(); + + eprintln!( + "master ordering: {COPIES} copies at master {MASTER} — after master {:.3}x, \ + heard {:.3}x, limiter deepest gain {:.3}", + inner.peak, heard.peak, inner.reduction, + ); + // The whole point: the limiter never had anything to do. + assert!( + inner.reduction > 0.99, + "the limiter engaged (deepest gain {:.3}) on a mix that never exceeded full scale — \ + the master is downstream of it again", + inner.reduction, + ); + // And the output is the scaled sum, not the limited-then-scaled one (~0.25). + assert!( + (heard.peak - MASTER * COPIES as f32).abs() < 0.05, + "expected the plain scaled sum ~{:.2}, heard {:.3}", + MASTER * COPIES as f32, + heard.peak, + ); + assert_eq!(heard.over, 0, "nothing should have passed full scale"); + } + /// The probe's two taps must **bracket** the limiter (decision 1556) — and this is the test /// that the capture handed to the director can actually tell the mechanisms apart. /// @@ -894,32 +1040,13 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp capture dir"); - // A 0 dBFS tone, in memory — the shape every WoW SFX ships in (mastered to full scale), - // without needing the gitignored install for a test about the *chain*. - let frames = RATE as usize / 4; - let mut wav = Vec::new(); - wav.extend_from_slice(b"RIFF"); - wav.extend_from_slice(&(36 + frames as u32 * 8).to_le_bytes()); - wav.extend_from_slice(b"WAVEfmt "); - wav.extend_from_slice(&16u32.to_le_bytes()); - wav.extend_from_slice(&3u16.to_le_bytes()); - wav.extend_from_slice(&2u16.to_le_bytes()); - wav.extend_from_slice(&RATE.to_le_bytes()); - wav.extend_from_slice(&(RATE * 8).to_le_bytes()); - wav.extend_from_slice(&8u16.to_le_bytes()); - wav.extend_from_slice(&32u16.to_le_bytes()); - wav.extend_from_slice(b"data"); - wav.extend_from_slice(&(frames as u32 * 8).to_le_bytes()); - for i in 0..frames { - let v = (i as f32 * 440.0 * std::f32::consts::TAU / RATE as f32).sin(); - wav.extend_from_slice(&v.to_le_bytes()); - wav.extend_from_slice(&v.to_le_bytes()); - } + let wav = full_scale_tone(RATE); { let level = Arc::new(MixLevel::default()); let limiter_on = Arc::new(AtomicBool::new(true)); - let (main, audio_pos) = main_track(&level, &limiter_on, Some(RATE), Some(&dir)); + let chain = main_track(&level, &limiter_on, Some(RATE), Some(&dir)); + let (main, audio_pos) = (chain.builder, chain.audio_pos); let audio_pos = audio_pos.expect("the pre-tap publishes a frame clock"); let mut manager = AudioManager::::new(AudioManagerSettings { backend_settings: MockBackendSettings { sample_rate: RATE }, diff --git a/crates/benilla-app/src/sound/zone.rs b/crates/benilla-app/src/sound/zone.rs index 8948bd7f..8a90e170 100644 --- a/crates/benilla-app/src/sound/zone.rs +++ b/crates/benilla-app/src/sound/zone.rs @@ -62,7 +62,7 @@ use benilla_assets::{AssetSet, LockRecover, WorldAssets}; use benilla_world::lighting::GameClock; use benilla_world::schedule::WorldStage; -use super::kit::{play_kit, KitRef, SoundCategory, SoundKits}; +use super::kit::{play_kit_ext, KitRef, Latch, PlayExtras, SoundCategory, SoundKits}; use super::mixer::{self, StreamingSoundHandle}; use super::{AudioListener, SoundConfig, SoundOutput}; @@ -668,7 +668,21 @@ fn server_sounds( .source .and_then(|e| transforms.get(e).ok()) .map(|t| t.translation); - if let Err(e) = play_kit( + // A resolved object sound also REGISTERS on its unit — the reference's + // `AISOUNDDESC` pool (`0x278` → `0x458fb0` → `0x459120`), which its own vocal + // gates then query through `0x4591f0` so a scripted voice line is not talked over + // by the creature's grunts ([`Latch::ObjectSound`]). A plain 2D push, or one + // whose source is not streamed to us, has no unit to register on. + let source = match m.kind { + ServerSoundKind::ObjectSound => m.source, + _ => None, + }; + let latch = if source.is_some() { + Latch::ObjectSound + } else { + Latch::None + }; + if let Err(e) = play_kit_ext( &mut kits, &assets, &mut out, @@ -677,6 +691,11 @@ fn server_sounds( KitRef::Id(m.sound_id), pos, SoundCategory::Sfx, + PlayExtras { + source, + latch, + ..default() + }, ) { warn!("server sound {}: {e:#}", m.sound_id); }