diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index ed6b04d3..23c48b2e 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -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 diff --git a/crates/benilla-app/src/capture/probe_pitch.rs b/crates/benilla-app/src/capture/probe_pitch.rs new file mode 100644 index 00000000..d985c461 --- /dev/null +++ b/crates/benilla-app/src/capture/probe_pitch.rs @@ -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` 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, +} + +/// 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 { + let spec = std::env::var("WOW_PROBE_PITCH").ok()?; + let aims: Vec = 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::().ok()?), + None => (rest, 0.0), + }; + match (deg.trim().parse::(), at.trim().parse::()) { + (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, + time: ProbeClock, + mut player: ResMut, + self_player: Query<(), With>, + mut announced: Local>, +) { + 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)), + ); + } +} diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index e722dcd0..57a90a16 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -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 diff --git a/crates/benilla-app/src/creature_anim/blood.rs b/crates/benilla-app/src/creature_anim/blood.rs index 56b06ca4..7a5f3d52 100644 --- a/crates/benilla-app/src/creature_anim/blood.rs +++ b/crates/benilla-app/src/creature_anim/blood.rs @@ -51,10 +51,11 @@ pub(super) fn load_blood_tables(mut commands: Commands, assets: Option 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)] diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index cefe1de8..a8c2284d 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -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=` appends per-second position + frame-time rows on a // director-driven run — "where does it dip" as coordinates (see `perf::FpsJournalPlugin`). diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 1bbdadd5..1ca6a018 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -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 { - 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 diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 3c434407..a0bd9fc6 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -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, diff --git a/crates/benilla-app/src/net/apply/objects.rs b/crates/benilla-app/src/net/apply/objects.rs index 0ad056df..a6ab5294 100644 --- a/crates/benilla-app/src/net/apply/objects.rs +++ b/crates/benilla-app/src/net/apply/objects.rs @@ -72,6 +72,7 @@ pub(super) fn object_create( orientation: f32, scale: f32, speeds: Option, + mover: Option, transport_progress: Option, transport: Option, spline: Option, @@ -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 diff --git a/crates/benilla-app/src/net/motion/remote.rs b/crates/benilla-app/src/net/motion/remote.rs index a73f382b..c53da175 100644 --- a/crates/benilla-app/src/net/motion/remote.rs +++ b/crates/benilla-app/src/net/motion/remote.rs @@ -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, Has, + // 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); } diff --git a/crates/benilla-app/src/net/motion/tests.rs b/crates/benilla-app/src/net/motion/tests.rs index 6e6057aa..7f8b873f 100644 --- a/crates/benilla-app/src/net/motion/tests.rs +++ b/crates/benilla-app/src/net/motion/tests.rs @@ -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::>(); + let e = world.spawn_empty().id(); + world + .run_system_once( + move |mut commands: bevy::prelude::Commands, + mut landings: bevy::prelude::MessageWriter| { + 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 diff --git a/crates/benilla-app/src/player/body_pose.rs b/crates/benilla-app/src/player/body_pose.rs index 8dd24c69..37246cb6 100644 --- a/crates/benilla-app/src/player/body_pose.rs +++ b/crates/benilla-app/src/player/body_pose.rs @@ -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 diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index 72dd6721..1d4743ce 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -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 diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index c58c2af9..51045c21 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -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 " — // 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; diff --git a/crates/benilla-app/src/target/cursor_mode.rs b/crates/benilla-app/src/target/cursor_mode.rs index 2844d3c7..893530d3 100644 --- a/crates/benilla-app/src/target/cursor_mode.rs +++ b/crates/benilla-app/src/target/cursor_mode.rs @@ -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 diff --git a/crates/benilla-app/src/ui_action/errors.rs b/crates/benilla-app/src/ui_action/errors.rs index e85fc0c9..3d356377 100644 --- a/crates/benilla-app/src/ui_action/errors.rs +++ b/crates/benilla-app/src/ui_action/errors.rs @@ -317,12 +317,37 @@ pub(crate) fn attack_actor_refusal( self_guid: Option, 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, +) -> 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`, diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index 65a291f3..f88ee72c 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -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 diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index 065edccf..7f9c68a8 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -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) } diff --git a/crates/benilla-formats/src/creatures.rs b/crates/benilla-formats/src/creatures.rs index 7712d657..fb59fc82 100644 --- a/crates/benilla-formats/src/creatures.rs +++ b/crates/benilla-formats/src/creatures.rs @@ -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, - /// 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; 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 { 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::(), cat.display.len()); + } + #[test] fn footprints_resolve_on_real_data() { let data = crate::wow_data_or_skip!(); diff --git a/crates/benilla-formats/src/unit_blood.rs b/crates/benilla-formats/src/unit_blood.rs index e9046636..78cd4595 100644 --- a/crates/benilla-formats/src/unit_blood.rs +++ b/crates/benilla-formats/src/unit_blood.rs @@ -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 `, //! 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, - /// UnitBlood: id → `[FrontSmall, FrontLarge, BackSmall, BackLarge]` effect ids. - rows: HashMap, + /// 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, + /// UnitBlood: id → the row's spurt effects + ground-splat textures. + rows: HashMap, +} + +/// 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, } 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 { + 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 { + 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 { - 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 { ); } } - m + (m, default_level) }; let rows = { let bytes = chain @@ -123,18 +200,27 @@ pub fn load_blood_catalog(chain: &mut Chain) -> Result { 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()); + } } diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 3d5a9e5c..49fab93c 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -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, + /// 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, /// 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 = diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index e0b68be6..e4631a0d 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -1102,6 +1102,7 @@ fn decode_objects(objects: Vec) -> Vec { orientation, scale, speeds, + mover: movement.mover, transport_progress: movement.transport_progress, transport: movement.transport, spline: movement.spline, diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs index 7c7bf00c..b1dbd6e3 100644 --- a/crates/benilla-protocol/src/lib.rs +++ b/crates/benilla-protocol/src/lib.rs @@ -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, diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 0406c34a..bc1c71b9 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -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, }; diff --git a/crates/benilla-protocol/src/messages/update_object/mod.rs b/crates/benilla-protocol/src/messages/update_object/mod.rs index 8598ce70..46c7b531 100644 --- a/crates/benilla-protocol/src/messages/update_object/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/mod.rs @@ -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 { diff --git a/crates/benilla-protocol/src/messages/update_object/movement.rs b/crates/benilla-protocol/src/messages/update_object/movement.rs index a121ae85..d901fe62 100644 --- a/crates/benilla-protocol/src/messages/update_object/movement.rs +++ b/crates/benilla-protocol/src/messages/update_object/movement.rs @@ -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, /// 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 { 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, diff --git a/crates/benilla-protocol/tests/update_object.rs b/crates/benilla-protocol/tests/update_object.rs index 48bee676..5361a3f5 100644 --- a/crates/benilla-protocol/tests/update_object.rs +++ b/crates/benilla-protocol/tests/update_object.rs @@ -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