plate armor rings like plate, and sea giants walk the seabed
- a plate-wearing player's incoming hits stop sounding like a punch on meat: the victim's impact column comes from the chest armor's material (plate, chain, else flesh), the clang follows the defending item's class and material (a wooden shield rings wood, an unarmed parry is silent), and Material.dbc's Flags column gets one home instead of three interim readings - a creature swims only if the server said it may: the reference tests three unit flags before starting a swim, and a Shore Strider carries none, so it wades chest-deep with its legs working instead of gliding — the collision height the triage suspected was right to the float; the height also takes the reference's max() floor over the display scale - a second nudge of the rotate arrow inside a half-run blend no longer stacks fades: the client refuses the snapshot past the halfway mark and lets the older pose finish on its own window Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<u32>,
|
||||
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>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Transform>, Without<SwimEvaluated>)>;
|
||||
/// 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<ObjectStore>` 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<Transform>,
|
||||
Changed<ObjectStore>,
|
||||
Without<SwimEvaluated>,
|
||||
)>;
|
||||
|
||||
type SwimMarkQuery = (
|
||||
Entity,
|
||||
&'static NetEntity,
|
||||
&'static Transform,
|
||||
Option<&'static CollisionHeight>,
|
||||
Option<&'static ObjectStore>,
|
||||
Has<CreatureSwimming>,
|
||||
Has<SwimEvaluated>,
|
||||
);
|
||||
@@ -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::<SwimEvaluated>();
|
||||
}
|
||||
}
|
||||
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<ObjectStore>` 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<ObjectStore>` 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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**,
|
||||
|
||||
@@ -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<Res<WorldAssets>>)
|
||||
#[derive(Default)]
|
||||
struct LastSwing(EntityHashMap<SwingMessage>);
|
||||
|
||||
/// 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<Embodied>,
|
||||
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<Res<'w, WeaponImpacts>>,
|
||||
swing_sounds: Option<Res<'w, WeaponSwings>>,
|
||||
materials: Option<Res<'w, super::Materials>>,
|
||||
/// 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<'w, crate::ui_items::ItemSubClasses>>,
|
||||
voices: Option<Res<'w, CreatureVoices>>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn combat_sounds(
|
||||
mut swings: MessageReader<SwingMessage>,
|
||||
mut contacts: MessageReader<SwingImpact>,
|
||||
mut events: MessageReader<AnimSoundEvent>,
|
||||
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>>,
|
||||
units: Query<CombatUnit>,
|
||||
tables: MeleeTables,
|
||||
mut items: Option<ResMut<crate::items::Items>>,
|
||||
net_commands: Res<crate::net::NetCommands>,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
mut out: NonSendMut<SoundOutput>,
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 = {
|
||||
@@ -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<Res<Materials>>,
|
||||
materials: Option<Res<super::Materials>>,
|
||||
creatures: Option<Res<Creatures>>,
|
||||
mut items: Option<ResMut<Items>>,
|
||||
net_commands: Res<NetCommands>,
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<Res<benilla_assets::WorldAssets>>) {
|
||||
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<u32> {
|
||||
/// 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::<SoundConfig>()
|
||||
.init_resource::<AudioListener>()
|
||||
.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)
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@ use crate::Chain;
|
||||
/// `Material.dbc` keyed by id.
|
||||
pub struct MaterialCatalog {
|
||||
foley: HashMap<u32, u32>,
|
||||
/// 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<u32, u32>,
|
||||
}
|
||||
|
||||
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<MaterialCatalog> {
|
||||
.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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user