diff --git a/crates/benilla-app/src/entities/collision_height.rs b/crates/benilla-app/src/entities/collision_height.rs index f52c3944..d0845047 100644 --- a/crates/benilla-app/src/entities/collision_height.rs +++ b/crates/benilla-app/src/entities/collision_height.rs @@ -21,12 +21,31 @@ use super::Creatures; /// within 2 mm of it) and wrong for every other race. A gnome female stands 1.15 yd and was held /// 1.52 yd under: she could not reach the surface, which is how the defect finally showed (B76). /// -/// `CreatureModelData.collisionHeight × render scale`. The column is the model's own MD20 -/// collision-box Z extent in raw model units — machine-pinned against the shipped client -/// (`benilla_formats` `collision_height_is_the_m2_collision_box`), which is what settles that it -/// scales with the geometry it bounds. The scale is [`NetEntity::scale`] = `OBJECT_FIELD_SCALE_X`, -/// the *complete* render scale the server has already folded the DBC scales into and the one value -/// our renderer sizes the model by — so the collision box and the drawn body cannot disagree. +/// `CreatureModelData.collisionHeight × k`. The column is the model's own MD20 collision-box Z +/// extent in raw model units — machine-pinned against the shipped client (`benilla_formats` +/// `collision_height_is_the_m2_collision_box`), which is what settles that it scales with the +/// geometry it bounds. +/// +/// **`k = max(OBJECT_FIELD_SCALE_X, CreatureDisplayInfo.creatureModelScale)`** — VERIFIED byte law +/// (wow-re `collision/scratch/mover-collision-scalars.md`): `0x60b270` fetches `SCALE_X` through +/// vtable slot `+0x1c` (`0x469f10`), compares it against the display row's own column at +/// `0x60b312` (`CreatureDisplayInfo+0x10`), and hands the **larger** to `0x6174b0`, which stores +/// `CollisionHeight · k` into `CMovement+0xb4` at `0x617501`. Its two callers include the unit +/// model (re)build `0x5fb9dd` with `force = 1`, so every unit gets it, not just the mover. +/// +/// The `max` is a **floor, never a second multiplier**. The *render* scale is [`NetEntity::scale`] +/// = `OBJECT_FIELD_SCALE_X` alone, which the server has already folded the DBC scales into +/// (`CreatureModelData.modelScale × CreatureDisplayInfo.scale`, vmangos `Unit::GetScaleForDisplayId` +/// — and wow-re `object-layer.md`'s render-scale CORRECTION says in as many words that a client +/// multiplying the display column in again would *square* it). So the collision box and the drawn +/// body still cannot disagree; the floor only stops the box shrinking below the display's own size. +/// +/// **At rest the floor is inert, which is why its absence hid**: not one of the 430 shipped +/// `CreatureModelData` rows scales below 1.0, so the folded `SCALE_X ≥ CreatureDisplayInfo.scale` +/// always and `max` picks `SCALE_X`. It bites exactly where `SCALE_X` drops *below* the display's +/// column — a shrink aura, and the **390** `creature_template` rows on our own server whose +/// `display_scale` override undercuts it (by up to 6×). Those units swam at a fraction of their +/// real depth. The floor can only ever *raise* `h`, so no unit that swims correctly today stops. /// /// **Not** the movement capsule: that stays the constant-height tunable it has always been /// ([`crate::player::CAPSULE_HEIGHT`] — see its doc for why the two are deliberately separate). @@ -50,14 +69,26 @@ pub(crate) fn collision_height_for( display_id: Option, scale: f32, ) -> CollisionHeight { - let raw = creatures - .zip(display_id) + let row = creatures.zip(display_id); + let raw = row .and_then(|(c, id)| c.collision_height(id)) // A zero column is real in the data (67 rows — invisible triggers and the like); it means // "no authored box", not "a unit of no height", so it falls back like a missing row. .filter(|h| *h > 0.0) .unwrap_or(crate::player::DEFAULT_COLLISION_HEIGHT); - CollisionHeight(raw * scale.max(f32::MIN_POSITIVE)) + let k = prism_scale(scale, row.and_then(|(c, id)| c.catalog.display_scale(id))); + CollisionHeight(raw * k) +} + +/// `k = max(OBJECT_FIELD_SCALE_X, CreatureDisplayInfo.creatureModelScale)` — the collision prism's +/// multiplier, split out so the FLOOR is assertable on its own (see [`CollisionHeight`] for the +/// byte law and why the floor is not a second multiplier). A display we can't resolve — and the +/// three shipped rows whose column is 0 — contributes nothing and leaves `SCALE_X` standing alone. +/// Never returns 0: at zero every depth line collapses and the unit swims on dry land. +fn prism_scale(scale: f32, display_scale: Option) -> f32 { + display_scale + .map_or(scale, |s| scale.max(s)) + .max(f32::MIN_POSITIVE) } /// Give every streamed unit its [`CollisionHeight`] the frame its display resolves. Queries the @@ -81,3 +112,37 @@ pub(super) fn stamp_collision_heights( commands.entity(entity).insert(h); } } + +#[cfg(test)] +mod prism { + use super::prism_scale; + + /// The **floor**, and the two shapes it has to get right. `SCALE_X` normally already carries + /// the DBC product (the server folded it), so the display column can only ever equal or + /// undercut it — which is why the floor is inert on shipped data and bit only when a server + /// override or a shrink aura pushed `SCALE_X` under the display's own size. + #[test] + fn the_display_column_is_a_floor_never_a_multiplier() { + // The Shore Strider (display 4945): `modelScale` 1.0 × `CDI.scale` 1.75, folded → 1.75. + // The floor is inert here, which is exactly why the height was NOT B311's cause. + assert_eq!(prism_scale(1.75, Some(1.75)), 1.75); + // A `creature_template.display_scale` override under the display's own column (390 rows on + // our server), or a shrink aura: the prism holds at the display size, it does not shrink. + assert_eq!(prism_scale(1.0, Some(6.0)), 6.0); + // A growth aura pushes SCALE_X above the column: the prism grows with it. + assert_eq!(prism_scale(3.5, Some(1.75)), 3.5); + // …and it is never a product: 1.75 × 1.75 = 3.06 would be the double-apply. + assert_ne!(prism_scale(1.75, Some(1.75)), 1.75 * 1.75); + } + + #[test] + fn an_unresolved_or_zero_column_leaves_scale_x_alone_and_never_collapses() { + assert_eq!(prism_scale(1.75, None), 1.75); + assert_eq!(prism_scale(1.75, Some(0.0)), 1.75, "3 shipped rows carry 0"); + assert!( + prism_scale(0.0, Some(0.0)) > 0.0, + "a 0 scale is briefly real" + ); + assert!(prism_scale(0.0, None) > 0.0); + } +} diff --git a/crates/benilla-app/src/net/motion/spline.rs b/crates/benilla-app/src/net/motion/spline.rs index 0f6f286e..946c38eb 100644 --- a/crates/benilla-app/src/net/motion/spline.rs +++ b/crates/benilla-app/src/net/motion/spline.rs @@ -12,7 +12,7 @@ use bevy::prelude::*; use crate::entities::CollisionHeight; use crate::player::swim_enter_depth; -use super::super::NetEntity; +use super::super::{NetEntity, ObjectStore}; /// A server-dictated movement path (`SMSG_MONSTER_MOVE`): the unit traverses `points` at constant /// speed over `duration` from `start`. All points are raw WoW coords; [`sample_splines`] interpolates @@ -673,17 +673,26 @@ fn clamp_seat_disabled() -> bool { } /// What [`mark_swimming_creatures`] reads per unit: identity, kind, pose, its own collision height -/// (`None` only on a unit's first frame, before the stamp), its own WMO-room claim (whose liquid may -/// answer for it — decision 0696), and whether it is already marked. +/// (`None` only on a unit's first frame, before the stamp), its `UNIT_FIELD_FLAGS` (the enter gate +/// — [`crate::player::may_enter_swim`]), its own WMO-room claim (whose liquid may answer for it — +/// decision 0696), and whether it is already marked. /// The gate: a stamped unit re-enters the query only when its `Transform` moved (spline ticks, -/// teleports, clamp writes); an unstamped one is retried every frame until it evaluates. -type SwimMarkGate = Or<(Changed, Without)>; +/// teleports, clamp writes) or its descriptors changed (a `UNIT_FIELD_FLAGS` delta — a pet losing +/// PLAYER_CONTROLLED, a GM `.modify unitflag` — must re-ask); an unstamped one is retried every +/// frame until it evaluates. `Changed` also covers the *insert*, which is what lets a +/// unit whose descriptor block lands a frame after its transform still evaluate. +type SwimMarkGate = Or<( + Changed, + Changed, + Without, +)>; type SwimMarkQuery = ( Entity, &'static NetEntity, &'static Transform, Option<&'static CollisionHeight>, + Option<&'static ObjectStore>, Has, Has, ); @@ -708,16 +717,45 @@ const CREATURE_SWIM_EXIT_BAND: f32 = 1.0 / 36.0; /// A **creature** currently past the swim boundary — the client-side derivation of the swim state /// the wire never carries for creatures: vmangos sets `MOVEFLAG_SWIMMING` only from player packets, /// and a water creature's create block + splines carry no swim marker at all (verified -/// `vmangos-src`, 2026-07-17). The real client derives every unit's swim mode locally per frame -/// (the `0x6030c0` boundary family — decision 0226 pinned it for the player). This derives it the -/// same way, on the creature's **own** collision height: marked once its feet sit deeper than -/// `0.75·h` ([`swim_enter_depth`]), unmarked once they rise a [`CREATURE_SWIM_EXIT_BAND`] above it -/// so the state can't flicker. The flat 2.0-yd stand-in it used before decision 0645 is retired -/// (0464's `collisionHeight` plumb was what it was waiting on); what stays open is the *reference -/// point* the real client measures a creature's depth from. +/// `vmangos-src`, 2026-07-17). The real client runs its own depth decision `0x6030c0` on **remote +/// units and creatures too**, not only on the body it steers (wow-re +/// `models/scratch/wmo-liquid-scoping.md` §4.5: nothing in the chain is pinned to a local-player +/// global; `0x60df70`'s active-mover GUID compare only chooses whether the transition is +/// additionally *reported* to the server, and its not-the-mover arm still enqueues StartSwim +/// `0x61a030` / sets it outright `0x61a130`). So deriving it here is faithful, and always was. +/// +/// **What was missing is the gate** (B311, decision 1568). `0x6030c0` enters swim only when +/// `depth > 0.75·h` **and** the unit's `UNIT_FIELD_FLAGS` carry one of the three bits +/// [`crate::player::may_enter_swim`] tests — PLAYER_CONTROLLED, PET_IN_COMBAT or +/// USE_SWIM_ANIMATION. A creature with none of them **walks the lakebed at any depth**, which is +/// what vmangos means by *"Giant type creatures walk underwater"*: the Shore Strider off the +/// Forgotten Coast is a sea giant with no `CREATURE_STATIC_FLAG_CAN_SWIM`, so the reference wades +/// it chest-deep on its legs while benilla, gating on depth alone, slid it along on a swim gait. +/// The gate is **enter-only** — the exit leg `0x6031b4` reads no flags — so a marked unit still +/// leaves on depth. +/// +/// Depth is measured on the creature's **own** collision height: marked once its feet sit deeper +/// than `0.75·h` ([`swim_enter_depth`]), unmarked once they rise a [`CREATURE_SWIM_EXIT_BAND`] +/// above it so the state can't flicker. The flat 2.0-yd stand-in it used before decision 0645 is +/// retired (0464's `collisionHeight` plumb was what it was waiting on); what stays open is the +/// *reference point* the real client measures a creature's depth from. +/// +/// `0x6030c0`'s other guard — bail entirely while `MOVEFLAG_LEVITATING` (`0x400`) is set — has no +/// creature-side expression here because a spline-walked creature carries no live move-flag word +/// at all (`creature_anim::select::unify`'s creature leg synthesises one); the local avatar's copy +/// of the same guard is `player::swim::update_swimming`'s first branch. /// /// Consumers: the swim-gait leg of the animation selector (`creature_anim::select::unify`), the -/// [`ground_clamp_creatures`] exemption above, and the enter-water splash (`sound::water`). +/// swimming body pitch ([`sample_splines`]), and the [`ground_clamp_creatures`] exemption — which +/// is why the gate reaches further than the gait. A creature the server did NOT flag is now +/// re-grounded like any other walker, and that is right by the same authority: vmangos paths an +/// unflagged creature along the **bottom** of the water (`Object.cpp`'s *"Giant type creatures +/// walk underwater"* early-return, no upward randomisation), so its wire Z is the seabed and +/// clamping it to our terrain is the same answer, not a different one. +/// +/// **Not** a consumer, deliberately: the enter-water splash (`sound::water`) reads `0.4·h` against +/// its own depth, exactly like the reference's `0x60314a` splash compare — which sits *outside* +/// the flag gate. A giant wading in still splashes. #[derive(Component)] pub(crate) struct CreatureSwimming; @@ -744,6 +782,23 @@ pub(crate) struct GroundClamped { epoch: u64, } +/// The reference's `0x6030c0` decision for one creature, as a pure function of everything it +/// reads — [`CreatureSwimming`]'s law in one place, so the enter/exit asymmetry is assertable +/// without a world. +/// +/// **Enter** needs both halves: deep enough (`depth > 0.75·h`, strictly — `0x603106 test ah,0x41`) +/// *and* a `UNIT_FIELD_FLAGS` that permits swimming at all ([`crate::player::may_enter_swim`]). +/// **Leave** is depth alone, a [`CREATURE_SWIM_EXIT_BAND`] lower: the exit leg `0x6031b4` reads no +/// unit flags, so a unit that loses its swim bit mid-water still leaves properly rather than being +/// stranded in a gait it can no longer re-enter. +fn creature_swim_state(marked: bool, depth: f32, boundary: f32, unit_flags: u32) -> bool { + if marked { + depth >= boundary - CREATURE_SWIM_EXIT_BAND + } else { + depth > boundary && crate::player::may_enter_swim(unit_flags) + } +} + /// Maintain [`CreatureSwimming`] on `Unit` creatures from the water over their feet (module docs on /// the INTERIM boundary). Runs chained before [`ground_clamp_creatures`] so a fresh mark exempts the /// clamp the same frame (Bevy inserts the deferred-command sync point for the chain). @@ -764,7 +819,7 @@ pub(in crate::net) fn mark_swimming_creatures( commands.entity(e).remove::(); } } - for (e, net, t, collision, marked, evaluated) in &units { + for (e, net, t, collision, store, marked, evaluated) in &units { if net.kind != EntityKind::Unit { continue; // players carry the real flag on the wire; GameObjects don't swim } @@ -788,11 +843,10 @@ pub(in crate::net) fn mark_swimming_creatures( .water_surface_at(who, wow) .map_or(f32::MIN, |s| s - wow[2]); let boundary = swim_enter_depth(collision.copied().unwrap_or_default().0); - let swimming = if marked { - depth >= boundary - CREATURE_SWIM_EXIT_BAND - } else { - depth > boundary - }; + // A unit whose descriptor block has not landed yet reads flags 0 and simply cannot enter + // this frame; `Changed` brings it straight back when the block arrives. + let flags = store.map_or(0, |s| s.0.unit_flags()); + let swimming = creature_swim_state(marked, depth, boundary, flags); if swimming != marked { if swimming { commands.entity(e).insert(CreatureSwimming); @@ -1106,3 +1160,98 @@ mod tests { ); } } + +/// **B311, in a world small enough to assert on** — [`creature_swim_state`] at the reported +/// giant's own numbers. The Shore Strider is a sea giant with no `CREATURE_STATIC_FLAG_CAN_SWIM`, +/// so no `UNIT_FLAG_USE_SWIM_ANIMATION` reaches the client: the reference wades it on its legs +/// however deep the water. The same depth with any one of the three gate bits set must still +/// swim — before this gate benilla swam both, which is the "gliding in deep water" the report +/// saw, and a fix that stopped every creature swimming would be the same bug wearing a hat. +#[cfg(test)] +mod swim_gate { + use super::{creature_swim_state, CREATURE_SWIM_EXIT_BAND}; + use crate::player::swim_enter_depth; + + /// The Shore Strider's real numbers, from the shipped DBCs: display 4945 → CreatureModelData 35 + /// (`Creature\SeaGiant\SeaGiant.mdx`), `collisionHeight` 2.083, `CreatureDisplayInfo.scale` + /// 1.75 over `modelScale` 1.0 — so `h = 2.083 × 1.75` and the boundary is `0.75·h`. + const SEA_GIANT_H: f32 = 2.083 * 1.75; + + const NO_FLAGS: u32 = 0; + const USE_SWIM_ANIMATION: u32 = 0x8000; + const PLAYER_CONTROLLED: u32 = 0x8; + const PET_IN_COMBAT: u32 = 0x800; + + #[test] + fn a_sea_giant_walks_the_lakebed_however_deep_the_water() { + let boundary = swim_enter_depth(SEA_GIANT_H); + // Chest-deep — past the boundary that used to be the whole test — and then absurdly deep. + for depth in [boundary + 0.01, boundary * 2.0, 100.0] { + assert!( + !creature_swim_state(false, depth, boundary, NO_FLAGS), + "a unit with no swim flag must never enter swim (depth {depth})" + ); + } + } + + /// The control that must not change: the gate is a gate, not a switch-off. Any ONE of the three + /// bits still admits the unit, and the depth law under it is untouched. + #[test] + fn a_flagged_unit_still_enters_on_the_same_depth_law() { + let boundary = swim_enter_depth(SEA_GIANT_H); + for flags in [ + USE_SWIM_ANIMATION, + PLAYER_CONTROLLED, + PET_IN_COMBAT, + USE_SWIM_ANIMATION | PLAYER_CONTROLLED | PET_IN_COMBAT, + ] { + assert!( + creature_swim_state(false, boundary + 0.01, boundary, flags), + "flags {flags:#x} deep enough → swims" + ); + assert!( + !creature_swim_state(false, boundary, boundary, flags), + "flags {flags:#x} exactly at the boundary → not yet (the compare is STRICT)" + ); + } + } + + /// The gate is **enter-only** — the reference's exit leg reads no unit flags. A unit that is + /// already swimming when its bit clears must still leave on depth, or it would be stranded in + /// a gait it can never re-enter. + #[test] + fn leaving_is_depth_alone_and_keeps_its_hysteresis() { + let boundary = swim_enter_depth(SEA_GIANT_H); + assert!( + creature_swim_state(true, boundary - CREATURE_SWIM_EXIT_BAND, boundary, NO_FLAGS), + "inside the band it holds, flags or no flags" + ); + assert!( + !creature_swim_state( + true, + boundary - CREATURE_SWIM_EXIT_BAND - 1e-4, + boundary, + NO_FLAGS + ), + "below the band it leaves" + ); + assert!( + !creature_swim_state(true, f32::MIN, boundary, USE_SWIM_ANIMATION), + "no liquid at all leaves, flag or no flag" + ); + } + + /// A unit whose descriptor block has not landed reads flags 0 — it must not enter on that, + /// and it must not be *stuck*: the system's `Changed` gate re-asks on the insert. + #[test] + fn an_unresolved_descriptor_cannot_enter_but_is_not_stuck() { + let boundary = swim_enter_depth(SEA_GIANT_H); + assert!(!creature_swim_state(false, 100.0, boundary, NO_FLAGS)); + assert!(creature_swim_state( + false, + 100.0, + boundary, + USE_SWIM_ANIMATION + )); + } +} diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index bfb8d549..512807fa 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -98,7 +98,7 @@ pub(crate) use state::{ /// The swim boundary `0.75·h` — and therefore the **wade ceiling**, since wading is the implicit /// in-liquid-but-not-swimming state and the two cannot be different numbers. Read by the creature /// swim marker and the footstep splash slot, which have no `Player` of their own. -pub(crate) use swim::swim_enter_depth; +pub(crate) use swim::{may_enter_swim, swim_enter_depth}; /// **`UNIT_FLAG_STUNNED`** — the `UNIT_FIELD_FLAGS` bit that freezes a character's *turning* /// (decision 0872). Not a movement flag and not an aura: the reference reads it straight off the diff --git a/crates/benilla-app/src/player/swim.rs b/crates/benilla-app/src/player/swim.rs index b9db854a..e1a886c2 100644 --- a/crates/benilla-app/src/player/swim.rs +++ b/crates/benilla-app/src/player/swim.rs @@ -81,6 +81,46 @@ const SWIM_DEPTH_FRAC: f32 = 0.75; /// `0.75·h`, leave against `0.75·h − 1/36`, so between them the swim state holds (`0x603100`/`0x6031c0`). const SWIM_HYSTERESIS: f32 = 1.0 / 36.0; +/// The `UNIT_FIELD_FLAGS` bits that permit a unit to **enter** swim at all — **VERIFIED** in the +/// shipped 5875 image, the gate `0x6030c0` runs *after* `depth > 0.75·h` and *before* TryStartSwim +/// `0x60df70`: +/// +/// ```text +/// 60310b mov ecx,[esi+0x110] ; the unit's descriptor block base (&m_uint32Values[OBJECT_END]) +/// 603111 mov eax,[ecx+0xa0] ; +0xa0 = field OBJECT_END+0x28 = UNIT_FIELD_FLAGS (1.12.1 idx 46) +/// 603117 test ah,ah ; js 60312c ; bit 15 set → proceed +/// 60311b shr edx,3 ; test dl,1 ; jne 60312c ; bit 3 set → proceed +/// 603125 shr eax,0xb ; test al,1 ; je 60314a ; bit 11 clear → SKIP TryStartSwim +/// ``` +/// +/// Any **one** of the three permits entry; with none of them the unit stays in walk mode however +/// deep the water, and **walks the lakebed**. What each bit is (vmangos `UnitDefines.h`, and its +/// own writers): +/// +/// - `0x8` **PLAYER_CONTROLLED** — every body a player can steer: players (set at creation, +/// `Player.cpp:462`), pets, totems, guardians, companions, charms (`SpellAuras.cpp` charm/possess). +/// - `0x800` **PET_IN_COMBAT**. +/// - `0x8000` **USE_SWIM_ANIMATION** — the server's "this creature swims" bit, applied on spawn from +/// `creature_template.static_flags1 & CREATURE_STATIC_FLAG_CAN_SWIM` (`Creature:: +/// ToggleUnitFlagsFromStaticFlags`). vmangos names the client behaviour it is naming exactly: +/// *"Without it units walk on the sea floor instead of swimming"*, and, at its pathing site, +/// *"Giant type creatures walk underwater"*. **956 of the 10912** rows on our server carry it. +/// +/// The gate is on **ENTER only** — the exit leg `0x6031b4` reads no flags, so a unit that is +/// already swimming leaves on depth alone. +/// +/// **The local avatar does not consult this**, and that is a verified no-op rather than an +/// omission: bit 3 is set on every body a player can be [`crate::net::Embodied`] in — its own +/// character, a charmed creature, a possessed one — so the gate always passes there. It bites only +/// on the creature marker (`crate::net::motion::spline::mark_swimming_creatures`), which is where +/// benilla derives a creature's swim state the wire never carries. +const SWIM_ENTER_UNIT_FLAGS: u32 = 0x8 | 0x800 | 0x8000; + +/// May this unit **enter** swim from its own depth? — [`SWIM_ENTER_UNIT_FLAGS`]'s one spelling. +pub(crate) fn may_enter_swim(unit_flags: u32) -> bool { + unit_flags & SWIM_ENTER_UNIT_FLAGS != 0 +} + /// Submersion depth (yd, water surface above the feet) to **start** swimming — VERIFIED `0.75·h` /// from the feet, where `h` is **the unit's own collision height** /// ([`crate::entities::CollisionHeight`]): water covering ~three-quarters of its collision box, @@ -419,6 +459,30 @@ mod tests { } } + /// The ENTER gate's three bits, named — `0x6030c0`'s `js` / `shr 3` / `shr 0xb` tests. + /// PET_IN_COMBAT is the one that reads like a typo and is not: the reference really does let a + /// pet's combat bit admit it to swim, and it is bit 11 of `UNIT_FIELD_FLAGS` in 1.12.1. + #[test] + fn the_swim_enter_gate_is_those_three_unit_flags_and_no_others() { + const PLAYER_CONTROLLED: u32 = 0x8; + const PET_IN_COMBAT: u32 = 0x800; + const USE_SWIM_ANIMATION: u32 = 0x8000; + assert_eq!( + SWIM_ENTER_UNIT_FLAGS, + PLAYER_CONTROLLED | PET_IN_COMBAT | USE_SWIM_ANIMATION + ); + for bit in [PLAYER_CONTROLLED, PET_IN_COMBAT, USE_SWIM_ANIMATION] { + assert!( + may_enter_swim(bit), + "any ONE of the three admits ({bit:#x})" + ); + } + assert!(!may_enter_swim(0), "no flags at all → walks the lakebed"); + // Not vacuous: a unit carrying every OTHER unit flag still cannot enter. IN_COMBAT, + // SKINNABLE, PVP, NOT_SELECTABLE — none of them is a swim permit. + assert!(!may_enter_swim(!SWIM_ENTER_UNIT_FLAGS)); + } + /// Swim mode latches with the verified 1/36-yd hysteresis: enter is a strict `depth > 0.75·h`, leave /// is `depth < 0.75·h − 1/36`, so a depth inside the band holds whatever state we were in — wading /// the boundary can't flicker the physics regime frame to frame (wow-re swim-transition `0x6030c0`). diff --git a/crates/benilla-app/src/portrait/booth.rs b/crates/benilla-app/src/portrait/booth.rs index 27fe707e..405e214e 100644 --- a/crates/benilla-app/src/portrait/booth.rs +++ b/crates/benilla-app/src/portrait/booth.rs @@ -653,6 +653,14 @@ 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; +/// How much of a cross-fade's window is **still to run**, `1 → 0` — the `t` the client recomputes +/// inline from the live descriptor and clock on every arm and every frame (`(blendEnd − now) · +/// blendRate`), never a cached weight. Named because two places must agree on it exactly: the +/// per-frame weights, and the half-blend refusal that decides whether an arm re-seeds at all. +fn fade_frac(fade: &super::Fade, now: f64) -> f32 { + ((fade.until - now) / f64::from(fade.span)) as f32 +} + /// 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: @@ -696,23 +704,49 @@ fn arm_turn( 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() { + // **The half-blend refusal** (decision 1570, `0x7125c9`/`0x7125d4`). A blend already running + // with λ > 0.5 — more than half its window still to run — is NOT re-seeded: the client keeps + // the old secondary on its old window, and the clip that was primary is simply dropped. Read + // as a rule: *a pose that never got past half weight is not worth fading out.* Equality takes + // the snapshot, and the strictness is real — the f32 lands exactly on 0.5 at `remaining = + // span/2`, so the boundary falls on the snapshot side. + // + // Reachable in ordinary use, which is why it is here: Stand's blend is 500 ms, so **a second + // nudge of the arrow within 250 ms of the last release** meets it — a repeated-nudge cadence, + // not an exotic input. (λ against 0.5 rather than the fraction against ½ because the law is + // stated on λ; at our amplitude of 1.0 the two are the same test, `smoothstep` being strictly + // increasing with `smoothstep(½) = ½`.) + let refused = turn + .fade + .is_some_and(|f| crate::creature_anim::select::blend_lambda(fade_frac(&f, now)) > 0.5); + if refused { + // The outgoing primary goes nowhere — not into the secondary, which keeps the older pose + // it was already fading, at the older λ. Only this clip is dropped. + if let Some(out) = outgoing.filter(|o| *o != node) { + player.stop(out); + } + } else if let Some(old) = turn.fade.take() { + // 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 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, - }); + let fade = if refused { + turn.fade // untouched: old node, old window, old λ + } else { + 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. @@ -841,7 +875,7 @@ fn step_turn( let Some(fade) = turn.fade else { return; }; - let frac = ((fade.until - now) / f64::from(fade.span)) as f32; + let frac = fade_frac(&fade, now); if frac <= 0.0 { turn.fade = None; player.stop(fade.node); @@ -1380,15 +1414,19 @@ mod tests { ); } - /// 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. + /// **The half-blend refusal, both legs** (decision 1570). One secondary slot, as the client + /// keeps one — but an arm only *takes* it when the running blend is at or past halfway. Inside + /// the first half, `0x7125d4` refuses: the older pose keeps fading on its own untouched window, + /// and the clip that was primary is dropped outright rather than fading out of a weight it + /// never reached. #[test] - fn a_reversal_displaces_the_fade_it_found() { + fn a_reversal_is_refused_inside_the_half_blend_and_displaces_it_after() { let (anims, catalog) = ( turning_model(), benilla_formats::AnimDataCatalog::from_rows([]), ); + + // Reverse at 0.1 s of a 0.25 s blend — 60% still to run, λ = 0.648. REFUSED. let (mut player, mut rng) = (baked(), 0u32); let mut turn = super::super::Turn { spun: Some(SHUFFLE_LEFT), @@ -1403,13 +1441,79 @@ mod tests { Some(node(4)), "the opposite shuffle took over" ); + assert_eq!( + turn.fade.map(|f| (f.node, f.until)), + Some((node(1), 1.25)), + "the ORIGINAL fade is untouched — same pose, same window" + ); + assert_eq!( + weight(&player, 3), + None, + "the first shuffle is dropped, not faded" + ); + assert_eq!(turn.shuffle.map(|(id, _)| id), Some(SHUFFLE_RIGHT)); + + // Reverse at 0.2 s instead — 20% still to run, λ = 0.104. The slot is taken. + 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.2); + 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)); + } + + /// The refusal's **reachable** case, and the one that decided it was worth building: Stand + /// blends in over 0.5 s, so a second nudge of the arrow within 250 ms of the last release meets + /// a blend that is more than half to run. Without the guard those stack — each nudge would fade + /// out a Stand that had barely faded in. With it, the doll goes on fading out of the shuffle it + /// was actually in. + #[test] + fn a_second_nudge_inside_stands_own_blend_is_refused() { + 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_LEFT); + step_turn(&mut player, &anims, &catalog, &mut rng, &mut turn, 1.1); + // Released: the expiry arms Stand, fading out of the shuffle over Stand's own 0.5 s. + step_turn(&mut player, &anims, &catalog, &mut rng, &mut turn, 1.3); + let settling = turn.playing.expect("Stand armed"); + assert_eq!(turn.fade.map(|f| (f.node, f.until)), Some((node(3), 1.8))); + + // Nudged again 100 ms later — 80% of Stand's blend still to run, λ = 0.896. REFUSED. + turn.spun = Some(SHUFFLE_RIGHT); + step_turn(&mut player, &anims, &catalog, &mut rng, &mut turn, 1.4); + + assert_eq!( + turn.playing, + Some(node(4)), + "the new shuffle is the primary" + ); + assert_eq!( + turn.fade.map(|f| (f.node, f.until)), + Some((node(3), 1.8)), + "still fading the shuffle it was already fading, on the same window" + ); + assert_eq!( + player.animation(settling).map(|a| a.weight()), + None, + "the half-faded Stand is dropped, not layered on" + ); } /// A display that does not author the shuffles at all — most pet models — is left **standing**, diff --git a/crates/benilla-app/src/sound/combat.rs b/crates/benilla-app/src/sound/combat.rs index 7c4fcaec..fcb8de27 100644 --- a/crates/benilla-app/src/sound/combat.rs +++ b/crates/benilla-app/src/sound/combat.rs @@ -93,9 +93,30 @@ //! 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 material law, and where the ten slots actually come from (1567) +//! +//! Three of this module's INTERIM readings were one table: **`Material.dbc`'s `Flags` column**. +//! +//! - **metal vs wood** is `Flags & 1` (`0x457e80` → `0x5d9a50`), not `material != WOOD`. Leather, +//! cloth and liquid are non-metal too — and so is the id 0 a creature with no virtual-item +//! info carries, which the old test called metal. +//! - **the victim's slot** is `[vt+0x90]`, two implementations naming disjoint halves of the ten: +//! a creature's `CreatureSoundData` column remapped `{0, 8, 7, 9}` (flesh/stone/wood/ethereal), +//! or a **player's chest armor** (`0x62fb70`: plate → plate, chain → chain, else flesh). The +//! chain and plate columns are reachable only through a player victim, and only the local one — +//! the same self-only reach as the foley. benilla sent every player to flesh, which is why a +//! plate warrior's own hits sounded like a punch on meat. +//! - **the clang's slot family follows the defending item's class**, not the victimState +//! (`0x457de6`/`0x457dfd`): class 2 the parry pair, class 4 the shield pair, material picking +//! metal or wood inside it. The item is `0x625400(sel)` — a parry prefers a mainhand weapon and +//! falls through to the offhand, a block goes straight to the offhand — and **no defending item +//! means no clang**, so an unarmed parry is silent. benilla read the mainhand for both and +//! hardcoded the metal shield. +//! +//! One more byte detail: the clang alone is emitted two yards up (`0x457e29`); the generic weapon +//! impact beside it passes its position straight through. +//! +//! INTERIM readings (flagged for a wow-re pass): 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 natural-weapon column is gated on @@ -176,9 +197,6 @@ 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 -/// parry slot. Read off the item itself (decision 0882), never inferred from its subclass. -const MATERIAL_WOOD: u8 = 2; /// Fist/unarmed subclass — the row a weaponless swing uses (`Unarmed_Generic`). const UNARMED_SUBCLASS: u32 = 13; @@ -225,22 +243,59 @@ fn load_weapon_impacts(mut commands: Commands, assets: Option>) #[derive(Default)] struct LastSwing(EntityHashMap); -/// The attacker's swinging weapon: `(subclass, wooden)`, unarmed when the hand is empty. The -/// wood-vs-metal half is the item's own **`Material`** off the wire (decision 0882) — not a +/// The attacker's swinging weapon: `(subclass, metal)`, unarmed when the hand is empty. The +/// metal-vs-wood half is the item's own **`Material`** off the wire (decision 0882) — not a /// subclass guess, which the real 5875 data contradicts outright: maces (subclass 4) ship in both /// materials, so a Cudgel is wood where a Mace is metal. -fn swing_weapon(wielded: Option<&Wielded>, offhand: bool) -> (u32, bool) { +/// +/// **`metal` is `Material.Flags & 1`, not `material != WOOD`** (`0x457e80` → `0x5d9a50`). The +/// distinction is not academic: leather, cloth and liquid are all non-metal, and so is the id 0 +/// a creature with no virtual-item info carries — which the old test called metal. An empty hand +/// is non-metal too, matching the reference's null-item path (`0x457e8d` writes the out-param 0). +fn swing_weapon( + wielded: Option<&Wielded>, + offhand: bool, + materials: Option<&benilla_formats::MaterialCatalog>, +) -> (u32, bool) { let hand = wielded.and_then(|w| if offhand { w.off } else { w.main }); match hand { // class 2 = weapon; anything else in hand (held misc) swings as unarmed. - Some((2, subclass)) => ( - u32::from(subclass), - wielded.is_some_and(|w| w.materials[usize::from(offhand)] == MATERIAL_WOOD), - ), + Some((2, subclass)) => { + let material = u32::from(wielded.map_or(0, |w| { + w.materials[usize::from(offhand).min(w.materials.len() - 1)] + })); + ( + u32::from(subclass), + materials.is_some_and(|m| m.is_metal(material)), + ) + } _ => (UNARMED_SUBCLASS, false), } } +/// The victim's `WeaponImpactSounds` **target slot** — the `[vt+0x90]` virtual, whose two +/// implementations name **disjoint halves of the same ten**: +/// +/// - a **creature** (`0x6238f0`) reads its `CreatureSoundData` column, refuses anything `>= 4`, +/// and remaps it through the four-entry table at `0x80db94` = `{0, 8, 7, 9}` — flesh, stone, +/// wood, ethereal. +/// - a **player** (`0x62fb70`) ignores that entirely and reads its **chest armor's `Material`** +/// flags: plate → the plate slot, chain → chain, everything else (leather and cloth included) +/// → flesh. Handled at the call site, which is where the chest lookup's data lives. +/// +/// So the chain and plate columns of every weapon row are reachable *only* through a player +/// victim, and only the local one — the same self-only reach as the foley +/// ([`super::worn_chest_material`]). benilla used to send every player to flesh, which is why a +/// plate warrior's own hits sounded like a punch on meat. +fn creature_impact_slot(impact_type: u32) -> usize { + match impact_type { + 1 => impact_slot::STONE, + 2 => impact_slot::WOOD, + 3 => impact_slot::ETHEREAL, + _ => impact_slot::FLESH, + } +} + /// **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 @@ -299,31 +354,52 @@ fn defended(victim_state: u32) -> bool { matches!(victim_state, VICTIM_PARRY | VICTIM_BLOCK) } -/// `0x624530`'s clang: the attacker's weapon row × the victim's defense (`0x623640`). Parry -/// picks metal/wood by the victim's own weapon body; block takes the shield slot. Crit does not -/// tier it — the parry/shield columns carry the same kit in both tables. +/// The victim's **defending item** — `0x625400(sel)`, `(class, material)`. +/// +/// A **parry** prefers the mainhand when it holds a weapon and otherwise falls through to the +/// offhand; a **block** goes straight to the offhand. Either way the item must be a weapon +/// (class 2) or armor (class 4) — and `None` here means the reference plays **no clang at all** +/// (`0x623690 test eax,eax; je`), which is what an unarmed parry sounds like. +fn defending_item(wielded: Option<&Wielded>, block: bool) -> Option<(u8, u8)> { + let w = wielded?; + if !block { + if let Some((2, _)) = w.main { + return Some((2, w.materials[0])); + } + } + let (class, _) = w.off?; + matches!(class, 2 | 4).then_some((class, w.materials[1])) +} + +/// `0x624530`'s clang: the attacker's weapon row × the victim's defending item (`0x623640` → +/// `0x457dc0`). Crit does not tier it — the parry/shield columns carry the same kit in both +/// tables. +/// +/// **The slot family follows the item's own class, not the victimState** (`0x457de6`/`0x457dfd`): +/// class 2 takes the parry pair, class 4 the shield pair, and the item's `Material` picks +/// metal or wood within it. So a dual-wielder who *blocks* with a weapon rings the parry slot, +/// and a wooden shield rings 3262 rather than 3263 — benilla used to read the victim's mainhand +/// for both outcomes and hardcode the metal shield, which got the whole block case wrong. fn defense_clang( row: &benilla_formats::WeaponImpactRow, - victim_state: u32, - victim_wooden: bool, + item: Option<(u8, u8)>, + materials: Option<&benilla_formats::MaterialCatalog>, ) -> u32 { - let slot = match (victim_state, victim_wooden) { - (VICTIM_PARRY, true) => impact_slot::PARRY_WOOD, - (VICTIM_PARRY, false) => impact_slot::PARRY_METAL, - _ => impact_slot::SHIELD_METAL, + let metal = |m: u8| materials.is_some_and(|c| c.is_metal(u32::from(m))); + let slot = match item { + Some((2, m)) if metal(m) => impact_slot::PARRY_METAL, + Some((2, _)) => impact_slot::PARRY_WOOD, + Some((4, m)) if metal(m) => impact_slot::SHIELD_METAL, + Some((4, _)) => impact_slot::SHIELD_WOOD, + _ => impact_slot::FLESH, }; row.impact[slot] } -/// `0x6247d0`'s generic weapon impact for a landed hit: the victim's `CreatureImpactType` -/// material slot off the attacker's weapon row, crit-tiered. -fn landed_impact(row: &benilla_formats::WeaponImpactRow, impact_type: u32, crit: bool) -> u32 { - let slot = match impact_type { - 1 => impact_slot::STONE, - 2 => impact_slot::WOOD, - 3 => impact_slot::ETHEREAL, - _ => impact_slot::FLESH, - }; +/// `0x6247d0`'s generic weapon impact for a landed hit: the victim's slot +/// ([`creature_impact_slot`] or the player's armor slot) off the attacker's weapon row, +/// crit-tiered. +fn landed_impact(row: &benilla_formats::WeaponImpactRow, slot: usize, crit: bool) -> u32 { if crit { row.crit[slot] } else { @@ -331,20 +407,43 @@ fn landed_impact(row: &benilla_formats::WeaponImpactRow, impact_type: u32, crit: } } +/// What a combat sound needs to know about a unit: where it is, what it wields, what it *is*, +/// whether it is you — and its descriptor store, which rides along only for the victim's chest +/// armor (`0x62fb70`; see [`creature_impact_slot`] on the two halves of `[vt+0x90]`). +type CombatUnit = ( + &'static Transform, + Option<&'static Wielded>, + &'static NetEntity, + Has, + Option<&'static crate::net::ObjectStore>, +); + +/// The five DBC-backed tables the melee sounds read, bundled so the system stays under Bevy's +/// parameter ceiling — and because they are one thing: the melee sound vocabulary. Each is +/// independently optional, like every DBC-backed resource here; absent, its own branch goes +/// quiet rather than the system failing. +#[derive(bevy::ecs::system::SystemParam)] +struct MeleeTables<'w> { + impacts: Option>, + swing_sounds: Option>, + materials: Option>, + /// One load, two consumers: the tooltip's slot|type line owns this resource + /// ([`crate::ui_items::ItemSubClasses`]) and the swing whoosh reads the same rows' weight + /// column. A second loader over one DBC is how a schema quietly drifts. + sub_classes: Option>, + voices: Option>, +} + #[allow(clippy::too_many_arguments)] fn combat_sounds( mut swings: MessageReader, mut contacts: MessageReader, mut events: MessageReader, mut last: Local, - units: Query<(&Transform, Option<&Wielded>, &NetEntity, Has)>, - impacts: Option>, - swing_sounds: Option>, - // One load, two consumers: the tooltip's slot|type line owns this resource - // ([`crate::ui_items::ItemSubClasses`]) and the swing whoosh reads the same rows' weight - // column. A second loader over one DBC is how a schema quietly drifts. - sub_classes: Option>, - voices: Option>, + units: Query, + tables: MeleeTables, + mut items: Option>, + net_commands: Res, kits: Option>, assets: Option>, mut out: NonSendMut, @@ -373,12 +472,24 @@ fn combat_sounds( if events.is_empty() && contacts.is_empty() && exertions.is_empty() { return; } + let MeleeTables { + impacts, + swing_sounds, + materials, + sub_classes, + voices, + } = tables; let (Some(impacts), Some(voices), Some(mut kits), Some(assets)) = (impacts, voices, kits, assets) else { return; }; let listener = listener.pos; + // `Material.dbc` decides the metal/wood half of every weapon row and the armor slot a player + // victim presents. Absent (a DBC that failed to load), every weapon reads non-metal and every + // victim reads flesh — the reference's own answers for an unknown material, so the fallback + // is its behaviour rather than a guess of ours. + let mats = materials.as_deref().map(|m| &m.0); // Every combat play carries its **voice bus** (decision 1555): the melee-contact family all // 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, @@ -412,7 +523,7 @@ fn combat_sounds( // (ExertionCritical) is 100 in both twins — a crit always grunts, an ordinary swing thins out, // and a player grunts about half as often as a creature. for (attacker, crit) in exertions { - let Ok((tr, _, net, _)) = units.get(attacker) else { + let Ok((tr, _, net, _, _)) = units.get(attacker) else { continue; }; // Same `AISOUNDDESC` gate, on the attacker this time — exertion is classes 0/1. @@ -458,12 +569,12 @@ fn combat_sounds( let Some(swing) = last.0.get(&ev.entity) else { continue; // an attack anim without a tracked swing (e.g. spawned mid-fight) }; - let Ok((attacker_tr, wielded, _, _)) = units.get(ev.entity) else { + let Ok((attacker_tr, wielded, _, _, _)) = units.get(ev.entity) else { continue; }; let offhand = swing.hit_info & HITINFO_LEFTSWING != 0; if whiffed(swing.victim_state) { - let (subclass, _) = swing_weapon(wielded, offhand); + let (subclass, _) = swing_weapon(wielded, offhand, mats); let kit = if TWO_HANDED.contains(&subclass) { COMBAT_MISS_2H } else { @@ -535,9 +646,9 @@ fn combat_sounds( if !no_contact(swing) { // The attacker's weapon row (`0x625460(attacker, leftswing)`) — shared by both // blocks below. `None` for a wand/thrown: no melee row, nothing to strike with. - let offhand = swing.hit_info & 0x4 != 0; - let (subclass, wooden) = swing_weapon(attacker.and_then(|(_, w, ..)| w), offhand); - let row = impacts.0.get(subclass, !wooden); + let offhand = swing.hit_info & HITINFO_LEFTSWING != 0; + let (subclass, metal) = swing_weapon(attacker.and_then(|(_, w, ..)| w), offhand, mats); + let row = impacts.0.get(subclass, metal); let defended = defended(swing.victim_state); // `0x6247d0`'s own weapon-sound block, BEFORE the victim dispatch: the `$AHn` digit @@ -549,7 +660,7 @@ fn combat_sounds( if let Some(n) = imp.natural { // The attacker's own natural-weapon sound replaces the generic weapon impact. let vocal = attacker - .and_then(|(_, _, net, _)| net.display_id) + .and_then(|(_, _, net, ..)| net.display_id) .and_then(|d| voices.0.for_display(d)) .and_then(|v| v.custom_attack.get(usize::from(n)).copied()) .unwrap_or(0); @@ -566,13 +677,29 @@ fn combat_sounds( ); } else if !defended { if let Some(row) = row { - // A landed hit: the victim's material slot (players/rowless → flesh). - let material = victim - .and_then(|(_, _, net, _)| net.display_id) - .and_then(|d| voices.0.for_display(d)) - .map(|v| v.impact_type) - .unwrap_or(0); - let kit = landed_impact(row, material, crit); + // A landed hit: the victim's own slot. A PLAYER victim presents its chest + // armor (`0x62fb70`) — the only route to the chain and plate columns, and + // self-only because the inv-slot array is; everything else takes the + // creature column's remap. + let slot = match (victim, mats) { + (Some((_, _, vnet, _, vstore)), Some(mats)) + if vnet.kind == EntityKind::Player => + { + items + .as_mut() + .and_then(|it| { + super::worn_chest_material(vstore, it, &net_commands) + }) + .map_or(impact_slot::FLESH, |m| mats.armor_impact_slot(m) as usize) + } + _ => creature_impact_slot( + victim + .and_then(|(_, _, net, ..)| net.display_id) + .and_then(|d| voices.0.for_display(d)) + .map_or(0, |v| v.impact_type), + ), + }; + let kit = landed_impact(row, slot, crit); play( &mut kits, &mut out, @@ -593,13 +720,19 @@ fn combat_sounds( // victim dispatch" (wow-re `melee-impact-timing.md` §f): a wolf's bite and the // parry clang both sound. Decision 0899 — 0525 wrongly let the natural column // swallow this, so every beast you parried was silent. - if let (true, Some(row)) = (defended, row) { - // The parry family by the *victim's* weapon body (INTERIM heuristic). - let victim_wooden = victim - .map(|(_, w, ..)| swing_weapon(w, false).1) - .unwrap_or(false); - let kit = defense_clang(row, swing.victim_state, victim_wooden); - let at = victim.map(|(t, ..)| t.translation).unwrap_or(pos); + // `0x625400(sel)` on the victim, then `0x457dc0`'s class+material slot pick. No + // defending item means no clang — the reference bails before the play — and that is + // a `None` here rather than a `continue`, because the wound vocal below still has to + // run. (An unarmed parry is silent; it is not a silent hit.) + let defending = defending_item( + victim.and_then(|(_, w, ..)| w), + swing.victim_state == VICTIM_BLOCK, + ); + if let (true, Some(row), Some(item)) = (defended, row, defending) { + let kit = defense_clang(row, Some(item), mats); + // The clang alone is lifted two yards (`0x457e29 fadd [0x801628]`); the generic + // weapon impact next to it passes its position straight through. + let at = victim.map(|(t, ..)| t.translation).unwrap_or(pos) + Vec3::Y * STUB_HEIGHT; play( &mut kits, &mut out, @@ -665,11 +798,11 @@ fn combat_sounds( // live on the victim suppresses its own vocal, classes 0-3 and 8. The CGPlayer twin // `0x62f880` omits the gate, so a player is never suppressed. Filtered off the victim // rather than `continue`d, because everything else in this iteration still stands. - let vocal_victim = victim.filter(|(_, _, net, _)| { + let vocal_victim = victim.filter(|(_, _, net, ..)| { net.kind == EntityKind::Player || !swing.victim.is_some_and(|v| object_sound_playing(&out, v)) }); - if let Some((victim_tr, _, net, victim_is_you)) = vocal_victim { + if let Some((victim_tr, _, net, victim_is_you, _)) = vocal_victim { let crushing = swing.hit_info & HITINFO_CRUSHING != 0; let vocal = net .display_id @@ -853,29 +986,88 @@ mod tests { } /// 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. + /// chain 145/146, plate 147/148, parry-metal 1002, parry-wood 1001, shields 3263/3262. fn sword1h_metal() -> benilla_formats::WeaponImpactRow { let mut impact = [0u32; 10]; let mut crit = [0u32; 10]; impact[impact_slot::FLESH] = 143; crit[impact_slot::FLESH] = 144; + impact[impact_slot::CHAIN] = 145; + crit[impact_slot::CHAIN] = 146; + impact[impact_slot::PLATE] = 147; + crit[impact_slot::PLATE] = 148; impact[impact_slot::STONE] = 3206; impact[impact_slot::SHIELD_METAL] = 3263; crit[impact_slot::SHIELD_METAL] = 3263; + impact[impact_slot::SHIELD_WOOD] = 3262; + crit[impact_slot::SHIELD_WOOD] = 3262; impact[impact_slot::PARRY_METAL] = 1002; impact[impact_slot::PARRY_WOOD] = 1001; benilla_formats::WeaponImpactRow { impact, crit } } - /// The clang picks the parry family by the VICTIM's weapon body, the shield slot for a - /// block — and never crit-tiers (the data carries one kit in both tables). + /// The clang's family follows the **defending item's class**, and metal-vs-wood follows its + /// `Material` — including on a shield, which benilla used to hardcode to metal. Skips + /// without client data (the metal test is `Material.dbc`'s). #[test] - fn defense_clang_picks_parry_by_victim_body_and_shield_for_block() { + fn defense_clang_follows_the_defending_items_class_and_material() { + 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 mats = benilla_formats::load_material_catalog(&mut chain).expect("Material.dbc"); + let m = Some(&mats); let row = sword1h_metal(); - assert_eq!(defense_clang(&row, VICTIM_PARRY, false), 1002); - assert_eq!(defense_clang(&row, VICTIM_PARRY, true), 1001); - assert_eq!(defense_clang(&row, VICTIM_BLOCK, false), 3263); - assert_eq!(defense_clang(&row, VICTIM_BLOCK, true), 3263); + + // Class 2 = the parry pair; 1 is metal, 2 is wood. + assert_eq!(defense_clang(&row, Some((2, 1)), m), 1002, "metal weapon"); + assert_eq!(defense_clang(&row, Some((2, 2)), m), 1001, "wooden weapon"); + // Class 4 = the shield pair — and a wooden shield is NOT the metal kit. + assert_eq!(defense_clang(&row, Some((4, 1)), m), 3263, "metal shield"); + assert_eq!(defense_clang(&row, Some((4, 2)), m), 3262, "wooden shield"); + // Plate and chain shields are metal-flagged; leather and cloth are not. + assert_eq!(defense_clang(&row, Some((4, 6)), m), 3263, "plate shield"); + assert_eq!(defense_clang(&row, Some((4, 8)), m), 3262, "leather shield"); + // A material the wire never resolved reads non-metal, like the reference's id 0. + assert_eq!(defense_clang(&row, Some((4, 0)), m), 3262, "no material"); + } + + /// `0x625400(sel)`: a parry prefers a mainhand **weapon** and otherwise falls through to the + /// offhand; a block goes straight to the offhand. Nothing defending = no clang. + #[test] + fn the_defending_item_is_the_hand_the_outcome_names() { + let sword_and_board = Wielded { + main: Some((2, 7)), + off: Some((4, 6)), + materials: [1, 6, 0], + ..default() + }; + assert_eq!( + defending_item(Some(&sword_and_board), false), + Some((2, 1)), + "a parry takes the mainhand sword" + ); + assert_eq!( + defending_item(Some(&sword_and_board), true), + Some((4, 6)), + "a block takes the offhand shield" + ); + + // A non-weapon mainhand falls through to the offhand on a parry. + let torch_and_board = Wielded { + main: Some((0, 0)), + off: Some((4, 6)), + materials: [0, 6, 0], + ..default() + }; + assert_eq!(defending_item(Some(&torch_and_board), false), Some((4, 6))); + + // Nothing in the offhand, nothing defending — an unarmed parry is silent. + let bare = Wielded::default(); + assert_eq!(defending_item(Some(&bare), false), None); + assert_eq!(defending_item(Some(&bare), true), None); + assert_eq!(defending_item(None, true), None); } /// Parry and block are the defended pair — the outcomes whose sound comes from the victim @@ -897,14 +1089,66 @@ mod tests { } } - /// The landed hit reads the victim's `CreatureImpactType` slot, crit-tiered; an unknown - /// material falls to flesh (players carry no creature row). + /// The landed hit indexes the victim's slot directly, crit-tiered. #[test] - fn landed_impact_reads_material_slot_crit_tiered() { + fn landed_impact_reads_the_victim_slot_crit_tiered() { let row = sword1h_metal(); - assert_eq!(landed_impact(&row, 0, false), 143); - assert_eq!(landed_impact(&row, 0, true), 144); - assert_eq!(landed_impact(&row, 1, false), 3206); - assert_eq!(landed_impact(&row, 99, false), 143, "unknown → flesh"); + assert_eq!(landed_impact(&row, impact_slot::FLESH, false), 143); + assert_eq!(landed_impact(&row, impact_slot::FLESH, true), 144); + assert_eq!(landed_impact(&row, impact_slot::CHAIN, false), 145); + assert_eq!(landed_impact(&row, impact_slot::PLATE, false), 147); + assert_eq!(landed_impact(&row, impact_slot::PLATE, true), 148); + assert_eq!(landed_impact(&row, impact_slot::STONE, false), 3206); + } + + /// The **creature** half of `[vt+0x90]` (`0x6238f0`): the `CreatureSoundData` column through + /// the four-entry remap `{0, 8, 7, 9}`, with everything `>= 4` refused back to flesh. It + /// reaches flesh, stone, wood and ethereal — and can never reach the chain or plate columns, + /// which is what makes the player override the only route to them. + #[test] + fn the_creature_impact_slot_is_the_four_entry_remap() { + assert_eq!(creature_impact_slot(0), impact_slot::FLESH); + assert_eq!(creature_impact_slot(1), impact_slot::STONE); + assert_eq!(creature_impact_slot(2), impact_slot::WOOD); + assert_eq!(creature_impact_slot(3), impact_slot::ETHEREAL); + for refused in [4, 5, 99, u32::MAX] { + assert_eq!( + creature_impact_slot(refused), + impact_slot::FLESH, + "impact type {refused} is past the reference's `>= 4` bail" + ); + } + for t in 0..4 { + let slot = creature_impact_slot(t); + assert_ne!(slot, impact_slot::CHAIN); + assert_ne!(slot, impact_slot::PLATE); + } + } + + /// The **player** half, on the shipped `Material.dbc`: the chest armor's slot. This is the + /// leg that used to send every player victim to flesh — a plate warrior's own hits sounded + /// like a punch on meat. Joined here to the real weapon row so the assertion is the kit you + /// actually hear, not an index. Skips without client data. + #[test] + fn a_players_chest_armor_picks_the_chain_and_plate_columns() { + 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 materials = benilla_formats::load_material_catalog(&mut chain).expect("Material.dbc"); + let impacts = + benilla_formats::load_weapon_impact_catalog(&mut chain).expect("WeaponImpactSounds"); + let sword = impacts.get(7, true).expect("metal 1H sword row"); + + let hit = |material: u32| { + landed_impact(sword, materials.armor_impact_slot(material) as usize, false) + }; + // Real 5875 row 8 (subclass 7, metal): flesh 143, chain 145, plate 147. + assert_eq!(hit(6), 147, "plate chest"); + assert_eq!(hit(5), 145, "chain chest"); + assert_eq!(hit(8), 143, "leather chest rings as flesh"); + assert_eq!(hit(7), 143, "cloth chest rings as flesh"); + assert_eq!(hit(0), 143, "no chest / unstreamed → flesh"); } } diff --git a/crates/benilla-app/src/sound/footsteps.rs b/crates/benilla-app/src/sound/footsteps.rs index 043ae98d..95789501 100644 --- a/crates/benilla-app/src/sound/footsteps.rs +++ b/crates/benilla-app/src/sound/footsteps.rs @@ -61,7 +61,7 @@ use bevy::prelude::*; use benilla_assets::coords::bevy_to_wow; -use benilla_formats::{FootstepCatalog, MaterialCatalog}; +use benilla_formats::FootstepCatalog; use crate::creature_anim::{is_footstep_sound, move_flags, AnimSoundEvent, MovementState}; use crate::entities::{CollisionHeight, Creatures}; @@ -81,35 +81,12 @@ use super::{AudioListener, SoundConfig, SoundOutput}; #[derive(Resource)] pub(crate) struct Footsteps(pub(crate) FootstepCatalog); -/// `Material.dbc` — the foley half of a footfall (`0x4584e0`'s table). -#[derive(Resource)] -pub(crate) struct Materials(pub(crate) MaterialCatalog); - -fn load_materials(mut commands: Commands, assets: Option>) { - let Some(assets) = assets else { return }; - let loaded = { - let mut chain = assets.chain.lock_recover(); - benilla_formats::load_material_catalog(&mut chain) - }; - match loaded { - Ok(cat) => { - info!("sound: {} material rows", cat.len()); - commands.insert_resource(Materials(cat)); - } - Err(e) => warn!("sound: materials failed to load: {e:#}"), - } -} - /// **The foley's Z offset** — `0x45851d fadd [0x801628]`, a flat `2.0` added to the emitter's Z /// before the play. The rustle comes from the body, not the boots, and the reference lifts it by /// a fixed two yards rather than anything model-derived. WoW's Z is Bevy's Y at the same scale /// (`benilla_assets::coords`), so this adds to `translation.y`. const FOLEY_HEIGHT: f32 = 2.0; -/// `EQUIPMENT_SLOT_CHEST` — index 4 of the player's inv-slot array, the one the reference's -/// player foley reads (`0x62fa50`'s `[ptr+0x20]`, i.e. the fifth 8-byte guid). -const EQUIPMENT_SLOT_CHEST: u8 = 4; - fn load_footsteps(mut commands: Commands, assets: Option>) { let Some(assets) = assets else { return }; let loaded = { @@ -142,7 +119,7 @@ fn footstep_sounds( // 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>, + materials: Option>, creatures: Option>, mut items: Option>, net_commands: Res, @@ -192,14 +169,7 @@ fn footstep_sounds( // 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) - } + EntityKind::Player => super::worn_chest_material(store, it, &net_commands), _ => net .display_id .and_then(|d| creatures.as_deref()?.foley_material(d)), @@ -287,9 +257,6 @@ fn footstep_sounds( /// Registration hook for [`super::SoundPlugin`]. pub(super) fn plugin(app: &mut App) { - app.add_systems( - Startup, - (load_footsteps, load_materials).after(AssetSet::Open), - ) - .add_systems(Update, footstep_sounds.in_set(WorldStage::Present)); + app.add_systems(Startup, load_footsteps.after(AssetSet::Open)) + .add_systems(Update, footstep_sounds.in_set(WorldStage::Present)); } diff --git a/crates/benilla-app/src/sound/mod.rs b/crates/benilla-app/src/sound/mod.rs index de690854..cc2eba3e 100644 --- a/crates/benilla-app/src/sound/mod.rs +++ b/crates/benilla-app/src/sound/mod.rs @@ -228,6 +228,57 @@ impl SoundOutput { /// *facing* about world-up (`Quat::from_rotation_y(face_yaw)`), so panning tracks where the body /// faces, NOT where the camera looks. The camera eye + basis are the fallback (the client's /// `=0` path): pre-login, in free-fly (`detached`), or before the body attaches. +/// `Material.dbc` — a shared sound fact with two consumers: the armor foley off `$FSD` +/// ([`footsteps`]) and, through the same table's `Flags` column, both the metal/wood split of +/// every weapon impact and the armor slot a player victim presents ([`combat`]). Loaded once +/// here rather than in either, because a second loader over one DBC is how a schema drifts. +#[derive(Resource)] +pub(crate) struct Materials(pub(crate) benilla_formats::MaterialCatalog); + +fn load_materials(mut commands: Commands, assets: Option>) { + use benilla_assets::LockRecover; + 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 material of the body you are wearing** — the chest item's `Material` id, or `None`. +/// +/// Two sounds ask this, and both ask it the reference's way, through `[player+0x1d38]` element 4 +/// (`EQUIPMENT_SLOT_CHEST`): the armor foley ([`footsteps`], `0x62fa30`) and the impact slot a +/// player victim presents ([`combat`], `0x62fb70`). Shared rather than written twice, because +/// they are one question with one answer and the reach is the subtle part. +/// +/// **That reach is self-only, and not by our choice.** The array's count is written 113 when the +/// object's guid matches the local player's and 0 otherwise (`0x5dd454`), so in the reference no +/// other player has a chest material at all. benilla lands there for free: `PLAYER_FIELD_INV_SLOT_*` +/// is a private descriptor field the server sends only to you, so `player_inv_slot` returns +/// `None` for everyone else. Callers therefore need no "is this me" test of their own — but they +/// must ask it of a store that IS the player's. +/// +/// `None` covers every one of the reference's own misses: no store, an empty chest, the item +/// object not streamed, and a template still in flight (asked once, answered next frame). +pub(super) fn worn_chest_material( + store: Option<&crate::net::ObjectStore>, + items: &mut crate::items::Items, + net: &crate::net::NetCommands, +) -> Option { + /// Index 4 of the inv-slot array — `0x62fa50`/`0x62fb86` read the fifth 8-byte guid. + const EQUIPMENT_SLOT_CHEST: u8 = 4; + let guid = store?.0.player_inv_slot(EQUIPMENT_SLOT_CHEST)?; + let entry = items.object(guid)?.object_entry()?; + Some(items.held(entry, net)?.material) +} + #[derive(Resource)] pub(crate) struct AudioListener { pub(crate) pos: Vec3, @@ -313,6 +364,12 @@ impl Plugin for SoundPlugin { .init_resource::() .init_resource::() .add_systems(Startup, hal_overload::setup) + // `Material.dbc` — shared by the foley and the melee impact, so it loads here rather + // than inside either consumer. + .add_systems( + Startup, + load_materials.after(benilla_assets::AssetSet::Open), + ) // The cover's audio hold (see [`SoundConfig::world_hold`]): fed in PreUpdate so every // trigger system this frame — whatever stage it runs in — reads one answer. .add_systems(PreUpdate, feed_world_hold) diff --git a/crates/benilla-formats/src/creatures.rs b/crates/benilla-formats/src/creatures.rs index 5349a534..4d79cc3f 100644 --- a/crates/benilla-formats/src/creatures.rs +++ b/crates/benilla-formats/src/creatures.rs @@ -843,4 +843,69 @@ mod tests { "a night elf is over twice a gnome ({nelf} vs {gnome}) — the whole point of the plumb" ); } + + /// **Why the collision-prism FLOOR is invisible on shipped data** — and therefore why its + /// absence hid until a server override went looking for it (B311's triage, decision 1568). + /// + /// The real client's prism height is `CollisionHeight × max(SCALE_X, CreatureDisplayInfo.scale)` + /// (`0x60b312` → `0x617501`). vmangos folds `modelScale × displayScale` into `SCALE_X`, so the + /// floor can only bite where `modelScale < 1` would drag the product under the display column — + /// and **no shipped row scales below 1.0**. So on stock data `max` always picks `SCALE_X`, our + /// old `× SCALE_X` was bit-identical, and only a `creature_template.display_scale` override or + /// a shrink aura can separate the two. + #[test] + fn no_shipped_model_scales_below_one_so_the_prism_floor_is_inert_at_rest() { + 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("load creature catalog"); + + let under: Vec<_> = cat + .models + .iter() + .filter(|(_, m)| m.scale < 1.0) + .map(|(id, m)| (*id, m.scale)) + .collect(); + assert!( + under.is_empty(), + "a sub-1 modelScale would make the floor bite at rest: {under:?}" + ); + // Not vacuous: the column is really read, and really varies. + assert!( + cat.models.values().any(|m| m.scale > 1.0), + "some row scales above 1.0, or this is asserting on a zeroed column" + ); + } + + /// **The Shore Strider, pinned** (B311, decision 1568). The reported giant's own chain and + /// numbers, recorded so nobody re-suspects the height: display 4945 → `CreatureModelData` 35, + /// `Creature\SeaGiant\SeaGiant.mdx`, column 2.083 over a display scale of 1.75 and a + /// `modelScale` of 1.0. Its prism is `2.083 × 1.75 = 3.645` yd under **both** the old + /// `× SCALE_X` reading and the corrected `× max(SCALE_X, displayScale)` — identical to the + /// float — so the height was never why it glided. The cause was the missing + /// `UNIT_FIELD_FLAGS` enter gate; this row is the control that says so. + #[test] + fn the_shore_strider_prism_is_the_same_under_both_readings() { + 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("load creature catalog"); + + let m = cat.model(4945).expect("display 4945 resolves"); + assert_eq!(m.model_path, "Creature\\SeaGiant\\SeaGiant.mdx"); + let column = cat.collision_height(4945).expect("collision height"); + let display_scale = cat.display_scale(4945).expect("display scale"); + assert!((column - 2.083).abs() < 5e-4, "column is {column}"); + assert!( + (display_scale - 1.75).abs() < 5e-4, + "CreatureDisplayInfo.scale is {display_scale}" + ); + // vmangos ships `creature_template.display_scale = 0` for entry 5359, so SCALE_X is the + // folded `modelScale × displayScale` = 1.0 × 1.75. + let scale_x = m.scale; + assert!((scale_x - 1.75).abs() < 5e-4, "folded SCALE_X is {scale_x}"); + assert_eq!( + column * scale_x, + column * scale_x.max(display_scale), + "the floor is inert on this row — the height was not the bug" + ); + } } diff --git a/crates/benilla-formats/src/material.rs b/crates/benilla-formats/src/material.rs index 282e3a8d..ecc1e81d 100644 --- a/crates/benilla-formats/src/material.rs +++ b/crates/benilla-formats/src/material.rs @@ -31,6 +31,11 @@ use crate::Chain; /// `Material.dbc` keyed by id. pub struct MaterialCatalog { foley: HashMap, + /// The `Flags` column — the *other* half of what this three-column table is for. It decides + /// the metal/wood split of every weapon impact ([`MaterialCatalog::is_metal`]) and, on a + /// player's chest, which armor slot the victim presents + /// ([`MaterialCatalog::armor_impact_slot`]). + flags: HashMap, } impl MaterialCatalog { @@ -42,6 +47,38 @@ impl MaterialCatalog { self.foley.get(&material).copied().filter(|&k| k != 0) } + /// Is this material **metal-bodied** — `Flags & 0x1`, the reference's `0x5d9a50`. + /// + /// It is the only thing that picks the metal vs wood half of `WeaponImpactSounds` (both the + /// weapon's own impact row and the victim's parry slot: `0x457e80` computes it, `0x457dc0` + /// asks it twice). The flag is set on metal (1), jewelry (4), chain (5) and plate (6) — so + /// it is genuinely "is this thing metal", not "is this thing not wood": leather, cloth and + /// liquid all read as non-metal, and so does an **unknown or absent material**, which is the + /// reference's answer for id 0 (no row) and the reason a guess of `material != WOOD` gets a + /// materialless creature weapon wrong. + pub fn is_metal(&self, material: u32) -> bool { + self.flags.get(&material).is_some_and(|f| f & 0x1 != 0) + } + + /// The `WeaponImpactSounds` **target slot** a body wearing this material presents — + /// `0x62fb70`, the CGPlayer override of the victim's impact type, read off the same `Flags` + /// column: bit 1 → 2 (plate), else bit 2 → 1 (chain), else 0 (flesh). + /// + /// On the shipped table that is exactly plate → plate and chain → chain, with **leather and + /// cloth landing on flesh** — armor that does not ring. Creatures do not come through here: + /// their slot is a `CreatureSoundData` column remapped through `{0, 8, 7, 9}` (`0x6238f0`), + /// i.e. flesh/stone/wood/ethereal, a disjoint half of the same ten. + pub fn armor_impact_slot(&self, material: u32) -> u32 { + let Some(&flags) = self.flags.get(&material) else { + return 0; + }; + if flags & 0x2 != 0 { + 2 + } else { + (flags & 0x4) >> 2 + } + } + pub fn len(&self) -> usize { self.foley.len() } @@ -66,12 +103,14 @@ pub fn load_material_catalog(chain: &mut Chain) -> Result { .context("reading Material.dbc")?; let rs = parse(&bytes, schema(), "Material")?; let mut foley = HashMap::with_capacity(rs.records().len()); + let mut flags = 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)); + flags.insert(id, u32_at(r, 1).unwrap_or(0)); } } - Ok(MaterialCatalog { foley }) + Ok(MaterialCatalog { foley, flags }) } #[cfg(test)] @@ -102,4 +141,31 @@ mod tests { assert_eq!(cat.foley_kit(0), None); assert_eq!(cat.foley_kit(9), None); } + + /// The `Flags` column's two consumers, on the shipped table. **`is_metal` is not + /// `!= wood`**: leather and cloth are non-metal too, and so is a missing material — the case + /// a creature with no virtual-item info actually hits. + #[test] + fn the_flags_column_splits_metal_and_names_the_armor_slot() { + 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"); + + for metal in [1, 4, 5, 6] { + assert!(cat.is_metal(metal), "material {metal} is metal-bodied"); + } + for soft in [2, 3, 7, 8] { + assert!(!cat.is_metal(soft), "material {soft} is not metal"); + } + assert!(!cat.is_metal(0), "an absent material is not metal"); + assert!(!cat.is_metal(99), "an unknown material is not metal"); + + // The armor slot a chest presents: plate rings as plate, chain as chain, everything + // else — leather and cloth included — as flesh. + assert_eq!(cat.armor_impact_slot(6), 2, "plate"); + assert_eq!(cat.armor_impact_slot(5), 1, "chain"); + for flesh in [0, 1, 2, 3, 4, 7, 8, 99] { + assert_eq!(cat.armor_impact_slot(flesh), 0, "material {flesh}"); + } + } }