master volume moves ahead of the limiter, and mute NPCs greet you again

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-25 18:27:52 +07:00
co-authored by Claude Fable 5
parent 5f00d35164
commit ef4cc8548d
7 changed files with 585 additions and 164 deletions
+137 -48
View File
@@ -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<Res<WorldAssets>>)
}
}
/// 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<SwingMessage>);
@@ -208,15 +246,26 @@ fn combat_sounds(
config: Res<SoundConfig>,
listener: Res<AudioListener>,
) {
// 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
+30 -21
View File
@@ -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<crate::net::AiReactionMessage>,
@@ -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<f32> {
let bear = Entity::from_raw_u32(1).expect("valid entity id");
let mut slot: Option<(Option<Entity>, Option<u8>, f32)> = None;
let mut slot: Option<(Option<Entity>, 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));
}
+5 -1
View File
@@ -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()
},
) {
+211 -53
View File
@@ -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<u8>,
/// 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<u8>,
/// 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<Entity>, voice: Option<u8>, unit: Entity) -> bool {
source == Some(unit) && voice.is_some()
pub(super) fn occupies_voice_slot(source: Option<Entity>, 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<Entity>,
voice: Option<u8>,
unit: Entity,
) -> bool {
source == Some(unit) && voice.is_none()
pub(super) fn occupies_greeting_latch(source: Option<Entity>, 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
+22 -7
View File
@@ -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};
+159 -32
View File
@@ -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<MixLevel>,
/// The `SoundOutputLimiter` CVar cell the limiter reads each block.
limiter_on: Arc<AtomicBool>,
/// 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<Arc<AtomicU64>>,
/// 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::<DefaultBackend> {
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<AtomicBool>,
sample_rate: Option<u32>,
probe_dir: Option<&Path>,
) -> (kira::track::MainTrackBuilder, Option<Arc<AtomicU64>>) {
) -> 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<Arc<AtomicU64>>,
}
/// 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::<MockBackend>::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<u8> {
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::<MockBackend>::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::<MockBackend>::new(AudioManagerSettings {
backend_settings: MockBackendSettings { sample_rate: RATE },
+21 -2
View File
@@ -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);
}