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:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user