the creature vocal gates fold back from the binary

- fidget vocals play through the reference's three gates byte-exact — the chance roll, the mute classes, the routing — replacing the interim guesses that shipped with the sound lane
- the owl's constant screech is a proven negative: the reference fires the wing-flap on every animation loop too, and a live count measured ours at exactly the authored rate — so the report stays open as an A/B question, not closed as a fix
- every fired animation event key can be traced now, the asking half of the sound instrument
- the worktree janitor names the dead claim it cannot reap instead of skipping it silently

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-17 17:22:00 +07:00
co-authored by Claude Fable 5
parent d3c1a501de
commit 76f316d040
4 changed files with 175 additions and 20 deletions
@@ -184,6 +184,15 @@ pub(crate) fn advance_track(
/// Fire the event keyframes `clip` crossed on `(prev, cur]`. A loop wrap (`cur < prev`) fires the
/// tail `(prev, duration]` of the last cycle then the head `[0, cur]` of the new one.
///
/// **Every fired key is traced under `aev`** (`WOW_MOVE_TRACE`, `WOW_MOVE_TRACE_TAGS=aev`). This is
/// the *asking* half of the sound instrument, and it was the half we did not have: the play log
/// (`RUST_LOG=benilla_app::sound=debug`) says what sounded, but a report of the shape "this
/// creature vocalises far too often" needs to separate *the tag fired too often* from *the tag
/// fired as authored and the gate above it is missing*. Those are different bugs with different
/// fixes — decision 1399 had to answer exactly that question for a pet owl and could only do it by
/// reading the M2 by hand. One line per key, on the same clock as the mover and wire traces, so a
/// vocal can be read against the clip that asked for it.
pub(crate) fn scan_events(
clip: &AnimClip,
entity: Entity,
@@ -194,9 +203,24 @@ pub(crate) fn scan_events(
if clip.events.is_empty() || cur == prev {
return;
}
let traced = benilla_assets::trace::enabled_for("aev");
let mut fire = |lo: f32, hi: f32| {
for e in clip.events.iter() {
if e.time > lo && e.time <= hi {
if traced {
benilla_assets::trace::line(
"aev",
&format!(
"{} unit={entity} anim={} key={:.3}s data={} clip={:.3}s{}",
String::from_utf8_lossy(&e.ident),
clip.anim_id,
e.time,
e.data,
clip.duration,
if clip.looping { " loop" } else { "" },
),
);
}
out.write(AnimSoundEvent {
entity,
ident: e.ident,
+56 -9
View File
@@ -18,13 +18,14 @@ use benilla_protocol::EntityKind;
use crate::creature_anim::AnimSoundEvent;
use crate::entities::mount::{MountBody, MountChild};
use crate::net::{NetEntity, ObjectStore};
use crate::net::{NetEntity, ObjectStore, SelfPlayer};
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use super::kit::{
play_kit, play_kit_ext, source_kit_playing, stop_source_kit, unit_voice_playing,
voice_category, KitRef, SoundCategory, SoundKits,
bark_chance_pass, play_kit, play_kit_ext, source_kit_playing, stop_source_kit,
unit_voice_playing, voice_category, KitRef, SoundCategory, SoundKits, STAND_CHANCE,
STAND_COOLDOWN,
};
use super::{AudioListener, SoundConfig, SoundOutput};
@@ -120,10 +121,13 @@ fn death_vocals(
/// heard as a phasing, stuttering, constantly-growling bear. The reference receives the same
/// packets and collapses them at this slot.
///
/// ALERT keeps its own shape: the note records it as reaching `vtable+0x88(8)` instead, with an
/// RNG sometimes-skip gate (`0x623520`) and **no** channel latch of its own. Neither the gate's
/// probability nor its exact mute test is byte-pinned yet, so ALERT is deliberately left playing
/// unconditionally rather than fitted to a guess — the open half of the same wow-re dispatch.
/// **ALERT is a different route, and is faithfully unconditional** — pinned since, decision 1401.
/// It reaches `vtable+0x88(8,0)` → `0x623490` → col 13, and it *is* rolled, but the class-8
/// threshold is **100** and the compare is inclusive, so `P = 1`. (wow-re's `smsg-ai-reaction.md`
/// calling ALERT "probabilistic" was too strong, and has been corrected at the table.) Its one
/// real gate is a mute while a **server-pushed** object sound is live on the unit — the
/// `SMSG_PLAY_OBJECT_SOUND` (opcode `0x278`) registry, an opcode benilla does not implement at
/// all — so nothing is owed here until that lands. ALERT stores no latch and stays off the slot.
#[allow(clippy::too_many_arguments)]
fn ai_reaction_vocals(
mut reactions: MessageReader<crate::net::AiReactionMessage>,
@@ -305,12 +309,38 @@ fn creature_body_loops(
/// carrying the mount's display — so they already resolve the mount's voice row, the split-
/// entity shape of the client's mounted redirect (`0x60c480`: `+0xb44 ?: +0xb40` — wow-re
/// `mount-composition.md` Q4, 0441 fold-back).
///
/// **The three tag families gate differently, and the difference is byte-exact** (wow-re
/// `sound/scratch/creature-vocal-gates.md`, decision 1401):
///
/// - **`$FD1..$FD4` → fidget 1..4, ungated.** `0x6232c0` → `0x623440` → `0x6230a0` reads
/// `row[+0x38 + 4*(n-1)]` literally and touches no gate at all. A zero slot bails silently, with
/// no fallback to another column.
/// - **`$WNG`/`$WGG` → wing flap/glide, also ungated — but by *constant*, not by construction.**
/// Both take the class-bark route (`0x5fff9c` pushes class 7, `0x5fff78` class 10, into
/// `[vtable+0x88]`), so they *are* rolled — against thresholds of **100**, which the inclusive
/// compare makes a tautology. They sit outside the mute guard `{0,1,2,3,8}` and run on the
/// uncapped bus. Playing them unconditionally is faithful; it is worth knowing that it is
/// faithful for a reason living in a table rather than in the control flow.
/// - **`$FDX` → the stand vocal, gated three times over**, which is what this function had wrong:
/// the local-player skip (`0x6236c2` — you never hear your own), then a **40.6 %** chance roll
/// ([`STAND_CHANCE`]), then a **10 s cooldown on one world-global timestamp**
/// ([`STAND_COOLDOWN`]). Ungated, a crocodile croaks every 4 s forever: `Crocodile.m2` keys
/// `$FDX` at t=0.000 of a 4.000 s **loop-flag** Stand variation carrying 45 % of the variation
/// weight, and a t=0 key re-fires on every wrap. Row 136's stand column is kit 571
/// `BasiliskStand2`, so it is audible — and it is the only model in 5875 that authors `$FDX`
/// *and* reaches a nonzero stand column (censused across all 20 models that reach one of the 19
/// rows carrying a stand kit).
#[allow(clippy::too_many_arguments)]
fn creature_anim_vocals(
mut events: MessageReader<AnimSoundEvent>,
// GlobalTransform: the mount child's local Transform is the seat-relative ~origin — world
// position is the only correct read for both parented and top-level sources.
units: Query<(&NetEntity, &GlobalTransform)>,
units: Query<(&NetEntity, &GlobalTransform, Has<SelfPlayer>)>,
// The class-5 window. A `Local` is exactly one instance, which is the point: the reference's
// `[0xc4e0e4]` is a single timestamp for the whole world, not one per unit.
mut stand_window: Local<Option<std::time::Duration>>,
time: Res<Time>,
voices: Option<Res<CreatureVoices>>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
@@ -325,13 +355,30 @@ fn creature_anim_vocals(
return;
};
let listener = listener.pos;
let now = time.elapsed();
for ev in events.read() {
let Ok((net, transform)) = units.get(ev.entity) else {
let Ok((net, transform, is_self)) = units.get(ev.entity) else {
continue;
};
let Some(voice) = net.display_id.and_then(|d| voices.0.for_display(d)) else {
continue;
};
// `$FDX`'s three gates, in the reference's own order. All of them run BEFORE the
// column-is-zero bail below, and the window is stamped on any *allowed* attempt — so a
// silent or distance-culled stand vocal burns the ten seconds for every creature in the
// world, exactly as `0x623290` does.
if ev.ident == *b"$FDX" {
if is_self {
continue; // `0x6236c2`: you never hear your own stand vocal
}
if !bark_chance_pass(STAND_CHANCE, kits.roll()) {
continue;
}
if stand_window.is_some_and(|last| now < last + STAND_COOLDOWN) {
continue;
}
*stand_window = Some(now);
}
let kit = match &ev.ident {
b"$FD1" => voice.fidget[0],
b"$FD2" => voice.fidget[1],
+66
View File
@@ -75,6 +75,14 @@ pub(crate) struct SoundKits {
rng: Rng,
}
impl SoundKits {
/// One 32-bit draw off the kit player's own generator — the input to [`bark_chance_pass`].
/// Shared with the per-shot volume/pitch variation draws, like the client's single stream.
pub(super) fn roll(&mut self) -> u32 {
self.rng.next()
}
}
/// One playing channel the pump owns — the client's ~0x90-byte channel struct, benilla-shaped.
pub(crate) struct ActiveChannel {
pub(crate) kit: u32,
@@ -122,6 +130,39 @@ pub(super) mod voice_category {
pub(in crate::sound) const HOSTILE: u8 = 0;
}
/// The **class-bark chance roll** (wow-re `sound/scratch/creature-vocal-gates.md`, §5): the
/// reference draws `r = MulHi32(101, rand32) ∈ [0, 100]` and admits the bark iff
/// `threshold >= r` — inclusive, so `P = (threshold + 1) / 101`.
///
/// The client's generator is its own shared lagged generator (`0x882664`/`0x882668` over the
/// `.rdata` table `0x802700`), **not** the MSVCRT LCG the animation variation walk uses, and it is
/// reseeded from the millisecond tick at `0x402802`. The note's own guidance follows from that:
/// **reproduce the probability, never the sequence.** So this takes any 32-bit draw — ours is the
/// kit player's own xorshift ([`SoundKits::roll`]) — and only the arithmetic is faithful.
pub(super) fn bark_chance_pass(threshold: u32, roll: u32) -> bool {
((101u64 * u64::from(roll)) >> 32) as u32 <= threshold
}
/// The class-5 (`$FDX` stand) chance threshold: `0x8626d4[5] = 40` in the creature table and
/// `0x86424c[5] = 40` in the player twin — identical, so which twin dispatches cannot change the
/// answer. **P = 41/101 ≈ 40.6 %.**
///
/// 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.
pub(super) const STAND_CHANCE: u32 = 40;
/// 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
/// vocal for ten seconds. Stamped on an *allowed* attempt and **before** the column-is-zero bail,
/// so a silent or distance-culled `$FDX` burns the window for everyone too.
pub(super) const STAND_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(10);
/// A kit to play, by id or by `PlaySoundByName` name.
pub(crate) enum KitRef<'a> {
Id(u32),
@@ -748,6 +789,31 @@ mod tests {
}
}
/// The class-bark chance arithmetic (`r = MulHi32(101, rand32)`, admit iff `threshold >= r`).
/// Two properties matter and both are byte-derived: the compare is **inclusive**, so
/// `threshold = 100` is a tautology — which is the whole reason `$WNG`, `$WGG` and the ALERT
/// bark are faithfully ungated — and `threshold = 40` admits 41 of the 101 buckets.
#[test]
fn the_bark_chance_is_inclusive_and_100_is_a_tautology() {
// 100 never refuses, at either end of the draw space.
for roll in [0, 1, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
assert!(bark_chance_pass(100, roll), "threshold 100 refused {roll}");
}
// 40 admits exactly buckets 0..=40 of 101 — measured over the bucket boundaries, not
// sampled, so this pins the arithmetic rather than a distribution.
let bucket = |r: u32| ((101u64 * u64::from(r)) >> 32) as u32;
assert_eq!(bucket(0), 0);
assert_eq!(bucket(u32::MAX), 100);
let admitted = (0..=100)
.filter(|&b| {
// the first draw landing in bucket b
let r = ((u64::from(b) << 32) / 101) as u32 + 1;
bucket(r) == b && bark_chance_pass(STAND_CHANCE, r)
})
.count();
assert_eq!(admitted, 41, "P = 41/101 for the class-5 stand vocal");
}
/// **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
+29 -11
View File
@@ -11,22 +11,40 @@
//! sheet calls with `"pet"` (ref `PetPaperDollFrame.lua:73-81`), through these very bindings. So
//! `UnitStat`/`UnitResistance`/`UnitArmor`/… route on the token — `"player"` and `"pet"` each read
//! their own pushed snapshot, and **every other token (and a snapshot the app has not pushed yet)
//! serves the absent shape**: zeros, `percent` 1.0. That last part *is* faithful — the 1.12 fields
//! behind these (`UNIT_FIELD_STAT*`, `POSSTAT`/`NEGSTAT`, the damage/AP block) are
//! PRIVATE/OWNER_ONLY, so no third unit ever streams them. A pet's descriptor carries the UNIT
//! half and no PLAYER block at all, which is why its buff decompositions read `0` and the ref's
//! own pet sheet shows plain white numbers.
//! serves the absent shape**: zeros, `percent` 1.0. A pet's descriptor carries the UNIT half and no
//! PLAYER block at all, which is why its buff decompositions read `0` and the ref's own pet sheet
//! shows plain white numbers.
//!
//! (Until 1057 the router was a hard `token == "player"` test documented as "the faithful
//! player-only gate". It was neither: it was "no consumer yet". The reference passes `"pet"` into
//! the same bindings, and the gate was simply never exercised.)
//!
//! Return shapes are the ref Lua's own inverse math (ref-PaperDollFrame): the descriptor carries
//! the *effective* value plus the split positive/negative buff deltas; `base = effective − pos −
//! neg`. The negative deltas arrive **negative-or-zero** (vmangos `Player.h:1505`
//! `ApplyStatBuffMod` routes a debuff's already-negative amount into `NEGSTAT`;
//! `StatSystem.cpp:335-336` writes the AP mods' negative half the same way), matching the ref's
//! `negBuff < 0` tests.
//! **The absent shape for a third unit is the right ANSWER for `UnitStat` and a known GAP for the
//! resistance pair — and the reason it used to be filed as blanket-faithful was wrong.** The
//! binary does not gate slots 1/2 on SELF at all: `UnitStat`'s are NULL + typemask bit 3 only, and
//! it returns whatever the client's copy of `UNIT_FIELD_STAT0+i` holds (VERIFIED wow-re
//! `ui/scratch/pet-paperdoll-stat-api.md` §4). What makes our zeros agree there is the *server's*
//! visibility, not a client gate: `UNIT_FIELD_STAT*` is PRIVATE + OWNER_ONLY, and the only
//! owner-visible units a 1.12 unit token can name are the two we already serve — so a stranger's
//! copy is zero on the reference too. `UNIT_FIELD_RESISTANCES` carries a third flag,
//! **`SPECIAL_INFO`**, which vmangos grants to the caster of `SPELL_AURA_EMPATHY` — its own comment
//! reads `// Beast Lore` (`Player.cpp:2603-2610`, `Object.cpp:1065-1067`). So with Beast Lore up on
//! a beast, the reference's `UnitResistance("target", i)` / `UnitArmor("target")` return that
//! creature's real numbers through their non-SELF leg, and ours return zeros. That is a real,
//! reachable divergence, unfed rather than decided: the app pushes snapshots for two tokens only.
//! `unit_combat_stats` already works over any store, so the missing piece is a third push.
//!
//! **Return shapes differ BY FAMILY, and the reference Lua's own asymmetry is the tell**
//! (decision 1397 — reading one and assuming the other is how 0208 got the stat row wrong).
//! `UnitStat` serves the **raw** `UNIT_FIELD_STAT` twice — once as-is, once clamped at zero — and
//! leaves the subtraction to `PaperDollFrame_SetStats`, which writes `(stat - posBuff - negBuff)`
//! itself. `UnitResistance`/`UnitArmor` serve a **decomposed** first return, because the engine
//! helper `0x5efcd0` does that subtraction for them; their callers use it directly.
//!
//! The negative deltas are **negative-or-zero** where the wire can carry them at all, matching the
//! ref's `negBuff < 0` tests — but see 1397 for how little of that survives a given server: these
//! four arrays are stored float and narrowed to int by `Object::BuildValuesUpdate`, and that cast
//! saturates a negative to `0` on an arm64 host while wrapping it correctly on x86.
use mlua::{Lua, Value};