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