a rider cannot loot from the saddle, and other swimmers tilt

- a rider does not loot: a corpse was looted from horseback, and the client-side mounted gate built for casts and attack-start had left interaction out of scope — `0x60bf98` reads the PLAYER's mount field and skips the loot leg into skin, a silent fall-through rather than a refusal; the stand-state gate (`ERR_LOOT_NOTSTANDING`), player bones, skin/insignia/key-turn (all through TryCast's 0x39 block, asked after the reagent check) and GameObjects (gated only when NO `Lock.dbc` row exists — nodes and herbs refuse loudly via their cast, signs and chairs in silence, the mailbox exempt) follow the RE; and the world right-click shows no attack-refusal text under any condition, since `0x5ecb70`'s extent holds no `DisplayError` at all — a correction to the earlier record; the cursor is untouched, so the pouch still lights from the saddle
- 595 creature displays were dropping their blood spurt: the `UnitBloodLevels` row resolve has a THIRD tier — display `BloodLevel`, else model `BloodID`, else the records base (`0x60b117`'s branchless `setle/dec/and`: file row 0, RED) — and `BloodID == -1` (122 of 430 shipped models: crocolisks, quilboar, gnolls, murlocs, pigs, deer) is a tier-2 MISS, not a bloodless marker; replayed over the shipped tables here and in the wow-re round with the same numbers (36 · 9903 · 595 of 10534 displays); the ground-splat textures are read (four per row) and the RE verdict on their trigger is dead code — `"BloodSplat"` occurs nowhere in the image, though all twelve `.blp` ship
- other swimmers tilt on arrival: `SMSG_UPDATE_OBJECT`'s LIVING block carried the mover's live flags and swim pitch and benilla threw both away (`let _pitch = read_f32_le(r)?;` — the director's own citation, exact), so a player who swam into view rendered LEVEL until their next relay packet, and an idle floater — who sends none while unmoving — stayed level as long as they floated; the create apply `0x619320` merges the block's flags under the same mask a relayed move uses and copies the pitch unconditionally, before the model is even built; one render law (`swim_body_rotation`) now serves both pose owners, the relay trace prints the pitch beside a guid that joins, and `WOW_PROBE_PITCH` can aim a scripted swimmer so a two-client probe of the lane can be written at last

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:07:48 +07:00
co-authored by Claude Fable 5.1
parent 6d34bb4ecc
commit 6ee4adc95d
27 changed files with 1225 additions and 160 deletions
+6 -3
View File
@@ -140,15 +140,18 @@ use scenarios::GlueScreen;
use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS};
pub(crate) mod fxview;
// The two scripted probe drivers, which lived in `player/` until decision 1174: they turn the
// avatar's aim and park the camera rig, and both order themselves BEFORE `player::control`. An
// instrument may name the gameplay system it runs against; gameplay may not name the instrument.
// The three scripted probe drivers, which lived in `player/` until decision 1174: they turn the
// avatar's aim, aim its swim pitch, and park the camera rig, and all three order themselves BEFORE
// `player::control`. An instrument may name the gameplay system it runs against; gameplay may not
// name the instrument.
mod probe_cam;
mod probe_look;
mod probe_pitch;
pub(crate) mod waterfx;
pub(crate) use probe_cam::ProbeCamPlugin;
pub(crate) use probe_look::ProbeLookPlugin;
pub(crate) use probe_pitch::ProbePitchPlugin;
/// Which screen a capture starts the client on — the dev arm of [`crate::run_mode::start_state`],
/// which is what `main` actually calls. A glue capture boots onto the screen it photographs; any
@@ -0,0 +1,132 @@
//! `WOW_PROBE_PITCH` — the scripted **dive**: aim a swimming avatar's nose up or down without a
//! human on the mouse. The twin of [`super::probe_look`]'s scripted turn, and it closes the same
//! shape of gap.
//!
//! The swim body pitch has exactly one writer in gameplay — the mouse-look push in
//! `player::controller`, gated on `mouselook` (a held right button or MOVEANDSTEER) *and* a moving
//! OS cursor inside the viewport. An unfocused probe window has neither. So the whole observed-swim
//! lane — the wire's pitch tail, the dead-reckon's pitched travel basis, and the body-pitch render
//! law (decision 0464 TU-A) — was **unreachable from any script**: a benilla client could be made
//! to swim, but only ever level. The tilt shipped in July 2026 with no test, no trace field and no
//! way to drive it, which is why "do other swimmers tilt?" had no answer short of the director's
//! eye, and why a two-client probe of it could not be written at all.
//!
//! Format: `WOW_PROBE_PITCH="<deg>@<start_s>[:<deg_per_sec>][;…]"` — `deg` is nose-**up** positive,
//! matching `Player::mover_pitch` and the wire's own sign. `"-30@22;30@40"` aims 30° down at
//! twenty-two seconds and 30° up at forty; `"-45@20:9"` starts level-ish and sweeps at 9°/s from
//! twenty seconds on, which is what a slow real dive looks like on the wire. The last entry whose
//! start has passed wins, and it is re-asserted **every frame** — the reference holds an unsteered
//! pitch rather than levelling it (TU-B(c)), so a one-shot write would be indistinguishable from a
//! held one; re-asserting is what makes the two separable when only one of them is under test.
//!
//! It writes [`Player::aim_pitch`] — the field the reference's `SetPitch` (`0x7c6f70`) writes,
//! under the same ±89° clamp — rather than faking mouse motion, for [`super::probe_look`]'s reason
//! exactly: the camera plumbing is not what is under test, and its `cursor_in_viewport` gate is
//! unreliable for a window nobody is looking at.
use bevy::prelude::*;
use crate::player::Player;
use benilla_world::schedule::WorldStage;
use super::ProbeClock;
/// One scripted aim: the target pitch (radians, +up), when it takes effect, and an optional sweep
/// rate (rad/s) applied from that moment — **wall-clock seconds** ([`ProbeClock`], decision 0789),
/// for [`super::probe_look`]'s reason: a dive scripted to reach 30° by second 25 has to reach it at
/// second 25 whatever the frame rate did, and the wire tail it feeds is paced on the real clock.
struct Aim {
pitch: f32,
at: f32,
sweep: f32,
}
#[derive(Resource)]
pub(crate) struct ProbePitch {
aims: Vec<Aim>,
}
/// Parse the env script. Absent or unparseable entries yield no aims (with a warning), so the probe
/// is inert unless asked for.
pub(crate) fn from_env() -> Option<ProbePitch> {
let spec = std::env::var("WOW_PROBE_PITCH").ok()?;
let aims: Vec<Aim> = spec
.split(';')
.map(str::trim)
.filter(|s| !s.is_empty())
.filter_map(|s| {
let (deg, rest) = s.split_once('@')?;
let (at, sweep) = match rest.split_once(':') {
Some((at, sweep)) => (at, sweep.trim().parse::<f32>().ok()?),
None => (rest, 0.0),
};
match (deg.trim().parse::<f32>(), at.trim().parse::<f32>()) {
(Ok(deg), Ok(at)) => Some(Aim {
pitch: deg.to_radians(),
at,
sweep: sweep.to_radians(),
}),
_ => {
warn!("probe-pitch: unparseable aim {s:?} (want e.g. -30@22:9) — skipped");
None
}
}
})
.collect();
(!aims.is_empty()).then_some(ProbePitch { aims })
}
/// Hold the aim of the latest entry whose start has passed. Runs in `WorldStage::Input` **before**
/// the controller, so the swim frame that reads the pitch is the frame that renders and streams it.
pub(crate) fn drive_probe_pitch(
probe: Res<ProbePitch>,
time: ProbeClock,
mut player: ResMut<Player>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
mut announced: Local<Option<f32>>,
) {
if self_player.is_empty() {
return; // in-world only, like the other probes
}
let now = time.elapsed_secs();
// The LAST armed entry wins, so a script reads as a timeline rather than a sum — a dive
// followed by a climb is two entries, not an arithmetic accident.
let Some(aim) = probe
.aims
.iter()
.filter(|a| now >= a.at)
.max_by(|a, b| a.at.total_cmp(&b.at))
else {
return;
};
// The STORED value, not the requested one: `aim_pitch` clamps at ±89°, and a sweep that runs
// past it must not log a number the body never held.
let held = player.aim_pitch(aim.pitch + aim.sweep * (now - aim.at));
// One line per whole degree crossed: enough to read the timeline back out of the log, few
// enough not to drown it. The observer's own reading is the point of comparison.
let whole = held.to_degrees().round();
if announced.replace(whole) != Some(whole) {
info!("probe-pitch: aiming {whole:+.0}° at t={now:.1}s");
}
}
/// `WOW_PROBE_PITCH`'s registration — the same shape (and the same ordering rationale) as
/// [`super::probe_look`]'s: inert with no resource and no systems when the script parses to
/// nothing, and ordered before [`crate::player::PlayerControlSet`] so the controller knows nothing
/// about it (decision 1174).
pub(crate) struct ProbePitchPlugin;
impl Plugin for ProbePitchPlugin {
fn build(&self, app: &mut App) {
let Some(pitch) = from_env() else {
return;
};
app.insert_resource(pitch).add_systems(
Update,
drive_probe_pitch
.in_set(WorldStage::Input)
.before(crate::player::PlayerControlSet)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
);
}
}
+3 -1
View File
@@ -46,7 +46,9 @@ use benilla_world::schedule::WorldStage;
/// picks, playback-rate math) — kept in its own file as it carries the bulk of the unit-tested selector
/// logic, separate from the Bevy driver systems in [`driver`].
pub(crate) mod select;
pub(crate) use select::{ease_strafe_yaw, move_flags, strafe_body_offset, MovementState};
pub(crate) use select::{
ease_strafe_yaw, move_flags, strafe_body_offset, swim_body_rotation, MovementState,
};
use select::{Mode, Special};
/// The display-facing counter-twist (the strafe body pose): the [`BodyTwist`] component + the
+13 -5
View File
@@ -51,10 +51,11 @@ pub(super) fn load_blood_tables(mut commands: Commands, assets: Option<Res<World
/// Spawn the spurt per landed swing — at the swing clip's **impact keyframe** ([`SwingImpact`],
/// not the raw packet: the blood flies when the blow lands, ~300–600 ms into the swing). The
/// client's gate verbatim — `HitInfo & 0x2`, nonzero damage, victimState ∈ {1, 4} — then the DBC
/// chain (the victim display's blood id → the violence-leveled UnitBloodLevels row → UnitBlood's
/// front/back × small/large effect), attached self-terminating on the victim. A missing link
/// anywhere (bloodless model, censored level, unresolved attacker) drops the spurt — like the
/// client's NULL-record skips, but *audibly*: every dropped damaging swing logs its reason at
/// chain (the victim display's two blood candidates → [`BloodCatalog::level_key`]'s three-tier
/// row resolve → the violence-leveled UnitBloodLevels row → UnitBlood's front/back × small/large
/// effect), attached self-terminating on the victim. A missing link anywhere (an unknown display,
/// a censored violence level) drops the spurt — like the client's NULL-record skips, but
/// *audibly*: every dropped damaging swing logs its reason at
/// `info` and every fired spurt at `debug`, so "I never see blood" localizes to a link in one
/// fight instead of a code audit (no drop lines at all ⇒ the break is upstream, in the
/// [`SwingImpact`] feed itself).
@@ -98,10 +99,17 @@ pub(super) fn blood_spurts(
info!("blood: dropped — victim carries no net display id");
continue;
};
let Some(blood_id) = creatures.blood(display_id) else {
let Some((disp_blood, model_blood)) = creatures.blood_candidates(display_id) else {
info!("blood: dropped — display {display_id} unknown to the creature catalog");
continue;
};
// The reference's three-tier row resolve (`0x60afb0`), including the tier-3 records-base
// fallback that 595 of the 10534 shipped displays land on — see `level_key`.
let Some(blood_id) = blood.0.level_key(disp_blood, model_blood) else {
info!("blood: dropped — UnitBloodLevels is empty");
continue;
};
let blood_id = blood_id as i32;
// Front or back: the client's `sign(victimForward · (attackerPos − victimPos))`, in WoW
// space — a unit Transform's Y rotation *is* its WoW yaw (net/motion's pose convention),
// so forward = (cos θ, sin θ) against the WoW-mapped position delta. An unresolvable
@@ -278,6 +278,46 @@ pub(crate) fn strafe_body_offset(flags: u32) -> f32 {
}
}
/// The **swim body pitch** — the one render law, for every mover that has a reported pitch
/// (TU-A, decision 0464 §1; `0x60a110`→`0x710620`, the Euler-ZYX builder).
///
/// The reference composes `T·S·Rz(facing)·Ry(2π − pitch)`: yaw about WoW +Z, pitch about WoW +Y
/// from `CMovement+0x20`, +pitch = nose up. That is this expression, worked through the basis
/// rather than assumed — WoW +Z → Bevy +Y (so `Rz(facing)` is `Quat::from_rotation_y`, the mapping
/// `net::motion::wire_yaw` already rests on, no 180° offset) and WoW +Y → Bevy **−X**
/// (`coords::wow_bevy_basis_is_golden`), so `Ry(2π − pitch)` = `Ry(−pitch)` about −X is `+pitch`
/// about Bevy +X. Same multiplication order, so the pitch stays body-local; and `Rx(+θ)` carries
/// Bevy forward (−Z) to `(0, sin θ, −cos θ)`, i.e. up — nose-up positive on both sides.
///
/// The facing it reads is `+0xc94`, the **render** facing rather than the raw movement one
/// (`+0x1c`); for a swimmer the two coincide, because SWIMMING is on the display-facing SNAP list
/// (`mov [esi+0xc94],[esi+0xc98]`, wow-re `body-facing-pipeline.md`) — the same gate our callers
/// apply before handing a yaw in.
///
/// The gate is `SWIMMING` **and** a forward/back bit: strafe-only, idle, and grounded all render
/// **level** (the ground path). It is `CMovement+0x40 & 3`, read at `0x60857d` off the very word
/// the create-block apply and every relayed `MSG_MOVE_*` merge into under `0x75a07dff` — **not**
/// a `CM2Model+0x3c & 3` dirty gate, which is what an earlier reading of this law named and which
/// a wow-re §5 has since scanned for exhaustively and found does not exist (`0x710620` is an
/// unconditional 16-dword copy). Recorded in wow-re
/// `system/collision/scratch/create-block-swim-pitch.md`.
///
/// One function rather than one per pose owner: the law has three consumers — our own avatar
/// ([`crate::player::body_pose`]), an observed relay mover
/// ([`crate::net::motion::extrapolate_remote_units`]), and the create-block seed
/// ([`crate::net::apply::objects`]) — and TU-A is explicit that all of them present the *same*
/// pitch. Held apart, they drift: the remote lane shipped in July 2026 with no test and no trace
/// of its own, so "does an observed swimmer tilt?" had no answer short of the director's eye.
pub(crate) fn swim_body_rotation(yaw: f32, flags: u32, pitch: f32) -> Quat {
if flags & move_flags::SWIMMING != 0
&& flags & (move_flags::FORWARD | move_flags::BACKWARD) != 0
{
Quat::from_rotation_y(yaw) * Quat::from_rotation_x(pitch)
} else {
Quat::from_rotation_y(yaw)
}
}
/// The per-frame movement descriptor that drives a unit's animation (wow-5875-re RF-0057). The player
/// controller fills it for our avatar from input; a streamed unit's is derived from its [`Spline`].
#[derive(Component, Clone, Copy, Default)]
+9 -3
View File
@@ -114,6 +114,10 @@ impl Plugin for DevProbesPlugin {
"WOW_PROBE_HOVER",
"WOW_PROBE_KEY",
"WOW_PROBE_LUA",
// The swim-pitch aim is a wall-clock timeline like every other probe schedule, and
// it is the one script that can be a run's *only* actuator (a drifting swimmer
// needs no keys) — so it arms the occlusion defence on its own.
"WOW_PROBE_PITCH",
"WOW_PROBE_CHEST",
"WOW_PROBE_CLAM",
"WOW_RIG",
@@ -374,11 +378,13 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_LIVE_FPS").is_ok() {
app.add_plugins(crate::capture::LiveFpsPlugin);
}
// The two scripted probe drivers that lived in `player/` until decision 1174 — the mouse-turn
// (`WOW_PROBE_LOOK`, decision 0621) and the camera park (`WOW_PROBE_CAM`, decision 0653). Added
// The scripted probe drivers that live beside `capture` rather than in `player/`
// (decision 1174) — the mouse-turn (`WOW_PROBE_LOOK`, decision 0621), the swim-pitch aim
// (`WOW_PROBE_PITCH`) and the camera park (`WOW_PROBE_CAM`, decision 0653). Added
// unconditionally because each plugin's own `from_env` is its gate, so the variable's name is
// spelled in exactly one place; both order themselves before `player::PlayerControlSet`.
// spelled in exactly one place; all three order themselves before `player::PlayerControlSet`.
app.add_plugins(crate::capture::ProbeLookPlugin);
app.add_plugins(crate::capture::ProbePitchPlugin);
app.add_plugins(crate::capture::ProbeCamPlugin);
// The FPS journal: `WOW_FPS_JOURNAL=<csv>` appends per-second position + frame-time rows on a
// director-driven run — "where does it dip" as coordinates (see `perf::FpsJournalPlugin`).
+9 -4
View File
@@ -329,10 +329,15 @@ impl Creatures {
self.catalog.model_scale(display_id)
}
/// A display's resolved blood id (decision 0137 phase 3 — see [`CreatureModel::blood`]):
/// the UnitBloodLevels key the melee spurt chain starts from; `None` = unknown display.
pub(crate) fn blood(&self, display_id: u32) -> Option<i32> {
self.catalog.model(display_id).map(|m| m.blood)
/// A display's two **blood-row candidates** — `CreatureDisplayInfo.BloodLevel` and
/// `CreatureModelData.BloodID`, tiers 1 and 2 of the reference's UnitBloodLevels resolve.
/// `None` = unknown display. The tiers themselves need the table, so they live in
/// [`benilla_formats::BloodCatalog::level_key`] — which is where tier 3 is, and 1850's whole
/// point is that tier 3 is not optional.
pub(crate) fn blood_candidates(&self, display_id: u32) -> Option<(i32, i32)> {
self.catalog
.model(display_id)
.map(|m| (m.blood_display, m.blood_model))
}
/// A built display's booth framing — the model's own **authored cameras**, which is what both
+2
View File
@@ -603,6 +603,7 @@ pub(super) fn apply_net_updates(
orientation,
scale,
speeds,
mover,
transport_progress,
transport,
spline,
@@ -617,6 +618,7 @@ pub(super) fn apply_net_updates(
orientation,
scale,
speeds,
mover,
transport_progress,
transport,
spline,
+18 -1
View File
@@ -72,6 +72,7 @@ pub(super) fn object_create(
orientation: f32,
scale: f32,
speeds: Option<MoveSpeeds>,
mover: Option<benilla_protocol::MoverState>,
transport_progress: Option<u32>,
transport: Option<benilla_protocol::TransportPose>,
spline: Option<benilla_protocol::CreateSpline>,
@@ -127,9 +128,25 @@ pub(super) fn object_create(
// Where this object is *pointed*. A mover carries one yaw; a GameObject is placed by its
// `GAMEOBJECT_ROTATION` quaternion, which is the reference's own placement input and is a
// strictly wider answer than the facing (decision 1459, `motion::gameobject_rotation`).
//
// **A mover's create pose is not a bare yaw.** The reference's create-block apply seeds both
// halves of the body-pitch law from this very block — the flags word through `0x618c30`'s
// `0x75a07dff` merge, the pitch through the pose commit `0x7c6420`'s unconditional
// `fst [ecx+0x20]` — and it does so *before* `0x613e10` builds the model or registers the
// per-frame render callback that reads them, so the tilt is in force on the unit's **first
// drawn frame** (byte-VERIFIED; wow-5875-re
// `system/collision/scratch/create-block-swim-pitch.md`). A player who swims into view
// nose-down renders nose-down, not level: [`crate::creature_anim::swim_body_rotation`], the
// same one law our own avatar and the relay extrapolator call. Without the block's flags +
// pitch — which we parsed and threw away until now — every observed swimmer entered the world
// flat, and an *idle* floater (who sends no packets at all) stayed flat for as long as they
// floated.
let placement = match kind {
EntityKind::GameObject => gameobject_rotation(go_quat, orientation),
_ => wire_yaw(orientation),
_ => mover.map_or_else(
|| wire_yaw(orientation),
|m| crate::creature_anim::swim_body_rotation(orientation, m.flags, m.pitch),
),
};
// A unit/player created already ON a transport (deck NPCs stream in this way): its LIVING
// block's rider tail is its local pose — `compose_riders` re-anchors it through the boat's
+47 -14
View File
@@ -131,9 +131,10 @@ pub(in crate::net) fn trace_relay(
benilla_assets::trace::line(
"rly",
&format!(
"guid={guid:#x} {kind} wire={} flags={:#x} out={} lead={lead:7.1} q={queued}",
"guid={guid:#x} {kind} wire={} flags={:#x} pitch={:+.3} out={} lead={lead:7.1} q={queued}",
mv.wire_ms,
mv.flags,
mv.pitch,
out.tag()
),
);
@@ -180,6 +181,21 @@ fn trace_runaway(guid_hint: Entity, rm: &RemoteMotion, now_ms: f64, silent_s: u3
/// How often the remote-pose watch samples a mover (ms) — see [`trace_remote`].
const REMOTE_TRACE_MS: f64 = 500.0;
/// What the swim-pitch render law will actually *present* for this mover — `rm.pitch` passed
/// through [`crate::creature_anim::swim_body_rotation`]'s gate, so the `rem` line reports the two
/// numbers side by side: what the wire said (`pitch=`) and what the body is tilted by (`tilt=`).
///
/// The pair is the whole point. A reported pitch with a zero tilt is the gate refusing (idle or
/// strafe-only swim, or not swimming at all); a zero on *both* is a mover whose client never sent
/// a pitch. Before this, neither number appeared anywhere — the observed-swimmer tilt landed in
/// July 2026 (decision 0464 TU-A) with no trace and no test, so "do other swimmers tilt?" was a
/// question only the director's eye could answer.
fn rendered_pitch(rm: &RemoteMotion) -> f32 {
let (_, x, _) = crate::creature_anim::swim_body_rotation(0.0, rm.flags, rm.pitch)
.to_euler(bevy::math::EulerRot::YXZ);
x
}
/// The **remote-pose watch** (tag `rem`): one line per mover per [`REMOTE_TRACE_MS`], reporting what
/// the dead-reckon asked for and what the world gave back. Until decision 0626 nothing measured the
/// *pose* of a watched player at all — `rly` measures the packets that arrive and `run` only fires
@@ -205,16 +221,25 @@ const REMOTE_TRACE_MS: f64 = 500.0;
/// measured 2.14 yd. A sustained nonzero `drop` on a mover whose `age` keeps climbing is us
/// inventing a height the server never described.
///
/// - **`pitch` / `tilt`** — what this mover *reported* as its swim pitch, and what the body-pitch
/// render law actually *presents* for it ([`rendered_pitch`]). The pair, not either alone: a zero
/// tilt is correct three ways (an idle floater, a strafe-only swim, dry land), so a single number
/// makes every one of those look like the defect the director reported. Reported-nonzero with
/// tilt-zero is the gate refusing; zero on both is a mover whose client never sent a pitch.
///
/// `age` (ms since a packet last applied) rides along so a sample is never read as server truth.
fn trace_remote(e: Entity, rm: &RemoteMotion, held: f32, dz: f32, now_ms: f64) {
fn trace_remote(e: Entity, guid: u64, rm: &RemoteMotion, held: f32, dz: f32, now_ms: f64) {
benilla_assets::trace::line(
"rem",
&format!(
"{e} flags={:#x} pos=[{:.2},{:.2},{:.2}] dz={dz:+.3} drop={:+.3} held={held:.3} age={:.0}ms",
"{e} guid={guid:#x} flags={:#x} pos=[{:.2},{:.2},{:.2}] pitch={:+.3} tilt={:+.3} dz={dz:+.3} \
drop={:+.3} held={held:.3} age={:.0}ms",
rm.flags,
rm.wow_pos[0],
rm.wow_pos[1],
rm.wow_pos[2],
rm.pitch,
rendered_pitch(rm),
rm.last_apply_pos[2] - rm.wow_pos[2],
now_ms - rm.last_apply_ms,
),
@@ -464,6 +489,12 @@ pub(in crate::net) fn extrapolate_remote_units(
Option<&mut crate::creature_anim::BodyTwist>,
Has<super::FacingStep>,
Has<crate::transport::TransportRider>,
// Trace-only: the mover's wire guid, so a `rem` line can be JOINED to the `rly` lines
// that fed it. `rem` printed a Bevy `Entity` and nothing else, which correlates with
// nothing in the file — not `rly`'s `guid=`, not the sender's own trace, not a `.gps`
// report — so two movers in one capture could not be told apart at all. `spl` has
// carried its guid since it was written; this was the odd one out.
Option<&crate::net::Guid>,
// The server-granted modes this unit was handed by the `SMSG_SPLINE_MOVE_*` family
// (decision 1780). Normally empty on a relayed **player** — vmangos only broadcasts
// that family for a unit it is driving itself — which is exactly why it is OR'd with
@@ -478,7 +509,7 @@ pub(in crate::net) fn extrapolate_remote_units(
use crate::creature_anim::{ease_strafe_yaw, strafe_body_offset, wrap_pi};
let dt = time.delta_secs();
let now_ms = time.elapsed_secs_f64() * 1000.0;
for (e, mut t, mut rm, speeds, twist, latched, riding, granted) in &mut q {
for (e, mut t, mut rm, speeds, twist, latched, riding, guid, granted) in &mut q {
// The runaway watch (trace-only): a mover still carrying a direction flag, with nothing
// queued behind it and nothing applied for seconds, is running on our extrapolation alone.
if benilla_assets::trace::enabled() {
@@ -690,7 +721,14 @@ pub(in crate::net) fn extrapolate_remote_units(
.is_none_or(|t| now_ms - t >= REMOTE_TRACE_MS)
{
sampled.insert(e, now_ms);
trace_remote(e, &rm, held, pos[2] - prev[2], now_ms);
trace_remote(
e,
guid.map_or(0, |g| g.0),
&rm,
held,
pos[2] - prev[2],
now_ms,
);
}
t.translation = wow_to_bevy(pos);
// The strafe body pose, same as our own avatar's (the client's display-facing blend): a
@@ -711,15 +749,10 @@ pub(in crate::net) fn extrapolate_remote_units(
} else {
orientation
};
// The swim body pitch (TU-A, `0x60a110`→`0x710620`): a swimmer moving fwd/back renders its
// root pitched by the reported swim pitch (nose-up positive) about the body's local X;
// strafe-only and idle swims render level — the same per-frame gate the client's `+0x3c`
// model-transform sync branches on.
t.rotation = if swimming && rm.flags & (move_flags::FORWARD | move_flags::BACKWARD) != 0 {
Quat::from_rotation_y(yaw) * Quat::from_rotation_x(rm.pitch)
} else {
Quat::from_rotation_y(yaw)
};
// The swim body pitch — [`crate::creature_anim::swim_body_rotation`], the same law our own
// avatar's pose owner calls, on the pitch this mover reported. TU-A is explicit that the
// observed mover takes the *reported* pitch here, exactly as the local one takes its own.
t.rotation = crate::creature_anim::swim_body_rotation(yaw, rm.flags, rm.pitch);
if let Some(mut twist) = twist {
twist.yaw_gap = wrap_pi(orientation - yaw);
}
@@ -65,6 +65,95 @@ fn motion(flags: u32, orientation: f32) -> RemoteMotion {
}
}
/// **The observed swimmer's body actually tilts** — the render half of TU-A (decision 0464 §1),
/// which shipped in July 2026 with no test of its own and no trace field, so the only instrument
/// that could contradict it was the director's eye.
///
/// The law is [`crate::creature_anim::swim_body_rotation`] and it is now *one* function: this
/// asserts the observed lane end to end — a relayed `MSG_MOVE_*` carrying `SWIMMING | FORWARD`
/// and a nose-up pitch lands on [`RemoteMotion::pitch`], and the rotation the extrapolator writes
/// decomposes back to exactly that pitch about the body's local X, with the yaw untouched and no
/// roll. Then the three gates that must render LEVEL, because a zero tilt is only correct when
/// one of them is the reason.
#[test]
fn a_relayed_swimmer_renders_pitched_and_the_gates_render_level() {
use bevy::ecs::system::RunSystemOnce;
use bevy::math::EulerRot;
use crate::creature_anim::swim_body_rotation;
// What the rotation the pose owners write decomposes back to: (yaw, pitch, roll).
let decompose = |flags: u32, pitch: f32, yaw: f32| {
swim_body_rotation(yaw, flags, pitch).to_euler(EulerRot::YXZ)
};
// The relay lands the wire pitch on the component (`apply_move`), and the extrapolator
// renders it: a swimmer nose-down 0.6 rad on a 1.2 rad heading.
let mut rm = motion(0, 0.0);
let mv = super::relay::RelayMove {
wire_ms: 0,
position: [0.0; 3],
orientation: 1.2,
flags: move_flags::SWIMMING | move_flags::FORWARD,
pitch: -0.6,
fall_time: 0,
jump: None,
transport: None,
heartbeat: false,
};
// Through the real arrival path, not a hand-set field: `apply_move` is what the relay and
// the queue drain both go through, so this is the seam that would drop the pitch.
let mut world = bevy::prelude::World::new();
world.init_resource::<bevy::ecs::message::Messages<crate::creature_anim::HardLanding>>();
let e = world.spawn_empty().id();
world
.run_system_once(
move |mut commands: bevy::prelude::Commands,
mut landings: bevy::prelude::MessageWriter<crate::creature_anim::HardLanding>| {
let mut rm = motion(0, 0.0);
super::remote::apply_move(e, &mv, &mut rm, 0.0, &mut commands, &mut landings);
rm
},
)
.map(|applied| rm = applied)
.expect("the one-shot apply runs");
assert_eq!(rm.pitch, -0.6, "the wire pitch reaches the component");
assert_eq!(
rm.flags,
move_flags::SWIMMING | move_flags::FORWARD,
"…and so do the flags its render gate reads"
);
let (yaw, pitch, roll) = decompose(rm.flags, rm.pitch, rm.orientation);
assert!((yaw - 1.2).abs() < 1e-5, "the heading is untouched: {yaw}");
assert!(
(pitch + 0.6).abs() < 1e-5,
"nose-down 0.6 rad renders: {pitch}"
);
assert!(roll.abs() < 1e-5, "a swimmer never banks: {roll}");
// Nose-UP is the opposite sign through the same axis — the two must not collapse.
let (_, up, _) = decompose(rm.flags, 0.6, 0.0);
assert!((up - 0.6).abs() < 1e-5, "nose-up 0.6 rad renders: {up}");
// The three LEVEL gates. Each is a *correct* zero, which is exactly why the trace reports
// the reported pitch beside the rendered one — otherwise they are indistinguishable from a
// swimmer whose pitch never arrived.
for (name, flags) in [
("an idle floater", move_flags::SWIMMING),
(
"a strafe-only swim",
move_flags::SWIMMING | move_flags::STRAFE_LEFT,
),
("a walker on dry land", move_flags::FORWARD),
] {
let (yaw, pitch, roll) = decompose(flags, 0.9, 1.2);
assert!((yaw - 1.2).abs() < 1e-5, "{name} keeps its heading");
assert_eq!(pitch, 0.0, "{name} renders level");
assert_eq!(roll, 0.0, "{name} never banks");
}
}
#[test]
fn swim_dead_reckon_folds_the_pitch_into_the_travel() {
// A swimmer's wire pitch folds into the travel direction the way the client's swim velocity
+9 -14
View File
@@ -10,8 +10,6 @@
use bevy::prelude::*;
use crate::creature_anim::move_flags;
use super::{model_pivot_height, wrap_pi, BodyQuery, CameraPivot, Player};
/// Write this frame onto the driven body and return the camera-pivot **target** height it carries.
@@ -41,19 +39,16 @@ pub(super) fn drive(
let mut cam_pivot_target = None;
if let Ok((entity, mut t, motion, pivot, .., twist, _, net_entity)) = body.single_mut() {
t.translation = player.pos;
// The swim body pitch (TU-A, `0x60a110`→`0x710620`): while swimming AND moving fwd/back
// the model root renders `Rz(yaw)·Ry(−pitch)` — in Bevy axes, the yaw then a nose-up
// pitch about the body's local X. Strafe-only, idle, and grounded all render LEVEL (the
// ground path) — exactly the gate the client's per-frame `+0x3c` sync branches on.
// The pitch presented is this frame's `swim_pitch` — the raw aim, except leveled by
// the 0499 surface redirect when the rest-line cap bites (the body swims flat along
// the surface, not pitched against it); the wire tail streams the same value.
// The swim body pitch — [`crate::creature_anim::swim_body_rotation`], the one law shared
// with every observed mover. The pitch presented is this frame's `swim_pitch` — the raw
// aim, except leveled by the 0499 surface redirect when the rest-line cap bites (the body
// swims flat along the surface, not pitched against it); the wire tail streams the same
// value, so what an observer renders for us is what we render for ourselves.
//
// `swimming` is not passed separately: it is `move_flags_now`'s own SWIMMING bit (the
// controller sets the wire word from the same state), and the law reads it there.
t.rotation =
if swimming && move_flags_now & (move_flags::FORWARD | move_flags::BACKWARD) != 0 {
Quat::from_rotation_y(player.model_yaw) * Quat::from_rotation_x(swim_pitch)
} else {
Quat::from_rotation_y(player.model_yaw)
};
crate::creature_anim::swim_body_rotation(player.model_yaw, move_flags_now, swim_pitch);
// Report every landing's fall height for the client-side landing predictor
// (`0x602d00`, decision 0412): its consumers gate on the descent and, past the HARD
// floor, play the wound grunt + a locally-predicted dust puff at THIS frame — the
+33
View File
@@ -1125,6 +1125,39 @@ impl Player {
self.face_yaw += radians;
}
/// Aim the avatar's **mover pitch** — the scripted dive's one lever (`capture::probe_pitch`),
/// the twin of [`Self::turn_aim`] and for the same reason.
///
/// The only writer of this field in gameplay is the mouse-look push in
/// `controller`, which needs a real held mouse button (`mouselook`) and a moving OS cursor
/// inside the viewport. Neither is available to an unfocused probe window, so before this
/// there was **no way to make a benilla client swim nose-up or nose-down without a human on
/// the mouse** — which is why the observed-swimmer tilt (decision 0464 TU-A) could ship, and
/// sit for weeks, with nothing but the director's eye able to say whether it worked.
///
/// Writes the same field `SetPitch 0x7c6f70` writes, under the same ±89°
/// [`MOUSELOOK_PITCH_CLAMP`], so from here on this is the identical path a real mouse-aimed
/// dive takes: the swim frame's travel basis, the body pose, and the wire tail all read this
/// one value.
///
/// Not a *byte* copy of `SetPitch`, and deliberately: that store is epsilon-gated — it commits
/// iff `|new − old| >= [0x8026bc] = 2^-20`, the `==` case included (a parity test, wow-re
/// `system/collision/scratch/create-block-swim-pitch.md`). Reproducing a 1e-6 rad deadband in
/// an instrument would buy nothing and would make a slow scripted sweep stutter; the gate is
/// gameplay's to model on the mouse-look push if it ever matters, which at that magnitude it
/// cannot.
/// Returns what was actually stored, which is the clamped value — so an instrument reports the
/// aim the mover *has* rather than the one it asked for. A sweep that runs past ±89° otherwise
/// logs a number the body never held.
pub(crate) fn aim_pitch(&mut self, radians: f32) -> f32 {
self.mover_pitch = radians.clamp(-MOUSELOOK_PITCH_CLAMP, MOUSELOOK_PITCH_CLAMP);
// The mouse-look push is edge-triggered on the camera pitch it last saw; parking that at
// the value we just wrote keeps a *real* mouse-look from re-pushing an unchanged camera
// over the top of the script on the next frame.
self.aim_pitch_seen = self.mover_pitch;
self.mover_pitch
}
/// The character's *facing* (Bevy yaw, radians) — the aim, kept in sync with the camera by
/// right-drag/movement. This is the unit's orientation as sent to the server, distinct from the
/// rendered body heading (`model_yaw`, which a strafe rotates). The 3D-audio listener panning
+361 -50
View File
@@ -194,6 +194,20 @@ pub(super) fn act_on_right_click(
if clicks.read().last().is_none() {
return;
}
// ── The interact family's ACTOR gate: am I in the saddle? ────────────────────────────────
// One predicate, the reference's own — the PLAYER's `UNIT_FIELD_MOUNTDISPLAYID` (decision
// 0481's "one mounted predicate"; wow-re `mounted-action-gate.md`: no aura, no taxi
// distinction, this field and nothing else). 0481 built the gate for the two action families
// the director reported then — casts (`0x6094f0`'s reason `0x39` "You are mounted") and
// attack-start (`0x612df0`'s `ERR_ATTACK_MOUNTED`) — and wrote interaction explicitly out of
// scope. This is that third family (decision 1851): the right-click interact dispatchers read
// the very same field, in their own ladders, and until now we read it in none of them.
let self_store = self_player
.single()
.ok()
.and_then(|(e, _, _)| stores.get(e).ok())
.map(|(s, _)| s);
let self_mounted = self_store.is_some_and(|s| s.0.unit_mount_display_id() != 0);
// A GameObject is the nearest thing under the cursor → use it (decision 0236), and never fall
// through to unit handling: a GO is not selectable, and a right-click on it acts on the GO or
// does nothing. The reference's `OnUse 0x5f8660` gates on the same two predicates the cursor
@@ -222,7 +236,7 @@ pub(super) fn act_on_right_click(
benilla_assets::trace::line(
"use",
&format!(
"right-click go guid={:?} type={ty} cursor={:?} unable={}",
"right-click go guid={:?} type={ty} mounted={self_mounted} cursor={:?} unable={}",
hovered_object.guid.map(|g| format!("{g:#x}")),
cursor.kind,
cursor.unable
@@ -235,6 +249,55 @@ pub(super) fn act_on_right_click(
.target
.and_then(|e| stores.get(e).ok())
.map(|(s, anim)| (s, crate::go_anim::go_state(anim, s)));
// ── The GameObject leg's OWN mounted gate — not the cast validator's ────────
// The strategy's usable predicate `0x5f3130` carries a mounted arm of its own at
// `0x5f31a8`: errorId `0x19f` `ERR_NOT_WHILE_MOUNTED`, then `0x5f31d6 xor al,al`,
// so `0x5f86b0` never reaches the opener it would invoke at `0x5f86eb`. No
// `CMSG_GAMEOBJ_USE`, no cast, no state write — the gate returns before the
// opener, not merely before the message (wow-re `mounted-interaction-gate.md`
// §5.1, the §5 this session dispatched).
//
// **It applies only to LOCK-LESS objects.** The gate sits behind
// `0x5f3195 call 0x5f8180` / `0x5f319c jne 0x5f3231`, which resolves the object's
// **Lock.dbc row** and skips the whole thing when one exists. So a mining node, a
// herb or a locked chest bypasses this and is refused further down by its opener
// CAST's own mounted block — loudly, "You are mounted" — while a spellcaster, a
// chair, a `lockId 0` quest goober and a readable sign are refused HERE, in
// silence. What buys the bypass is a **lock**, not a spell; the round's first
// draft had that backwards and its own cross-check caught it.
//
// The test is pure **row existence**: `0x5f819c` tests the row *pointer*, and
// nothing reads `Type[]`/`Index[]`/`Skill[]` until `0x5f83d0`. So it is
// deliberately NOT [`benilla_formats::LockCatalog::is_locked`], which also demands
// a non-empty slot — an all-empty Lock row is "no lock" to our opener resolver and
// "a lock" to this gate, and the reference lets that object be used from the
// saddle. Collapsing the two is the one divergence the RE explicitly warned about.
//
// **MAILBOX (type 19) is the only exemption**, and it lives inside the gate on the
// mounted arm alone (`0x5f31bb` → `0x47cff0`: a sixteen-byte `type == 0x13`
// equality, no table, no range). TEXT is NOT exempt — the reference refuses a sign
// from horseback even though its opener is every bit as client-side as the
// mailbox's, which is why this sits ABOVE both local-open branches below.
//
// Silent by Blizzard's omission, not our choice: `ERR_NOT_WHILE_MOUNTED` is one of
// 1.12.1's orphan keys with no GlobalStrings value, and both of `DisplayError`'s
// side-effect branches skip for this row (`[row+0xc] == 0x44` takes the no-sound
// jump; `[row+0x8]` is the literal `"NONE"`), so nothing is shown, sounded or
// fired. Whether that leaves any visible artifact at all is the one thing the
// round could not settle from the binary — it wants one mounted click on a sign.
let lock_id = go_inputs.templates.get(guid).map_or(0, |t| t.lock_id);
let has_lock_row = lock_id != 0
&& go_inputs
.locks
.as_deref()
.is_some_and(|l| l.0.slots(lock_id).is_some());
let go_type = go.map_or(-1, |(s, _)| s.0.gameobject_type_id());
if self_mounted && !has_lock_row && go_type != cursor_mode::GO_TYPE_MAILBOX {
debug!(
"right-click gameobject {guid:#x}: refused, mounted (lock-less type {go_type}, silent)"
);
return;
}
// Mailbox (GO type 19): open the mail window client-side (decision 0544), BEFORE the
// lock fork (a mailbox is never locked). The wow-re §5 confirms the MAILBOX use
// handler overrides the shared use-sender to a LOCAL open — it sends NO packet (no
@@ -277,17 +340,12 @@ pub(super) fn act_on_right_click(
// locked.", "Requires Herbalism", "Requires Mining 100", "Requires <key item>" —
// and sends nothing (the ref's validate/error block `0x5f3427..` fires
// `DisplayError` with no packet; wow-re cursor-system.md §8.4/§8.8).
let me_store = self_player
.single()
.ok()
.and_then(|(e, _, _)| stores.get(e).ok())
.map(|(s, _)| s);
match resolve_go_action(
guid,
&mut go_inputs,
&player_actions.spells,
go,
me_store,
self_store,
&seam.net,
) {
GoAction::Use if cursor.unable => {}
@@ -308,18 +366,31 @@ pub(super) fn act_on_right_click(
// `0x6e4000` gates it too (decision 0552): a pickless Mining cast
// refuses HERE with the local red "Requires Mining Pick" and sends
// nothing — vmangos would answer the sent cast with the wrong code.
let def = go_inputs
.spells
.as_ref()
.and_then(|s| s.catalog.get(spell_id));
if crate::ui_action::reagent_totem_refusal(
spell_id,
go_inputs
.spells
.as_ref()
.and_then(|s| s.catalog.get(spell_id)),
me_store,
def,
self_store,
&go_inputs.items,
&mut cast_errors,
) {
return;
}
// Same funnel, same requirement validator: a mounted opener refuses with
// reason `0x39` and sends nothing (`0x609c6c`). Mining and Herbalism are
// exactly the gathering casts a rider tries without dismounting, and the
// server would silently dismount us instead of saying so. **After** the
// reagent check, which is TryCast's own order — step 5 (`0x6e4dec`) before
// step 7 (`0x6e4f3b`) — so a pickless mounted miner still reads "Requires
// Mining Pick", exactly as [`CastLadder`] orders its own two rungs.
if crate::ui_action::cast_mounted_refusal(self_mounted, def) {
debug!("right-click gameobject open-lock: refused locally — mounted");
cast_errors.push_local(spell_id, 0x39);
return;
}
debug!("right-click gameobject open-lock: cast {spell_id} at {guid:#x}");
let _ = seam.net.0.send(ClientCommand::CastSpellGameObject {
spell_id,
@@ -334,6 +405,26 @@ pub(super) fn act_on_right_click(
// No reagent/totem pre-check here, unlike the skill arm above: that gate is
// about a Mining cast without a pick, and a key has no reagents — the ref's
// `0x6e4000` would pass every key trivially.
//
// The MOUNTED gate is not skippable the same way: an item use IS a cast
// (decision 0908) and reaches the same requirement validator, so a rider
// turning a key refuses and sends nothing. The key's ON_USE *spell* is
// exactly what this arm does not resolve (the stated 0914 gap below), so
// the record is `None` — which the predicate reads as "no exemption to
// claim" and refuses. That is the right way to be wrong: no 1.12 key
// carries Attributes bit 24. Raised by KEY rather than through
// [`CastErrors`], because a reason-coded entry wants a spell id and we
// have none — the red line is identical either way; what a spell-less
// entry would cost is the combat-log twin, which needs the spell's name
// and cannot have it here. A disclosed shortfall of 0914's gap, not a new
// one.
if crate::ui_action::cast_mounted_refusal(self_mounted, None) {
debug!("right-click gameobject open-by-key: refused locally — mounted");
ui_error_keys
.0
.push(crate::ui_action::UiError::key("SPELL_FAILED_NOT_MOUNTED"));
return;
}
debug!(
"right-click gameobject open-by-key: use item ({bag_index},{slot}) blk {spell_index} at {guid:#x}"
);
@@ -391,7 +482,7 @@ pub(super) fn act_on_right_click(
benilla_assets::trace::line(
"use",
&format!(
"right-click corpse guid={guid:#x} bones={} lootable={} insignia={} cursor={:?} unable={}",
"right-click corpse guid={guid:#x} bones={} lootable={} insignia={} mounted={self_mounted} cursor={:?} unable={}",
store.is_some_and(|s| s.0.corpse_is_bones()),
store.is_some_and(|s| s.0.corpse_lootable()),
store.is_some_and(|s| s.0.corpse_pvp_insignia()),
@@ -400,19 +491,22 @@ pub(super) fn act_on_right_click(
),
);
}
if store.is_some_and(|s| s.0.corpse_lootable()) {
// **Leg 1's first conjunct is `!mounted`** (`0x5d6c2a jg`, the player's MOUNTDISPLAYID read
// one test before the lootable one). The block comment above has named this conjunct since
// the leg was transcribed and the code never carried it — a rider could loot bones from the
// saddle. A mounted click is **not** an error here: it falls through to leg 2 exactly as the
// unit dispatcher falls through to skin, and since no 1.12.1 player has the insignia latch,
// what the rider actually gets is silence.
if !self_mounted && store.is_some_and(|s| s.0.corpse_lootable()) {
// **`GetStandState() != 0` refuses before the send** (`0x5d6c3b call [playerVtbl+0xa4]`
// = `0x5ed570`, non-zero → `0x496720(0x85)`): sitting, kneeling or asleep, the click
// raises the client-local red **`ERR_LOOT_NOTSTANDING`** as a `UI_ERROR_MESSAGE` and
// sends nothing. Client-local, so the line shows even where the server would also have
// refused — and unlike the range gray it is loud, because the player can fix it.
// Scoped to the corpse leg: this is the gate the §5 round read, and the unit loot
// branch's own copy of it is unverified here (1729).
let standing = self_player
.single()
.ok()
.and_then(|(e, _, _)| stores.get(e).ok())
.is_none_or(|(s, _)| s.0.unit_stand_state() == 0);
// No longer scoped to the corpse leg: 1851 read the unit dispatcher's own copy
// (`0x60bfb7` → `0x60c007 push 0x85`) off the same fork and built it too, so the
// "unverified here" caveat this comment carried since 1729 is retired.
let standing = self_store.is_none_or(|s| s.0.unit_stand_state() == 0);
if !standing {
debug!("right-click corpse loot: refused, not standing ({guid:#x})");
ui_error_keys
@@ -436,11 +530,26 @@ pub(super) fn act_on_right_click(
// reason rather than by omission.
if let Some(spell_id) = learned.skin_player_corpse {
if !cursor.unable {
debug!("right-click corpse insignia: {guid:#x} (spell {spell_id})");
let _ = seam.net.0.send(ClientCommand::CastSpell {
spell_id,
target: Some(guid),
});
// Same TryCast funnel as the unit skin leg, so the same mounted block — and
// this is the leg a mounted bones-click FALLS INTO, which makes the gate here
// the difference between "nothing happens" and a rider yanking insignias.
// The record comes off [`GoLockInputs`]'s catalog because that bundle is this
// system's only handle on `Spells` and the param list is at the 16 ceiling —
// the bundle is named for its first tenant, not scoped to it.
let def = go_inputs
.spells
.as_ref()
.and_then(|s| s.catalog.get(spell_id));
if crate::ui_action::cast_mounted_refusal(self_mounted, def) {
debug!("right-click corpse insignia: refused locally — mounted (0x39)");
cast_errors.push_local(spell_id, 0x39);
} else {
debug!("right-click corpse insignia: {guid:#x} (spell {spell_id})");
let _ = seam.net.0.send(ClientCommand::CastSpell {
spell_id,
target: Some(guid),
});
}
}
}
}
@@ -450,13 +559,56 @@ pub(super) fn act_on_right_click(
return;
};
let attack = cursor.kind == cursor_mode::CursorKind::Attack;
let target = stores.get(entity).ok().map(|(s, _)| s);
// ── The dead-target fork of the reference's unit interact dispatcher `0x60bea0` ──────────
// Loot routes by the same CLASSIFICATION the cursor used — dead + `UNIT_DYNFLAG_LOOTABLE` —
// not by the cursor kind (wow-re cursor-system.md §6: the right-click "routes the same hovered
// object by the same classification"; its dead-unit row sends CMSG_LOOT). The loot cursor's
// base mode is Pickup(8), which a live vendor also shows, so the kind alone can't name loot.
let loot = stores
.get(entity)
.is_ok_and(|(s, _)| s.0.unit_is_dead() && s.0.unit_lootable());
//
// **The fork's first test is the rider**, and we never carried it (decision 1851): `0x60bf98`
// reads `[ecx+0x1fc]` off the *player's* descriptor block — the one still in `ecx` from
// `0x60bee5` — and `jg 0x60c01f` skips the whole loot leg for a mounted player, landing on the
// skin leg. That is a **fall-through, not a refusal**: no packet, no red line, no "You are
// mounted". The cast and attack families of this same gate (0481) each announce themselves;
// this one is silent, which is exactly why it could sit here unbuilt without ever looking
// broken from the inside.
// Step 0 of the fork, hoisted because the trace prints it too: a corpse the dispatcher will
// actually treat as one.
let dead_fork = target.is_some_and(|s| s.0.unit_is_dead() && !s.0.unit_dynflag_dead());
let leg = dead_unit_leg(
self_mounted,
dead_fork,
target.is_some_and(|s| s.0.unit_lootable()),
target.is_some_and(|s| s.0.unit_flags() & cursor_mode::UNIT_FLAG_SKINNABLE != 0),
learned.skinning.is_some(),
);
let (loot, skin) = (leg == DeadUnitLeg::Loot, leg == DeadUnitLeg::Skin);
// The unit twin of the GO/corpse legs' one-line answer (tag `use`). Every refusal on this
// ladder is silent from the outside — the range gray, the mounted fall-through, a corpse
// somebody else owns — and they are indistinguishable on screen. One run says which.
if benilla_assets::trace::enabled_for("use") {
benilla_assets::trace::line(
"use",
&format!(
"right-click unit guid={guid:#x} dead={} lootable={} skinnable={} mounted={self_mounted} leg={} cursor={:?} unable={}",
target.is_some_and(|s| s.0.unit_is_dead()),
target.is_some_and(|s| s.0.unit_lootable()),
target.is_some_and(|s| s.0.unit_flags() & cursor_mode::UNIT_FLAG_SKINNABLE != 0),
if attack {
"attack"
} else if dead_fork {
leg.tag()
} else {
// The alive branch (`0x60c162`): `CanInteract` → the service send. Not this
// fork's to name, and NOT mounted-gated — a rider talks to a flight master.
"service"
},
cursor.kind,
cursor.unable
),
);
}
let me = self_player.single().ok();
// The one SetSelection law ([`scan::commit`]): dedup + selection + the engaged-switch
// stop→select→re-swing. The Attack cursor kind is Attack `0x5ecb70`'s new-target validation
@@ -472,16 +624,23 @@ pub(super) fn act_on_right_click(
attack,
);
if attack {
// The actor-eligibility block (decision 0481, widened to `0x612df0`'s full Phase A): the
// click still SELECTED — the commit above already ran, matching the ref's
// select-then-refuse order — but the melee auto-draw and the swing never happen; the red
// `ERR_ATTACK_*` line shows instead.
if crate::ui_action::attack_actor_refusal(
me.and_then(|(e, _, _)| stores.get(e).ok()).map(|(s, _)| s),
me.map(|(_, g, _)| g.0),
&mut ui_error_keys,
) {
// refused — selection stands, no swing
// The actor-eligibility block — **silent here**, and that is 1851 correcting 0481. The
// click still SELECTED (the commit above already ran, the ref's select-then-refuse order)
// and the melee auto-draw and swing still never happen; what changed is that no red
// `ERR_ATTACK_*` line shows any more.
//
// 0481 attached `0x612df0`'s Phase A ladder to this path. The §5 this session dispatched
// found that validator has exactly three callers image-wide — pet-attack `0x4bd40d`, the
// Attack action/keybind `0x6131aa`, and TryCast `0x6e4efb` — and the world right-click is
// none of them: it runs `0x60c247 call 0x5ecb70`, an extent containing no `DisplayError`
// at all. So all eight of those red lines belong to the bar and the pet command, never to
// the click, and the fix is not to delete six of them but to move where they are asked
// for. [`attack_actor_blocked`] is the same ladder with the message removed; the bar keeps
// [`attack_actor_refusal`]. (The predicate is still `0x612df0`'s and not `0x5ecb70`'s own
// overlapping set — a separate slice — but the SILENCE is the verified law, and the swing
// is suppressed either way.)
if crate::ui_action::attack_actor_blocked(self_store, me.map(|(_, g, _)| g.0)).is_some() {
// refused — selection stands, no swing, and nothing is said
} else {
debug!("right-click attack: {guid:#x}");
// The right-click's own StartAttack, through the one seam (auto-draw + the swing +
@@ -498,7 +657,21 @@ pub(super) fn act_on_right_click(
// its loot (`CMSG_LOOT`). Range-gated like the interact branch — the cursor grays a corpse
// beyond the melee interact reach (`unable`), and we don't send then (no auto-approach yet).
// No EmoteTalk: looting is not an NPC interaction, the corpse plays no talk.
if !cursor.unable {
//
// **The unit leg's own stand-state gate** (`0x60bfb7 call [playerVtbl+0xa4]`, non-zero →
// `0x60c007 push 0x85; call DisplayError 0x496720`) — the exact instruction pair the corpse
// leg carries at `0x5d6c3b`/`0x496720(0x85)`, in the same fork, one test after the lootable
// one. The corpse leg's comment has said since 1729 that "the unit loot branch's own copy of
// it is unverified here"; it is the same `push 0x85` and the same virtual, read off the same
// dispatcher this session's `0x60bf98` mounted gate came out of. So the caveat retires and
// the two legs say the same thing: sitting, kneeling or asleep, the click raises the
// client-local red `ERR_LOOT_NOTSTANDING` and sends nothing.
if self_store.is_some_and(|s| s.0.unit_stand_state() != 0) {
debug!("right-click loot: refused, not standing ({guid:#x})");
ui_error_keys
.0
.push(crate::ui_action::UiError::key("ERR_LOOT_NOTSTANDING"));
} else if !cursor.unable {
debug!("right-click loot: {guid:#x}");
let _ = seam.net.0.send(ClientCommand::Loot { guid });
// The kneel is client-predicted AT THE SEND: the real client's `CMSG_LOOT` sender
@@ -507,20 +680,32 @@ pub(super) fn act_on_right_click(
// reads for the self unit; the release/refusal drops it.
loot_latch.0 = Some(guid);
}
} else if cursor.kind == cursor_mode::CursorKind::Skin {
// A dead, unlootable, SKINNABLE corpse (the classifier's own gate): cast our known
// Skinning spell at it — the unit-side mirror of the GO lock split (0239; decision 0437's
// gathering finish). The spell comes from the reference's own learn-time latch
} else if skin {
// A dead SKINNABLE corpse the loot leg declined (`0x60c01f`): cast our known Skinning spell
// at it — the unit-side mirror of the GO lock split (0239; decision 0437's gathering
// finish). The spell comes from the reference's own learn-time latch
// ([`crate::ui_action::LearnedAbilities`] = `[0xb700e4]`, decision 0752), which is the same
// thing the classifier gated the Skin cursor on — so reaching here at all means we know it.
// thing the classifier gated the Skin cursor on. Ordinarily that means an unlootable
// corpse; **while mounted it also means a still-lootable one**, and the cast then meets the
// cast family's own mounted gate (`0x6094f0`'s reason `0x39`) and says "You are mounted" —
// the one leg of this dispatcher where the rider is told anything at all.
// Range rides the cursor's melee-reach gray, like loot.
if !cursor.unable {
if let Some(spell_id) = learned.skinning {
debug!("right-click skin: {guid:#x} (spell {spell_id})");
let _ = seam.net.0.send(ClientCommand::CastSpell {
spell_id,
target: Some(guid),
});
let def = go_inputs
.spells
.as_ref()
.and_then(|s| s.catalog.get(spell_id));
if crate::ui_action::cast_mounted_refusal(self_mounted, def) {
debug!("right-click skin: refused locally — mounted (0x39)");
cast_errors.push_local(spell_id, 0x39);
} else {
debug!("right-click skin: {guid:#x} (spell {spell_id})");
let _ = seam.net.0.send(ClientCommand::CastSpell {
spell_id,
target: Some(guid),
});
}
}
}
} else if !cursor.unable {
@@ -761,6 +946,73 @@ fn route_lock_refusal(
}
}
/// Which leg the reference's unit interact dispatcher `0x60bea0` takes on a **dead** target — its
/// `0x60bf75` fork, transcribed. Pure, because every conjunct in it is a fact about two units'
/// wire state and nothing else, and because the conjunct this project was missing is invisible
/// from the outside (see [`DeadUnitLeg::Nothing`]).
///
/// The reference's order, and ours:
///
/// 0. **`0x60bf75`/`0x60bf86` — is it a corpse at all?** `HEALTH <= 0` **and** the target's
/// `UNIT_DYNFLAG_DEAD` (`[+0x224]` bit 5, the feign-death bit — decision 1022) **clear**; either
/// failing routes to the alive branch `0x60c162` instead. The caller passes this as `dead`.
/// 1. **`0x60bf98` — am I mounted?** `mov eax,[ecx+0x1fc]` on the *player's* descriptor block
/// (`UNIT_FIELD_MOUNTDISPLAYID`, the one mounted signal — decision 0481), `jg 0x60c01f`. A
/// rider does not loot. It is a jump into the skin leg, not a refusal: nothing is sent and
/// nothing is said.
/// 2. **`0x6003a0` — is it lootable?** (`UNIT_DYNFLAG_LOOTABLE`, plus the decay deadline the server
/// owns.) Yes ⇒ [`DeadUnitLeg::Loot`] and `CMSG_LOOT`.
/// 3. **`0x60c01f` — is it skinnable?** `UNIT_FIELD_FLAGS` bit 26, on the TARGET, plus the
/// learn-time latch `[0xb700e4]` that says we know a Skinning spell (decision 0752). Yes ⇒
/// [`DeadUnitLeg::Skin`] and the skin cast — which, being a cast, meets the mounted gate's cast
/// face (`0x6094f0`, reason `0x39`) and is where a rider finally gets told something.
/// 4. Otherwise nothing at all (`0x60c25f`, the bare epilogue).
///
/// Note what step 1 does **not** do: it does not consult the target. A lootable-*and*-skinnable
/// corpse — an already-looted one, which is the common case for a skinner — routes to Skin while
/// mounted and to Loot on foot, off the same two units.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum DeadUnitLeg {
/// `CMSG_LOOT` (`0x60bff7` → `0x5df2a0`).
Loot,
/// The skin cast (`0x60c082` → `0x5f05e0`).
Skin,
/// **The silent leg.** No packet, no message, no state write — the dispatcher just returns.
/// A mounted click on an ordinary lootable corpse lands here, and that silence is why the
/// missing gate looked like working code: the loot window simply opened, as it would on foot.
Nothing,
}
impl DeadUnitLeg {
/// The one-word tag the `use` trace prints.
fn tag(self) -> &'static str {
match self {
Self::Loot => "loot",
Self::Skin => "skin",
Self::Nothing => "none",
}
}
}
fn dead_unit_leg(
mounted: bool,
dead: bool,
lootable: bool,
skinnable: bool,
know_skinning: bool,
) -> DeadUnitLeg {
if !dead {
return DeadUnitLeg::Nothing;
}
if !mounted && lootable {
return DeadUnitLeg::Loot;
}
if skinnable && know_skinning {
return DeadUnitLeg::Skin;
}
DeadUnitLeg::Nothing
}
/// The interact packet a right-click on a friendly service NPC sends, by its classified
/// [`cursor_mode::CursorKind`] (decision 0081). A **vendor-only** NPC (Pickup) opens the stock list
/// directly; a **flight master** (Taxi) opens the taxi map directly (byte-verified — decision 0496
@@ -1095,6 +1347,65 @@ mod tests {
assert_eq!(e.key, "ERR_USE_CANT_OPEN");
}
/// The dead-target fork of `0x60bea0`, and the conjunct that was missing: **the rider does not
/// loot** (`0x60bf98`). The director found it from the saddle, over a Young Wolf, with the loot
/// window open — the one shape of bug that never looks like one from inside the client.
#[test]
fn a_mounted_player_never_takes_the_loot_leg() {
// On foot, a lootable corpse loots. This is the control: the fix must not touch it.
assert_eq!(
dead_unit_leg(false, true, true, false, false),
DeadUnitLeg::Loot
);
// Mounted, the same corpse: the loot leg is skipped and, with nothing to skin, the
// dispatcher returns having done nothing. Silent — no packet and no red line.
assert_eq!(
dead_unit_leg(true, true, true, false, false),
DeadUnitLeg::Nothing
);
// Mounted over a corpse that is BOTH lootable and skinnable (an already-looted body a
// skinner rides up to): the fall-through reaches the skin leg, which `cursor.kind` alone
// could never have routed — the classifier names Skin only on an unlootable corpse.
assert_eq!(
dead_unit_leg(true, true, true, true, true),
DeadUnitLeg::Skin
);
// ...and on foot the very same body loots instead. Step 1 never consults the target.
assert_eq!(
dead_unit_leg(false, true, true, true, true),
DeadUnitLeg::Loot
);
}
/// The rest of the fork's ladder, so the mounted conjunct above cannot be "fixed" by
/// collapsing a leg it shares with the others.
#[test]
fn the_dead_fork_keeps_its_other_three_gates() {
// A live unit is not this fork's business at all (`0x60bf75 jg` → the alive branch).
assert_eq!(
dead_unit_leg(false, false, true, true, true),
DeadUnitLeg::Nothing
);
// Dead, unlootable, skinnable, and we know the trade → Skin (`0x60c01f`).
assert_eq!(
dead_unit_leg(false, true, false, true, true),
DeadUnitLeg::Skin
);
// The learn-time latch `[0xb700e4]` is the leg's second precondition (0752): a
// non-skinner gets nothing on the same corpse.
assert_eq!(
dead_unit_leg(false, true, false, true, false),
DeadUnitLeg::Nothing
);
// A plain looted corpse: nothing, mounted or not.
for mounted in [false, true] {
assert_eq!(
dead_unit_leg(mounted, true, false, false, true),
DeadUnitLeg::Nothing
);
}
}
#[test]
fn interact_routes_vendor_direct_and_gossip_universal() {
use cursor_mode::CursorKind;
+1 -1
View File
@@ -169,7 +169,7 @@ pub(crate) mod npc_flags {
}
/// `UNIT_FLAG_SKINNABLE` in `UNIT_FIELD_FLAGS` (vanilla).
const UNIT_FLAG_SKINNABLE: u32 = 0x0400_0000;
pub(super) const UNIT_FLAG_SKINNABLE: u32 = 0x0400_0000;
/// NPC-service range gate: gray beyond 5.5556 yd (squared 30.864 — the client's `0xb4b32c` cell,
/// `[0x804328]²`; checked at `0x482320`, boundary-inclusive). Shared with the merchant window's
+30 -7
View File
@@ -317,12 +317,37 @@ pub(crate) fn attack_actor_refusal(
self_guid: Option<u64>,
errors: &mut UiErrorKeys,
) -> bool {
let Some(key) = attack_actor_blocked(actor, self_guid) else {
return false;
};
debug!("attack refused locally by the actor's own state — {key}");
errors.0.push(UiError::key(key));
true
}
/// The same ladder **without the message** — which condition blocks the swing, or `None`.
///
/// It exists because `0x612df0` is not on every attack-start path, and 1851's §5 pinned which:
/// its three callers image-wide are the pet-attack command (`0x4bd40d`), the Attack
/// action/keybind (`0x6131aa`) and TryCast (`0x6e4efb`) — and **not** the world right-click.
/// That click runs `0x60bea0` → `0x60c247 call 0x5ecb70`, whose whole extent
/// `[0x5ecb70, 0x5ecda3)` contains no `call 0x496720` and no `push` of `0xa0`–`0xa9`: it has its
/// own short, silent conjunct set (`0x5ecc06` is its mounted bail) and every failure lands on
/// `0x5ecc37`, which bails or stops attacking. **A right-click on a hostile shows no error text
/// under any condition** — not mounted, dead, charmed, stunned, pacified, fleeing or confused.
///
/// So the click asks this, and the bar asks [`attack_actor_refusal`]. The *predicate* is still
/// `0x612df0`'s rather than `0x5ecb70`'s own — the two sets overlap but are not identical, and
/// transcribing `0x5ecb70`'s is its own slice — but the **silence** is now the verified law, and
/// the swing is still suppressed, which is the half that keeps us off the wire.
pub(crate) fn attack_actor_blocked(
actor: Option<&ObjectStore>,
self_guid: Option<u64>,
) -> Option<&'static str> {
// No descriptor is no refusal. The reference resolves the actor object first and skips the
// whole chain when it cannot (`0x4bd403`: no pet ⇒ send unmodified) — an un-streamed unit is
// not an ineligible one.
let Some(fields) = actor.map(|s| &s.0) else {
return false;
};
let fields = &actor?.0;
let key = if fields.unit_health().is_some_and(|h| h == 0) {
"ERR_ATTACK_DEAD"
} else if fields
@@ -340,11 +365,9 @@ pub(crate) fn attack_actor_refusal(
} else if fields.unit_mount_display_id() > 0 {
"ERR_ATTACK_MOUNTED"
} else {
return false;
return None;
};
debug!("attack refused locally by the actor's own state — {key}");
errors.0.push(UiError::key(key));
true
Some(key)
}
/// The ref's pre-send totem/reagent possession check — `CheckReagentsAndTotems 0x6e4000`,
+11 -2
View File
@@ -66,9 +66,18 @@ pub(crate) struct CooldownEvents;
pub(crate) use cast_send::{CastCommit, CastLadder};
pub(crate) use cast_target::AutoSelfCast;
pub(crate) use errors::{
attack_actor_refusal, keyed_line, reagent_totem_refusal, show_messages, ui_error_text,
CastErrors, CastFail, MessageSink, MountErrors, Shown, UiError, UiErrorKeys, UiErrorTexts,
attack_actor_blocked, attack_actor_refusal, keyed_line, reagent_totem_refusal, show_messages,
ui_error_text, CastErrors, CastFail, MessageSink, MountErrors, Shown, UiError, UiErrorKeys,
UiErrorTexts,
};
// `pub(crate)`: the requirement validator's mounted block is ONE gate in the reference
// (`0x6094f0` @ `0x609c6c`) sitting under the ONE cast entry `TryCast 0x6e4b60` — but benilla still
// has cast sends outside [`CastLadder`]: the world click's skin cast, the corpse insignia cast and
// the GameObject opener (decisions 0552/0914). Each of them reaches TryCast in the reference
// (`0x5f05e0 → 0x6e5a90 → 0x6e4b60`; `0x5f35c0 → 0x6e5a90 → 0x6e4b60`), so each of them must ask
// this question — the same reason `reagent_totem_refusal` above is already exported. Exporting the
// predicate is the honest interim; the ladder refactor that would retire it is 0914's, not 1851's.
pub(crate) use state::cast_mounted_refusal;
// `pub(crate)`: the target chain registers the cursor pre-empt + the click commit, and the
// spellbook/stance/craft drains thread the mode through the one cast-send path (decision 0792).
// `TargetingWants` travels with it because the chain also holds a *seam-specific* consumer — the
+1 -1
View File
@@ -105,7 +105,7 @@ pub(super) fn cast_range_refusal(
/// the unsheathed leg), arm B is `ChannelInterruptFlags & 0x40` at `0x609c3a` — corrected by
/// the 1063 §5 in wow-re `mounted-action-gate.md`. Note the plain mounted gate above is NOT in
/// that family: it is single-armed on `Attributes`.
pub(super) fn cast_mounted_refusal(mounted: bool, spell: Option<&SpellDisplay>) -> bool {
pub(crate) fn cast_mounted_refusal(mounted: bool, spell: Option<&SpellDisplay>) -> bool {
mounted && spell.is_none_or(|d| d.attributes & 0x0100_0000 == 0)
}
+57 -14
View File
@@ -50,11 +50,14 @@ pub struct CreatureModel {
/// `ExtendedDisplayInfoID`). `None` for a plain beast/monster (ExtendedDisplayInfoID 0) — those
/// skin from [`Self::textures`], not here.
pub npc_appearance: Option<NpcAppearance>,
/// The resolved **UnitBloodLevels** key for the melee blood spurt (decision 0137 phase 3,
/// wow-re `melee-blood-spurt.md`): `CreatureDisplayInfo.BloodLevel` when nonzero, else
/// `CreatureModelData.BloodID`; `≤ 0` = a bloodless model (elementals, ghosts — the client's
/// negative-id skip). Consumed via [`crate::BloodCatalog::effect_id`].
pub blood: i32,
/// `CreatureDisplayInfo.BloodLevel` (+0x28) — **tier 1** of the reference's UnitBloodLevels
/// row resolve. Not a resolved key: the three tiers need the table to know which candidate
/// lands, so they live in [`crate::BloodCatalog::level_key`], which is what a consumer calls.
pub blood_display: i32,
/// `CreatureModelData.BloodID` (+0x14) — **tier 2** of the same resolve. `−1` in 122 of the
/// 430 shipped models; that is *not* "bloodless", it is a tier-2 miss that falls through to
/// tier 3 (see [`crate::BloodCatalog::level_key`]).
pub blood_model: i32,
/// `CreatureModelData.collisionHeight` — the unit's collision box height in **raw model units**
/// (multiply by the unit's render scale for world yards; see
/// [`CreatureCatalog::collision_height`], which is the accessor every consumer should use).
@@ -97,8 +100,9 @@ struct DisplayRow {
extended_id: u32,
scale: f32,
textures: [Option<String>; 3],
/// `BloodLevel` (field 10) — a per-display UnitBloodLevels override; 0 = fall through to the
/// model's `BloodID`.
/// `BloodLevel` (field 10) — a per-display UnitBloodLevels override. `0` in 10498 of the
/// 10534 shipped displays, and `0` is not a row of that table, so it falls through to the
/// model's `BloodID` (see [`CreatureModel::blood_display`]).
blood_level: u32,
/// `CreatureModelAlpha` (field 5, @+0x14) — the display's **base render opacity**, 0..=255.
/// This is the `baseAlpha` of the reference's per-unit alpha product (`0x60d2d0`, the CGUnit
@@ -116,7 +120,8 @@ struct ModelRow {
scale: f32,
/// `Flags` (field 1) — see [`CreatureCatalog::breathes`] for the one bit we read.
flags: u32,
/// `BloodID` — see [`CreatureModel::blood`]. Reads signed: `−1` marks a bloodless model.
/// `BloodID` — see [`CreatureModel::blood_model`]. Reads signed: `−1` in 122 of the 430
/// shipped rows, which the reference treats as a tier-2 miss, not as bloodlessness.
blood: i32,
/// `FootprintTextureID` (field 6) — the `FootprintTextures.dbc` key. Reads signed: `−1`
/// (133 of 430 shipped rows) marks a model that leaves no prints.
@@ -324,11 +329,8 @@ impl CreatureCatalog {
scale: model.scale * row.scale,
textures: row.textures.clone(),
npc_appearance,
blood: if row.blood_level != 0 {
row.blood_level as i32
} else {
model.blood
},
blood_display: row.blood_level as i32,
blood_model: model.blood,
collision_height: model.collision_height,
})
}
@@ -441,7 +443,9 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result<CreatureCatalog> {
path: name,
scale: f32_at(r, 4).unwrap_or(1.0),
flags: u32_at(r, 1).unwrap_or(0),
// BloodID (field 5) reads signed: −1 marks a bloodless model in the real data.
// BloodID (field 5) reads signed: −1 in 122 of the 430 shipped rows.
// That is a tier-2 MISS, not bloodlessness — the resolve falls through
// to the records base (1850). Kept signed so the miss is visible.
blood: u32_at(r, 5).map_or(0, |v| v as i32),
// FootprintTextureID reads signed too: −1 = no prints (see ModelRow docs).
footprint_texture: u32_at(r, 6).map_or(-1, |v| v as i32),
@@ -543,6 +547,45 @@ mod tests {
/// → 1/3 × 5/18 yards (the client's ×1/36 cache conversion, byte-verified at `0x607a00`);
/// a display whose model authors `FootprintTextureID = −1` resolves `None`. Guards the
/// schema columns (a shifted field would misread every print) and the inches→yards space.
/// **The blood-row tier populations** over the shipped tables — the measurement behind 1850.
/// `CreatureModelData.BloodID = −1` (122 of 430 models) is a tier-2 *miss*, not a bloodless
/// marker, so those displays fall through to the reference's tier-3 records base and bleed
/// RED. benilla read `−1` as bloodless and dropped the spurt on all 595 of them.
#[test]
fn blood_row_tiers_over_the_shipped_displays() {
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 blood = crate::load_blood_catalog(&mut chain).expect("blood tables");
// A tier resolves iff its id names a real UnitBloodLevels row — which is exactly what
// `level_key` reports when the *other* tier is forced to miss.
let resolves = |v: i32| {
u32::try_from(v)
.ok()
.is_some_and(|k| blood.level_key(v, i32::MIN) == Some(k))
};
let mut tiers = [0usize; 3];
for &display_id in cat.display.keys() {
let Some(m) = cat.model(display_id) else {
continue;
};
tiers[if resolves(m.blood_display) {
0
} else if resolves(m.blood_model) {
1
} else {
2
}] += 1;
}
assert_eq!(
tiers,
[36, 9903, 595],
"tier 1 / tier 2 / tier 3 over the 10534 shipped displays"
);
assert_eq!(tiers.iter().sum::<usize>(), cat.display.len());
}
#[test]
fn footprints_resolve_on_real_data() {
let data = crate::wow_data_or_skip!();
+185 -29
View File
@@ -2,15 +2,28 @@
//!
//! The byte-verified chain (wow-re `melee-blood-spurt.md`, `0x624530 → 0x625010`): a melee hit with
//! `HitInfo & 0x2`, nonzero damage, and victimState ∈ {1,4} resolves the victim's blood id
//! (`CreatureDisplayInfo.BloodLevel` override, else `CreatureModelData.BloodID`; ≤ 0 = bloodless) →
//! a **UnitBloodLevels** row, whose three columns are the bloodID per `violenceLevel` (0–2) — the
//! ([`BloodCatalog::level_key`]: the `CreatureDisplayInfo.BloodLevel` override, else
//! `CreatureModelData.BloodID`, else **UnitBloodLevels' file row 0** — the tier-3 fallback 1850
//! restored) → a **UnitBloodLevels** row, whose three columns are the bloodID per
//! `violenceLevel` (0–2) — the
//! vanilla censorship table: a red-blooded creature at violence 1 bleeds *green*, at 0 nothing —
//! → a **UnitBlood** row whose first four fields are `SpellVisualEffectName` ids. Column order
//! pinned empirically from the effect names (`Combat Blood Spurt <Front|Back> <Small|Large> <color>`,
//! 12/12 rows consistent): **[FrontSmall, FrontLarge, BackSmall, BackLarge]** — front/back from
//! `sign(victimForward · (attackerPos − victimPos))`, Large on the *crushing* bit `HitInfo & 0x2000`
//! (not crit — crit `0x80` belongs to the wound flinch). Fields 5–9 are the ground-splat decal
//! textures (`textures\BloodSplats\…`) — a separate mechanism, not read here (no consumer yet).
//! (not crit — crit `0x80` belongs to the wound flinch).
//!
//! **Fields 5–9 are the row's ground-splat decal textures, and they are DEAD in 1.12.1**
//! (`textures\BloodSplats\…`, [`BloodCatalog::splats`]; wow-re `ground-blood-splat-dead.md`,
//! decision 1850). Not "unimplemented" — *absent*: no instruction in the image reads
//! `UnitBloodRecord + 0x14 … +0x24`, and the string `"BloodSplat"` occurs nowhere in `WoW.exe`,
//! though all twelve `.blp` ship in `texture.MPQ` and the DBC resolves them. Data shipped, art
//! shipped, code gone. The only ground decal in 1.12.1 that takes a DBC string column is the
//! footprint. So there is no trigger, no placement and no fade to be faithful to, and the accessor
//! exists to pin the finding, not to feed a lane: the shipped table names **four** textures per
//! row and leaves the fifth column empty in all three (red / green / black), so a reader takes the
//! non-empty prefix, never a fixed five. Drawing them at all would be a benilla embellishment —
//! the director's call, not a fidelity gap.
use std::collections::HashMap;
@@ -18,7 +31,7 @@ use crate::Chain;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, u32_at};
use crate::dbc::{parse, str_at, u32_at};
const UNIT_BLOOD: &str = "DBFilesClient\\UnitBlood.dbc";
const UNIT_BLOOD_LEVELS: &str = "DBFilesClient\\UnitBloodLevels.dbc";
@@ -28,14 +41,59 @@ const UNIT_BLOOD_LEVELS: &str = "DBFilesClient\\UnitBloodLevels.dbc";
pub struct BloodCatalog {
/// UnitBloodLevels: id → the bloodID per violence level 0/1/2 (`0` = no blood at that level).
levels: HashMap<u32, [u32; 3]>,
/// UnitBlood: id → `[FrontSmall, FrontLarge, BackSmall, BackLarge]` effect ids.
rows: HashMap<u32, [u32; 4]>,
/// The id of UnitBloodLevels' **file row 0** — the records base the reference's tier-3
/// fallback returns ([`BloodCatalog::level_key`]). `1` in the shipped table (ids 1/2/3, so
/// this is emphatically *not* `idIndex[0]`, which is NULL). `None` for an empty table.
default_level: Option<u32>,
/// UnitBlood: id → the row's spurt effects + ground-splat textures.
rows: HashMap<u32, BloodRow>,
}
/// One `UnitBlood.dbc` row: the four spurt `SpellVisualEffectName` ids and the row's ground-splat
/// decal textures.
struct BloodRow {
/// `[FrontSmall, FrontLarge, BackSmall, BackLarge]` effect ids.
effects: [u32; 4],
/// The non-empty ground-splat textures, in column order (`textures\BloodSplats\…`,
/// extensionless). Four in every shipped row; the fifth column is empty throughout.
splats: Vec<String>,
}
impl BloodCatalog {
/// The spurt effect for a victim: `blood_id` is the creature's resolved UnitBloodLevels key
/// (≤ 0 = a bloodless model — `None`), `violence` the gore level (0–2, clamped), `front`/`large`
/// the hit geometry/weight. `None` = no spurt (bloodless, censored at this level, or absent row).
/// The victim's **UnitBloodLevels key**, by the reference's three-tier fallback — the display
/// resolve `0x60afb0`, which stores the resulting row pointer at `[unit+0xb48]` and is the only
/// place blood ids are turned into a row (wow-re `melee-blood-spurt.md` §c,
/// `ground-blood-splat-dead.md` §Q9):
///
/// 1. `CreatureDisplayInfo.BloodLevel` (`display_blood`), else
/// 2. `CreatureModelData.BloodID` (`model_blood`), else
/// 3. **UnitBloodLevels' file row 0** — id `1`, the RED row, in the shipped table.
///
/// A tier "fails" on `< 0`, on `> maxId`, **or** on an `idIndex` slot that holds no row —
/// which a [`HashMap`] miss covers exactly. That third tier is the load-bearing one: 595 of
/// the 10534 shipped displays (5.6 %) reach it, because `BloodID = −1` is a tier-2 *miss*,
/// not a bloodless marker. Reading `−1` as "bloodless" is what benilla did until 1850, and
/// it silently dropped the spurt on every one of those 595.
///
/// `None` only for an empty table — no shipped display is bloodless by this chain. A unit
/// still ends up with no blood at violence 0, where every row's column is `0`.
pub fn level_key(&self, display_blood: i32, model_blood: i32) -> Option<u32> {
let resolves = |v: i32| {
u32::try_from(v)
.ok()
.filter(|k| self.levels.contains_key(k))
};
resolves(display_blood)
.or_else(|| resolves(model_blood))
.or(self.default_level)
}
/// The spurt effect for a victim: `blood_id` is the victim's UnitBloodLevels key — what
/// [`Self::level_key`] resolved, which for shipped data is always a real row — `violence` the
/// gore level (0–2, clamped), `front`/`large` the hit geometry/weight. `None` = no spurt:
/// censored away at this violence level (every row's column is `0` at violence 0), or a key
/// that names no row. The `<= 0` guard below is defensive, not a "bloodless model" case:
/// 1850 found that no shipped display resolves to one.
pub fn effect_id(
&self,
blood_id: i32,
@@ -43,6 +101,29 @@ impl BloodCatalog {
front: bool,
large: bool,
) -> Option<u32> {
let row = self.row(blood_id, violence)?;
let idx = match (front, large) {
(true, false) => 0,
(true, true) => 1,
(false, false) => 2,
(false, true) => 3,
};
Some(row.effects[idx]).filter(|&id| id != 0)
}
/// The **ground-splat decal textures** for a victim — the same row resolve as
/// [`Self::effect_id`] (`blood id → UnitBloodLevels[violence] → UnitBlood row`), so a censored
/// violence level drops the splat exactly as it drops the spurt. The slice is the row's non-empty texture columns in column order: extensionless
/// `textures\BloodSplats\…` paths, four per row in the shipped table. Empty = no splat.
pub fn splats(&self, blood_id: i32, violence: usize) -> &[String] {
self.row(blood_id, violence).map_or(&[], |r| &r.splats)
}
/// The victim's resolved `UnitBlood` row: the [`Self::level_key`] blood id keyed through the
/// violence-level censorship table (`GetUnitBloodRecord 0x60a390`). `None` for a violence
/// level this creature yields no blood at, for a row the table doesn't carry, or — the
/// defensive arm — for a non-positive key no resolve produces.
fn row(&self, blood_id: i32, violence: usize) -> Option<&BloodRow> {
if blood_id <= 0 {
return None;
}
@@ -50,14 +131,7 @@ impl BloodCatalog {
if level == 0 {
return None;
}
let row = self.rows.get(&level)?;
let idx = match (front, large) {
(true, false) => 0,
(true, true) => 1,
(false, false) => 2,
(false, true) => 3,
};
Some(row[idx]).filter(|&id| id != 0)
self.rows.get(&level)
}
/// Row counts (levels, rows) for logging/diagnostics.
@@ -76,7 +150,7 @@ fn unit_blood_levels_schema() -> Schema {
}
/// UnitBlood.dbc — 10 fields in build 5875: ID, the four spurt `SpellVisualEffectName` ids, and
/// five ground-splat texture strings (unread — see the module doc).
/// five ground-splat texture strings (four named + one empty in every shipped row).
fn unit_blood_schema() -> Schema {
let mut s = Schema::new("UnitBlood");
for name in ["ID", "FrontSmall", "FrontLarge", "BackSmall", "BackLarge"] {
@@ -93,11 +167,14 @@ fn unit_blood_schema() -> Schema {
/// Load both blood tables from the patch chain into a [`BloodCatalog`].
pub fn load_blood_catalog(chain: &mut Chain) -> Result<BloodCatalog> {
let levels = {
let (levels, default_level) = {
let bytes = chain
.read_file(UNIT_BLOOD_LEVELS)
.with_context(|| format!("reading {UNIT_BLOOD_LEVELS}"))?;
let rs = parse(&bytes, unit_blood_levels_schema(), "UnitBloodLevels")?;
// File order matters here and nowhere else: tier 3 returns the RECORDS BASE, so the
// fallback id is row 0's, read before the rows go into the (unordered) map.
let default_level = rs.records().first().and_then(|r| u32_at(r, 0));
let mut m = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
if let Some(id) = u32_at(r, 0) {
@@ -111,7 +188,7 @@ pub fn load_blood_catalog(chain: &mut Chain) -> Result<BloodCatalog> {
);
}
}
m
(m, default_level)
};
let rows = {
let bytes = chain
@@ -123,18 +200,27 @@ pub fn load_blood_catalog(chain: &mut Chain) -> Result<BloodCatalog> {
if let Some(id) = u32_at(r, 0) {
m.insert(
id,
[
u32_at(r, 1).unwrap_or(0),
u32_at(r, 2).unwrap_or(0),
u32_at(r, 3).unwrap_or(0),
u32_at(r, 4).unwrap_or(0),
],
BloodRow {
effects: [
u32_at(r, 1).unwrap_or(0),
u32_at(r, 2).unwrap_or(0),
u32_at(r, 3).unwrap_or(0),
u32_at(r, 4).unwrap_or(0),
],
// `str_at` drops the empty columns, so a row keeps only the textures it
// actually names — the fifth column throughout the shipped table.
splats: (5..10).filter_map(|i| str_at(&rs, r, i)).collect(),
},
);
}
}
m
};
Ok(BloodCatalog { levels, rows })
Ok(BloodCatalog {
levels,
default_level,
rows,
})
}
#[cfg(test)]
@@ -178,9 +264,79 @@ mod tests {
Some(183),
"censored green"
);
// Violence 0: no blood at all; bloodless models (≤0) never spurt.
// Violence 0: no blood at all; the defensive non-positive guard yields nothing either.
assert_eq!(blood.effect_id(1, 0, true, false), None);
assert_eq!(blood.effect_id(-1, 2, true, false), None);
assert_eq!(blood.effect_id(0, 2, true, false), None);
}
/// The **three-tier row resolve** (`0x60afb0`), the bug 1850 fixed: a display whose
/// `BloodLevel` is `0` and whose model's `BloodID` is `−1` is NOT bloodless — both tiers miss
/// and tier 3 hands back UnitBloodLevels' file row 0, id 1, the RED row. Pinned end to end
/// against the shipped creature tables, including the population that reaches each tier.
#[test]
fn the_blood_row_resolve_falls_through_to_the_records_base() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let blood = load_blood_catalog(&mut chain).expect("blood tables");
// Tier 1 wins when the display carries a real override; tier 2 when only the model does.
assert_eq!(
blood.level_key(3, 1),
Some(3),
"tier 1: the display override"
);
assert_eq!(
blood.level_key(0, 2),
Some(2),
"tier 2: 0 is not a row of the table"
);
// Tier 3 — the 595-display case. `-1` is a MISS, not a bloodless marker.
assert_eq!(
blood.level_key(0, -1),
Some(1),
"tier 3: the records base, id 1 (red)"
);
assert_eq!(
blood.level_key(-1, -1),
Some(1),
"tier 3: both tiers negative"
);
assert_eq!(
blood.level_key(99, 99),
Some(1),
"tier 3: both tiers out of range"
);
// …and a tier-3 unit really does spurt, which is exactly what benilla dropped before.
assert_eq!(blood.effect_id(1, 2, true, false), Some(109));
}
/// The ground-splat columns of the same three rows: **four** textures each, colour-matched to
/// the row the violence level selects — red at max, the censored green one rung down, nothing
/// at 0 — and the fifth column empty throughout, so the shipped slice is length 4, never 5.
#[test]
fn real_blood_tables_carry_four_ground_splats_per_row() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let blood = load_blood_catalog(&mut chain).expect("blood tables");
assert_eq!(
blood.splats(1, 2),
[
r"textures\BloodSplats\BloodSplatRed01",
r"textures\BloodSplats\BloodSplatRed02",
r"textures\BloodSplats\BloodSplatRed03",
r"textures\BloodSplats\BloodSplatRed04",
],
"red blood at max violence"
);
// Censored one rung down: the same creature's splats go green (row 2), matching the spurt.
assert!(blood.splats(1, 1).iter().all(|p| p.contains("Green")));
assert_eq!(blood.splats(1, 1).len(), 4);
// Row 3 is the black-blood row; violence 0 (and the defensive guard) splat nothing.
assert!(blood.splats(3, 2).iter().all(|p| p.contains("Black")));
assert!(blood.splats(1, 0).is_empty());
assert!(blood.splats(-1, 2).is_empty());
assert!(blood.splats(0, 2).is_empty());
}
}
+13 -2
View File
@@ -17,8 +17,8 @@ use crate::messages::{
GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse, GuildRoster,
InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem,
LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MonsterMoveFacing,
ObjectFields, PartyKillLog, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
PetitionQueryResponse, PetitionRename, PetitionShowList, PetitionShowSignatures,
MoverState, ObjectFields, PartyKillLog, PartyMemberStatsInfo, PeriodicAuraLog, PetMode,
PetSpells, PetitionQueryResponse, PetitionRename, PetitionShowList, PetitionShowSignatures,
PetitionSignResults, PvpCredit, QuestComplete, QuestConfirmAccept, QuestDetails,
QuestGiverList, QuestOfferReward, QuestRequestItems, QuestShareMsg, QuestTemplate,
SpellDamageLog, SpellDispelLog, SpellEnergizeLog, SpellHealLog, SpellInstaKillLog,
@@ -236,6 +236,17 @@ pub enum SessionEvent {
/// animation selector's walk-vs-run boundary (RF-0057) and the net bridge's remote-mover
/// extrapolation. (Movement-block data, not a descriptor field — hence not in `fields`.)
speeds: Option<MoveSpeeds>,
/// The **live mover state** this unit is already in as it streams into view — its
/// `MOVEMENTFLAGS` word and swim pitch ([`MoverState`]); `None` for a GameObject. The
/// reference applies both from the create block (byte-VERIFIED — wow-5875-re
/// `system/collision/scratch/create-block-swim-pitch.md`; the flags through the very same
/// `0x75a07dff` merge a relayed `MSG_MOVE_*` uses, the pitch through the shared pose commit
/// `0x7c6420`), and it does so *before* the model or its render callback exist — so a unit
/// that comes into view mid-motion is already in that motion on its first drawn frame. We
/// used to drop the whole word: a player who swam into view rendered level and untilted
/// until their next relay packet, and an idle floater — who sends none — stayed level for
/// as long as they floated.
mover: Option<MoverState>,
/// A transport GameObject's (`UPDATE_FLAG_TRANSPORT`) path-progress anchor — `Some` only for a
/// type-15/type-11 transport create (a boat/zeppelin/elevator). Decision 0438's cycle anchor:
/// `anchor = this value`, `t₀ = Instant::now()` on create/re-create; per frame `progress =
@@ -1102,6 +1102,7 @@ fn decode_objects(objects: Vec<Object>) -> Vec<SessionEvent> {
orientation,
scale,
speeds,
mover: movement.mover,
transport_progress: movement.transport_progress,
transport: movement.transport,
spline: movement.spline,
+3 -3
View File
@@ -19,9 +19,9 @@ pub use events::{
};
pub use messages::{
CharCreateReq, CharEnumItem, Character, CorpseLook, CreateSpline, ItemInfo, JumpInfo,
MonsterMoveFacing, MoveMode, ObjectFields, OwnerFallback, ServerPacket, SpeedKind, SplineMode,
TransportPose, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM,
CHARACTER_FLAG_RENAME,
MonsterMoveFacing, MoveMode, MoverState, ObjectFields, OwnerFallback, ServerPacket, SpeedKind,
SplineMode, TransportPose, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK,
CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME,
};
pub use world::{
WardenRequired, WorldAuthReject, WorldReader, WorldSession, WorldWriter, WORLD_PORT,
+2 -2
View File
@@ -240,8 +240,8 @@ pub use trade::{
};
pub use trainer::{train_fail, trainer_buy_spell, trainer_list, trainer_spell_state, TrainerSpell};
pub use update_object::{
quest_slot_state, CorpseLook, CreateSpline, MovementBlock, Object, ObjectFields, ObjectType,
OwnerFallback, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE,
quest_slot_state, CorpseLook, CreateSpline, MovementBlock, MoverState, Object, ObjectFields,
ObjectType, OwnerFallback, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE,
AURA_FLAG_EFF_INDEX_MASK, FIELD_PLAYER_SKILL_INFO_1_1, PLAYER_EXPLORED_ZONES_SLOTS,
PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS,
};
@@ -18,7 +18,7 @@ pub use fields::{
FIELD_PLAYER_SKILL_INFO_1_1, PLAYER_EXPLORED_ZONES_SLOTS, PLAYER_QUEST_LOG_SLOTS,
PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS,
};
pub use movement::{CreateSpline, MovementBlock, ObjectType};
pub use movement::{CreateSpline, MovementBlock, MoverState, ObjectType};
/// One entry in an `SMSG_UPDATE_OBJECT` object list.
pub enum Object {
@@ -93,12 +93,46 @@ pub struct CreateSpline {
pub run_mode: bool,
}
/// A `LIVING` block's live mover state: the `MOVEMENTFLAGS` word and the swim pitch that rides it.
/// See [`MovementBlock::mover`] for why both are surfaced rather than parsed and dropped.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MoverState {
/// The raw `MOVEMENTFLAGS` word (`CMovement+0x40`) — direction bits, swim, falling, the granted
/// modes.
pub flags: u32,
/// The **swim pitch** (radians, +up), present on the wire iff `MOVEFLAG_SWIMMING`; `0.0` (level)
/// otherwise.
pub pitch: f32,
}
/// The decoded fields a movement block carries: the pose (from its `LIVING`/`HAS_POSITION` flag),
/// for a `LIVING` block the unit's 6 movement speeds, its live [`CreateSpline`] and (if
/// `ON_TRANSPORT`) its rider pose, and (if `UPDATE_FLAG_TRANSPORT`) a transport GameObject's path
/// progress. The rest of the block is parsed and discarded to stay aligned.
pub struct MovementBlock {
pub position: Option<(Vector3d, f32)>,
/// A `LIVING` block's **live `MOVEMENTFLAGS` word** and its **swim pitch** — the mover state this
/// unit is *already in* at the moment it streams into view. `None` for a `HAS_POSITION`-only
/// block (a GameObject has no mover).
///
/// **The reference applies both, byte-VERIFIED** (wow-5875-re §5,
/// `system/collision/scratch/create-block-swim-pitch.md`). The create-block apply `0x619320`
/// reaches `0x618c30`, which merges the block's flags into `CMovement+0x40` under the same
/// `0x75a07dff` mask a relayed `MSG_MOVE_*` uses (`0x618df3`; `SWIMMING`, `FORWARD` and
/// `BACKWARD` are all inside it) and then calls the pose commit `0x7c6420`, whose
/// `fld [edx+0x34]` / `fst [ecx+0x20]` copies **this pitch** into the mover pitch field
/// unconditionally, alongside the position and facing. So a unit that streams in mid-swim is
/// pitched on its **first drawn frame** — the apply runs before `0x613e10` has even built the
/// model or registered its per-frame render callback.
///
/// Dropping the pair is why a player who came into view mid-swim rendered *level* until their
/// next relay packet: the body-pitch render law reads exactly these two values and had neither.
///
/// The pitch is `0.0` (level) whenever `MOVEFLAG_SWIMMING` is clear, and that is the
/// reference's own value, not our convenience: the block field is explicitly zeroed at
/// `0x47ec4a` on the non-swimming leg, so the unconditional commit stores a real 0.0 rather
/// than stale memory.
pub mover: Option<MoverState>,
/// A `LIVING` block's 6 movement speeds (yd/s) in wire order `[walk, run, run_back, swim, swim_back,
/// turn_rate]`; `None` for a `HAS_POSITION`-only block (GameObjects, which don't move under their
/// own power). The animation selector keys walk-vs-run on `walk` (boundary 2× it — RF-0057); the
@@ -128,6 +162,7 @@ impl MovementBlock {
pub(super) fn read(r: &mut impl Read) -> io::Result<Self> {
let update_flag = read_u8(r)?;
let mut position = None;
let mut mover = None;
let mut speeds = None;
let mut transport = None;
let mut transport_progress = None;
@@ -150,9 +185,14 @@ impl MovementBlock {
orientation: read_f32_le(r)?,
});
}
if flags & MOVEMENT_FLAG_SWIMMING != 0 {
let _pitch = read_f32_le(r)?;
}
// The swim-pitch tail — a lone `f32` present iff `MOVEFLAG_SWIMMING`, the same gate
// (and the same position in the layout) the `MSG_MOVE_*` body uses.
let pitch = if flags & MOVEMENT_FLAG_SWIMMING != 0 {
read_f32_le(r)?
} else {
0.0
};
mover = Some(MoverState { flags, pitch });
let _fall_time = read_f32_le(r)?;
if flags & MOVEMENT_FLAG_JUMPING != 0 {
for _ in 0..4 {
@@ -228,6 +268,7 @@ impl MovementBlock {
Ok(Self {
position,
mover,
speeds,
transport_progress,
transport,
@@ -447,6 +447,111 @@ fn unit_living_block_surfaces_on_transport_rider_pose() {
}
}
/// A player who streams in **already swimming**: the `LIVING` block's `MOVEFLAG_SWIMMING` word and
/// the swim-pitch float that rides it both reach [`SessionEvent::ObjectCreate::mover`], and the six
/// speeds after the tail still parse from the right offset.
///
/// This is the byte the client threw away for as long as observed swimming has existed
/// (`let _pitch = read_f32_le(r)?;`). Its cost was the whole body-pitch render law being
/// unreachable at create: the app's only source of a mover's flags + pitch was a relayed
/// `MSG_MOVE_*`, so a player who swam into view rendered LEVEL until their next packet — and an
/// idle floater, who sends none while unmoving, stayed level for as long as they floated. The
/// reference re-authors `CMovement`'s live flags word from this same block (wow-5875-re
/// `system/collision/collision.md`, the create-block apply's `0x75a07dff` merge).
#[test]
fn unit_living_block_surfaces_the_swim_it_is_already_in() {
let mut body = 1u32.to_le_bytes().to_vec();
body.push(0); // has_transport
body.push(2); // update_type: CREATE_OBJECT
write_packed_guid(0xAA, &mut body).unwrap();
body.push(4); // TypeId::Player
body.push(0x20); // movement block: LIVING only
let flags: u32 = 0x20_0000 | 0x1; // MOVEFLAG_SWIMMING | MOVEFLAG_FORWARD
body.extend_from_slice(&flags.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
body.extend_from_slice(&(-812.5f32).to_le_bytes()); // living pos.x
body.extend_from_slice(&(-566.0f32).to_le_bytes()); // living pos.y
body.extend_from_slice(&(-3.25f32).to_le_bytes()); // living pos.z (under the surface)
body.extend_from_slice(&2.0f32.to_le_bytes()); // living orientation
// The SWIMMING tail: one f32, no ON_TRANSPORT pose before it, `fall_time` right after.
body.extend_from_slice(&(-0.4f32).to_le_bytes()); // swim pitch — nose DOWN
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
body.extend_from_slice(&v.to_le_bytes()); // the 6 speeds — must land right after the tail
}
body.push(0); // mask: 0 blocks
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
match decode(packet).as_slice() {
[SessionEvent::ObjectCreate {
guid: 0xAA,
kind: EntityKind::Player,
orientation,
mover,
speeds,
..
}] => {
assert_eq!(*orientation, 2.0);
assert_eq!(
mover,
&Some(benilla_protocol::MoverState { flags, pitch: -0.4 }),
"the live flags word and its swim-pitch tail both reach the app"
);
assert_eq!(
speeds.map(|s| s.walk),
Some(2.5),
"the 6 speeds still parse right after the swim-pitch tail"
);
}
other => panic!("expected one ObjectCreate, got {other:?}"),
}
}
/// A **dry** create carries no pitch tail at all — the four bytes are absent, so a parse that read
/// one unconditionally would swallow `fall_time` and misalign every speed after it. The mover state
/// still surfaces (the flags word is not conditional); its pitch is a level `0.0`, which is what the
/// render law's own SWIMMING gate then reads.
#[test]
fn a_dry_living_block_has_no_pitch_tail_and_reads_level() {
let mut body = 1u32.to_le_bytes().to_vec();
body.push(0);
body.push(2);
write_packed_guid(0xAB, &mut body).unwrap();
body.push(3); // TypeId::Unit
body.push(0x20); // LIVING
let flags: u32 = 0x1; // FORWARD, walking on dry land
body.extend_from_slice(&flags.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
for v in [10.0f32, 20.0, 30.0, 0.5] {
body.extend_from_slice(&v.to_le_bytes()); // pos + orientation
}
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time — NO pitch tail before it
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
body.extend_from_slice(&v.to_le_bytes());
}
body.push(0);
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
match decode(packet).as_slice() {
[SessionEvent::ObjectCreate { mover, speeds, .. }] => {
assert_eq!(
mover,
&Some(benilla_protocol::MoverState { flags, pitch: 0.0 }),
"no tail on the wire reads as a level pitch, not a misparse"
);
assert_eq!(
speeds.map(|s| s.walk),
Some(2.5),
"the speeds land right after fall_time — the tail really was absent"
);
}
other => panic!("expected one ObjectCreate, got {other:?}"),
}
}
/// A unit that is **already walking** when it streams in: the `LIVING` block's
/// `MOVEFLAG_SPLINE_ENABLED` (0x0040_0000) tail, byte-shaped exactly as vmangos writes it
/// (`PacketBuilder::WriteCreate`, `packet_builder.cpp:152`). Two things are verified, and both are