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