diff --git a/crates/benilla-app/src/creature_anim/events.rs b/crates/benilla-app/src/creature_anim/events.rs index 268bfaf3..aab4fdf5 100644 --- a/crates/benilla-app/src/creature_anim/events.rs +++ b/crates/benilla-app/src/creature_anim/events.rs @@ -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, diff --git a/crates/benilla-app/src/sound/creature.rs b/crates/benilla-app/src/sound/creature.rs index c6898aeb..9bacd3e4 100644 --- a/crates/benilla-app/src/sound/creature.rs +++ b/crates/benilla-app/src/sound/creature.rs @@ -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, @@ -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, // 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)>, + // 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>, + time: Res