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