From c61882cf42cfa384ef7e1e2a1270253cf66e1cb2 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 25 Aug 2026 18:31:38 +0700 Subject: [PATCH] every melee hit makes a sound, and footsteps get their armor foley MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - every connecting melee swing makes a sound: the reference splits its swing sound in two — a miss whoosh by handedness, and for a landed hit, parry, block, immune or deflect the WeaponSwingSounds2 kit picked by the swinging hand's weight class, a DBC column (ItemSubClass.WeaponSwingSize) this crate had carried unread; deflect and absorb get their real kits after three weeks as silent stubs - a footfall is two sounds: the terrain step and the armor foley of your chest piece's material — chain, plate and leather rustle, cloth is silent by data; on the real 1.12.1 files every creature model's foley material is 0, so the foley you hear in the reference is your own, and that is what ships - the paper doll's turn cross-fades at both ends instead of cutting — the reference plays every turn clip blended over the incoming clip's own authored blend time (0.25 s into a shuffle, 0.5 s back to Stand on HumanMale), and the outgoing clip keeps running under the fade rather than freezing, which corrects a "universal snapshot law" an earlier record had over-generalised; `m2seq` prints the blend column that settles such questions Co-Authored-By: Claude Fable 5 --- crates/benilla-app/src/creature_anim.rs | 6 +- .../src/creature_anim/driver/play.rs | 24 +- .../benilla-app/src/creature_anim/select.rs | 2 +- crates/benilla-app/src/entities.rs | 7 + crates/benilla-app/src/portrait/booth.rs | 439 +++++++++++++--- crates/benilla-app/src/portrait/dressup.rs | 2 + crates/benilla-app/src/portrait/mod.rs | 57 ++- crates/benilla-app/src/sound/combat.rs | 481 +++++++++++++++--- crates/benilla-app/src/sound/footsteps.rs | 111 +++- crates/benilla-app/src/sound/kit.rs | 42 +- .../src/bin/benilla-extract/m2dump.rs | 11 +- crates/benilla-formats/src/creatures.rs | 64 +++ crates/benilla-formats/src/itemsubclass.rs | 15 + crates/benilla-formats/src/lib.rs | 4 + crates/benilla-formats/src/material.rs | 105 ++++ crates/benilla-formats/src/weapon_swing.rs | 112 ++++ 16 files changed, 1310 insertions(+), 172 deletions(-) create mode 100644 crates/benilla-formats/src/material.rs create mode 100644 crates/benilla-formats/src/weapon_swing.rs diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index e2fe599c..3cec502b 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -822,8 +822,10 @@ pub(crate) struct AnimDriver { /// survives to its clip's end). last_special: Option, /// The **airborne snapshot** node whose clock [`driver::play::leave_special`] stopped - /// (decision 0503 — the client blends out of a cut airborne clip from a frozen pose, not from - /// a still-running one). The per-frame rate write + /// (decision 0503, the swim hop's too-short kick — kept as a symptom fix whose mechanism is + /// open, because the "blends from a frozen pose" law it was justified by is refuted: the + /// client's blend source keeps running, decision **1566**. Scoped to the airborne cut and + /// **not** to be generalised). The per-frame rate write /// ([`driver::play::sync_base_rate`](driver::play)) must not restart it, so it is named here /// rather than inferred from a zero speed — a rate-scaled clip legitimately reads 0 when the /// body is standing still, and skipping *that* would strand it frozen forever. Self-clearing: diff --git a/crates/benilla-app/src/creature_anim/driver/play.rs b/crates/benilla-app/src/creature_anim/driver/play.rs index ba6afaae..797587bd 100644 --- a/crates/benilla-app/src/creature_anim/driver/play.rs +++ b/crates/benilla-app/src/creature_anim/driver/play.rs @@ -319,16 +319,20 @@ pub(super) fn leave_special( window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>, frozen: &mut Option, ) -> Mode { - // The client blends OUT of a cut airborne clip from a **pose snapshot** — op4 `0x7121a0` - // with blendFlag≠0 copies the outgoing pose to `+0xc4` and *decays the frozen pose* under - // the incoming's blend-in (wow-re `swim-jump-anim-law.md`; the swim-hop §5, decision 0503). - // Bevy's transitions instead keep the outgoing clip RUNNING while its weight ramps down — - // and on an early cut (the swim re-latch ~0.24 s into the 833 ms JumpStart) the clip's - // remaining frames are the leg RECOVERY, so the fading kick actively retracts: the kick - // reads far shorter than the ref's lingering mid-kick pose (director-reported). Freezing - // the outgoing node reproduces the snapshot-decay. Scoped to the airborne cut, where the - // divergence is visible; the client's snapshot law is universal (every blended arm), and - // adopting it for all cross-fades is 0503's recorded follow-up. + // Freeze the cut airborne clip before handing off, so the incoming gait fades in over a + // still kick instead of one that actively retracts. On the swim re-latch (~0.24 s into the + // 833 ms JumpStart) the clip's remaining frames are the leg RECOVERY, and ours read far + // shorter than the reference's lingering mid-kick — the director's report behind 0503. + // + // **This is a symptom fix whose mechanism is open** (decision 1566). 0503 justified it with + // "the client blends from a pose snapshot, universally", which the bytes REFUTE: the blend + // source keeps running on its own clock — `0x7125ea` copies the outgoing track's base, rate + // and bias, and the kernel re-derives its time every frame (`0x7146b2`–`0x7147a5`). It looks + // still only when the source's own window has elapsed AND it is clamp-flagged, which the cut + // JumpStart's has not. So do NOT generalise this to other cross-fades — that was 0503's + // recorded follow-up and 1566 strikes it; it would freeze every gait and turn transition in + // the client. It stays HERE because the director saw the symptom and their eye outranks a + // derivation; what produces the reference's lingering kick is not yet known. // …and the frozen node is NAMED (decision 0906), so the per-frame rate write // ([`sync_base_rate`]) skips it instead of restarting the clock a line above just stopped. if matches!(sp, Special::Jump | Special::Fall) { diff --git a/crates/benilla-app/src/creature_anim/select.rs b/crates/benilla-app/src/creature_anim/select.rs index 39d50703..4f154904 100644 --- a/crates/benilla-app/src/creature_anim/select.rs +++ b/crates/benilla-app/src/creature_anim/select.rs @@ -906,7 +906,7 @@ pub(super) fn wound_weight(remaining_frac: f32, others: f32) -> f32 { /// `0x7123af`: `+0x104 = 1/150`, amplitude `+0x108 = 1.0`) and the blended re-arm that cross-fades /// a new masked clip in over the *incoming* sequence's own blendTime (`0x7125f2`). The wound's /// twin ([`wound_weight`]) is the same curve at amplitude 0.75. -pub(super) fn blend_lambda(remaining_frac: f32) -> f32 { +pub(crate) fn blend_lambda(remaining_frac: f32) -> f32 { let t = remaining_frac.clamp(0.0, 1.0); (3.0 - 2.0 * t) * t * t } diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 9281e825..0f3badc0 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -267,6 +267,13 @@ pub(crate) struct Creatures { } impl Creatures { + /// A display's **foley material** (`Material.dbc` id) — the creature half of the footfall + /// rustle; see [`benilla_formats::CreatureCatalog::foley_material`] and + /// [`crate::sound::footsteps`]. `None` for an unknown display. + pub(crate) fn foley_material(&self, display_id: u32) -> Option { + self.catalog.foley_material(display_id) + } + /// A display's collision height in **raw model units** — see [`CollisionHeight`] for the world /// value and everything that reads it. `None` for an unknown display. pub(crate) fn collision_height(&self, display_id: u32) -> Option { diff --git a/crates/benilla-app/src/portrait/booth.rs b/crates/benilla-app/src/portrait/booth.rs index 434c71c7..27fe707e 100644 --- a/crates/benilla-app/src/portrait/booth.rs +++ b/crates/benilla-app/src/portrait/booth.rs @@ -11,6 +11,7 @@ //! park is one [`AnimParked`](benilla_world::rig_anim::AnimParked) marker on the root. use benilla_formats::BillboardKind; +use bevy::animation::RepeatAnimation; use bevy::camera::visibility::RenderLayers; use bevy::prelude::*; @@ -652,17 +653,107 @@ pub(super) fn turn_shuffle(faced: f32, angle: f32) -> u16 { /// last paint" latch, and a paint preceded by a `SetRotation` can never expire. const SHUFFLE_HOLD_SECS: f64 = 0.100; -/// **Step the doll's feet round when it turns** (decision 1559, director report B313). +/// Arm one `AnimationData` id on a booth root the way the reference's turn does — every one of its +/// plays is `0x7121a0(bone -1, id, variation -1, offset 0, rate 1.0f, blend 1, primary 1)` +/// (wow-re `modelframe-camera-law.md` §13.4), and all three of those trailing arguments show: +/// +/// - **variation `-1`** — a freshly *rolled* frequency-weighted variation, not the head. HumanMale +/// authors four Stands (frequencies 14199 / 2184 / 2184 / 14199), and the reference re-rolls on +/// every arm including the one its expiry timer fires, so a doll does not come back from two +/// turns into the same idle twice. +/// - **offset `0`** — from the top of the clip, not from wherever it happened to be. +/// - **blend `1`** — cross-faded out of the outgoing pose over the **incoming** clip's own +/// `M2Sequence.blendTime` ([`super::Fade`]). On HumanMale that is 0.25 s entering a shuffle and +/// **0.5 s** coming back to Stand (`benilla-extract m2seq`), and that half-second is the whole of +/// B321: the release used to cut from a mid-stride shuffle straight onto Stand in one frame. +/// +/// Returns whether it armed. A display that does not author the id at all — a pet's model, mostly, +/// for the shuffles — is left standing rather than stepping whatever the resolver walked away to, +/// so `false` says "there is no turn to run here" and no expiry is scheduled for it. (The old node +/// compare this replaces only caught the case where the fallback landed on Stand's *head* +/// variation, which is not where a frequency roll usually lands.) +fn arm_turn( + player: &mut AnimationPlayer, + anims: &benilla_assets::ModelAnimations, + catalog: &benilla_formats::AnimDataCatalog, + rng: &mut u32, + turn: &mut super::Turn, + id: u16, + now: f64, +) -> bool { + // What this arm fades *out of*: whatever the last one left playing, or — before the first turn + // of this bake — the Stand the bake itself armed, which is the head variation + // (`spawn_booth_model` plays `find(resolve(0))`). + let outgoing = turn + .playing + .or_else(|| anims.find(anims.resolve(STAND, catalog).id).map(|c| c.node)); + let res = anims.resolve(id, catalog); + if res.id != id { + return false; + } + let Some(clip) = anims.pick_variation(res.id, crate::creature_anim::select::msvc_rand(rng)) + else { + return false; + }; + let (node, looping, blend) = (clip.node, clip.looping, clip.blend_time.max(0.0)); + // The one secondary slot: a fade still in flight is displaced here, and its pose stops being + // drawn at all — except when it is the node we are about to (re)play, which a reversal *back* + // inside a running window makes it (hold left, flick right, flick left again). The second + // clause holds the invariant rather than a live case: an arm always leaves `fade.node` and + // `playing` distinct, so the displaced fade is never the pose the new one starts from. + if let Some(old) = turn.fade.take() { + if old.node != node && Some(old.node) != outgoing { + player.stop(old.node); + } + } + let fade = outgoing + .filter(|o| *o != node && blend > 0.0) + .map(|node| super::Fade { + node, + until: now + f64::from(blend), + span: blend, + }); + { + // Explicit, not defaulted: `play` is idempotent on a live node, so a re-arm of a node + // some earlier turn left running would otherwise keep that turn's clock and weight. + let active = player.play(node); + active.set_repeat(if looping { + RepeatAnimation::Forever + } else { + RepeatAnimation::Never + }); + active.set_speed(1.0); + active.seek_to(0.0); + // λ = 1 on a blended arm's first frame, so the incoming contributes nothing yet — which is + // exactly the point: the pose does not move on the frame the arm lands. + active.set_weight(if fade.is_some() { 0.0 } else { 1.0 }); + } + if fade.is_none() { + // Either nothing was playing, or the sequence authors `blendTime = 0` — an instant cut, + // the client's own `[blk+0xd0] = -1` no-blend path. + if let Some(out) = outgoing.filter(|o| *o != node) { + player.stop(out); + } + } + turn.playing = Some(node); + turn.fade = fade; + true +} + +/// **Step the doll's feet round when it turns, and settle out of the step** (decisions 1559 + +/// 1565, director reports B313/B321). /// /// The reference's rotate arrows do not spin the model on the spot: `Model:SetRotation` queues a /// turn-in-place shuffle by direction *and then* writes the facing. Ours wrote only the facing -/// (0638's bake yaw), so the doll pivoted like a turntable. +/// (0638's bake yaw), so the doll pivoted like a turntable (B313). /// -/// The pose evaluator's own gate does the switching: a node at literal weight `0.0` is skipped -/// entirely (`rig_anim::pose`), so raising the shuffle and dropping Stand leaves exactly one -/// contribution — committed at full value — without stopping either clock. That matters on the -/// way back: Stand's seek kept running under the shuffle, so resuming it is a continuation of the -/// idle loop, never a pop back to `t = 0`. +/// Both halves of that turn are **blended** arms ([`arm_turn`]), which is the second half of the +/// same law and the fix for B321: the reference's 100 ms expiry does not drop the shuffle, it +/// cross-fades Stand in over half a second while the shuffle keeps stepping underneath and fades +/// out. Ours cut, and a cut out of a mid-stride pose is the "snap back to the stop pose" the +/// director saw. Nothing here stops a clock early: the client's `rep movsd` copies the outgoing +/// track's clock, not a pose, so the shuffle goes on stepping underneath the whole fade — a +/// half-second, on HumanMale, which is a further whole loop of it (decision 1566). /// /// Body panes only ([`Booth::live`]), which is the same set the reference's turn machinery sits /// on. The doll cannot clack while it steps: footstep keys are fired by @@ -687,77 +778,81 @@ pub(super) fn drive_booth_turn( continue; } let Ok((mut player, anims)) = rigs.get_mut(booth.root) else { - // No rig in this booth (empty, or a boneless display): a shuffle in flight when the - // bake went away has nothing left to step. - booth.turn.shuffle = None; - booth.turn.spun = None; - booth.turn.playing = None; + // No rig in this booth (empty, or a boneless display): a turn in flight when the bake + // went away has nothing left to step or fade, and its nodes name a player that is gone. + booth.turn.rebaked(); continue; }; - // Every id through the model's own resolution, the same path the bake's Stand takes. - // `find` is the HEAD variation — the stable identity to compare and to drop back to; - // `pick` is the reference's own weighted roll, which is what an *arm* uses. - let head = |id: u16| { - anims - .find(anims.resolve(id, &anim_data.0).id) - .map(|c| c.node) - }; - let stand = head(STAND); - // Arm: a rotation this frame plays its direction's sequence and re-arms the expiry. A - // reversal mid-step is a fresh arm — the opposite id is, by definition, not the one on the - // root bone — and the reference restarts the clip from t = 0 there, which a `stop` before - // the `play` gives us (`play` alone is idempotent, so a HELD direction arms once and lets - // the clip loop, exactly as the reference's id-equality test does). - if let Some(want) = booth.turn.spun.take() { - let armed = booth.turn.shuffle.map(|(id, _)| id).unwrap_or(STAND); - if want == STAND { - // Equal facings — and NaN — arm Stand outright (wow-re §13.2). Nothing is - // stepping after this. - if let (Some(stand), Some(prev)) = (stand, booth.turn.playing) { - player.stop(prev); - player.play(stand).set_weight(1.0); - } - booth.turn.shuffle = None; - booth.turn.playing = None; - } else if want != armed { - // `resolve` walks the model's own fallback chain, so a display that authors no - // shuffle lands back on Stand — and "switch to Stand, then drop Stand to weight - // 0" would freeze the doll mid-turn instead of leaving it standing. - let next = anims - .pick_variation( - anims.resolve(want, &anim_data.0).id, - crate::creature_anim::select::msvc_rand(&mut rng), - ) - .map(|c| c.node) - .filter(|n| Some(*n) != stand); - if let Some(next) = next { - if let Some(prev) = booth.turn.playing { - player.stop(prev); - } - player.play(next).repeat().set_weight(1.0); - if let Some(stand) = stand { - player.play(stand).set_weight(0.0); - } - booth.turn.playing = Some(next); - booth.turn.shuffle = Some((want, now + SHUFFLE_HOLD_SECS)); - } - } else if let Some((_, until)) = booth.turn.shuffle.as_mut() { - // Same direction still held: only the deadline moves. + step_turn( + &mut player, + anims, + &anim_data.0, + &mut rng, + &mut booth.turn, + now, + ); + } +} + +/// One booth's turn, for one frame — [`drive_booth_turn`]'s whole rule, with the ECS shelled off so +/// it can be stepped directly at chosen times. +fn step_turn( + player: &mut AnimationPlayer, + anims: &benilla_assets::ModelAnimations, + catalog: &benilla_formats::AnimDataCatalog, + rng: &mut u32, + turn: &mut super::Turn, + now: f64, +) { + // Arm: a rotation this frame plays its direction's sequence and re-arms the expiry. The + // reference's test is an id compare against what sits on the root bone (`0x712090`), so a HELD + // direction arms once and lets the 500 ms clip loop, while a reversal is a fresh arm — the + // opposite id is by definition not the armed one — restarted from `t = 0`. + if let Some(want) = turn.spun.take() { + let armed = turn.shuffle.map_or(STAND, |(id, _)| id); + if want == armed { + // Same direction still held: only the deadline moves. (An equal facing sent to an + // already-standing doll lands here too, and correctly does nothing — the reference's + // compare skips the play for exactly the same reason.) + if let Some((_, until)) = turn.shuffle.as_mut() { *until = now + SHUFFLE_HOLD_SECS; } + } else if arm_turn(player, anims, catalog, rng, turn, want, now) { + // Equal facings — and NaN — arm Stand outright (wow-re §13.2), which is an *active* + // play, not a no-op; nothing is stepping after it, so it schedules no expiry. + turn.shuffle = (want != STAND).then_some((want, now + SHUFFLE_HOLD_SECS)); } - // Expire: the arrow came up (nothing re-armed the deadline) — back to Stand, which never - // stopped running underneath, so the idle continues rather than popping to t = 0. - if let Some((_, until)) = booth.turn.shuffle { - if now > until { - if let Some(prev) = booth.turn.playing.take() { - player.stop(prev); - } - if let Some(stand) = stand { - player.play(stand).set_weight(1.0); - } - booth.turn.shuffle = None; - } + } + // Expire: the arrow came up (nothing re-armed the deadline) — back to Stand, re-rolled and + // blended, the reference's `0x505c98`. Unreachable in the same frame as an arm, which always + // leaves the deadline a full [`SHUFFLE_HOLD_SECS`] out. + if let Some((_, until)) = turn.shuffle { + if now > until { + arm_turn(player, anims, catalog, rng, turn, STAND, now); + turn.shuffle = None; + } + } + // Advance the cross-fade. λ = smoothstep of the window STILL TO RUN + // ([`crate::creature_anim::select::blend_lambda`], the client's `0x714880` kernel) and it + // weights the OUTGOING pose; the incoming takes `1 − λ`. The pair sums to 1 every frame, so + // under the evaluator's normalized weighted fold the mix is exactly the client's + // `out = primary + (secondary − primary)·λ` — and anything else on the rig (the hand-grip + // finger overlay the bake holds) keeps the relative weight it had throughout. + let Some(fade) = turn.fade else { + return; + }; + let frac = ((fade.until - now) / f64::from(fade.span)) as f32; + if frac <= 0.0 { + turn.fade = None; + player.stop(fade.node); + if let Some(cur) = turn.playing { + player.play(cur).set_weight(1.0); + } + } else { + let lambda = crate::creature_anim::select::blend_lambda(frac); + player.play(fade.node).set_weight(lambda); + if let Some(cur) = turn.playing { + player.play(cur).set_weight(1.0 - lambda); } } } @@ -1144,4 +1239,202 @@ mod tests { "clicked right" ); } + + // ── The turn's cross-fades (decision 1565, B321) ──────────────────────────────────────────── + + /// The three ids a turn ever arms, with HumanMale's **real** authored numbers + /// (`benilla-extract m2seq Character\\Human\\Male\\HumanMale.m2`): the shuffles blend in over + /// 0.25 s and loop for 0.5 s; Stand blends in over **0.5 s** and has four variations. Two + /// Stands here, weighted so the frequency roll cannot land on the head — that is what makes + /// "did it re-roll, or did it just take `find`'s head?" a decidable question below. + fn turning_model() -> benilla_assets::ModelAnimations { + let clip = |anim_id, node, blend_time, frequency| benilla_assets::AnimClip { + anim_id, + seq_index: 0, + node: bevy::animation::graph::AnimationNodeIndex::new(node), + looping: true, + duration: 0.5, + move_speed: 0.0, + blend_time, + bounds_center: Vec3::ZERO, + bounds_radius: 0.0, + bounds_min: Vec3::ZERO, + bounds_max: Vec3::ZERO, + events: Vec::new().into(), + arm_nodes: None, + upper_node: None, + frequency, + replay: (0, 0), + poses_bones: true, + }; + benilla_assets::ModelAnimations { + graph: Handle::default(), + clips: vec![ + clip(STAND, 1, 0.5, 1), // the head — all but unreachable by the roll + clip(STAND, 2, 0.5, 32766), // where a roll lands + clip(SHUFFLE_LEFT, 3, 0.25, 0), // the reference's 32767, i.e. the only variation + clip(SHUFFLE_RIGHT, 4, 0.25, 0), + ], + hand_close: [None, None], + playable_animation_lookup: Vec::new(), + animation_lookup: Vec::new(), + global_bones: Vec::new(), + first_seq: None, + pose: Default::default(), + } + } + + fn node(n: usize) -> bevy::animation::graph::AnimationNodeIndex { + bevy::animation::graph::AnimationNodeIndex::new(n) + } + + /// The bake's own arm: Stand's **head** variation, looping — what `spawn_booth_model` leaves + /// running, and therefore the pose the first turn of a session fades out of. + fn baked() -> AnimationPlayer { + let mut player = AnimationPlayer::default(); + player.play(node(1)).repeat(); + player + } + + fn weight(player: &AnimationPlayer, n: usize) -> Option { + player.animation(node(n)).map(|a| a.weight()) + } + + /// **The release settles instead of snapping** — B321, and the half of the reference's turn + /// that decision 1559 shipped without. + /// + /// Every arm `0x505bb0`/`0x505c50` makes carries `blendFlag = 1`, so both ends of a turn are + /// cross-fades, and each runs for the **incoming** clip's own `blendTime`: 0.25 s into the + /// shuffle, 0.5 s back out of it. The frame an arm lands on must not move the doll at all + /// (λ = 1 ⇒ the outgoing pose, whole), and the weights must sum to 1 throughout so that the + /// evaluator's normalized fold lands exactly on the client's + /// `out = primary + (secondary − primary)·λ` — and so the hand-grip overlay riding the same rig + /// keeps its share. + #[test] + fn a_turn_blends_both_ways_over_the_incoming_clips_own_time() { + let (anims, catalog) = ( + turning_model(), + benilla_formats::AnimDataCatalog::from_rows([]), + ); + let (mut player, mut rng) = (baked(), 0u32); + let mut turn = super::super::Turn { + // Arrow down. The shuffle is armed but contributes NOTHING yet. + spun: Some(SHUFFLE_LEFT), + ..Default::default() + }; + step_turn(&mut player, &anims, &catalog, &mut rng, &mut turn, 1.0); + assert_eq!(turn.playing, Some(node(3)), "the shuffle is the primary"); + assert_eq!(weight(&player, 3), Some(0.0), "and it starts at nothing"); + assert_eq!( + weight(&player, 1), + Some(1.0), + "the bake's Stand still holds" + ); + + // Mid-blend: λ = smoothstep of the window still to run, on the SHUFFLE's 0.25 s. + turn.spun = Some(SHUFFLE_LEFT); + step_turn(&mut player, &anims, &catalog, &mut rng, &mut turn, 1.2); + let (out, inc) = (weight(&player, 1).unwrap(), weight(&player, 3).unwrap()); + assert!((out + inc - 1.0).abs() < 1e-5, "{out} + {inc}"); + assert!(inc > 0.8, "four fifths in, the shuffle dominates: {inc}"); + + // Past 0.25 s the fade is done and the pose it faded out of is gone from the player. + turn.spun = Some(SHUFFLE_LEFT); + step_turn(&mut player, &anims, &catalog, &mut rng, &mut turn, 1.3); + assert_eq!(weight(&player, 3), Some(1.0)); + assert_eq!(weight(&player, 1), None, "the outgoing Stand is stopped"); + assert!(turn.fade.is_none()); + + // Arrow up. 100 ms later the expiry arms Stand — and THIS frame must not move the doll. + step_turn(&mut player, &anims, &catalog, &mut rng, &mut turn, 1.41); + let stand = turn.playing.expect("Stand armed"); + assert_ne!(stand, node(1), "a fresh frequency roll, not `find`'s head"); + assert_eq!(stand, node(2)); + assert_eq!( + weight(&player, 2), + Some(0.0), + "the release frame does not jump" + ); + assert_eq!( + weight(&player, 3), + Some(1.0), + "the shuffle still holds the pose" + ); + assert!(turn.shuffle.is_none()); + + // …and it runs for STAND's 0.5 s, not the shuffle's 0.25 s: still blending at 0.3 s in. + step_turn(&mut player, &anims, &catalog, &mut rng, &mut turn, 1.71); + let (out, inc) = (weight(&player, 3).unwrap(), weight(&player, 2).unwrap()); + assert!((out + inc - 1.0).abs() < 1e-5, "{out} + {inc}"); + assert!( + out > 0.0 && inc > 0.0, + "both still contribute: {out} / {inc}" + ); + + step_turn(&mut player, &anims, &catalog, &mut rng, &mut turn, 1.92); + assert_eq!(weight(&player, 2), Some(1.0), "settled on Stand"); + assert_eq!( + weight(&player, 3), + None, + "the shuffle is stopped, not muted" + ); + } + + /// One secondary slot, exactly as the client keeps one (`0x7125d6` overwrites `[blk+0xc4]`): a + /// reversal inside a running window displaces the fade it found — the pose that was fading out + /// is dropped there and then, and the new fade runs from the shuffle being replaced. + #[test] + fn a_reversal_displaces_the_fade_it_found() { + let (anims, catalog) = ( + turning_model(), + benilla_formats::AnimDataCatalog::from_rows([]), + ); + let (mut player, mut rng) = (baked(), 0u32); + let mut turn = super::super::Turn { + spun: Some(SHUFFLE_LEFT), + ..Default::default() + }; + step_turn(&mut player, &anims, &catalog, &mut rng, &mut turn, 1.0); + turn.spun = Some(SHUFFLE_RIGHT); + step_turn(&mut player, &anims, &catalog, &mut rng, &mut turn, 1.1); + + assert_eq!( + turn.playing, + Some(node(4)), + "the opposite shuffle took over" + ); + assert_eq!( + turn.fade.map(|f| f.node), + Some(node(3)), + "…fading out of the first" + ); + assert_eq!(weight(&player, 1), None, "the displaced Stand is gone"); + assert_eq!(turn.shuffle.map(|(id, _)| id), Some(SHUFFLE_RIGHT)); + } + + /// A display that does not author the shuffles at all — most pet models — is left **standing**, + /// not stepping whatever the id resolver walked away to. Nothing is armed, so nothing expires. + #[test] + fn a_display_with_no_shuffle_never_leaves_stand() { + let mut anims = turning_model(); + anims.clips.retain(|c| c.anim_id == STAND); + // The model's own baked resolution: both shuffles fall back to Stand. + anims.playable_animation_lookup = vec![ + benilla_formats::PlayableAnim { + resolved_id: STAND, + dir_flags: 0, + }; + usize::from(SHUFFLE_RIGHT) + 1 + ]; + let catalog = benilla_formats::AnimDataCatalog::from_rows([]); + let (mut player, mut rng) = (baked(), 0u32); + let mut turn = super::super::Turn::default(); + for t in [1.0, 1.1, 1.2, 2.0] { + turn.spun = Some(SHUFFLE_LEFT); + step_turn(&mut player, &anims, &catalog, &mut rng, &mut turn, t); + } + assert!(turn.shuffle.is_none(), "no expiry was ever scheduled"); + assert!(turn.fade.is_none(), "and nothing was cross-faded"); + assert_eq!(weight(&player, 1), Some(1.0), "the bake's Stand, untouched"); + } } diff --git a/crates/benilla-app/src/portrait/dressup.rs b/crates/benilla-app/src/portrait/dressup.rs index 6cb4309b..894a28c9 100644 --- a/crates/benilla-app/src/portrait/dressup.rs +++ b/crates/benilla-app/src/portrait/dressup.rs @@ -319,6 +319,8 @@ pub(super) fn sync_dressup_booth( ); // The bake animates, so its camera can't sleep — `gate_booth_cameras` runs it every frame // the window is drawing this pane, and none once it closes. + // The turn's node bookkeeping named the player this bake just replaced ([`Turn::rebaked`]). + booth.turn.rebaked(); booth.live = true; // A fresh bake is animated by construction; the park state is the new rig's. booth.rigged = booth_rig.rigged(); diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 431a9f28..4d5105de 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -480,10 +480,59 @@ struct Turn { /// The shuffle currently stepping — its `AnimationData` id and the wall-clock second it /// expires at. `None` = the doll is back on Stand. shuffle: Option<(u16, f64)>, - /// The graph node that shuffle is actually running, kept because the reference arms a - /// **frequency-weighted random variation** (`0x7121a0`'s `-1`), so the node playing is not - /// recoverable from the id alone. + /// The graph node the turn's **primary** is running — the shuffle while one steps, the Stand + /// variation it settled into otherwise. Kept because every arm rolls a **frequency-weighted + /// random variation** (`0x7121a0`'s `-1`), so the node playing is not recoverable from the id + /// alone. `None` before the first arm, where the bake's own Stand is the pose in flight. playing: Option, + /// The cross-fade out of the pose the last arm replaced ([`Fade`]) — `None` between turns. + fade: Option, +} + +impl Turn { + /// **The bake replaced this booth's `AnimationPlayer`** — drop every node reference the turn was + /// holding, because they name `ActiveAnimation`s in a player that no longer exists (the bake + /// `insert`s a fresh one on the same root). Without this a re-bake mid-turn — an equipment + /// change while the arrow is held, the pane's aspect settling — leaves + /// [`booth::drive_booth_turn`] naming a node the new player never played, and the next weight + /// write **starts** it: a phantom shuffle at full weight over the doll's idle. + /// + /// [`Self::faced`] survives, and must: the yaw is the bake's own (`sync_body_booth` re-poses + /// the root to it), so a re-bake is not a rotation and must not step the feet. That is the + /// reference's `RefreshUnit`, which re-snapshots the unit without ever calling `SetRotation`. + fn rebaked(&mut self) { + *self = Turn { + faced: self.faced, + ..Default::default() + }; + } +} + +/// The **cross-fade out of the pose an arm replaced** — the reference's per-bone SECONDARY blend +/// slot, seeded by every arm the turn makes (decision 1565, director report B321). +/// +/// `0x7121a0`'s sixth argument is `1` at both of the turn's call sites (`0x505c23` the rotation, +/// `0x505c98` the 100 ms expiry — wow-re `modelframe-camera-law.md` §13.4). A non-zero there is +/// `0x7125d6`: `rep movsd` copies the live primary track into the secondary sub-record `[blk+0xc4]` +/// — the outgoing clip, **still on its own clock**, not a frozen pose — then writes the window end +/// `[blk+0x100]`, the rate `[blk+0x104] = 1/blendTime` and the amplitude `[blk+0x108] = 1.0f`. The +/// kernel at `0x714880` decays λ = smoothstep across it and blends `out = primary + (secondary − +/// primary)·λ` ([`crate::creature_anim::select::blend_lambda`], the same curve the world lane's +/// key-bone slot already runs — decision 0878). +/// +/// **One slot, not a list**, exactly as the client keeps one: an arm inside a running window +/// overwrites `[blk+0xc4]`, and the pose it was fading out is dropped there and then. +#[derive(Clone, Copy)] +struct Fade { + /// The outgoing graph node. Its clock is left running — the `rep movsd` copies the live + /// track's window base and rate, so the client fades out of a clip that is still stepping. + node: bevy::animation::graph::AnimationNodeIndex, + /// The wall-clock second λ reaches 0 at (`[blk+0x100]`). + until: f64, + /// The window's full width — the **incoming** clip's own `M2Sequence.blendTime` (`0x7125f2`), + /// which is why a release settles so much more slowly than a turn starts: HumanMale authors + /// 0.25 s on ShuffleLeft/Right and **0.5 s** on Stand (`benilla-extract m2seq`). + span: f32, } /// How many frames a content edge keeps a booth camera rendering ([`Booth::wake`]): covers the @@ -1725,6 +1774,8 @@ fn sync_body_booth( booth_light.pane.buffer.as_ref(), &booth_effects, ); + // The turn's node bookkeeping named the player this bake just replaced. + booth.turn.rebaked(); // So the whole bake is live, emitters or not — `wake` can't gate a looping animation. // `gate_booth_cameras` renders it every frame its pane is on screen, and none when it isn't. booth.live = true; diff --git a/crates/benilla-app/src/sound/combat.rs b/crates/benilla-app/src/sound/combat.rs index 93645e3e..7c4fcaec 100644 --- a/crates/benilla-app/src/sound/combat.rs +++ b/crates/benilla-app/src/sound/combat.rs @@ -5,9 +5,11 @@ //! swing *animation*, decision 0073) → the attack sequence's M2 events fire mid-swing through //! [`AnimSoundEvent`]. Two tags route directly from that stream: //! -//! - **`$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). +//! - **`$CSS`** — the swing whoosh, in **both** of its forms. Nothing contacted (miss/dodge/ +//! evade) gets 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). Anything +//! else gets the *connecting* swing's `WeaponSwingSounds2` whoosh by weapon weight; see "The +//! connecting swing" below. //! - **`$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]` @@ -53,46 +55,50 @@ //! 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 still unvoiced — and what each is actually blocked on +//! ## The connecting swing — the other half of `$CSS` (decision 1567) //! -//! 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. +//! `$CSS` is **two** sounds, not one, and `0x624ca0` picks between them off the victimState +//! alone: `{0 unaffected, 2 dodge, 6 evade}` take the by-handedness miss whoosh, and **every +//! other outcome — a landed hit, a parry, a block, an immune, a deflect — takes +//! `WeaponSwingSounds2` on the capped bus 6** ([`whiffed`]). benilla voiced only the first half +//! until 1567, so every landed melee swing in the game was missing a sound. //! -//! **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`). +//! What blocked it was `0x623870`, the `swingType` source, read now: it is **not a heuristic**. +//! The function asks the swinging hand (`hitInfo & LEFTSWING` picks which) for its item, requires +//! item class 2, and returns `ItemSubClass[(2, subclass)]` field 9 — `WeaponSwingSize`, a shipped +//! DBC column ([`swing_weight`]). The shipped weapon rows put daggers and fist weapons at Light, +//! every two-hander plus polearms, staves and spears at Heavy, and the rest at Medium; an **empty +//! hand returns Light** (`0x623892` writes 0 and returns *true*) and a **non-weapon in hand +//! returns false**, i.e. silence. The kit is then `cache[critical + weight*2]` over the six +//! `WeaponSwingSounds2` rows — 233..238, `mWooshSmall/Medium/Large` — at volume 0.5 when the hit +//! flags carry `HITINFO_MISS` and 1.0 otherwise (`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}`). +//! One benilla behaviour changed beyond the new sound: immune and deflect used to take the *miss* +//! whoosh here, because this branch borrowed [`no_contact`] — the impact family's wider question. +//! `0x624ca0` reads neither the hit flags nor those two states, so they now whoosh like the +//! contacts they are. //! -//! **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. +//! ## The victim dispatch's tail, read out (1567) +//! +//! `0x624530` past the clang is a four-way ladder, and two of its arms were silent here: +//! +//! - **deflect** (victimState 8) plays a fixed `(DONOTRENAME)ShieldWoodImpact`, id 3262 +//! ([`DEFLECT_KIT`]) — not a weapon-row slot. +//! - **absorb, resist or immune** (`hitInfo & 0x60`, or victimState 7) plays +//! `(DONOTRENAME)AbsorbGetHit`, id 3334 ([`ABSORB_KIT`]), **instead of** the wound vocal — +//! it returns before reaching it. +//! - otherwise the wound vocal, keyed crushing → critical → (`MISS` → nothing) → injury. +//! +//! Both stub kits come out of the same startup name cache as the miss whooshes (`0x4575b0`), and +//! both emit at the victim two yards up. One reading is *not* taken literally: see the wound +//! vocal's own note on `HITINFO_AFFECTS_VICTIM` and what vmangos does with it on a parry. //! //! INTERIM readings (flagged for a wow-re pass): victims' armor lands on the flesh slot (the //! chain/plate slots need the armor-material chain); //! blocks assume a metal shield; a defended outcome suppresses block 1's generic weapon impact //! (the tail's latch test `0x624936` carries no victimState gate in the trace, so whether it //! also plays under a clang is unpinned); -//! the injury vocal plays on every damaging hit (the client may -//! throttle); the deflect (`0x457f20`) and immune/absorb (`0x458610`) positioned stubs' kit ids -//! are unpinned, so those branches stay silent here; the natural-weapon column is gated on +//! the natural-weapon column is gated on //! contact like the weapon impact (whether the digit block also plays on a whiff is unpinned). //! `$CPP`/`$CST` are pinned NON-audio (decision 0279): `$CPP` is the victim defense-anim //! dispatch, `$CST` re-pings the attached combat-kit list — neither belongs to this module. @@ -111,7 +117,7 @@ use benilla_world::schedule::WorldStage; use super::creature::CreatureVoices; use super::kit::{ bark_chance_pass, object_sound_playing, play_kit_ext, Bus, KitRef, PlayExtras, SoundCategory, - SoundKits, EXERTION_CHANCE_CREATURE, EXERTION_CHANCE_PLAYER, + SoundKits, Volume, EXERTION_CHANCE_CREATURE, EXERTION_CHANCE_PLAYER, }; use super::{AudioListener, SoundConfig, SoundOutput}; @@ -127,11 +133,47 @@ const VICTIM_EVADE: u32 = 6; const VICTIM_IMMUNE: u32 = 7; const VICTIM_DEFLECT: u32 = 8; +/// `HitInfo` bit 2 — the **offhand** swing (vmangos `HITINFO_LEFTSWING`), and the reference's own +/// hand selector: `0x624c36` derives `slot = (hitInfo >> 2) & 1` and hands it to `0x623870`, which +/// asks that hand for its item. +const HITINFO_LEFTSWING: u32 = 0x4; + /// The two `_DONOTRENAME_` whoosh kits the client caches by name at startup (wow-re `0x4575b0`); /// byte-verified ids in the 5875 SoundEntries dump. const COMBAT_MISS_1H: u32 = 7080; const COMBAT_MISS_2H: u32 = 7081; +/// The two **fixed, name-cached stub kits** of the victim dispatch `0x624530`, resolved from the +/// `(DONOTRENAME)` cache the client fills at startup (`0x4575b0`) — the same cache the miss +/// whooshes come out of. Both play at the **victim**, two yards up, on the uncapped bus 0 at +/// volume 1.0 (`0x458870`), and neither is weapon-row-keyed: +/// +/// - **deflect** (`0x6245f5`, victimState 8 → `0x457f20` → `[0xb05fb8]`) → +/// `(DONOTRENAME)ShieldWoodImpact`, id 3262, `WoodenShieldBlock1..3.wav`. +/// - **absorb / resist / immune** (`0x62460f` `hitInfo & 0x60`, or `0x624613` victimState 7 → +/// `0x458610` → `[0xb05fb4]`) → `(DONOTRENAME)AbsorbGetHit`, id 3334, +/// `AbsorbGetHitA/B/C.wav`. +/// +/// Both ids are byte-verified in the shipped `SoundEntries` dump against the exact cache strings +/// at `0x835e8c`/`0x835eac`. These were the "unpinned stubs" the module used to leave silent. +const DEFLECT_KIT: u32 = 3262; +const ABSORB_KIT: u32 = 3334; + +/// `HitInfo & (HITINFO_ABSORB | HITINFO_RESIST)` — the reference's own `test al, 0x60`, which +/// sends the hit to [`ABSORB_KIT`] instead of the wound vocal. +const HITINFO_ABSORB_OR_RESIST: u32 = 0x60; + +/// **The stub emitters' height offset** — `0x457f4a`/`0x45863a` `fadd [0x801628]`, the same flat +/// `2.0` the armor foley uses (`crate::sound::footsteps`). WoW Z is Bevy Y at the same scale. +/// The weapon impact and the parry/block clang do NOT take it: they pass the caller's position +/// straight through, so only the three `0x458870` sites that build a local vector are lifted. +const STUB_HEIGHT: f32 = 2.0; + +/// `ItemClass` 2 — a weapon. `0x623870` compares the equipped item's class byte against it +/// (`0x6238b4 cmp byte ptr [eax], 2`) and **returns false** on anything else, so a held +/// non-weapon swings in silence rather than falling back to a weight. +const ITEM_CLASS_WEAPON: u32 = 2; + /// Weapon subclasses swung two-handed (item weapon subclass ids) — picks the 2H whoosh. const TWO_HANDED: [u32; 6] = [1, 5, 6, 8, 10, 17]; /// `Material.dbc` id 2 — a wood-bodied item, which picks the non-metal impact row and the wood @@ -140,6 +182,23 @@ const MATERIAL_WOOD: u8 = 2; /// Fist/unarmed subclass — the row a weaponless swing uses (`Unarmed_Generic`). const UNARMED_SUBCLASS: u32 = 13; +/// `WeaponSwingSounds2.dbc` as the reference's own six-slot cache — the connecting swing's kit +/// by `(weight, critical)`. See [`benilla_formats::WeaponSwingCatalog`]. +#[derive(Resource)] +pub(crate) struct WeaponSwings(pub(crate) benilla_formats::WeaponSwingCatalog); + +fn load_weapon_swings(mut commands: Commands, assets: Option>) { + let Some(assets) = assets else { return }; + let loaded = { + let mut chain = assets.chain.lock_recover(); + benilla_formats::load_weapon_swing_catalog(&mut chain) + }; + match loaded { + Ok(cat) => commands.insert_resource(WeaponSwings(cat)), + Err(e) => warn!("sound: weapon swing sounds failed to load: {e:#}"), + } +} + #[derive(Resource)] pub(crate) struct WeaponImpacts(pub(crate) WeaponImpactCatalog); @@ -182,6 +241,47 @@ fn swing_weapon(wielded: Option<&Wielded>, offhand: bool) -> (u32, bool) { } } +/// **Which whoosh a swing gets** — `0x624ca0`, the reference's own two-way split, and the only +/// input is the victimState. `{0 unaffected, 2 dodge, 6 evade}` take the *miss* whoosh; **every +/// other outcome — a landed hit, a parry, a block, an immune, a deflect — takes the connecting +/// swing's `WeaponSwingSounds2` whoosh** on the capped bus 6. The two are alternatives, never +/// both: the call site branches on this one test (`0x624ba4 je 0x624c36`). +/// +/// Deliberately NOT [`no_contact`], which is a wider "nothing for the weapon to strike" question +/// asked of the *impact* family. That predicate also folds in `HITINFO_MISS` and treats immune +/// and deflect as whiffs; `0x624ca0` reads neither the hit flags nor those two states, and +/// sorting a deflect onto the miss whoosh was the audible consequence of borrowing it here. +fn whiffed(victim_state: u32) -> bool { + matches!(victim_state, 0 | VICTIM_DODGE | VICTIM_EVADE) +} + +/// The swinging weapon's **weight** — `0x623870`, verbatim, and the whole of the Light/Medium/ +/// Heavy classification benilla could not build before. +/// +/// It is not a heuristic and never was: the function asks the swinging hand for its item, checks +/// the class byte is 2, and returns `ItemSubClass[(2, subclass)].WeaponSwingSize` — a shipped DBC +/// column (`[row+0x24]`, field 9) that puts daggers and fist weapons at Light, every two-hander +/// plus polearms, staves and spears at Heavy, and the rest at Medium. +/// +/// Three outcomes, each the reference's own: +/// - **empty hand** → `Some(0)`, Light. `0x623892` writes 0 into the out-param and returns +/// *true*, so an unarmed swing whooshes with the small `mWooshSmall*` samples. +/// - **a weapon** → its row's weight, or `None` when the pair has no row. +/// - **a non-weapon in hand** → `None`. `0x6238b7` returns false and the caller plays nothing. +fn swing_weight( + wielded: Option<&Wielded>, + offhand: bool, + sub_classes: &benilla_formats::ItemSubClassCatalog, +) -> Option { + match wielded.and_then(|w| if offhand { w.off } else { w.main }) { + None => Some(0), + Some((class, subclass)) if u32::from(class) == ITEM_CLASS_WEAPON => { + sub_classes.weapon_swing_size(ITEM_CLASS_WEAPON, u32::from(subclass)) + } + Some(_) => None, + } +} + /// The whiff/no-weapon-contact family: nothing for the weapon to strike. Immune and deflect /// route to positioned stubs in the client (`0x457f20`/`0x458610`, decision 0279) whose kit ids /// are unpinned — grouped here (whoosh, no impact) as the INTERIM stand-in. @@ -239,6 +339,11 @@ fn combat_sounds( mut last: Local, units: Query<(&Transform, Option<&Wielded>, &NetEntity, Has)>, impacts: Option>, + swing_sounds: Option>, + // One load, two consumers: the tooltip's slot|type line owns this resource + // ([`crate::ui_items::ItemSubClasses`]) and the swing whoosh reads the same rows' weight + // column. A second loader over one DBC is how a schema quietly drifts. + sub_classes: Option>, voices: Option>, kits: Option>, assets: Option>, @@ -278,25 +383,29 @@ fn combat_sounds( // contends for bus 10's four voices, the vocals for their own one or two, and the miss whoosh // for nothing at all. A kit refused at the cap is not an error — it is the gate doing its job, // and `play_kit_ext` reports it as an ordinary silent success. - let play = - |kits: &mut SoundKits, out: &mut SoundOutput, kit: u32, pos: Vec3, bus: Bus, what: &str| { - if kit == 0 { - return; - } - if let Err(e) = play_kit_ext( - kits, - &assets, - out, - &config, - listener, - KitRef::Id(kit), - Some(pos), - SoundCategory::Sfx, - PlayExtras { bus, ..default() }, - ) { - warn!("combat {what} (kit {kit}): {e:#}"); - } - }; + let play = |kits: &mut SoundKits, + out: &mut SoundOutput, + kit: u32, + pos: Vec3, + extras: PlayExtras, + what: &str| { + if kit == 0 { + return; + } + if let Err(e) = play_kit_ext( + kits, + &assets, + out, + &config, + listener, + KitRef::Id(kit), + Some(pos), + SoundCategory::Sfx, + extras, + ) { + warn!("combat {what} (kit {kit}): {e:#}"); + } + }; // 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 @@ -330,12 +439,18 @@ fn combat_sounds( &mut out, vocal, tr.translation, - Bus::EXERTION, + PlayExtras { + bus: Bus::EXERTION, + ..default() + }, "exertion", ); } - // The one tag this module still consumes: the swing whoosh. + // The one tag this module still consumes: the swing whoosh — **both** of them. `0x624ca0` + // splits the tag two ways by victimState ([`whiffed`]) and the branches are exclusive: a + // swing that touched nothing gets the by-handedness miss whoosh on the uncapped bus 0, and + // every other swing gets `WeaponSwingSounds2` by weapon weight on bus 6's cap of 2. for ev in events.read() { if ev.ident != *b"$CSS" { continue; @@ -346,8 +461,8 @@ fn combat_sounds( let Ok((attacker_tr, wielded, _, _)) = units.get(ev.entity) else { continue; }; - if no_contact(swing) { - let offhand = swing.hit_info & 0x4 != 0; + let offhand = swing.hit_info & HITINFO_LEFTSWING != 0; + if whiffed(swing.victim_state) { let (subclass, _) = swing_weapon(wielded, offhand); let kit = if TWO_HANDED.contains(&subclass) { COMBAT_MISS_2H @@ -359,10 +474,45 @@ fn combat_sounds( &mut out, kit, attacker_tr.translation, - Bus::DEFAULT, + PlayExtras { + bus: Bus::DEFAULT, + ..default() + }, "miss whoosh", ); + continue; } + // The connecting swing (`0x624c36`). Both catalogs are optional like every DBC-backed + // resource: without them this swing is silent, which is the pre-1567 behaviour. + let (Some(swings), Some(subs)) = (swing_sounds.as_deref(), sub_classes.as_deref()) else { + continue; + }; + let Some(weight) = swing_weight(wielded, offhand, &subs.0) else { + continue; // a non-weapon in the hand: `0x623870` returns false and nothing plays + }; + let Some(kit) = swings.0.kit(weight, swing.hit_info & HITINFO_CRITICAL != 0) else { + continue; // `0x457f63`'s `swingType >= 3` bail — silence, never a fallback weight + }; + play( + &mut kits, + &mut out, + kit, + attacker_tr.translation, + PlayExtras { + bus: Bus::WEAPON_SWING, + // `0x457f74`/`0x457f7d`: half volume when the hit flags carry `HITINFO_MISS`, + // full otherwise. Structurally that arm needs a MISS *with* a connecting + // victimState, which vmangos does not produce — carried anyway because it is + // what the bytes say, and it costs one field. + volume_mult: Volume(if swing.hit_info & HITINFO_MISS != 0 { + 0.5 + } else { + 1.0 + }), + ..default() + }, + "connecting swing", + ); } // The contact family: the weapon-sound block + victim dispatch, at the impact crossing. @@ -408,7 +558,10 @@ fn combat_sounds( &mut out, vocal, pos, - Bus::MELEE_IMPACT, + PlayExtras { + bus: Bus::MELEE_IMPACT, + ..default() + }, "natural impact", ); } else if !defended { @@ -420,7 +573,17 @@ fn combat_sounds( .map(|v| v.impact_type) .unwrap_or(0); let kit = landed_impact(row, material, crit); - play(&mut kits, &mut out, kit, pos, Bus::MELEE_IMPACT, "impact"); + play( + &mut kits, + &mut out, + kit, + pos, + PlayExtras { + bus: Bus::MELEE_IMPACT, + ..default() + }, + "impact", + ); } } @@ -442,17 +605,60 @@ fn combat_sounds( &mut out, kit, at, - Bus::MELEE_IMPACT, + PlayExtras { + bus: Bus::MELEE_IMPACT, + ..default() + }, "defense clang", ); } } - // The victim's wound vocal rides the same dispatch (INTERIM: unthrottled). An - // absorbed/resisted hit reroutes the voice to the `0x458610` stub instead - // (`HitInfo & 0x60`, decision 0279) — stub kit unpinned, so INTERIM silence. + // The dispatch's two **fixed stub kits** ([`DEFLECT_KIT`] / [`ABSORB_KIT`]) — outside the + // contact guard above on purpose: `0x624530` reaches them on every impact, and + // [`no_contact`] sorts deflect and immune into the whiff family, so a guarded placement + // would leave exactly these two branches silent, which is what they were. + let stub_at = |t: &Transform| t.translation + Vec3::Y * STUB_HEIGHT; + if swing.victim_state == VICTIM_DEFLECT { + if let Some((victim_tr, ..)) = victim { + play( + &mut kits, + &mut out, + DEFLECT_KIT, + stub_at(victim_tr), + PlayExtras::default(), + "deflect", + ); + } + } + if swing.hit_info & HITINFO_ABSORB_OR_RESIST != 0 || swing.victim_state == VICTIM_IMMUNE { + if let Some((victim_tr, ..)) = victim { + play( + &mut kits, + &mut out, + ABSORB_KIT, + stub_at(victim_tr), + PlayExtras::default(), + "absorb", + ); + } + } + + // The victim's wound vocal, the tail of the same dispatch. The reference's ladder is + // fully read now (`0x62460c`..`0x624674`): absorb/resist/immune leave through the stub + // above and never reach it; then `hitInfo & AFFECTS_VICTIM (0x2)` is required, and the + // class is crushing `0x8000` → 9, else critical `0x80` → 3, else `MISS 0x10` → nothing, + // else 2. + // + // **`damage > 0` stands in for that `0x2`, deliberately** (and the parry/block + // suppression with it). vmangos leaves `HITINFO_AFFECTS_VICTIM` *set* on a parry and a + // dodge — it only clears it for immune and for zero damage with MISS|ABSORB + // (`Unit.cpp:1366`/`1587`), where its own comment calls the bit "no being hit animation + // on victim without it". Taking `0x2` literally against this server would therefore + // grunt on every parry, which is audibly wrong and is not what the bit meant. The + // reference's ladder is faithful to the reference; this gate is faithful to the sound. if swing.damage > 0 - && swing.hit_info & 0x60 == 0 + && swing.hit_info & HITINFO_ABSORB_OR_RESIST == 0 && !matches!(swing.victim_state, VICTIM_PARRY | VICTIM_BLOCK) { // The `AISOUNDDESC` gate (`0x4591f0` from `0x6234cb`): a server-pushed object sound @@ -489,7 +695,7 @@ fn combat_sounds( &mut out, vocal, victim_tr.translation, - bus, + PlayExtras { bus, ..default() }, "injury", ); } @@ -499,14 +705,153 @@ fn combat_sounds( /// Registration hook for [`super::SoundPlugin`]. pub(super) fn plugin(app: &mut App) { - app.add_systems(Startup, load_weapon_impacts.after(AssetSet::Open)) - .add_systems(Update, combat_sounds.in_set(WorldStage::Present)); + app.add_systems( + Startup, + (load_weapon_impacts, load_weapon_swings).after(AssetSet::Open), + ) + .add_systems(Update, combat_sounds.in_set(WorldStage::Present)); } #[cfg(test)] mod tests { use super::*; + /// **The `$CSS` split** — `0x624ca0`'s `{0, 2, 6}`, and only that. The two outcomes this + /// pins hardest are immune and deflect: benilla used to sort them onto the miss whoosh + /// (via [`no_contact`], which is the *impact* family's question), where the reference sends + /// them through the connecting swing like any other contact. + #[test] + fn only_unaffected_dodge_and_evade_take_the_miss_whoosh() { + for whiff in [0, VICTIM_DODGE, VICTIM_EVADE] { + assert!(whiffed(whiff), "victimState {whiff} whiffs"); + } + for connects in [ + 1, + VICTIM_PARRY, + 4, + VICTIM_BLOCK, + VICTIM_IMMUNE, + VICTIM_DEFLECT, + ] { + assert!( + !whiffed(connects), + "victimState {connects} takes the connecting swing" + ); + } + } + + /// `0x623870`'s three answers, on the real shipped `ItemSubClass.dbc`: an empty hand is + /// Light (the out-param is written 0 and the function returns *true*), a weapon is its + /// subclass's `WeaponSwingSize`, and a **non-weapon in hand is `None`** — the function + /// returns false there, so a held misc item swings in silence rather than borrowing a + /// weight. Skips without client data. + #[test] + fn the_swing_weight_is_the_dbc_column_not_a_guess() { + let Some(data) = benilla_formats::wow_data() else { + eprintln!("skipping: no WoW install found"); + return; + }; + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let subs = benilla_formats::load_item_sub_classes(&mut chain).expect("ItemSubClass.dbc"); + + let hand = |item: Option<(u8, u8)>| { + swing_weight( + Some(&Wielded { + main: item, + ..default() + }), + false, + &subs, + ) + }; + // Light: daggers (15) and fist weapons (13). + assert_eq!(hand(Some((2, 15))), Some(0), "dagger"); + assert_eq!(hand(Some((2, 13))), Some(0), "fist weapon"); + // Medium: the one-handers and the ranged bodies. + for medium in [0u8, 2, 3, 4, 7, 16, 18, 19] { + assert_eq!(hand(Some((2, medium))), Some(1), "subclass {medium}"); + } + // Heavy: every two-hander, plus polearms, staves and spears. + for heavy in [1u8, 5, 6, 8, 10, 17] { + assert_eq!(hand(Some((2, heavy))), Some(2), "subclass {heavy}"); + } + // An empty hand swings Light; a shield (class 4) or any other non-weapon is silent. + assert_eq!(hand(None), Some(0), "unarmed"); + assert_eq!(hand(Some((4, 6))), None, "shield in hand"); + assert_eq!(hand(Some((0, 0))), None, "consumable in hand"); + // No `Wielded` component at all reads as an empty hand, like the reference's null item. + assert_eq!(swing_weight(None, false, &subs), Some(0)); + } + + /// The join the whole leg rests on, end to end on shipped data: the weight column picks a + /// row of `WeaponSwingSounds2`, and the crit bit picks its column. A dagger crit is + /// `LightWeaponCritical`; a two-handed sword is `HeavyWeaponNormal`; unarmed is Light. + /// Skips without client data. + #[test] + fn a_weapons_subclass_reaches_its_own_woosh_kit() { + let Some(data) = benilla_formats::wow_data() else { + eprintln!("skipping: no WoW install found"); + return; + }; + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let subs = benilla_formats::load_item_sub_classes(&mut chain).expect("ItemSubClass.dbc"); + let swings = + benilla_formats::load_weapon_swing_catalog(&mut chain).expect("WeaponSwingSounds2.dbc"); + + let kit = |item: Option<(u8, u8)>, crit: bool| { + let w = swing_weight( + Some(&Wielded { + main: item, + ..default() + }), + false, + &subs, + )?; + swings.kit(w, crit) + }; + assert_eq!(kit(Some((2, 15)), false), Some(233), "dagger"); + assert_eq!(kit(Some((2, 15)), true), Some(234), "dagger crit"); + assert_eq!(kit(Some((2, 7)), false), Some(235), "1H sword"); + assert_eq!(kit(Some((2, 7)), true), Some(236), "1H sword crit"); + assert_eq!(kit(Some((2, 8)), false), Some(237), "2H sword"); + assert_eq!(kit(Some((2, 8)), true), Some(238), "2H sword crit"); + assert_eq!(kit(None, false), Some(233), "unarmed swings light"); + assert_eq!(kit(Some((4, 6)), false), None, "a shield makes no whoosh"); + } + + /// The two fixed stub kits resolve to the exact `(DONOTRENAME)` rows the client's startup + /// cache names — the join that used to be the "unpinned kit id" leaving both branches + /// silent. Skips without client data. + #[test] + fn the_deflect_and_absorb_stubs_name_real_kits() { + let Some(data) = benilla_formats::wow_data() else { + eprintln!("skipping: no WoW install found"); + return; + }; + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let kits = benilla_formats::load_sound_kit_catalog(&mut chain).expect("SoundEntries.dbc"); + + let named = |id: u32| kits.get(id).map(|k| k.name.clone()); + assert_eq!( + named(DEFLECT_KIT).as_deref(), + Some("(DONOTRENAME)ShieldWoodImpact") + ); + assert_eq!( + named(ABSORB_KIT).as_deref(), + Some("(DONOTRENAME)AbsorbGetHit") + ); + // …and they are the ids the miss whooshes sit beside in the same cache, so a rename in + // shipped data would take all four out together rather than one silently. + assert_eq!( + named(COMBAT_MISS_1H).as_deref(), + Some("(DONOTRENAME)Combat Miss 1H") + ); + assert_eq!( + named(COMBAT_MISS_2H).as_deref(), + Some("(DONOTRENAME)Combat Miss 2H") + ); + } + /// A row shaped like the real 5875 Sword1H-metal row (byte-verified ids): flesh 143/144, /// parry-metal 1002, parry-wood 1001, shield-metal 3263. fn sword1h_metal() -> benilla_formats::WeaponImpactRow { diff --git a/crates/benilla-app/src/sound/footsteps.rs b/crates/benilla-app/src/sound/footsteps.rs index ac0f7905..043ae98d 100644 --- a/crates/benilla-app/src/sound/footsteps.rs +++ b/crates/benilla-app/src/sound/footsteps.rs @@ -17,6 +17,28 @@ //! turn-in-place shuffle, whose *only* keys are `$SL0 $SR0` at `t = 0.000`, clatter where the //! reference is silent ([`crate::creature_anim::is_footstep_sound`]). //! +//! ## A footfall is two sounds +//! +//! `$FSD` fires the terrain step **and** an armor foley, and the reference orders them: the foley +//! is `0x623390`'s first act after the state gates (`0x6233d9 call [vt+0x8c]`), *ahead of* the +//! class gate and every terrain lookup below. So a creature whose footstep class is 0 still +//! rustles, and the two land on different buses — the foley uncapped on bus 0, the step on bus +//! 9's cap of 6. Both are voiced here, in that order, rather than in two systems, because they +//! share those three gates and the client shares them too. +//! +//! The foley's material has two sources, one per vtable slot: +//! - **a unit** (`0x623610`) — `CreatureModelData.FoleyMaterialID` off its display +//! (`[[unit+0xb3c]+0x28]`), i.e. what the *model* is dressed in. +//! - **a player** (`0x62fa30`) — the **chest** item's `Material`, read through the equipment +//! GUID array at `[player+0x1d38]` element 4. That array is populated only for the local +//! player (its ctor writes a count of 113 when the object's guid matches +//! `0x468550`'s and **0 otherwise**, `0x5dd454`), so in the reference *other* players are +//! silent-bodied. benilla reproduces that for free: `PLAYER_FIELD_INV_SLOT_*` is a private +//! field the server sends only to you, so the same read is naturally self-only. +//! +//! `Material.dbc` then names the kit, and only three of its eight rows carry one — chain, plate +//! and leather. **Cloth is silent**, which is data, not a gap: a robed mage rustles nothing. +//! //! The unit's class comes from its voice row (`CreatureSoundData.FootstepID`) through the //! generic display→sound chain incl. the model fallback (`benilla_formats::creature_sound` — //! characters reach class 7 as *data*). **Class 0, or no row at all, = no footstep sounds**: @@ -39,13 +61,15 @@ use bevy::prelude::*; use benilla_assets::coords::bevy_to_wow; -use benilla_formats::FootstepCatalog; +use benilla_formats::{FootstepCatalog, MaterialCatalog}; use crate::creature_anim::{is_footstep_sound, move_flags, AnimSoundEvent, MovementState}; -use crate::entities::CollisionHeight; -use crate::net::{NetEntity, ObjectStore}; +use crate::entities::{CollisionHeight, Creatures}; +use crate::items::Items; +use crate::net::{NetCommands, NetEntity, ObjectStore}; use crate::player::swim_enter_depth; use benilla_assets::{AssetSet, LockRecover, WorldAssets}; +use benilla_protocol::EntityKind; use benilla_world::schedule::WorldStage; use super::creature::CreatureVoices; @@ -57,6 +81,35 @@ use super::{AudioListener, SoundConfig, SoundOutput}; #[derive(Resource)] pub(crate) struct Footsteps(pub(crate) FootstepCatalog); +/// `Material.dbc` — the foley half of a footfall (`0x4584e0`'s table). +#[derive(Resource)] +pub(crate) struct Materials(pub(crate) MaterialCatalog); + +fn load_materials(mut commands: Commands, assets: Option>) { + let Some(assets) = assets else { return }; + let loaded = { + let mut chain = assets.chain.lock_recover(); + benilla_formats::load_material_catalog(&mut chain) + }; + match loaded { + Ok(cat) => { + info!("sound: {} material rows", cat.len()); + commands.insert_resource(Materials(cat)); + } + Err(e) => warn!("sound: materials failed to load: {e:#}"), + } +} + +/// **The foley's Z offset** — `0x45851d fadd [0x801628]`, a flat `2.0` added to the emitter's Z +/// before the play. The rustle comes from the body, not the boots, and the reference lifts it by +/// a fixed two yards rather than anything model-derived. WoW's Z is Bevy's Y at the same scale +/// (`benilla_assets::coords`), so this adds to `translation.y`. +const FOLEY_HEIGHT: f32 = 2.0; + +/// `EQUIPMENT_SLOT_CHEST` — index 4 of the player's inv-slot array, the one the reference's +/// player foley reads (`0x62fa50`'s `[ptr+0x20]`, i.e. the fifth 8-byte guid). +const EQUIPMENT_SLOT_CHEST: u8 = 4; + fn load_footsteps(mut commands: Commands, assets: Option>) { let Some(assets) = assets else { return }; let loaded = { @@ -86,6 +139,13 @@ fn footstep_sounds( parents: Query<&ChildOf>, root_state: Query<(Option<&ObjectStore>, Option<&MovementState>)>, footsteps: Option>, + // The foley half: the material table, the creature catalog that answers a unit's material, + // and the item store that answers a player's (a chest template ask rides the same + // once-per-entry discipline every other consumer uses). + materials: Option>, + creatures: Option>, + mut items: Option>, + net_commands: Res, voices: Option>, world: benilla_world::world_point::WorldPoint, kits: Option>, @@ -124,6 +184,44 @@ fn footstep_sounds( if store.is_some_and(|s| s.0.unit_is_stealthed() || s.0.player_is_ghost()) { continue; } + // **The armor foley** — `0x6233d9 call [vt+0x8c]`, the handler's first act past the gates + // above and ahead of every gate below (module docs). Emitted on the stepping entity, not + // the root: a mount's own body is what rustles under the rider. + if let (Some(materials), Some(it)) = (materials.as_deref(), items.as_mut()) { + let material = match net.kind { + // The player override (`0x62fa30`) reads the chest through the *private* + // inv-slot array, so this resolves for you and no one else — the reference's + // own reach, not a restriction added here. + EntityKind::Player => { + let entry = store + .and_then(|s| s.0.player_inv_slot(EQUIPMENT_SLOT_CHEST)) + .and_then(|guid| it.object(guid)?.object_entry()); + entry + .and_then(|entry| it.held(entry, &net_commands)) + .map(|t| t.material) + } + _ => net + .display_id + .and_then(|d| creatures.as_deref()?.foley_material(d)), + }; + if let Some(kit) = material.and_then(|m| materials.0.foley_kit(m)) { + let mut at = transform.translation(); + at.y += FOLEY_HEIGHT; + if let Err(e) = play_kit_ext( + &mut kits, + &assets, + &mut out, + &config, + listener, + KitRef::Id(kit), + Some(at), + SoundCategory::Sfx, + PlayExtras::default(), // bus 0, uncapped, volume 1.0 — `0x458870`'s own + ) { + warn!("foley (kit {kit}): {e:#}"); + } + } + } // The unit's footstep class (module docs): the voice row's class verbatim; zero or no // row = silent (the client's class-0 gate — no code default exists, B11). let class = match net.display_id.and_then(|d| voices.0.for_display(d)) { @@ -189,6 +287,9 @@ fn footstep_sounds( /// Registration hook for [`super::SoundPlugin`]. pub(super) fn plugin(app: &mut App) { - app.add_systems(Startup, load_footsteps.after(AssetSet::Open)) - .add_systems(Update, footstep_sounds.in_set(WorldStage::Present)); + app.add_systems( + Startup, + (load_footsteps, load_materials).after(AssetSet::Open), + ) + .add_systems(Update, footstep_sounds.in_set(WorldStage::Present)); } diff --git a/crates/benilla-app/src/sound/kit.rs b/crates/benilla-app/src/sound/kit.rs index 6a67ac60..9a1180bd 100644 --- a/crates/benilla-app/src/sound/kit.rs +++ b/crates/benilla-app/src/sound/kit.rs @@ -247,8 +247,9 @@ pub(super) struct Bus(pub(super) u8); impl Bus { /// Bus 0 — cap `0x7FFFFFFF`, i.e. **uncapped**. Where spell impacts, UI, music and ambience /// all live in the reference, so it is also benilla's default and the honest answer for any - /// play whose bus we have not pinned. The `$CSS` **miss** whoosh is here too (bus 0, uncapped) - /// — only a *connecting* swing takes the capped bus 6, which benilla does not yet voice. + /// play whose bus we have not pinned. The `$CSS` **miss** whoosh is here too (bus 0, + /// uncapped) — only a *connecting* swing takes the capped [`Self::WEAPON_SWING`]. The armor + /// foley off `$FSD` is here as well, uncapped beside the capped step it accompanies. pub(super) const DEFAULT: Bus = Bus(0); /// Bus 5 — **cap 1**. The attacker's exertion vocal (`CreatureSoundData.Exertion` = class 0, @@ -260,6 +261,12 @@ impl Bus { /// which is why benilla can route by column without resolving that bit.) pub(super) const EXERTION: Bus = Bus(5); + /// Bus 6 — **cap 2**. The *connecting* swing's whoosh (`WeaponSwingSounds2`, `0x624c81` → + /// `0x457f60` → `0x458890` with `ecx = 6`) — the sound a landed or defended melee swing + /// makes as the weapon travels, which the **miss** whoosh on [`Self::DEFAULT`] replaces + /// rather than joins: `0x624ca0` picks exactly one of the two by victimState. + pub(super) const WEAPON_SWING: Bus = Bus(6); + /// Bus 7 — **cap 2**. The victim's wound vocal (`Injury` = class 2, `InjuryCritical` = 3, /// `InjuryCrushingBlow` = 9, which is dead in shipped data: 0 of 406 rows populate it). /// Bus **12** is its classified twin, cap 2 as well. @@ -271,10 +278,10 @@ impl Bus { /// injury half only — your exertion still competes on [`Self::EXERTION`]. pub(super) const SELF_INJURY: Bus = Bus(8); - /// Bus 9 — **cap 6**. The terrain footstep off `$FSD` (`0x62342a` → `0x458380`). Note the - /// same `$FSD` also fires a *material foley* on bus 0 in the reference (`[vt+0x8c]` → - /// `0x4584e0`), which benilla does not voice — benilla plays one sound per footstep, so the - /// one it plays is this one. + /// Bus 9 — **cap 6**. The terrain footstep off `$FSD` (`0x62342a` → `0x458380`) — one half + /// of a footfall. The same `$FSD` fires the *armor foley* on the uncapped [`Self::DEFAULT`] + /// first (`[vt+0x8c]` → `0x4584e0`), ahead of every gate below this one, so a creature with + /// no footstep class still rustles. pub(super) const FOOTSTEP: Bus = Bus(9); /// Bus 10 — **cap 4**. The whole melee-contact family, which all contends for the same four @@ -465,6 +472,24 @@ pub(super) struct PlayExtras { pub(super) latch: Latch, /// The **voice bus** this play competes on ([`Bus`]). Defaults to the uncapped bus 0. pub(super) bus: Bus, + /// The caller's **per-shot volume multiplier** — `0x458890`'s own last argument, which every + /// site but one passes as `1.0`. It lands where the reference puts it: inside + /// [`math::variation_volume`]'s `mult`, i.e. *before* the `[0,1]` clamp and ahead of distance + /// attenuation, so it scales the kit's authored volume rather than the final amplitude. + /// [`Volume::default`] is that `1.0`. + pub(super) volume_mult: Volume, +} + +/// A per-shot volume multiplier that defaults to unity — [`PlayExtras::volume_mult`]'s type. +/// A newtype only so `PlayExtras` can keep deriving `Default`, which a bare `f32` field would +/// silence at 0.0. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct Volume(pub(super) f32); + +impl Default for Volume { + fn default() -> Self { + Self(1.0) + } } /// A kit to play, by id or by `PlaySoundByName` name. @@ -530,6 +555,7 @@ pub(super) fn play_kit_ext( force_loop, latch, bus, + volume_mult: Volume(mult), } = extras; // The cover's audio hold ([`SoundConfig::world_hold`]): while the loading screen is up, no // new sound starts — checked before anything allocates, so the per-frame retry drivers (the @@ -627,9 +653,9 @@ pub(super) fn play_kit_ext( // sets 0x800, so volume variation is dormant in this build's data — the gate is faithful). let v = if flags & sound_kit_flags::VARY_VOLUME != 0 { let draw = math::variation_draw(kits.rng.next()); - math::variation_volume(Some(draw), volume, 1.0) + math::variation_volume(Some(draw), volume, mult) } else { - math::variation_volume(None, volume, 1.0) + math::variation_volume(None, volume, mult) }; let atten = d_sq.map_or(1.0, |d| { diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index da87b534..f140e1e5 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -87,7 +87,9 @@ pub fn m2seq(chain: &mut Chain, internal_path: &str) -> Result<()> { // non-global-sequence track selects its keys from it, so it is what says whether a track is // actually keyed HERE or is holding a clamped value from some other sequence's band. // NB `idx` is this list's index, not the file's: zero-duration sequences are dropped. - println!("idx anim mode dur(s) mspd band(ms) freq replay bones keys"); + println!( + "idx anim mode dur(s) mspd blend band(ms) freq replay bones keys" + ); for (i, s) in seqs.iter().enumerate() { // How much data the sequence's own time band actually holds: bones with any // keyed track, and total keys across T/R/S (clamp constants included — a bone @@ -105,7 +107,7 @@ pub fn m2seq(chain: &mut Chain, internal_path: &str) -> Result<()> { .map(|b| b.translation.len() + b.rotation.len() + b.scale.len()) .sum(); println!( - "{i:>3} {:>4} {} {:>7.3} {:>5.2} {:>7}..{:<7} {:>5} ({}, {}) {bones:>5} {keys:>5}", + "{i:>3} {:>4} {} {:>7.3} {:>5.2} {:>5.3} {:>7}..{:<7} {:>5} ({}, {}) {bones:>5} {keys:>5}", s.anim_id, if s.looping { "loop " } else { "clamp" }, s.duration, @@ -113,6 +115,11 @@ pub fn m2seq(chain: &mut Chain, internal_path: &str) -> Result<()> { // locomotion playback rate (`speed / (mspd · |modelScale|)`, wow-re `0x5fe2f0` // @0x5fe4be..0x5fe550). `0.00` ⇒ not a locomotion sequence, so it plays at rate 1×. s.move_speed, + // `blend` = the sequence's authored blend-IN time (s) — how long the client cross-fades + // from the outgoing pose into this one on a blended arm (op4 `blendFlag != 0`, the + // `+0x98 -> +0xc4` snapshot decayed over `1/blendTime`). `0.000` = an instant cut, and + // that is what a doll's turn looks like without it. + s.blend_time, s.start_ms, s.end_ms, s.frequency, diff --git a/crates/benilla-formats/src/creatures.rs b/crates/benilla-formats/src/creatures.rs index 6409ddfb..5349a534 100644 --- a/crates/benilla-formats/src/creatures.rs +++ b/crates/benilla-formats/src/creatures.rs @@ -127,6 +127,10 @@ struct ModelRow { footprint_width: f32, /// `collisionHeight` (field 15), raw model units — see [`CreatureCatalog::collision_height`]. collision_height: f32, + /// `FoleyMaterialID` (field 10) — a `Material.dbc` id, and the whole of a *creature's* armor + /// foley (see [`CreatureCatalog::foley_material`]). `[unit+0xb3c]` is this row, and the + /// reference reads it at `+0x28`, which is field 10 on the 16-field 5875 record. + foley_material: u32, /// `FootstepShakeSize` (field 11) and `DeathThudShakeSize` (field 12) — **`CameraShakes.dbc` /// row ids**, 0 on a model that shakes nothing. Only 25 of the 430 shipped rows carry a /// footstep shake, and the set is exactly the thumping-giant list (Ancients, kodos, sea and @@ -243,6 +247,27 @@ impl CreatureCatalog { Some(self.models.get(&row.model_id)?.collision_height) } + /// The display's **foley material** — a `Material.dbc` id, the creature half of the footfall + /// rustle (`0x623610`: `[[unit+0xb3c]+0x28]` handed straight to `0x4584e0`). A *player* does + /// not come through here at all: its own override reads the equipped chest instead + /// (`0x62fa30`), so this is the answer for creatures — and, for a player wearing a + /// non-character display, the body it is actually wearing. + /// + /// `None` when either DBC lookup misses. `Some(0)` is the real "no material" the data + /// carries, and resolves to silence at [`crate::MaterialCatalog::foley_kit`]. + /// + /// **In shipped 5875 data this column is 0 in every one of the 430 rows**, in both the base + /// archive's 333-row copy and the patched 430 (`tests::no_shipped_model_carries_a_foley`). + /// The creature branch of the foley is therefore inert against the real client's own files: + /// the armor rustle you hear is the *player* override's, and no NPC has one. Kept because it + /// is the reference's own path and one map lookup, and because a server shipping patched + /// DBCs would light it up — not because it does anything today. A future reader finding this + /// silent has found the data, not a bug. + pub fn foley_material(&self, display_id: u32) -> Option { + let row = self.display.get(&display_id)?; + Some(self.models.get(&row.model_id)?.foley_material) + } + /// Does this display's model **breathe** — i.e. may it wear the `$BTH` hardcoded effects /// (cold vapour, underwater bubbles, inebriated bubbles)? /// @@ -416,6 +441,7 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result { footprint_length: f32_at(r, 7).unwrap_or(0.0), footprint_width: f32_at(r, 8).unwrap_or(0.0), collision_height: f32_at(r, 15).unwrap_or(0.0), + foley_material: u32_at(r, 10).unwrap_or(0), footstep_shake: u32_at(r, 11).unwrap_or(0), death_thud_shake: u32_at(r, 12).unwrap_or(0), }, @@ -721,6 +747,44 @@ mod tests { ); } + /// **The creature foley is dead data in 5875.** Every `CreatureModelData` row ships + /// `FoleyMaterialID = 0`, so `0x623610`'s branch resolves to silence for every NPC in the + /// game and the armor rustle is the player override's alone. Pinned as a test rather than a + /// comment because it is a *negative* that a future reader will otherwise re-derive by + /// wondering why NPCs are quiet — and because a shipped file that ever grows a nonzero here + /// should make this fail loudly rather than change the soundscape silently. + /// + /// The row alignment this rests on is checked in the same pass: field 11 (footstep shake) + /// is nonzero on exactly the 25 thumping-giant rows [`ModelRow`] documents, which would not + /// hold if the schema had slipped a column. Skips without client data. + #[test] + fn no_shipped_model_carries_a_foley() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_creature_catalog(&mut chain).expect("creature catalog"); + + assert!(!cat.models.is_empty(), "models loaded"); + let foleyed: Vec = cat + .models + .iter() + .filter(|(_, m)| m.foley_material != 0) + .map(|(&id, _)| id) + .collect(); + assert!( + foleyed.is_empty(), + "shipped data grew a creature foley material: models {foleyed:?}" + ); + + // The alignment guard: the neighbouring column is NOT uniformly zero, so a zero at + // field 10 is the data's own answer and not a schema that slid. + let shakers = cat + .models + .values() + .filter(|m| m.footstep_shake != 0) + .count(); + assert_eq!(shakers, 25, "footstep-shake rows (schema alignment guard)"); + } + #[test] fn collision_height_is_the_m2_collision_box() { let data = crate::wow_data_or_skip!(); diff --git a/crates/benilla-formats/src/itemsubclass.rs b/crates/benilla-formats/src/itemsubclass.rs index 71094716..683ff0c2 100644 --- a/crates/benilla-formats/src/itemsubclass.rs +++ b/crates/benilla-formats/src/itemsubclass.rs @@ -38,6 +38,11 @@ pub struct ItemSubClassInfo { pub postrequisite_proficiency: i32, /// Bit 0 = never print the type name on the slot|type line. pub display_flags: u32, + /// `WeaponSwingSize@9` — the swinging weight, 0 light · 1 medium · 2 heavy, and the sole + /// input to the connecting swing's whoosh ([`crate::WeaponSwingCatalog`]). Meaningful on + /// class 2 only; the shipped weapon rows put daggers and fist weapons at light, every + /// two-hander plus polearms, staves and spears at heavy, and everything else at medium. + pub weapon_swing_size: u32, } /// ItemSubClass.dbc keyed by `(class, subclass)`. @@ -76,6 +81,15 @@ impl ItemSubClassCatalog { .map(|v| v as u32) } + /// The swinging weight for `(class, subclass)` — `WeaponSwingSize`, the reference's + /// `0x623870` return: it reads the equipped item's class/subclass, looks the pair up in this + /// table and hands `[row+0x24]` straight to the swing-sound play. `None` for an unknown pair, + /// which is the reference's "not a weapon" answer — it returns *false* there and plays + /// nothing rather than defaulting to a weight. + pub fn weapon_swing_size(&self, class: u32, subclass: u32) -> Option { + Some(self.rows.get(&(class, subclass))?.weapon_swing_size) + } + /// The subclass display name (verbose-first, the wow-re `tradeskill` node's byte law) — the /// crafting book's group header text; `None` for an unknown key. pub fn name(&self, class: u32, subclass: u32) -> Option<&str> { @@ -277,6 +291,7 @@ pub fn load_item_sub_classes(chain: &mut Chain) -> Result { prerequisite_proficiency: i32_at(r, 2).unwrap_or(-1), postrequisite_proficiency: i32_at(r, 3).unwrap_or(-1), display_flags: u32_at(r, 5).unwrap_or(0), + weapon_swing_size: u32_at(r, 9).unwrap_or(0), }, ); // VerboseName enUS (col 19) first, DisplayName enUS (col 10) fallback — the struct doc's diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 76e9ecc1..93e663ef 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -158,6 +158,10 @@ pub use weapon_impact::{ }; mod sheathe; pub use sheathe::{load_sheathe_sound_catalog, SheatheSoundCatalog, SheatheSounds}; +mod weapon_swing; +pub use weapon_swing::{load_weapon_swing_catalog, WeaponSwingCatalog}; +mod material; +pub use material::{load_material_catalog, MaterialCatalog}; mod wmo_area; pub use wmo_area::{load_wmo_area_catalog, WmoArea, WmoAreaCatalog}; mod spells; diff --git a/crates/benilla-formats/src/material.rs b/crates/benilla-formats/src/material.rs new file mode 100644 index 00000000..282e3a8d --- /dev/null +++ b/crates/benilla-formats/src/material.rs @@ -0,0 +1,105 @@ +//! `Material.dbc` — the **armor foley**: the rustle a body makes as it moves, separate from the +//! terrain footstep it lands on. +//! +//! A footfall is two sounds in the reference, not one. `$FSD` runs `0x623390`, whose *first* act +//! after the three state gates is `call [vt+0x8c]` — the foley (`0x623610` for a unit, +//! `0x62fa30` for a player) — and only then the terrain chain, which has gates of its own. So a +//! creature whose footstep class is 0 still rustles, and the two sounds are on different buses +//! (foley on the uncapped bus 0, the step on bus 9's cap of 6). +//! +//! Both foley paths converge on `0x4584e0`, which is this table: a `Material` **id** → the row's +//! `+0x8` field → a `SoundEntries` kit, played positionally at the unit's feet **+2.0 yd** +//! (`0x45851d fadd [0x801628]`) at volume 1.0. +//! +//! Layout — VERIFIED against the shipped build 5875 file (8 records × 3 fields × 12 B): +//! `ID(0), Flags(1), FoleySoundID(2)`. The `+0x8` the binary reads is field 2 on a 12-byte +//! record, and the `[row+4] & 1` flag test at `0x5d9a68` lands on field 1 — two independent +//! offsets agreeing on the same three-column shape. +//! +//! **Only three of the eight materials make any sound**: chain (5) → 1005 `FoleySoundChain`, +//! plate (6) → 1004 `FoleySoundPlate`, leather (8) → 1003 `FoleySoundLeather`. Metal, wood, +//! liquid, jewelry and **cloth** carry 0 — a robed mage is silent, and that is data, not a gap. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; +use crate::Chain; + +/// `Material.dbc` keyed by id. +pub struct MaterialCatalog { + foley: HashMap, +} + +impl MaterialCatalog { + /// The foley kit for a material id, or `None` when the material has no foley (five of the + /// eight shipped rows) or the id names no row. The reference's own two misses — a negative + /// id and an id past the table's max — land in the same place (`0x4584e6`/`0x4584ea`), so + /// callers need no sentinel of their own. + pub fn foley_kit(&self, material: u32) -> Option { + self.foley.get(&material).copied().filter(|&k| k != 0) + } + + pub fn len(&self) -> usize { + self.foley.len() + } + + pub fn is_empty(&self) -> bool { + self.foley.is_empty() + } +} + +fn schema() -> Schema { + let mut s = Schema::new("Material"); + for name in ["ID", "Flags", "FoleySoundID"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s +} + +/// Read `Material.dbc` off the patch chain. +pub fn load_material_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file("DBFilesClient\\Material.dbc") + .context("reading Material.dbc")?; + let rs = parse(&bytes, schema(), "Material")?; + let mut foley = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let Some(id) = u32_at(r, 0) { + foley.insert(id, u32_at(r, 2).unwrap_or(0)); + } + } + Ok(MaterialCatalog { foley }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The shipped table, whose material ids are the same ones + /// `SMSG_ITEM_QUERY_SINGLE_RESPONSE` puts on the wire (1 metal · 2 wood · 5 chain · 6 plate · + /// 7 cloth · 8 leather). Skips without client data. + #[test] + fn real_materials_decode() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_material_catalog(&mut chain).expect("load materials"); + assert_eq!(cat.len(), 8); + + assert_eq!(cat.foley_kit(5), Some(1005), "chain"); + assert_eq!(cat.foley_kit(6), Some(1004), "plate"); + assert_eq!(cat.foley_kit(8), Some(1003), "leather"); + + // Cloth is the load-bearing silence: a robe must not borrow leather's rustle. + assert_eq!(cat.foley_kit(7), None, "cloth"); + for quiet in [1, 2, 3, 4] { + assert_eq!(cat.foley_kit(quiet), None, "material {quiet}"); + } + + // Off the end of the table, and the "no material" id the wire sends for an empty slot. + assert_eq!(cat.foley_kit(0), None); + assert_eq!(cat.foley_kit(9), None); + } +} diff --git a/crates/benilla-formats/src/weapon_swing.rs b/crates/benilla-formats/src/weapon_swing.rs new file mode 100644 index 00000000..06b534fe --- /dev/null +++ b/crates/benilla-formats/src/weapon_swing.rs @@ -0,0 +1,112 @@ +//! `WeaponSwingSounds2.dbc` — the *connecting* melee swing's whoosh, by weapon weight. +//! +//! The reference builds a flat six-slot cache out of this file at load +//! (`0x45cb00`: a reverse walk over the 16-byte records writing +//! `cache[(critical != 0) + swingType*2] = SoundEntriesID`, bounded `swingType < 3` and +//! `critical < 2`), and the play site indexes it with exactly that arithmetic +//! (`0x457f8d lea eax,[eax+ecx*2]`, `0x457f91 mov edx,[eax*4 + 0xb06bd4]`). This catalog is that +//! cache, built the same way, so a file whose rows moved would still land where the reference +//! puts them. +//! +//! Layout — VERIFIED against the shipped build 5875 file (6 records × 4 fields × 16 B, one-byte +//! string block): `ID(0), SwingType(1), Critical(2), SoundEntriesID(3)`. The six rows are the +//! full cross product, and the kits they name are `LightWeaponNormal/Critical` (233/234), +//! `MediumWeaponNormal/Critical` (235/236) and `HeavyWeaponNormal/Critical` (237/238) — the +//! `mWooshSmall*` / `mWooshMedium*` / `mWooshLarge*` samples. +//! +//! **The weight is not a heuristic**: `swingType` is `ItemSubClass.WeaponSwingSize` for the +//! swinging weapon's `(class 2, subclass)` — see [`crate::ItemSubClassCatalog::weapon_swing_size`]. + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; +use crate::Chain; + +/// How many `swingType` values the reference's cache holds — `0x457f63 cmp ecx,3 / jge` bails +/// above this, and the loader's own `cmp edi,3 / jge` refuses to write past it. +const SWING_TYPES: usize = 3; + +/// The six-slot swing-kit cache (`0xb06bd4`), indexed `critical + swingType*2`. +pub struct WeaponSwingCatalog { + cache: [u32; SWING_TYPES * 2], +} + +impl WeaponSwingCatalog { + /// The kit for a swing of `swing_type` (0 light · 1 medium · 2 heavy), critical or not. + /// `None` above the reference's ceiling — its play site *returns without playing anything* + /// on `swingType >= 3`, so an out-of-range weight is silence, never a fallback to light. + pub fn kit(&self, swing_type: u32, critical: bool) -> Option { + let slot = usize::try_from(swing_type).ok()?; + if slot >= SWING_TYPES { + return None; + } + let kit = self.cache[usize::from(critical) + slot * 2]; + (kit != 0).then_some(kit) + } +} + +fn schema() -> Schema { + let mut s = Schema::new("WeaponSwingSounds2"); + for name in ["ID", "SwingType", "Critical", "SoundEntriesID"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s +} + +/// Read `WeaponSwingSounds2.dbc` off the patch chain into the reference's cache shape. +pub fn load_weapon_swing_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file("DBFilesClient\\WeaponSwingSounds2.dbc") + .context("reading WeaponSwingSounds2.dbc")?; + let rs = parse(&bytes, schema(), "WeaponSwingSounds2")?; + let mut cache = [0u32; SWING_TYPES * 2]; + for r in rs.records() { + let g = |i: usize| u32_at(r, i).unwrap_or(0); + let (swing_type, critical) = (g(1), g(2)); + // The loader's own bounds, kept verbatim: a row outside them is dropped, not clamped. + if swing_type as usize >= SWING_TYPES || critical >= 2 { + continue; + } + cache[(critical != 0) as usize + swing_type as usize * 2] = g(3); + } + Ok(WeaponSwingCatalog { cache }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The shipped table: the full 3 × 2 cross product, on the six `mWoosh*` kits. Skips without + /// client data. + #[test] + fn real_weapon_swing_sounds_decode() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_weapon_swing_catalog(&mut chain).expect("load weapon swing sounds"); + + assert_eq!(cat.kit(0, false), Some(233), "LightWeaponNormal"); + assert_eq!(cat.kit(0, true), Some(234), "LightWeaponCritical"); + assert_eq!(cat.kit(1, false), Some(235), "MediumWeaponNormal"); + assert_eq!(cat.kit(1, true), Some(236), "MediumWeaponCritical"); + assert_eq!(cat.kit(2, false), Some(237), "HeavyWeaponNormal"); + assert_eq!(cat.kit(2, true), Some(238), "HeavyWeaponCritical"); + } + + /// Above the reference's `swingType < 3` ceiling there is no sound at all — `0x457f60` + /// returns before touching the cache. Nothing to skip: the bound is ours, not the file's. + #[test] + fn a_weight_past_the_ceiling_is_silence_not_a_fallback() { + let cat = WeaponSwingCatalog { + cache: [233, 234, 235, 236, 237, 238], + }; + assert_eq!(cat.kit(3, false), None); + assert_eq!(cat.kit(u32::MAX, true), None); + // …and a slot the file never filled reads as absent rather than kit 0. + let sparse = WeaponSwingCatalog { + cache: [233, 0, 0, 0, 0, 0], + }; + assert_eq!(sparse.kit(0, false), Some(233)); + assert_eq!(sparse.kit(0, true), None); + } +}