every melee hit makes a sound, and footsteps get their armor foley

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-25 18:31:38 +07:00
co-authored by Claude Fable 5
parent 11a0b68da6
commit c61882cf42
16 changed files with 1310 additions and 172 deletions
+4 -2
View File
@@ -822,8 +822,10 @@ pub(crate) struct AnimDriver {
/// survives to its clip's end).
last_special: Option<Special>,
/// 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:
@@ -319,16 +319,20 @@ pub(super) fn leave_special(
window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>,
frozen: &mut Option<bevy::animation::graph::AnimationNodeIndex>,
) -> 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) {
@@ -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
}
+7
View File
@@ -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<u32> {
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<f32> {
+366 -73
View File
@@ -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<f32> {
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");
}
}
@@ -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();
+54 -3
View File
@@ -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<bevy::animation::graph::AnimationNodeIndex>,
/// The cross-fade out of the pose the last arm replaced ([`Fade`]) — `None` between turns.
fade: Option<Fade>,
}
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;
+413 -68
View File
@@ -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<Res<WorldAssets>>) {
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<u32> {
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<LastSwing>,
units: Query<(&Transform, Option<&Wielded>, &NetEntity, Has<Embodied>)>,
impacts: Option<Res<WeaponImpacts>>,
swing_sounds: Option<Res<WeaponSwings>>,
// 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<Res<crate::ui_items::ItemSubClasses>>,
voices: Option<Res<CreatureVoices>>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
@@ -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 {
+106 -5
View File
@@ -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<Res<WorldAssets>>) {
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<Res<WorldAssets>>) {
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<Res<Footsteps>>,
// 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<Res<Materials>>,
creatures: Option<Res<Creatures>>,
mut items: Option<ResMut<Items>>,
net_commands: Res<NetCommands>,
voices: Option<Res<CreatureVoices>>,
world: benilla_world::world_point::WorldPoint,
kits: Option<ResMut<SoundKits>>,
@@ -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));
}
+34 -8
View File
@@ -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| {
@@ -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,
+64
View File
@@ -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<u32> {
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<CreatureCatalog> {
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<u32> = 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!();
@@ -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<u32> {
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<ItemSubClassCatalog> {
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
+4
View File
@@ -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;
+105
View File
@@ -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<u32, u32>,
}
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<u32> {
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<MaterialCatalog> {
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);
}
}
+112
View File
@@ -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<u32> {
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<WeaponSwingCatalog> {
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);
}
}