nothing plays over a cinematic, and a lootable corpse sparkles
- the music slot refuses everyone while a cinematic runs: the suppression was checked at two of the four callers, and the other two — the zone intro fanfare and SMSG_PLAY_MUSIC — played over the narration to their end, the fanfare even stamping its once-per-cooldown play under a shot you couldn't hear it through; the guard moved into the slot itself, the reference's own shape (its music pump dies at its own first instruction), and the narration now holds its own line in the voice budget, where before the client believed it had one more free voice than it did for the length of every cinematic - zone chat channels stop rejoining under a cinematic: the join walk reads the streamed area, which during a fly-by is wherever the camera is, so joins fired against zones the player was only looking at; the reference gates exactly this at two sites and rejoins once at the end — which our still-armed walk now does by simply falling through on the next frame - a lootable corpse sparkles: the reference watches the corpse's dynamic-flags loot bit and spawns its hard-coded "Loot Art" effect row — Particles\LootFX.m2 at the body's attachment point, no scale, colour or lifetime set, the asset's own emitters being the whole look — the same asset at the same tag our lootable dead units already wore, so both share one arm; the corpse ground decal, meanwhile, is (almost certainly) dead code; and the probes see a corpse as its own entity kind again — the death probe had been identifying it by a heuristic a refactor outdated, so it failed runs whose wire was entirely correct - the player controller splits along its concerns: one 1705-line function becomes six modules (the netted axes, posture, transport riding, the move-flag words, writing the frame onto the body, and the frame order itself), player.rs 2093 → 438 lines with zero behaviour change — checked by a source-line multiset and a live A/B of the same scripted key drive producing the identical opcode sequence and flags (walk 7.000 vs 6.978 yd/s, phase not physics) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1229,11 +1229,15 @@ pub(crate) fn arm_aura_state_fx(
|
||||
armed.retain(|e, _| units.contains(*e));
|
||||
}
|
||||
|
||||
/// Arm/reap the **lootable-corpse sparkle** (wow-re `loot-corpse-effect.md`, §5 cross-checked):
|
||||
/// a DEAD unit carrying `UNIT_DYNFLAG_LOOTABLE` wears the `SpellVisualEffectName` row named
|
||||
/// Arm/reap the **lootable sparkle** on both things that can wear it (wow-re
|
||||
/// `loot-corpse-effect.md` + `corpse-decal-and-loot-sparkle.md` §Q2, both §5 cross-checked):
|
||||
/// a DEAD unit carrying `UNIT_DYNFLAG_LOOTABLE`, **and a corpse object carrying
|
||||
/// `CORPSE_FIELD_DYNAMIC_FLAGS` bit 0** (decision 1723 — a battleground body's insignia). Each
|
||||
/// wears the `SpellVisualEffectName` row named
|
||||
/// `"HARDCODED Loot Art"` (5875: `Particles\LootFX.mdl` — a golden flare + three star-twinkle
|
||||
/// emitters; cadence/size/color/blend all authored in the asset, the client sets none of them)
|
||||
/// attached at `0x13`, its own first sequence looping. The real client is edge-driven off the
|
||||
/// attached at `0x13` — the unit's looping its own first sequence, the corpse's arming no sequence
|
||||
/// at all (the divergence is named at the `stage` line below). The real client is edge-driven off the
|
||||
/// descriptor apply (watcher `0x600440`) with **no viewer/tap/distance/loot-window logic** — the
|
||||
/// server already strips the flag per viewer (vmangos's "hide lootable animation for unallowed
|
||||
/// players"). The falling edge (looted empty, rights lost) reaps with no fade (`0x600680`);
|
||||
@@ -1243,9 +1247,12 @@ pub(crate) fn arm_aura_state_fx(
|
||||
pub(super) fn arm_loot_fx(
|
||||
// Dead+lootable is a pure function of store fields — the same `Changed<ObjectStore>` gate as
|
||||
// `arm_level_up_fx` below and `arm_aura_state_fx` above, with the same one-shot full sweep
|
||||
// when the DBC resource lands after units already streamed in.
|
||||
units: Query<(Entity, &ObjectStore)>,
|
||||
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
|
||||
// when the DBC resource lands after units already streamed in. `NetEntity` splits the two
|
||||
// predicates: a **unit** answers `UNIT_DYNFLAG_LOOTABLE`, a **corpse object** answers its own
|
||||
// `CORPSE_FIELD_DYNAMIC_FLAGS` bit 0 (decision 1723) — different fields at different indices,
|
||||
// and a corpse descriptor has no UNIT block to ask at all.
|
||||
units: Query<(Entity, &ObjectStore, &crate::net::NetEntity)>,
|
||||
changed: Query<(Entity, &ObjectStore, &crate::net::NetEntity), Changed<ObjectStore>>,
|
||||
visuals: Option<Res<SpellVisuals>>,
|
||||
mut fx: MessageWriter<SpellKitFx>,
|
||||
mut armed: Local<EntityHashSet>,
|
||||
@@ -1259,8 +1266,16 @@ pub(super) fn arm_loot_fx(
|
||||
} else {
|
||||
changed.iter().collect::<Vec<_>>()
|
||||
};
|
||||
for (entity, store) in scan {
|
||||
let lootable = store.0.unit_is_dead() && store.0.unit_lootable();
|
||||
for (entity, store, net) in scan {
|
||||
// The **corpse object**'s own sparkle (wow-re `corpse-decal-and-loot-sparkle.md` §Q2, §5
|
||||
// cross-checked): `0x5d6de0` watches `CORPSE_FIELD_DYNAMIC_FLAGS`, and bit 0 rising calls
|
||||
// `0x5d6e30` → the same `"HARDCODED Loot Art"` row at the same attachment `0x13`. Same
|
||||
// asset, same tag — so it shares this body rather than growing a second one.
|
||||
let lootable = if net.kind == benilla_protocol::EntityKind::Corpse {
|
||||
store.0.corpse_lootable()
|
||||
} else {
|
||||
store.0.unit_is_dead() && store.0.unit_lootable()
|
||||
};
|
||||
if lootable && armed.insert(entity) {
|
||||
fx.write(SpellKitFx::Begin {
|
||||
entity,
|
||||
@@ -1272,7 +1287,20 @@ pub(super) fn arm_loot_fx(
|
||||
// loot art is "its own first sequence, LOOPING" — which is precisely what
|
||||
// [`FxStage::Relive`] renders, and unlike a bare loop-flag arm it holds even if the
|
||||
// art model's sequence were clamp-flagged.
|
||||
stage: FxStage::Relive,
|
||||
// **The one place the two paths differ, and it is a real divergence.** The unit's
|
||||
// loot art is an `Effect_C` node with a clip-end re-arm loop — "its own first
|
||||
// sequence, LOOPING", which is what `Relive` renders. The corpse's is a **bare
|
||||
// model instance**: `0x5d6e30` arms no sequence and registers no callback at all
|
||||
// (`[model+0x80]` stays `-1`), so the `.m2`'s emitters are the entire look and
|
||||
// nothing re-arms them. `OneShot` + `persistent` is the nearest shape this
|
||||
// plumbing expresses — no watcher, no re-arm, and it lives until the flag falls.
|
||||
// Named rather than smoothed over: if a lootable bone pile is ever seen sparkling
|
||||
// on a loop where the reference's plays out and stops, this is the line.
|
||||
stage: if net.kind == benilla_protocol::EntityKind::Corpse {
|
||||
FxStage::OneShot
|
||||
} else {
|
||||
FxStage::Relive
|
||||
},
|
||||
effects: vec![(HARDCODED_FX_ATTACH, path.to_string())],
|
||||
});
|
||||
} else if !lootable && armed.remove(&entity) {
|
||||
|
||||
+86
-1741
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
//! **Writing the frame onto the body we drive** — the last thing the controller does to the
|
||||
//! avatar itself, after the mover has moved it and [`super::gait`] has decided where it faces.
|
||||
//!
|
||||
//! The driven entity is a streamed unit like any other (decision 0041), so this is not a special
|
||||
//! "player render" path: it writes the same transform and the same `MovementState` the entity
|
||||
//! renderer would, which is exactly why a *possessed creature* needs nothing extra here
|
||||
//! (decision 1277). It is also where the frame's landing is reported to the local hard-landing
|
||||
//! predictor, and where the camera-pivot target for this frame is read off the body — the one
|
||||
//! value that leaves this module, because the camera seat consumes it.
|
||||
|
||||
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.
|
||||
/// `anim_flags` is [`super::gait::drive_body_heading`]'s verdict; `move_flags_now` is the live
|
||||
/// wire word, whose forward/back bits gate the swim body pitch.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn drive(
|
||||
player: &Player,
|
||||
body: &mut BodyQuery,
|
||||
hard_landing: &mut MessageWriter<crate::creature_anim::HardLanding>,
|
||||
swimming: bool,
|
||||
swim_pitch: f32,
|
||||
move_flags_now: u32,
|
||||
anim_flags: u32,
|
||||
landed: bool,
|
||||
stand_now: u8,
|
||||
) -> Option<f32> {
|
||||
// Drive the streamed self entity: its transform is the avatar's pose (feet position + body
|
||||
// heading, like every other streamed unit), and its `MovementState` is the live movement the
|
||||
// animation selector reads. Scale is left untouched (the renderer baked the display scale on).
|
||||
// `horiz_vel` is already the directional speed (runBack when backpedaling), so the backpedal
|
||||
// clip scales by it and no longer drags.
|
||||
// This frame's camera-pivot **target** — the model-local [`CameraPivot`] × the body's RAW
|
||||
// scale, clamped (see [`super::camera::head_height`] for why raw and not the rendered scale).
|
||||
// `None` while the body has no model yet: the channel holds rather than aiming at a
|
||||
// placeholder, which is what makes a display swap one glide instead of two.
|
||||
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.
|
||||
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)
|
||||
};
|
||||
// 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
|
||||
// server's 0x1FC echo arrives ~an RTT later (the reference double-fires the dust the
|
||||
// same way). `fall_start_y` still holds this arc's launch height here (it is only
|
||||
// re-seeded at the next take-off).
|
||||
if landed {
|
||||
hard_landing.write(crate::creature_anim::HardLanding {
|
||||
entity,
|
||||
descent: player.fall_start_y - player.pos.y,
|
||||
});
|
||||
}
|
||||
cam_pivot_target = pivot_target(pivot, net_entity);
|
||||
if let Some(mut motion) = motion {
|
||||
// A swimmer's stroke rate takes the flag-scalar directional speed (full rate at
|
||||
// any pitch — a vertical climb must not freeze the stroke); the ground gaits
|
||||
// scale by the achieved horizontal speed as before.
|
||||
motion.speed = if swimming {
|
||||
player.swim_stroke_speed
|
||||
} else {
|
||||
player.horiz_vel.length()
|
||||
};
|
||||
motion.vertical_speed = player.vel_y;
|
||||
motion.flags = anim_flags;
|
||||
motion.stand_state = stand_now;
|
||||
}
|
||||
// The counter-twist gap: how far the aim sits from the rendered body — the strafe
|
||||
// offset while it lasts, unwinding to zero as `model_yaw` closes on `face_yaw`.
|
||||
if let Some(mut twist) = twist {
|
||||
// `WOW_TWIST_GAP=<radians>` forces the gap — the counter-twist's A/B lever. The
|
||||
// pass is inert at `yaw_gap == 0` and a scripted probe cannot open a real gap
|
||||
// (`WOW_PROBE_CAM` turns the model with the camera, so the measured gap is float
|
||||
// noise, ~1e-6 rad), which means "removing the twist changed nothing" has never
|
||||
// yet been a measurement of the twist — only of a pass that never ran. This is
|
||||
// what lets it actually be exercised.
|
||||
twist.yaw_gap =
|
||||
twist_gap_override().unwrap_or_else(|| wrap_pi(player.face_yaw - player.model_yaw));
|
||||
}
|
||||
}
|
||||
cam_pivot_target
|
||||
}
|
||||
|
||||
/// The camera pivot's **target** height for a driven body: its model-local [`CameraPivot`] × the
|
||||
/// body's raw `OBJECT_FIELD_SCALE_X`, clamped — or `None` before its model has attached.
|
||||
///
|
||||
/// `None` is the load-bearing half. The reference recomputes the pivot preset only on a model event
|
||||
/// and *skips the camera update entirely* while the model is unresolved (`0x50e907`), so a
|
||||
/// display swap reads as a brief hold and then one glide. Aiming the channel at a placeholder height
|
||||
/// during those frames instead would send the camera on a round trip nobody asked for.
|
||||
///
|
||||
/// The **raw** scale (not the transform's eased one) is the reference's own input — see
|
||||
/// [`super::camera::head_height`] for the byte citation and why the distinction is visible.
|
||||
pub(super) fn pivot_target(
|
||||
pivot: Option<&CameraPivot>,
|
||||
net: Option<&crate::net::NetEntity>,
|
||||
) -> Option<f32> {
|
||||
pivot.map(|p| model_pivot_height(p, net.map_or(1.0, |n| n.scale)))
|
||||
}
|
||||
|
||||
/// `WOW_TWIST_GAP=<radians>`: pin the body counter-twist's yaw gap instead of deriving it from
|
||||
/// aim-minus-model. Zero-cost when unset: one env read, once.
|
||||
fn twist_gap_override() -> Option<f32> {
|
||||
static G: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
|
||||
*G.get_or_init(|| {
|
||||
std::env::var("WOW_TWIST_GAP")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<f32>().ok())
|
||||
})
|
||||
}
|
||||
@@ -1021,6 +1021,65 @@ pub(super) fn apply_zoom_scroll(scroll: f32, dt: f32, rig: &mut CameraControl, m
|
||||
rig.distance += (rig.target_distance - rig.distance).clamp(-max_step, max_step);
|
||||
}
|
||||
|
||||
/// Seat the camera on **whatever the rig is orbiting this frame** — our own body, or a far-sight
|
||||
/// subject (B151: Mind Vision, Sentry Totem, and Mind Control's camera half in B211) while
|
||||
/// `PLAYER_FARSIGHT` names one. Three substitutions and then [`seat_camera`]: the orbit centre,
|
||||
/// the collision sweep's origin, and the pivot height's target.
|
||||
///
|
||||
/// It exists because the controller seats the camera from **two** places — the ordinary driving
|
||||
/// path, and the stand-down path where a spline, a possession or a reseat window owns the body —
|
||||
/// and far sight outlives all of those (Sentry Totem carries no interrupt flags at all, so you can
|
||||
/// board a taxi with your view still on the totem). Two copies of the substitution is how one of
|
||||
/// them silently stops honouring it.
|
||||
///
|
||||
/// `feet`/`head` are the caller's, because the head offset is the avatar capsule's and those
|
||||
/// constants are a movement concern; `body_pivot` is the target height read off the driven body
|
||||
/// this frame, used only when nothing else is being watched.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn seat_on_subject(
|
||||
dt: f32,
|
||||
turn_delta: f32,
|
||||
feet: Vec3,
|
||||
head: Vec3,
|
||||
body_pivot: Option<f32>,
|
||||
view: &super::view_subject::ViewSubject,
|
||||
rig: &mut CameraControl,
|
||||
cam: &mut FlyCam,
|
||||
cam_t: &mut Mut<Transform>,
|
||||
collide: &benilla_world::collision::WorldCollision<'_, '_>,
|
||||
cam_probe: &Collider,
|
||||
follow: &FollowInput,
|
||||
) {
|
||||
// The sweep origin moves with the subject too; rooting it at our own head would cast the boom
|
||||
// across the world and jam it on the first wall in between
|
||||
// ([`super::view_subject::RemoteView::sweep_origin`]).
|
||||
let (orbit_pos, sweep_from) = match view.remote {
|
||||
Some(v) => (v.feet, v.sweep_origin()),
|
||||
None => (feet, head),
|
||||
};
|
||||
// The framing height is the **channel's**, not this frame's target: it eases there over
|
||||
// `|Δh| / 1.2` s with a cosine profile, so a shapeshift, a mount, a growth aura or a far-sight
|
||||
// switch move the camera smoothly instead of teleporting it ([`PivotGlide`]; wow-re
|
||||
// `pivot-height-glide.md`). A far-sight subject supplies the target the same way the body
|
||||
// does — one channel, whatever it is looking at.
|
||||
let orbit_pivot = rig
|
||||
.pivot
|
||||
.advance(view.remote.map(|v| v.pivot_height).or(body_pivot), dt);
|
||||
seat_camera(
|
||||
dt,
|
||||
turn_delta,
|
||||
orbit_pos,
|
||||
sweep_from,
|
||||
orbit_pivot,
|
||||
rig,
|
||||
cam,
|
||||
cam_t,
|
||||
collide,
|
||||
cam_probe,
|
||||
follow,
|
||||
);
|
||||
}
|
||||
|
||||
/// Seat the third-person camera: orient it, orbit it behind the avatar's torso with a collision
|
||||
/// sweep from the head to the ideal seat (snap-in instantly, ease back out), write the resulting
|
||||
/// transform, and compute the self-avatar zoom-in fade from the realized camera-to-pivot distance.
|
||||
|
||||
@@ -0,0 +1,992 @@
|
||||
//! **The per-frame player controller** — [`control`], the one system that turns this frame's input
|
||||
//! into the avatar's pose, its animation state and the packets we owe the server.
|
||||
//!
|
||||
//! It reads as a spine of named phases, and every phase that is a *concern of its own* lives in a
|
||||
//! module beside this one: [`super::input`] decodes the keys, [`super::wire_in`] applies what the
|
||||
//! server said, [`super::ride`] carries us on a deck, [`super::posture`] holds the body,
|
||||
//! [`super::mover`]/[`super::swim`] move it, [`super::flags`] builds the word, [`super::gait`] and
|
||||
//! [`super::body_pose`] render it, [`super::camera`] seats the eye, and [`super::movement_net`]
|
||||
//! streams it. What is left here is the *order* — which is itself load-bearing, because the
|
||||
//! reference's own frame runs in this order and several of the gates below only work where they
|
||||
//! stand (the swim latch before the mover, the posture commit before the pose, the ack after the
|
||||
//! launch).
|
||||
//!
|
||||
//! Split out of the module root by the same rule the rest of `player/` follows: the root is the
|
||||
//! map (the plugin, the shared types, the ordering edges), the concerns are the files.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Camera + avatar controller. Free-flies until the server reports our position; then takes
|
||||
/// third-person control (WASD walks the avatar; right-drag turns it, left-drag orbits the camera,
|
||||
/// wheel zooms) and streams our movement to the server as the confirmed mover. The dev chord + `F`
|
||||
/// toggles free-fly (decision 1043).
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||
pub(super) fn control(
|
||||
time: Res<Time>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
// Nested into one param to stay within Bevy's 16-element system-param tuple limit. (The
|
||||
// scroll wheel left this tuple with 0997: zoom reads the CAMERAZOOM bindings now.)
|
||||
// The pointer's motion this frame + the camera knobs that scale what it does with it — the
|
||||
// two look/zoom knobs and the auto-follow style (decision 1493). Bundled because a Bevy system
|
||||
// takes at most 16 parameters and this one is at the ceiling — the grouping is the existing
|
||||
// `mouse` tuple, widened rather than a seventeenth argument.
|
||||
pointer: (
|
||||
Res<AccumulatedMouseMotion>,
|
||||
Res<camera::LookConfig>,
|
||||
Res<camera::ZoomLimit>,
|
||||
Res<camera::FollowConfig>,
|
||||
),
|
||||
// The net bridge, bundled into one param (16-param limit): the outbound command channel + the
|
||||
// inbound teleport/worldport messages `apply_net_updates` wrote earlier this frame
|
||||
// (WorldStage::Net), + the sheath-setter queue (the Z toggle's request — decision 0080).
|
||||
mut net: (
|
||||
Res<NetCommands>,
|
||||
MessageReader<TeleportMessage>,
|
||||
MessageReader<WorldportMessage>,
|
||||
MessageWriter<crate::creature_anim::SheathRequest>,
|
||||
MessageReader<crate::net::SpeedChangeMessage>,
|
||||
// The ack'd movement-mode family (decisions 0308, 0866): root / water-walk / feather-fall /
|
||||
// hover, granted on our mover — applied to `player.modes` and acked here with the live pose.
|
||||
MessageReader<crate::net::MoveModeMessage>,
|
||||
// The landing report for the client-side hard-landing predictor (`0x602d00` — wound
|
||||
// vocal + dust; the consumers gate on the threshold, `creature_anim::env_damage`).
|
||||
MessageWriter<crate::creature_anim::HardLanding>,
|
||||
// The cast bar's local self-cancel trigger (decision 0256 open item 2): the controller
|
||||
// reports the move edges the real client's movement machine hands `AbortCast 0x6e4940`.
|
||||
ResMut<crate::ui_cast::LocalMoveStart>,
|
||||
// The mounted space-bar flourish (decision 0441 P2): our own MountSpecial(94) plays
|
||||
// locally at send time; the net drain self-suppresses any broadcast echo.
|
||||
MessageWriter<crate::creature_anim::MountFlourish>,
|
||||
// A `MSG_MOVE_*` the server addressed to our OWN mover (decision 0725) — a pose it wrote,
|
||||
// with no handshake and no ack owed. `wire_in` snaps to it.
|
||||
MessageReader<crate::net::SelfMoveMessage>,
|
||||
// The posture queue (decision 0881): `/sit` and its family ask here; the X key is read
|
||||
// inline in [`super::posture`]. One setter, either way.
|
||||
MessageReader<StandStateRequest>,
|
||||
// The possession handoff (B211): control of a unit granted or revoked. Lands here rather
|
||||
// than at the net drain because both answers it needs — the mover claim and the parting
|
||||
// pose — are the controller's to give.
|
||||
MessageReader<crate::net::ClientControlMessage>,
|
||||
// A knockback the server aimed at our mover (decision 1702) — `wire_in` latches it, the
|
||||
// take-off site below flies it, and the movement stream acks it with the post-launch pose.
|
||||
MessageReader<crate::net::KnockBackMessage>,
|
||||
),
|
||||
// Nested into one param to stay within Bevy's 16-element system-param tuple limit (see `mouse`).
|
||||
speed_capsule: (
|
||||
Res<MoveSpeed>,
|
||||
Res<PlayerCapsule>,
|
||||
Res<CameraProbe>,
|
||||
Res<PointerOverUi>,
|
||||
Res<InspectMode>,
|
||||
Res<crate::ui_script::UiKeyboardCapture>,
|
||||
Res<crate::ui_script::PlayerUiClickConsumed>,
|
||||
// The binding dispatch (decision 0997): every rebindable input below reads command
|
||||
// state from here — raw `keys` remain only for the dev chord's free-fly toggle
|
||||
// (decision 1043) and the look-session mouse.
|
||||
Res<crate::bindings::BindingsState>,
|
||||
// What the camera orbits, when that is not our body ([`view_subject`], B151). Resolved by
|
||||
// a system ordered just before us, because `control` holds the self `Transform` mutably
|
||||
// and so cannot also read the far-sight object's.
|
||||
Res<view_subject::ViewSubject>,
|
||||
// Our own guid — the control handoff (B211) is a statement about *some* unit, and telling
|
||||
// "the server revoked my body" from "the server handed me a creature" is exactly this test.
|
||||
Res<crate::net::SelfGuid>,
|
||||
// The spyglass scope ([`scoped_view`]): while held, the rig is pinned to first person and
|
||||
// the wheel cannot leave it.
|
||||
Res<scoped_view::ScopedView>,
|
||||
),
|
||||
mut commands: Commands,
|
||||
mut player: ResMut<Player>,
|
||||
mut rig: ResMut<CameraControl>,
|
||||
// Avian's kinematic move-and-slide: sweeps the capsule against the streamed colliders (decision 0009).
|
||||
collide: benilla_world::collision::WorldCollision,
|
||||
mut cameras: Query<(&mut Transform, &mut FlyCam, &Camera)>,
|
||||
// **The body in our hands** — see [`BodyQuery`] for what rides on it and why a possessed
|
||||
// creature needs nothing special here.
|
||||
mut body: BodyQuery,
|
||||
window: Single<(&mut Window, &mut CursorOptions), With<PrimaryWindow>>,
|
||||
// Clicks go out here — left for the target picker, right for the context action (attack) — and
|
||||
// the third is the right button's raw DOWN edge (targeting's cancel, decision 0792). A press
|
||||
// engages its camera look *and* arms a click test; the release settles that test on the
|
||||
// reference's time/travel predicate, so one gesture can orbit and select both (decision 1122).
|
||||
// The locals hold each button's pending [`camera::PressGesture`] (`None` = no press pending).
|
||||
mut world_clicks: (
|
||||
MessageWriter<WorldClick>,
|
||||
MessageWriter<WorldRightClick>,
|
||||
MessageWriter<WorldRightPress>,
|
||||
),
|
||||
mut click_test: (
|
||||
Local<Option<camera::PressGesture>>,
|
||||
Local<Option<camera::PressGesture>>,
|
||||
),
|
||||
// World context for the mover, bundled into one param (16-param limit): the world query the
|
||||
// swim mode + buoyant float ask the liquid through (see [`swim`]), the armed transports (the
|
||||
// platform-frame carry/attach — decision 0438 phase 2; `Without`s only disjoint the borrows),
|
||||
// and the parent chain (the attach walk resolves a deck prop's collider child to the
|
||||
// transport that owns it — solid cargo, 0470).
|
||||
world_q: (
|
||||
benilla_world::world_point::WorldPoint,
|
||||
TransportQuery,
|
||||
Query<&ChildOf>,
|
||||
),
|
||||
) {
|
||||
let (world, transports, child_of) = (&world_q.0, &world_q.1, &world_q.2);
|
||||
let (left_click, right_click) = (&mut *click_test.0, &mut *click_test.1);
|
||||
let Ok((mut cam_t, mut cam, camera)) = cameras.single_mut() else {
|
||||
return;
|
||||
};
|
||||
let (mut window, mut cursor_opts) = window.into_inner();
|
||||
let mouse_motion = &pointer.0;
|
||||
let look_cfg = *pointer.1;
|
||||
let zoom_max = pointer.2.max;
|
||||
let (move_speed, capsule, cam_probe, pointer_over_ui, inspect, ui_capture, click_consumed) = (
|
||||
&speed_capsule.0,
|
||||
&speed_capsule.1 .0,
|
||||
&speed_capsule.2 .0,
|
||||
&speed_capsule.3,
|
||||
&speed_capsule.4,
|
||||
&speed_capsule.5,
|
||||
&speed_capsule.6,
|
||||
);
|
||||
let binds = &speed_capsule.7;
|
||||
let view_subject = &speed_capsule.8;
|
||||
let self_guid = speed_capsule.9 .0;
|
||||
let scoped = &speed_capsule.10;
|
||||
// The auto-follow knobs (decisions 1493/1502), with far sight's one exception folded in here so
|
||||
// both camera seats below agree: while the rig orbits somebody ELSE's body (Mind Vision, Sentry
|
||||
// Totem), our own facing is not what "behind" means, so the return is forced off rather than
|
||||
// reeling that camera toward a heading with nothing to do with the view.
|
||||
let follow_cfg = camera::FollowConfig {
|
||||
style: if view_subject.remote.is_some() {
|
||||
camera::FollowStyle::Never
|
||||
} else {
|
||||
pointer.3.style
|
||||
},
|
||||
tracking_style: if view_subject.remote.is_some() {
|
||||
camera::FollowStyle::Never
|
||||
} else {
|
||||
pointer.3.tracking_style
|
||||
},
|
||||
..*pointer.3
|
||||
};
|
||||
|
||||
let dt = time.delta_secs();
|
||||
// While a focused UI EditBox (the chat input, a mail field) owns the keyboard, keyboard reads see
|
||||
// "no keys held" — so the avatar isn't also driven while typing (a `.tele` command). Mouse still
|
||||
// works. The gate is `UiKeyboardCapture`, which the focused chat EditBox drives; the free-fly
|
||||
// chord below is deliberately outside it, like every dev chord ([`modkeys::dev_chord`]).
|
||||
let typing = ui_capture.typing;
|
||||
// The rebindable inputs all read `binds` (decision 0997): the dispatch already enforced the
|
||||
// typing gate and 0585's exact-modifier law when it latched, so this module carries neither
|
||||
// anymore. Nothing here reads a bare key any more (decision 1043) — the free-fly toggle is on
|
||||
// the dev chord, and the Ctrl run boost is gone.
|
||||
|
||||
// The both-button state and the camera's input command word ([`input::look_input`]) — read
|
||||
// here, before the look session, because the not-driving path below seats its camera from the
|
||||
// same word and returns without ever reaching the movement axes.
|
||||
let input::LookInput {
|
||||
both_buttons,
|
||||
follow_command,
|
||||
} = input::look_input(&buttons, binds, &player);
|
||||
|
||||
// The look session gets a SHADOW copy of `CursorOptions`, written back only on a real change:
|
||||
// handing it the component's `Mut` directly reborrowed mutably every frame, which marks it
|
||||
// Changed regardless of writes — and bevy_winit's `changed_cursor_options` then re-applied
|
||||
// cursor state to AppKit per frame, an OS call that intermittently stalls the main thread for
|
||||
// milliseconds (the 0366 frame-tail hunt's second-biggest line).
|
||||
let mut opts_shadow = cursor_opts.bypass_change_detection().clone();
|
||||
// Snapshot for the seated-turn stand-up ([`super::posture`]): a right-drag (or both-button) look session
|
||||
// writes `face_yaw` directly — any change is a real mouse TURN of the character (a left-drag
|
||||
// orbits the camera only and never touches it).
|
||||
let yaw_before_look = player.face_yaw;
|
||||
// **Stunned** (`UNIT_FIELD_FLAGS & 0x40000` — decision 0872): read once here, because the very
|
||||
// first thing a stun suppresses is the mouse turn below. This is a descriptor bit, NOT a
|
||||
// movement flag and NOT an aura: the reference's `0x5145b0` computes `!STUNNED` straight off
|
||||
// `[[unit+0x110]+0xa0]` (`not eax; shr eax,0x12; and eax,1`) and `0x514755` consumes it to skip
|
||||
// the turn and pitch emitters outright.
|
||||
let stunned = body
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|(.., store, _, _, _, _, _)| {
|
||||
store.map(|s| s.0.unit_flags() & UNIT_FLAG_STUNNED != 0)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
// Drunkenness (B210): this frame's wobble angle, computed once — the facing veer and the
|
||||
// swim-pitch porpoise ([`swim::drive_step`]) both read it. Zero while sober (`wobble` early-outs on a 0.0
|
||||
// fraction), and zero while stunned — the reference's wobble sits behind the same
|
||||
// input-allowed chain as the turn emitters (`0x60aa47` → `0x5145b0`).
|
||||
let drunk_wobble = {
|
||||
let f = body
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|(.., store, _, _, _, _, _)| store.and_then(|s| s.0.player_drunk_byte()))
|
||||
.map_or(0.0, drunk::fraction);
|
||||
if stunned {
|
||||
0.0
|
||||
} else {
|
||||
drunk::wobble(time.elapsed().as_millis() as u32, f)
|
||||
}
|
||||
};
|
||||
run_look_session(
|
||||
&buttons,
|
||||
mouse_motion,
|
||||
both_buttons,
|
||||
&mut rig,
|
||||
&mut cam,
|
||||
&mut player.face_yaw,
|
||||
&mut window,
|
||||
&mut opts_shadow,
|
||||
camera,
|
||||
pointer_over_ui.0,
|
||||
inspect.enabled,
|
||||
click_consumed.0,
|
||||
&mut world_clicks.0,
|
||||
&mut world_clicks.1,
|
||||
&mut world_clicks.2,
|
||||
left_click,
|
||||
right_click,
|
||||
look_cfg,
|
||||
time.elapsed_secs(),
|
||||
);
|
||||
// A stun freezes the BODY, not the view. The look session has already moved `cam.yaw` (and, on
|
||||
// a right-drag, coupled `face_yaw = cam.yaw`); putting the aim back leaves the camera orbiting
|
||||
// a body that does not turn — which is what a stunned character looks like, and what the
|
||||
// reference produces by never running the turn emitter at all. Restoring rather than gating
|
||||
// inside the session keeps the approved camera path (0050/0366's right-drag coupling) untouched.
|
||||
// `mouse_turned` then reads false by construction, so the seated-turn stand-up cannot fire either.
|
||||
// Losing the reins has the same shape, and the binary says so explicitly: with the mover global
|
||||
// zeroed, `0x514640` skips the whole tick at `51466c` — input is still *sampled*, the body turn
|
||||
// is skipped at `514474` — while the camera rotate at `514444` happens BEFORE the mover lookup
|
||||
// and so keeps working. A mind-controlled player can still look around; they just cannot turn
|
||||
// or move their body. Same restore, for the same reason — and `reseat` is the same condition
|
||||
// once more, the frames where the mover global would not resolve at all.
|
||||
//
|
||||
// Holding *somebody else's* reins is emphatically not in this set: the turn belongs to whatever
|
||||
// we are driving, and once the marker has caught up that is the creature, whose `Transform` is
|
||||
// the one this `face_yaw` writes.
|
||||
if stunned || player.control_lost || player.reseat {
|
||||
player.face_yaw = yaw_before_look;
|
||||
}
|
||||
let mouse_turned = player.face_yaw != yaw_before_look;
|
||||
{
|
||||
let cur = cursor_opts.bypass_change_detection();
|
||||
if cur.visible != opts_shadow.visible
|
||||
|| cur.grab_mode != opts_shadow.grab_mode
|
||||
|| cur.hit_test != opts_shadow.hit_test
|
||||
{
|
||||
*cursor_opts = opts_shadow;
|
||||
}
|
||||
}
|
||||
|
||||
// Camera zoom rides the CAMERAZOOMIN/OUT bindings (0997; defaults = the wheel pair). The
|
||||
// wheel-over-UI routing lives in the dispatch now — a wheel the quest log consumed never
|
||||
// reaches these commands — and a rebound zoom KEY steps 1.0 per press, 1.12's own
|
||||
// `CameraZoomIn(1.0)` argument.
|
||||
let zoom = binds.amount(crate::bindings::cmd::CAMERA_ZOOM_IN)
|
||||
- binds.amount(crate::bindings::cmd::CAMERA_ZOOM_OUT);
|
||||
apply_zoom_scroll(zoom, dt, &mut rig, zoom_max);
|
||||
|
||||
// Free-fly is a dev instrument, so it sits on the dev chord, not a bare `F` (decision 1043).
|
||||
// A bare `F` is a key the reference lets a player bind — our own store test binds it to JUMP —
|
||||
// and a dev doesn't get to squat on the game's namespace (0585, the same rule that moved the
|
||||
// perf HUD off bare `P`). Ungated on `typing` like every chord: it can't be mistaken for text.
|
||||
if crate::run_mode::dev_chord(&keys, KeyCode::KeyF) {
|
||||
player.detached = !player.detached;
|
||||
}
|
||||
|
||||
// Server-authored movement edges + their mandatory acks (worldport/teleport snaps, root,
|
||||
// water-walk, the take-control edge — [`wire_in`]). The returned forced-speed changes were
|
||||
// already acked pre-control/detached; controlled, the movement stream below acks them with
|
||||
// its live per-frame payload.
|
||||
let speed_acks = wire_in::apply_server_moves(
|
||||
&time,
|
||||
&mut commands,
|
||||
&mut player,
|
||||
&mut cam,
|
||||
&net.0,
|
||||
&mut net.1,
|
||||
&mut net.2,
|
||||
&mut net.4,
|
||||
&mut net.5,
|
||||
&mut net.12,
|
||||
&mut net.9,
|
||||
&mut net.11,
|
||||
self_guid,
|
||||
transports,
|
||||
body.single()
|
||||
.ok()
|
||||
.map(|(_, t, ..)| (t.translation, server_ride::yaw_of(t.rotation))),
|
||||
);
|
||||
|
||||
let flat = |v: Vec3| Vec3::new(v.x, 0.0, v.z).normalize_or_zero();
|
||||
|
||||
// The platform carry ([`ride::carry`], decision 0438 phase 2): while attached to a transport,
|
||||
// recompose the whole rider — feet, aim, rendered body and camera — from the boat's THIS-frame
|
||||
// pose, before any input integrates.
|
||||
ride::carry(&mut player, &mut cam, transports);
|
||||
|
||||
if player.active && !player.detached {
|
||||
// Server ride guard: a server-authored spline (Charge/knockback/taxi) owns the avatar this
|
||||
// frame. `drive_self_ride` (ordered just before us) already synced `player.pos` + facing from
|
||||
// the `sample_splines` transform and set the run animation; here we only carry the
|
||||
// follow-camera onto the moving avatar. Input, physics, and the outbound movement stream all
|
||||
// yield until the ride ends (where `drive_self_ride` acks `CMSG_MOVE_SPLINE_DONE` and resumes).
|
||||
// **The three ways of not driving**, and they share every line of their answer: the camera
|
||||
// keeps seating on the body, input and physics and the outbound stream all yield.
|
||||
//
|
||||
// - `server_riding` — a server-authored spline (Charge/knockback/taxi/a flee path) owns the
|
||||
// avatar. `drive_self_ride`, ordered just before us, has already mirrored the sampled
|
||||
// transform into `Player`, so there is nothing to sync and nothing to park: it is
|
||||
// reporting FORWARD on the wire on purpose.
|
||||
// - `control_lost` (B211) — somebody else is driving our body, or the body in our hands has
|
||||
// been feared out of our control. It is NOT the free-fly branch below, which would fly
|
||||
// the camera off the body; and it is not root, which leaves turning live. Nothing else
|
||||
// will stop us: the server neither roots the victim nor validates their movement, so this
|
||||
// gate IS the immobility (see `Player::control_lost`).
|
||||
// - [`Player::reseat`] — the window between mover guids where what we intend to drive and
|
||||
// what carries `Embodied` have not yet met: the frame a grant lands, and every frame
|
||||
// after it while the claimed unit has not streamed in. Driving during that window writes
|
||||
// one body's pose onto another, because outbound moves carry no guid of their own.
|
||||
// `apply_server_moves` above closes it the moment a pose is there to adopt, so this is
|
||||
// normally a single frame.
|
||||
//
|
||||
// Note the middle one is deliberately NOT "we are possessing": once the marker has caught
|
||||
// up, possession runs the *ordinary* controlled path below, on the creature (decision 1277).
|
||||
if player.server_riding || player.control_lost || player.reseat {
|
||||
// Whoever is moving the body, its transform is the truth and `Player` follows it
|
||||
// (decision 1281). Skipping this is what stranded the camera during a fear: the body
|
||||
// ran off on its spline while the orbit stayed at the pose the controller last wrote,
|
||||
// which reads as the view detaching into free flight (director, 2026-08-13). A
|
||||
// `reseat` window is excluded — there the resource still describes the body we are
|
||||
// letting go of, and `apply_server_moves` owns the adoption.
|
||||
//
|
||||
if player.control_lost && !player.reseat {
|
||||
if let Ok((_, t, ..)) = body.single() {
|
||||
let yaw = server_ride::yaw_of(t.rotation);
|
||||
player.pos = t.translation;
|
||||
player.face_yaw = yaw;
|
||||
player.model_yaw = yaw;
|
||||
}
|
||||
}
|
||||
let head = player.pos + Vec3::Y * (CAPSULE_HEIGHT - CAPSULE_RADIUS);
|
||||
// Far sight outlives all three, so it has to be honoured here too — Sentry Totem
|
||||
// carries no interrupt flags at all, which means you can board a taxi with your view
|
||||
// still on the totem. [`camera::seat_on_subject`] is the shared substitution, which is
|
||||
// exactly why it is shared: skipping it here would read as far sight mysteriously
|
||||
// dropping the moment a spline takes the body.
|
||||
//
|
||||
// The auto-follow still runs on this path — a taxi, a Charge, a knockback or a fear
|
||||
// all translate the avatar while the controller stands down, and the reference has
|
||||
// states for exactly those (`Track`, `Fear`: a 0.4 s delay and a lazy 18 °/s return
|
||||
// under Smart). The word carries both flags, so the edge into and out of one of them
|
||||
// is what arms it.
|
||||
camera::seat_on_subject(
|
||||
dt,
|
||||
0.0,
|
||||
player.pos,
|
||||
head,
|
||||
body.single()
|
||||
.ok()
|
||||
.and_then(|(_, _, _, pivot, .., net)| body_pose::pivot_target(pivot, net)),
|
||||
view_subject,
|
||||
&mut rig,
|
||||
&mut cam,
|
||||
&mut cam_t,
|
||||
&collide,
|
||||
cam_probe,
|
||||
&camera::FollowInput {
|
||||
cfg: follow_cfg,
|
||||
face_yaw: player.face_yaw,
|
||||
command: follow_command,
|
||||
},
|
||||
);
|
||||
// Flush a stale run once, so observers stop extrapolating it — but never under a ride,
|
||||
// whose FORWARD report is deliberate and would be cancelled every frame.
|
||||
if !player.server_riding {
|
||||
movement_net::park_mover(&net.0 .0, &mut player);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// This frame's netted movement axes, the mouselook/turn modes they imply, and the autorun
|
||||
// latch with its verified cancel set — all of it decoded once in [`input::move_axes`].
|
||||
let axes = input::move_axes(binds, &buttons, &mut player, &rig, both_buttons, stunned);
|
||||
let input::MoveAxes {
|
||||
fwd: fwd_axis,
|
||||
side: side_axis,
|
||||
mouselook,
|
||||
turning,
|
||||
translating,
|
||||
autorun_armed,
|
||||
turn_left,
|
||||
turn_right,
|
||||
..
|
||||
} = axes;
|
||||
// **The mover's own** six speeds, read once here for the whole frame — the turn below and
|
||||
// the run/backpedal selection further down. All six live on the driven unit's `CMovement`
|
||||
// and nothing in the reference's applied-input path ever reads *our* speeds when the mover
|
||||
// is a different object (VERIFIED, decision 1278), so a possessed creature moves and turns
|
||||
// at its own numbers for free: the component was on its entity all along.
|
||||
let mover_speeds = body.single().ok().and_then(|q| q.7).map(|s| s.0);
|
||||
// The 6th speed. Zero is the ctor state, not a rate — the client keeps no default of its
|
||||
// own, so a unit whose create block has not landed falls back rather than freezing solid.
|
||||
let turn_rate = mover_speeds
|
||||
.map(|s| s.turn_rate)
|
||||
.filter(|r| *r > 0.0)
|
||||
.unwrap_or(TURN_RATE);
|
||||
// This frame's keyboard-turn rotation — `seat_camera` carries the camera by it rigidly
|
||||
// (char and camera turn as one on the reference; director's call, closing 0050's open
|
||||
// "camera follow on turn" feel item).
|
||||
let mut turn_delta = 0.0;
|
||||
if turning {
|
||||
let mut turn = 0.0;
|
||||
if turn_left {
|
||||
turn += 1.0;
|
||||
}
|
||||
if turn_right {
|
||||
turn -= 1.0;
|
||||
}
|
||||
// 0.75× while translating **or falling** — the verified `flags & 0x200f` case, whose
|
||||
// `0x2000` is FALLING (`0x7c5c73`). A jump mid-turn keeps the reduced rate.
|
||||
let slowed = translating || player.airborne_since.is_some();
|
||||
let rate = turn_rate * if slowed { TURN_RATE_MOVING } else { 1.0 };
|
||||
turn_delta = turn * rate * dt;
|
||||
player.face_yaw += turn_delta;
|
||||
}
|
||||
// The drunk veer (B210): while moving, the facing increments by the wobble angle every
|
||||
// frame (`0x60aa70–0x60aab7`: `facing + wobble`, 2π-wrapped, committed via the normal
|
||||
// facing pipeline `0x60de30` — so it streams on the wire like any turn). The slow sign
|
||||
// oscillation of the pulse is what makes the walk meander. Skipped while a keyboard turn
|
||||
// is held — the reference's `flags & 0x30` guard (`0x60aa5a`) — so deliberate turning
|
||||
// stays crisp; both yaw conventions increase turning left, so the add maps sign-for-sign.
|
||||
// The veer joins `turn_delta` so `seat_camera` carries the camera with it exactly like a
|
||||
// keyboard turn (char and camera turn as one) — the reference's camera follows the drunk
|
||||
// meander too (director's ref observation, decision 1018); without the carry the character
|
||||
// staggers out from under a fixed camera.
|
||||
if drunk_wobble != 0.0 && translating && !turning {
|
||||
player.face_yaw += drunk_wobble;
|
||||
turn_delta = drunk_wobble;
|
||||
}
|
||||
let face_rot = Quat::from_rotation_y(player.face_yaw);
|
||||
let move_fwd = flat(face_rot * Vec3::NEG_Z);
|
||||
let move_right = flat(face_rot * Vec3::X);
|
||||
let mut dir = Vec3::ZERO;
|
||||
// Forward/back comes from the net axis (W, S, both-button and autorun already summed) — one
|
||||
// step in its sign, never a doubled push, exactly as the emitter issues one START in
|
||||
// `sign(axis)`. (`mover::step` normalizes anyway, but the axis is the honest shape.)
|
||||
match fwd_axis.signum() {
|
||||
1 => dir += move_fwd,
|
||||
-1 => dir -= move_fwd,
|
||||
_ => {}
|
||||
}
|
||||
// Strafe slides without turning, one step in the netted sign — never a doubled push, and a
|
||||
// cancelled pair is genuinely no strafe (the same shape as the forward/back axis).
|
||||
match side_axis.signum() {
|
||||
1 => dir += move_right,
|
||||
-1 => dir -= move_right,
|
||||
_ => {}
|
||||
}
|
||||
// **Rooted: translation intent dies here — turning above stays live.** Confirmed at the
|
||||
// bytes (decisions 0866/0872) and it is *authored*, not accidental: the reference's input
|
||||
// tick consults an allow-list (`0x615c71` → the byte table at `0x618054`) which blocks the
|
||||
// translation command ids and **explicitly permits** the turn ids 8/9/0xa, pitch, run/walk
|
||||
// and SetFacing. A character who cannot even pivot is STUNNED, a separate `UNIT_FIELD_FLAGS`
|
||||
// gate handled above — and vmangos's `HandleModStun` grants both at once, which is why Ice
|
||||
// Block freezes completely while Frost Nova lets you turn.
|
||||
if player.modes.rooted {
|
||||
dir = Vec3::ZERO;
|
||||
}
|
||||
let moving = dir != Vec3::ZERO;
|
||||
// The character's own turn this frame — a keyboard turn (or the drunk veer, which rides
|
||||
// `turn_delta` the same way) or a real mouse TURN of the body. One of the movement inputs
|
||||
// that stands a seated avatar back up, and the camera's rigid carry below.
|
||||
let turned = turn_delta != 0.0 || mouse_turned;
|
||||
// Posture ([`posture`]) — the stand state (`X` and the `/sit` family) and the sheath
|
||||
// toggle (`Z`), which interlock: the stow rider fires off the stand state this commits,
|
||||
// and the toggle's guard chain refuses on it. Returns the **committed** state, which the
|
||||
// body pose below streams to the animation selector.
|
||||
let stand_now = posture::update(
|
||||
&mut player,
|
||||
&body,
|
||||
binds,
|
||||
&net.0,
|
||||
&mut net.3,
|
||||
&mut net.10,
|
||||
moving,
|
||||
turned,
|
||||
);
|
||||
// Backpedaling is slower: the backward move-flag selects the backward speed, dominating
|
||||
// strafe (binary-VERIFIED — see RUN_BACK_RATIO). Net-backward = the S key held without a
|
||||
// forward override (W or both-button run). The backward arm is a **min**, not a plain
|
||||
// select — `0x7c4d1d` computes `min(runBack, run)` (the swim §5's TU-H; observably the
|
||||
// plain runBack whenever it's the slower, i.e. always at vanilla values, but a server
|
||||
// that force-sets runBack above run is clamped like the ref). The resulting (slower)
|
||||
// speed also feeds jump takeoff, so a backward jump lands shorter for free.
|
||||
let net_backward = fwd_axis < 0;
|
||||
// Run/runback are server-authoritative (`UnitSpeeds`: seeded by our create's LIVING block,
|
||||
// updated live by SMSG_FORCE_*_SPEED_CHANGE — so `.modify speed`, mounts and slows actually
|
||||
// move us at the server's number). `$WOW_MOVE_SPEED` stays the absolute dev override
|
||||
// (backpedal keeps the vanilla 4.5/7.0 ratio under it); pre-create frames fall back the
|
||||
// same way.
|
||||
let (run_speed, run_back_speed) = match mover_speeds {
|
||||
Some(s) if !move_speed.env_override => (s.run, s.run_back),
|
||||
_ => (move_speed.value, move_speed.value * RUN_BACK_RATIO),
|
||||
};
|
||||
let speed = if net_backward {
|
||||
run_back_speed.min(run_speed)
|
||||
} else {
|
||||
run_speed
|
||||
};
|
||||
// Root is refused HERE (the reference refuses it twice upstream of the handler: the Lua
|
||||
// gate's `test ch,0x12` and the replay allow-list `0x615c71`/`0x618030`, where command
|
||||
// id 7 is blocked). HOVER's refusal is NOT here — it belongs to the movement handler
|
||||
// itself (`0x7c623a`, the breach term below and [`mover::step`]'s grounded arm), which
|
||||
// is what keeps the mounted flourish reachable while hovering, as the reference has it.
|
||||
let mut want_jump = binds.fired(crate::bindings::cmd::JUMP) && !player.modes.rooted;
|
||||
|
||||
// Swim vs walk: the water over our feet decides. Hysteresis-latched (`update_swimming`,
|
||||
// the verified `0x6030c0` boundary — B7 resolved, decision 0226) so wading the line
|
||||
// doesn't flicker between the two physics regimes.
|
||||
let surface_y = swim::surface_over_feet(world, player.pos);
|
||||
let swimming = swim::update_swimming(&mut player, surface_y, time.elapsed_secs());
|
||||
if let Some(surface) = surface_y {
|
||||
move_trace::swim(player.pos.y, surface, swimming, player.collision_height.0);
|
||||
}
|
||||
// Space while swimming = the ref's Jump routing (decision 0487, superseding 0479),
|
||||
// fired on the PRESS EDGE only — one hop per press, a held key does not re-fire
|
||||
// (decision 0498, director-verified on the ref; 0487's held-chaining was our
|
||||
// over-extension of TU-F, and near the surface its re-latch→re-fire loop bounced the
|
||||
// avatar under the waterline — the "invisible wall"). VERIFIED TU-F/TU-G (`0x7c6230`):
|
||||
// the routing has no depth gate and no swim re-route — at the surface the press
|
||||
// breaches out; submerged it's the ~1.6-yd dolphin-hop, re-latching into swim once the
|
||||
// launch velocity halves (`0x7c5de0`). The smooth way UP is aiming up in mouselook and
|
||||
// swimming forward (the 0492 pitch law). The breach exits the water mode INSIDE this
|
||||
// frame — the byte handler runs before the mover, clearing SWIMMING unconditionally —
|
||||
// so the latch drops now and this frame's mover, flags, and wire all see the leap as a
|
||||
// jump.
|
||||
// HOVER refuses the breach too: `0x7c623a`'s test sits AHEAD of the SWIMMING take-off
|
||||
// select (`0x7c6261` only picks the seed velocity, it gates nothing), and hover does not
|
||||
// suppress swim entry — `0x6030c0` tests only LEVITATING (`0x400`) — so a hovering
|
||||
// swimmer is a real state and their Space does nothing at all (wow-re
|
||||
// `fall-steep-response.md` §10). The land leg's refusal lives in [`mover::step`],
|
||||
// the same handler's grounded arm.
|
||||
// **The wire's jump** — the `Jump(force = 0)` a `SetHover(true)` owes
|
||||
// ([`Player::hover_launch`], decision 1620). It differs from Space in exactly one gate and
|
||||
// that gate is the point: `0x7c6236 test eax,eax; je 0x7c6243` skips the hover refusal when
|
||||
// `force` is 0, so this leg jumps a body that is *already* hovering — which is every body
|
||||
// that just got granted hover. The two refusals it keeps are ROOT and FALLING
|
||||
// (`0x7c625c test ah,0x30`); the seed select at `0x7c6261` is shared, so the swim/land
|
||||
// choice is made below by the same two take-off sites Space uses.
|
||||
let wire_jump = player.take_wire_jump();
|
||||
// **The knockback the server aimed at us** (decision 1702) — taken at the same site as the
|
||||
// other two take-offs so all three enter the mover through one door. Resolved here from the
|
||||
// wire quad into one Bevy launch velocity: the horizontal is `(cos, sin)·xy_speed` in
|
||||
// **absolute world XY** (nothing to do with our facing or our keys), and the vertical is
|
||||
// `−zspeed`, because the wire's take-off speed is DOWN-positive — the same convention the
|
||||
// jump tail this quad is about to be echoed back as already uses (decision 0054).
|
||||
let knockback = player.take_knockback();
|
||||
let knock_launch = knockback.map(|k| {
|
||||
let (c, sn, xy) = (k.launch.cos_angle, k.launch.sin_angle, k.launch.xy_speed);
|
||||
wow_to_bevy([c * xy, sn * xy, -k.launch.zspeed])
|
||||
});
|
||||
|
||||
// A knockback lifts a swimmer clear of the water like any other take-off — it sets FALLING,
|
||||
// and FALLING and SWIMMING are exclusive. It is NOT routed through `breach_step`, though:
|
||||
// that arm exists to seed `SWIM_JUMP_SPEED`, and a knockback brings its own seed on both
|
||||
// axes, so it leaves the water here and is flown by the ordinary land mover below.
|
||||
let breach = swimming && (want_jump && !player.modes.hover || wire_jump);
|
||||
let knock_breach = swimming && knock_launch.is_some();
|
||||
if breach || knock_breach {
|
||||
player.swimming = false;
|
||||
}
|
||||
let swimming = swimming && !breach && !knock_breach;
|
||||
|
||||
// The netted swim translation amounts ([`swim::translate_amounts`]) — read by the swim
|
||||
// mover arm AND the flag build, so the two can never disagree (decision 0056).
|
||||
let (swim_fwd, swim_side) = if swimming {
|
||||
swim::translate_amounts(&axes, player.modes.rooted)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
|
||||
// The mounted space-bar flourish (decision 0441 P2). The gate is byte-VERIFIED — the
|
||||
// client's jump-key handler `0x60dea0` (wow-re `mount-composition.md` Q3): mounted +
|
||||
// no translational move + not turning + grounded → play MountSpecial(94) locally FIRST,
|
||||
// then send `CMSG_MOUNTSPECIAL_ANIM` (the receive side self-suppresses the echo, see
|
||||
// `net/apply.rs`); translational move → a real jump, the unmounted path; **turn-only
|
||||
// (the `0x30` turn flags) → a silent no-op** — the press is consumed, nothing plays;
|
||||
// airborne → silent no-op (the client's geometric ground-clearance test `0x605650`;
|
||||
// our airborne arc stands in — an airborne press falls through and the mover ignores
|
||||
// it, the same net silence). Swim disposition is INFERRED-moot (you can't be mounted
|
||||
// while swimming in 1.12); a swimming Space is the jump-exit above — and only that
|
||||
// (TU-F: Space is the Jump command; it is NOT a pitch or ascend input) — and never
|
||||
// reaches this walk-side gate.
|
||||
if want_jump && !moving && !swimming && player.airborne_since.is_none() {
|
||||
if let Ok((e, .., store, _, _, _, _, _)) = body.single() {
|
||||
if store.is_some_and(|s| s.0.unit_mount_display_id() != 0) {
|
||||
want_jump = false;
|
||||
if !turning {
|
||||
let _ = net.0 .0.send(ClientCommand::MountSpecial);
|
||||
net.8.write(crate::creature_anim::MountFlourish { unit: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This frame's PRESENTED swim pitch — the persistent [`Player::mover_pitch`] while swimming
|
||||
// (held even idle, the client's `CMovement+0x20`), except leveled by the 0499 surface
|
||||
// redirect when the rest-line cap bites. Feeds the body pose and the wire pitch tail (one
|
||||
// source — the pose and the stream can't disagree); the tail only serializes with the
|
||||
// SWIMMING flag, so the walking value is inert.
|
||||
// **The mover pitch, set — in every mode, not just the swim one** ([`Player::mover_pitch`]
|
||||
// = `CMovement+0x20`). HELD when unsteered (VERIFIED TU-B(c) — an idle floater keeps its
|
||||
// pitch, never auto-levels), and steered by mouselook as a DIRECT set of the camera aim —
|
||||
// **VERIFIED** (the camera-pitch §5, wow-re `swim-camera-pitch.md`, decision 0492, closing
|
||||
// 0488's INTERIM and refuting the earlier no-camera-coupling census): the ref's mouse-move
|
||||
// chain ends in `SetPitch 0x7c6f70`, an unconditional store — no integrator, no rate limit
|
||||
// — clamped ±89° ([`MOUSELOOK_PITCH_CLAMP`], the byte constant; the ±π/2 clamp belongs to
|
||||
// the unbound pitch-KEY integrator), with the velocity basis rebuilt in-call: the aim
|
||||
// re-points travel the same frame, zero lag. (The ref's `fchs` negate is its own camera
|
||||
// sign convention; ours maps aim-up to pitch-up already.) A left-drag camera orbit steers
|
||||
// NOTHING — it moves the camera without turning the character (the walk rule at `move_fwd`
|
||||
// above), so it must not bend the swim either (director-reported, 2026-07-18).
|
||||
//
|
||||
// It lived inside the swimming branch until decision 1616 (B322). Nothing on the ref's
|
||||
// write path is swim-gated — not the mouse handler `0x514400`, not the applier `0x5103e0`,
|
||||
// not the relay `0x515330`, not the enqueuer `0x6198a0`, and not `SetPitch`'s own store at
|
||||
// `0x7c6f91`, which precedes the `test [esi+0x40],0x200000` that splits the two arms
|
||||
// (`swim-camera-pitch.md` §7: "the mouse-look pitch push is swim-agnostic … on land too").
|
||||
// Swimming gates only the *readers* — the travel basis, the body pose, the wire tail — and
|
||||
// on land the field has two more, both water walking's: the trace-mask arm's third gate
|
||||
// below, and `SetPitch`'s own dive-through complement.
|
||||
//
|
||||
// The push is **per mouse-move, not per frame** ([`Player::aim_pitch_seen`]): the ref's
|
||||
// enqueue hangs off the mouse-MOTION event `0x400500cb`, so a still mouse pushes nothing
|
||||
// and the other writers of the field — the wobble, StopSwim's levelling — survive.
|
||||
if mouselook && cam.pitch != player.aim_pitch_seen {
|
||||
player.aim_pitch_seen = cam.pitch;
|
||||
player.mover_pitch = cam
|
||||
.pitch
|
||||
.clamp(-MOUSELOOK_PITCH_CLAMP, MOUSELOOK_PITCH_CLAMP);
|
||||
}
|
||||
let mut swim_pitch = 0.0_f32;
|
||||
// The ground height the mover starts this frame at (pre-step feet Y). For a jump this is the
|
||||
// true takeoff height — the mover integrates one jump-tick upward *within* the step, so the
|
||||
// post-step `pos.y` is already ~0.13 yd (60 fps) above the ground and must not be used as
|
||||
// the launch height (see [`Player::advance_airborne_arc`]).
|
||||
let launch_y = player.pos.y;
|
||||
let mover::Outcome {
|
||||
held,
|
||||
grounded,
|
||||
jumped,
|
||||
knocked,
|
||||
air_nudged,
|
||||
ground,
|
||||
} = if breach {
|
||||
// Jump while swimming (**VERIFIED**, wow-re `swim-mechanism.md` TU-B(f)+TU-F,
|
||||
// `0x7c6230`): clears SWIMMING and enters the FALLING lifecycle *unconditionally* —
|
||||
// no swim re-route, no surface-proximity gate — seeding a take-off ~14% over a land
|
||||
// jump. At the surface this is the jump-out hop (the leap clears the water and can
|
||||
// carry onto a low bank); deep, it's the ~1.6-yd dolphin-hop — swim re-latches once
|
||||
// the upward velocity halves (`update_swimming`'s verified `0x7c5de0` gate). The wire
|
||||
// streams it as a normal JUMP: fall clock 0, the seeded zspeed in the tail —
|
||||
// `advance_airborne_arc` snapshots it like any land jump ([`super::flags`]).
|
||||
swim::breach_step(&mut player, &time, &collide, capsule)
|
||||
} else if swimming {
|
||||
// One swimming frame ([`swim::drive_step`]): the drunk porpoise, the pitched travel
|
||||
// basis, the directional speed select, the stroke rate, and the float physics. It also
|
||||
// decides this frame's PRESENTED pitch — the raw aim, except leveled by the 0499
|
||||
// surface redirect when the rest-line cap bites.
|
||||
let frame = swim::drive_step(
|
||||
&mut player,
|
||||
&time,
|
||||
&collide,
|
||||
capsule,
|
||||
world,
|
||||
surface_y,
|
||||
(move_fwd, move_right),
|
||||
(swim_fwd, swim_side),
|
||||
mover_speeds,
|
||||
move_speed,
|
||||
if translating { drunk_wobble } else { 0.0 },
|
||||
);
|
||||
swim_pitch = frame.pitch;
|
||||
frame.outcome
|
||||
} else {
|
||||
// The kinematic mover step — walk/fall physics + the step-down snap (decisions
|
||||
// 0009/0182/0190); the mechanism lives in [`mover`].
|
||||
// **Water walking** (decisions 0866 + 1611): hand the mover the liquid surface, which
|
||||
// it treats as ordinary ground — the classify sees it, the grounded arm runs, and the
|
||||
// clamp finalises Y ([`mover::step`], where the *why* of both halves lives). In the
|
||||
// reference this is not a floor that gets handed anywhere: `MOVEFLAG_WATERWALKING` ORs
|
||||
// the ADT liquid layers into the walk trace's class mask (`0x63162e`), so the surface
|
||||
// simply *is* geometry. Passing it down is our stand-in for that, because liquid is
|
||||
// queried rather than swept here.
|
||||
//
|
||||
// All three of the arm's gates live in [`mover::water_floor`], where each one's byte
|
||||
// site and its consequence are written out — including the pitch gate that 1611 could
|
||||
// only *name*, because the pitch was steered inside the swim branch until 1616's
|
||||
// hoist put it above this call.
|
||||
let water_floor = mover::water_floor(
|
||||
player.modes.water_walking,
|
||||
swimming,
|
||||
player.mover_pitch,
|
||||
surface_y,
|
||||
);
|
||||
mover::step(
|
||||
&mut player,
|
||||
&time,
|
||||
&collide,
|
||||
capsule,
|
||||
moving,
|
||||
dir,
|
||||
speed,
|
||||
want_jump,
|
||||
wire_jump,
|
||||
knock_launch,
|
||||
water_floor,
|
||||
)
|
||||
};
|
||||
|
||||
// The knockback's own trace line (decision 1702) — flown or refused, with the quad. Emitted
|
||||
// here because this is the first point where both halves are known: the latch we took, and
|
||||
// the mover's verdict on it.
|
||||
if let Some(k) = knockback {
|
||||
move_trace::knockback(knocked, k.launch);
|
||||
}
|
||||
|
||||
let now = time.elapsed_secs();
|
||||
// Airborne is a walk-only concept — swimming never falls, so the body-heading / anim-flags
|
||||
// logic below reads this hoisted value (false while swimming) instead of the walk branch's.
|
||||
//
|
||||
// **A root ends the arc outright** (decision 0880): `airborne` is our `MOVEFLAG_FALLING`,
|
||||
// and `SetRoot 0x7c7340`'s second act is `StopFalling 0x7c6290`, which clears FALLING and
|
||||
// FALLINGFAR together. So a root or a stun taken mid-air is not a body that lands — it is a
|
||||
// body that is no longer falling, hanging where it was ([`mover::step`]'s anchor holds the
|
||||
// position). Ground contact is left honest in `grounded` (the transport attach and the trace
|
||||
// both want the truth); it is the *arc* that the root ends.
|
||||
let airborne = !swimming && !held && !player.modes.rooted && (!grounded || jumped);
|
||||
// Transport attach/detach + the deck-local re-snapshot, and the `ride` trace ([`ride`],
|
||||
// decision 0438 phase 2 / 0470's solid cargo). Airborne keeps the current attachment, so
|
||||
// a jump above the deck is deck-frame ballistics and lands where it took off.
|
||||
ride::update_attachment(
|
||||
&mut player,
|
||||
transports,
|
||||
child_of,
|
||||
ground,
|
||||
grounded,
|
||||
swimming,
|
||||
);
|
||||
// This frame's two move-flag words + the wire's fall clock ([`flags::this_frame`]): the
|
||||
// live word the wire and the local gates read, and the take-off-frozen one the animation
|
||||
// does. The arc bookkeeping (snapshot / FALLINGFAR / the landing edge) runs inside.
|
||||
let flags::FrameFlags {
|
||||
wire: move_flags_now,
|
||||
pose: pose_flags,
|
||||
landed,
|
||||
fall_time: wire_fall_time,
|
||||
} = flags::this_frame(
|
||||
&mut player,
|
||||
&axes,
|
||||
swimming.then_some((swim_fwd, swim_side)),
|
||||
airborne,
|
||||
jumped,
|
||||
held,
|
||||
air_nudged,
|
||||
stunned,
|
||||
now,
|
||||
launch_y,
|
||||
);
|
||||
// The rendered body heading + the animation's view of the flags — the display-facing law
|
||||
// lives in [`gait::drive_body_heading`] (strafe offset ease / moving snap / the standing
|
||||
// FROZEN chase whose body-step latches the turn-in-place shuffle).
|
||||
let anim_flags = gait::drive_body_heading(
|
||||
&mut player,
|
||||
pose_flags,
|
||||
dt,
|
||||
swimming,
|
||||
moving,
|
||||
airborne,
|
||||
turning || mouselook,
|
||||
turn_rate,
|
||||
);
|
||||
// Write the frame onto the driven body — pose, `MovementState`, the counter-twist gap and
|
||||
// the landing report ([`body_pose::drive`]) — and take back the camera-pivot target it
|
||||
// read off that body.
|
||||
let cam_pivot_target = body_pose::drive(
|
||||
&player,
|
||||
&mut body,
|
||||
&mut net.6,
|
||||
swimming,
|
||||
swim_pitch,
|
||||
move_flags_now,
|
||||
anim_flags,
|
||||
landed,
|
||||
stand_now,
|
||||
);
|
||||
|
||||
// The camera-collision sweep is rooted at the *head* (capsule top hemisphere centre), not the
|
||||
// framing pivot — see `seat_camera`'s doc for why. Computed here (not in `camera`) because it
|
||||
// depends on the avatar's own capsule constants, which are a movement concern.
|
||||
let head = player.pos + Vec3::Y * (CAPSULE_HEIGHT - CAPSULE_RADIUS);
|
||||
// The spyglass lock (aura 76): pin the rig to first person for as long as the scope is
|
||||
// held. Parking BOTH the live distance and the wheel target is what makes it a lock rather
|
||||
// than a nudge — the wheel writes `target_distance`, and re-parking here every frame is our
|
||||
// equivalent of the reference's camera flag `0x8` making `SetCameraView` early-return.
|
||||
if scoped.active() {
|
||||
rig.park_distance(0.0);
|
||||
}
|
||||
// Far sight (B151): while `PLAYER_FARSIGHT` names an object, the rig orbits IT instead of
|
||||
// the body — the substitution is [`camera::seat_on_subject`]'s, and it is the entire
|
||||
// feature. Everything else in this system runs untouched, which is the point: Mind Vision
|
||||
// leaves you walking, streaming, hearing and sending movement as yourself while only the
|
||||
// picture moves.
|
||||
|
||||
// `WOW_CAM_DUMP`: the per-frame INPUT signal beside `seat_camera`'s realized-pose `[cam]`
|
||||
// line — wall clock, frame dt, this frame's accumulated mouse delta, the active look mode,
|
||||
// and the yaw/pos the frame produced. A turn-feel question ("keyboard turn smooth, mouse
|
||||
// turn jittery") needs the input cadence and the output cadence on the same timeline: a
|
||||
// bursty `dx` under a steady `dt` convicts event delivery; a steady `dx` with an uneven
|
||||
// realized pose convicts everything downstream.
|
||||
if std::env::var_os("WOW_CAM_DUMP").is_some() {
|
||||
eprintln!(
|
||||
"[turn] t={:.6} dt={:.6} dx={:.3} dy={:.3} look={} face={:.6} model={:.6} \
|
||||
pos [{:.4},{:.4},{:.4}] pivot={:.4}->{:.4}",
|
||||
time.elapsed_secs_f64(),
|
||||
dt,
|
||||
mouse_motion.delta.x,
|
||||
mouse_motion.delta.y,
|
||||
match rig.look {
|
||||
Some(LookButton::Right) => "R",
|
||||
Some(LookButton::Left) => "L",
|
||||
None => "-",
|
||||
},
|
||||
player.face_yaw,
|
||||
player.model_yaw,
|
||||
player.pos.x,
|
||||
player.pos.y,
|
||||
player.pos.z,
|
||||
// The pivot channel's live height and where it is heading — the two columns that
|
||||
// answer "does the camera snap?" numerically (decision 0404: timing is measured).
|
||||
rig.pivot.probe().0,
|
||||
rig.pivot.probe().1,
|
||||
);
|
||||
}
|
||||
// `turn_delta` is the character's own turn this frame (keyboard turn, or the drunk veer)
|
||||
// — the deck's yaw delta was already applied to `cam.yaw` at the ride block (frame motion
|
||||
// carries the camera unconditionally; only input turns respect `seat_camera`'s
|
||||
// look-session gate).
|
||||
// The auto-follow's own three facts (decisions 1493/1502): the knobs, where "behind" is,
|
||||
// and the input word whose edges arm a return.
|
||||
let follow = camera::FollowInput {
|
||||
cfg: follow_cfg,
|
||||
face_yaw: player.face_yaw,
|
||||
command: follow_command,
|
||||
};
|
||||
camera::seat_on_subject(
|
||||
dt,
|
||||
turn_delta,
|
||||
player.pos,
|
||||
head,
|
||||
cam_pivot_target,
|
||||
view_subject,
|
||||
&mut rig,
|
||||
&mut cam,
|
||||
&mut cam_t,
|
||||
&collide,
|
||||
cam_probe,
|
||||
&follow,
|
||||
);
|
||||
|
||||
// The cast bar's local self-cancel trigger (`ui_cast::local_self_cancel`): a fresh
|
||||
// *directional* start (the same wire-axis edge the stream below turns into a
|
||||
// MSG_MOVE_START_*; diffed against the pre-stream `player.move_flags`) or a jump launch.
|
||||
// Turn-in-place and pitch deliberately absent — VERIFIED (wow-re `move-selfcancel.md`,
|
||||
// 0445): the client's interrupt mask `0x10f0` is {fwd, back, strafe L/R, autorun};
|
||||
// turn/pitch flags sit outside it and never cancel.
|
||||
// `autorun_armed` is 0445's dormant fifth mask member waking up — the `0x1000` bit IS in the
|
||||
// verified `0x10f0` interrupt mask, but **only on the ON edge**: `ToggleAutoRun` computes its
|
||||
// `setBool` as the new state, and the dispatcher short-circuits the whole interrupt block on a
|
||||
// clear edge (`0x5150c8`) *before* the mask is tested. So arming autorun kills a cast;
|
||||
// disarming it does not. It needs its own term because the flag-delta test above can't see it —
|
||||
// toggling autorun on with W already held raises no new direction bit (VERIFIED wire-silence),
|
||||
// yet the reference still cancels. (0445's row says "YES" unqualified; wow-re RF-0079 §5
|
||||
// corrects it to the ON edge.)
|
||||
//
|
||||
// **And it is a fact about our own character, not about whatever we are steering**
|
||||
// (decision 1281). The whole point of Mind Control is walking the victim around while the
|
||||
// channel holds, and the interrupt this feeds is the *caster's*: vmangos breaks a channel on
|
||||
// `m_caster`'s own position moving (`Spell::update`), and a possessed creature's steps never
|
||||
// touch it. Without this gate the first movement key after a Mind Control shipped
|
||||
// `CMSG_CANCEL_CHANNELLING` for our own channel 23 ms later, ending the possession — and
|
||||
// since the reins then came home mid-keypress, the still-held key ran our own character,
|
||||
// which reads exactly like "moving my character cancelled the spell" (director, 2026-08-13;
|
||||
// reproduced live, `WOW_CAST_TRACE`).
|
||||
let steering_ourselves = player.foreign_mover.is_none();
|
||||
if steering_ourselves
|
||||
&& (move_flags_now & move_flags::ANY_MOVE & !player.move_flags != 0
|
||||
|| jumped
|
||||
|| autorun_armed)
|
||||
{
|
||||
net.7 .0 = true;
|
||||
}
|
||||
|
||||
// Stream this frame's movement to the server — a `MSG_MOVE_*` per movement-axis transition, the
|
||||
// jump/fall lifecycle, and a ~500 ms heartbeat, each carrying the live `MovementInfo` (decisions
|
||||
// 0052 + 0053). vmangos relays it to nearby players, who extrapolate from the flags. See the
|
||||
// [`movement_net`] module (the outbound mirror of `net::motion`'s remote integration).
|
||||
// The rider's local pose for the wire's ON_TRANSPORT tail: `bevy_to_wow` is a pure basis
|
||||
// rotation, so the boat-local Bevy vector converts directly, and the local orientation is
|
||||
// `face_yaw − boat_yaw` (the GetAbsoluteFacing law in reverse), normalized like any wire
|
||||
// orientation.
|
||||
let wire_transport = player.ride.as_ref().map(|r| {
|
||||
let local = benilla_assets::coords::bevy_to_wow(r.local_pos);
|
||||
benilla_protocol::TransportPose {
|
||||
guid: r.guid,
|
||||
pos: benilla_protocol::wire::Vector3d {
|
||||
x: local[0],
|
||||
y: local[1],
|
||||
z: local[2],
|
||||
},
|
||||
orientation: (player.face_yaw - r.boat_yaw).rem_euclid(std::f32::consts::TAU),
|
||||
}
|
||||
});
|
||||
movement_net::stream_self_movement(
|
||||
&net.0 .0,
|
||||
&mut player,
|
||||
move_flags_now,
|
||||
swim_pitch,
|
||||
movement_net::ArcEdges {
|
||||
jumped,
|
||||
// **The wire's own take-offs announce themselves, and not with `MSG_MOVE_JUMP`**
|
||||
// (decision 1702). `wire_jump` covers the hover grant's `CMovement::Jump(force = 0)`,
|
||||
// whose handler `0x61a620` sends nothing at all — and it cannot be racing a keyboard
|
||||
// jump, because the body it launches was granted HOVER in the same breath and hover
|
||||
// is the first refusal Space takes (`0x7c623a`). `knocked` covers the knockback,
|
||||
// which pushes its ack instead. This is the correction 1620 needed: benilla streamed
|
||||
// a JUMP for the hover launch, and the reference streams nothing.
|
||||
wire_launch: knocked || wire_jump,
|
||||
air_nudged,
|
||||
landed,
|
||||
fall_time: wire_fall_time,
|
||||
},
|
||||
now,
|
||||
&speed_acks,
|
||||
// Owed only if the mover actually flew it: a launch the settle hold or the root anchor
|
||||
// refused is dropped in silence, exactly as the reference discards an already-popped
|
||||
// knockback record under `MOVEFLAG_ROOT` (`0x615c71 test ah,0x10` → the kind-28 table
|
||||
// byte is 0 → `0x615c80 je 0x616539`: no apply, and no ack).
|
||||
knockback.filter(|_| knocked),
|
||||
wire_transport,
|
||||
);
|
||||
} else {
|
||||
// Free fly (pre-connect or detached): aim from the look angles, move the camera directly
|
||||
// ([`camera::fly_free`]). If we just detached mid-move, the controlled branch above (which
|
||||
// owns the per-frame movement stream) has stopped running with our last move-flags still
|
||||
// live on the wire — park the mover so the server clears them, else observers extrapolate a
|
||||
// phantom walk/spin until we re-attach. No-op pre-connect / once already stopped (decision
|
||||
// 0056). The avatar stays frozen at `player.pos`.
|
||||
movement_net::park_mover(&net.0 .0, &mut player);
|
||||
camera::fly_free(dt, &keys, typing, &mut rig, &mut cam, &mut cam_t);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! **The move-flag word this frame carries** — the client's `CMovement+0x40`, rebuilt from state
|
||||
//! every frame and then read by three different consumers, which is the whole reason it is built
|
||||
//! in one place: the animation selector, the outbound `MSG_MOVE_*` stream, and the local gates
|
||||
//! (the sit refusal, the cast self-cancel) must never disagree about what the avatar is doing
|
||||
//! (decision 0056 — the wire mirrors the avatar's actual motion).
|
||||
//!
|
||||
//! Two words come out, and the difference between them is verified, not cosmetic: the **wire**
|
||||
//! word keeps the direction bits live mid-air (the 1.12.1 sniff proves the real client's do), while
|
||||
//! the **pose** word freezes them at take-off, because the anim layer plays the step-off gait off
|
||||
//! the takeoff-frozen flags until FALLINGFAR latches or the unit lands.
|
||||
//!
|
||||
//! The airborne arc's own bookkeeping — the snapshot, the FALLINGFAR latch, the landing edge —
|
||||
//! belongs to [`Player::advance_airborne_arc`]; this module only reads its verdict and the fall
|
||||
//! clock that rides the landing packet.
|
||||
|
||||
use crate::creature_anim::move_flags;
|
||||
|
||||
use super::{input, state, Player};
|
||||
|
||||
/// The two flag words this frame, plus the two facts the wire lifecycle needs from the arc.
|
||||
pub(super) struct FrameFlags {
|
||||
/// What goes on the wire (and into the local gates): direction bits stay live mid-air.
|
||||
pub wire: u32,
|
||||
/// What the animation sees: direction bits frozen at take-off.
|
||||
pub pose: u32,
|
||||
/// The airborne arc ended this frame — the `MSG_MOVE_FALL_LAND` edge.
|
||||
pub landed: bool,
|
||||
/// Milliseconds since the arc began, snapshotted *before* the landing clears it — vmangos
|
||||
/// gates fall damage on this field.
|
||||
pub fall_time: u32,
|
||||
}
|
||||
|
||||
/// Build this frame's two flag words. `swim` is `Some((fwd, side))` exactly while swimming — the
|
||||
/// netted swim amounts that actually drove the mover, so a rooted or key-cancelled swimmer cannot
|
||||
/// stream a phantom direction.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn this_frame(
|
||||
player: &mut Player,
|
||||
axes: &input::MoveAxes,
|
||||
swim: Option<(f32, f32)>,
|
||||
airborne: bool,
|
||||
jumped: bool,
|
||||
held: bool,
|
||||
air_nudged: bool,
|
||||
stunned: bool,
|
||||
now: f32,
|
||||
launch_y: f32,
|
||||
) -> FrameFlags {
|
||||
// The wire fall clock (ms since the airborne arc began), snapshotted HERE — before the arc
|
||||
// bookkeeping below clears `airborne_since` on the landing frame — so the MSG_MOVE_FALL_LAND
|
||||
// reports the *accumulated* fall time. vmangos `Player::HandleFall` gates fall damage on the
|
||||
// land packet's fallTime ≥ 1229 ms (the free-fall time of the 14.57-yd damage threshold); a
|
||||
// clock zeroed by the landing silently disables fall damage. The takeoff frame still sends 0
|
||||
// (`airborne_since` is not yet set at this point in that frame).
|
||||
let wire_fall_time = if jumped {
|
||||
// A jump launch starts a fresh arc — its fall clock is zero. This also covers a
|
||||
// same-frame land+relaunch, where `airborne_since` still holds the *previous* arc's
|
||||
// start; without this the bounce's JUMP would carry a stale (accumulated) fall time,
|
||||
// and a long spam-jump chain could spuriously cross the server's fall-damage gate.
|
||||
0
|
||||
} else {
|
||||
player
|
||||
.airborne_since
|
||||
.map_or(0, |t0| ((now - t0) * 1000.0).max(0.0) as u32)
|
||||
};
|
||||
// The CMovement move-flags this frame's input implies. The same bitset drives our avatar's
|
||||
// animation *and* the movement stream we send the server, so the two can
|
||||
// never disagree. Direction bits mirror the client's MOVEMENTFLAGS; FALLING marks the airborne
|
||||
// arc (animation-only — it is masked off before going on the wire, see the send block).
|
||||
// **Every granted mover mode rides every packet**, in or out of the water — the reference's
|
||||
// builder reads the one `[cmov+0x40]` the server's merge wrote them into, so it echoes back
|
||||
// whatever was granted for free (decisions 0726, 0866). Ours has to put them back
|
||||
// explicitly, because this word is rebuilt from state each frame; drop one and the server
|
||||
// forgets the mode, then the next server-authored move echoes a mode-less word back and
|
||||
// clears it under us. Root rides too — moving bits are what must not accompany it, and
|
||||
// rooted input can't produce any (the controller zeroes `dir`, jumps refused).
|
||||
let mut move_flags_now = player.modes.wire_flags();
|
||||
// `landed` gates the wire's jump/fall lifecycle; the swim branch never sets
|
||||
// them (leaving the water resumes the ground mover from rest, no airborne report).
|
||||
let landed;
|
||||
if let Some((swim_fwd, swim_side)) = swim {
|
||||
// Swimming: `MOVEFLAG_SWIMMING` (the swim-pitch tail rides with it) plus the travel-direction
|
||||
// bits the swim gait selector cascades on (TU-E: turn→41, strafe→43/44, back→45, fwd→42,
|
||||
// idle→41). The bits mirror the NET swim amounts that actually drive the mover — one
|
||||
// source, so a rooted or key-cancelled swimmer can't stream a phantom direction
|
||||
// (decision 0056). Space sets nothing here — its whole swim role is the jump-exit,
|
||||
// which runs the breach arm (TU-F). No FALLING, no airborne bookkeeping: the
|
||||
// arc state is cleared so leaving the water starts a clean walk/fall from rest.
|
||||
move_flags_now |= move_flags::SWIMMING;
|
||||
if swim_fwd < 0.0 {
|
||||
move_flags_now |= move_flags::BACKWARD;
|
||||
} else if swim_fwd > 0.0 {
|
||||
move_flags_now |= move_flags::FORWARD;
|
||||
}
|
||||
if swim_side < 0.0 {
|
||||
move_flags_now |= move_flags::STRAFE_LEFT;
|
||||
} else if swim_side > 0.0 {
|
||||
move_flags_now |= move_flags::STRAFE_RIGHT;
|
||||
}
|
||||
player.airborne_since = None;
|
||||
player.fall_far = false;
|
||||
landed = false;
|
||||
} else {
|
||||
// Straight off the net axis, so a netted-to-zero press pair streams NO direction bit
|
||||
// (the emitter's genuine STOP) rather than a phantom FORWARD we aren't actually moving
|
||||
// in — decision 0056's law that the flags mirror the avatar's motion.
|
||||
match axes.fwd.signum() {
|
||||
1 => move_flags_now |= move_flags::FORWARD,
|
||||
-1 => move_flags_now |= move_flags::BACKWARD,
|
||||
_ => {}
|
||||
}
|
||||
// Straight off the netted strafe axis, so a cancelled press pair streams NO strafe bit —
|
||||
// the two are mutually exclusive on the wire, and both-set is silently dropped by the
|
||||
// server (decision 0622).
|
||||
match axes.side.signum() {
|
||||
-1 => move_flags_now |= move_flags::STRAFE_LEFT,
|
||||
1 => move_flags_now |= move_flags::STRAFE_RIGHT,
|
||||
_ => {}
|
||||
}
|
||||
if !axes.mouselook {
|
||||
if axes.turn_left {
|
||||
move_flags_now |= move_flags::TURN_LEFT;
|
||||
}
|
||||
if axes.turn_right {
|
||||
move_flags_now |= move_flags::TURN_RIGHT;
|
||||
}
|
||||
}
|
||||
// Airborne (a jump or a step-off a ledge) — the caller's hoisted value. The arc's
|
||||
// snapshot / far-latch / landing edges live in [`Player::advance_airborne_arc`] (a
|
||||
// fresh jump is always a NEW arc, even a same-frame land+relaunch — see there). FALLING
|
||||
// also rides the wire (decision 0053), so observers replay it.
|
||||
let arc = player.advance_airborne_arc(airborne, jumped, now, launch_y);
|
||||
landed = arc.landed;
|
||||
if airborne {
|
||||
move_flags_now |= move_flags::FALLING;
|
||||
// Mid-air the direction flags stay LIVE — the real client's `CMovement+0x40` keeps
|
||||
// tracking the keys while airborne, and the wire proves it (VERIFIED, vanilla-sniffs
|
||||
// `dwarf_rogue_dun_morogh`: a strafe pressed mid-air rides the landing FALL_LAND as
|
||||
// `(Forward, StrafeLeft)`; an S→W swap mid-air lands as `(Forward)`). What's frozen
|
||||
// at takeoff is the *velocity basis* (the mover's momentum — `0x7c5a20` skips the
|
||||
// basis recompute while FALLING), never the reported state; the landing-anim pick
|
||||
// (`jump_land_pick`, the ref's `0x602c60`) keys on the flags *at touchdown*, so a
|
||||
// frozen wire strands observers on stale flags and they play a locomotion anim
|
||||
// instead of the landing. The ANIM path keeps the takeoff-frozen dirs (`pose_flags`
|
||||
// below — the RE'd step-off gait freeze); a new arc (re)seeds them, and the
|
||||
// standstill air nudge is the one mid-arc input that really moves us.
|
||||
if arc.new_arc || air_nudged {
|
||||
player.airborne_dirs = move_flags_now & move_flags::ANY_MOVE;
|
||||
}
|
||||
// FALLINGFAR (latched by `advance_airborne_arc` above — the exclusive distance/timer
|
||||
// legs, decision 0179) rides the live flags: the mid-air Fall(40) pose, the
|
||||
// landing-anim gate, and the wire (heartbeats carry it; the axis differ ignores it).
|
||||
if player.fall_far {
|
||||
move_flags_now |= move_flags::FALLING_FAR;
|
||||
}
|
||||
}
|
||||
// While `held` (post-teleport/login settle) the avatar is frozen in place with gravity off,
|
||||
// so it has no locomotion to report — clear the flags so we never stream a phantom walk/turn
|
||||
// the server would extrapolate onto observers while we sit on the settle. The frozen position
|
||||
// was already reported by the teleport Stop; a facing change still streams a harmless
|
||||
// SET_FACING. The same bitset drives the local animation (0052), so this also keeps the
|
||||
// held avatar idle rather than moonwalking in place. (Decision 0056 — the wire mirrors the
|
||||
// avatar's actual motion.)
|
||||
if held {
|
||||
move_flags_now = 0;
|
||||
}
|
||||
}
|
||||
// The two incapacitate suppressions — rooted drops the direction bits, stunned drops the
|
||||
// turn bits — applied to the whole word in one place, whichever branch built it, and with
|
||||
// the reference's byte trail in [`state::incapacitated_flags`] (decision 0880).
|
||||
move_flags_now = state::incapacitated_flags(move_flags_now, player.modes.rooted, stunned);
|
||||
// Riding a transport: the ON_TRANSPORT bit rides every packet with its local-pose tail
|
||||
// (built at the send). Set from the POST-attach state so flag and tail agree the
|
||||
// very frame we board or step off (decision 0438 phase 2).
|
||||
if player.ride.is_some() && !held {
|
||||
move_flags_now |= move_flags::ON_TRANSPORT;
|
||||
}
|
||||
|
||||
// The animation/body-pose view of the flags: airborne it keeps the TAKEOFF-FROZEN direction
|
||||
// bits — the reference's anim layer plays the step-off gait off the takeoff-frozen
|
||||
// flags/speed until FALLINGFAR latches or the unit lands (wow-re `land-anim-height-gate.md`),
|
||||
// and a mid-air Q press must not twist the body or animate a strafe. The *wire* flags above
|
||||
// stay live (the sniff-verified send law); only the pose reads the freeze.
|
||||
let pose_flags = if airborne {
|
||||
(move_flags_now & !move_flags::ANY_MOVE) | player.airborne_dirs
|
||||
} else {
|
||||
move_flags_now
|
||||
};
|
||||
|
||||
FrameFlags {
|
||||
wire: move_flags_now,
|
||||
pose: pose_flags,
|
||||
landed,
|
||||
fall_time: wire_fall_time,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! **This frame's decoded input**, in one place — the two things the controller derives from the
|
||||
//! keyboard, the mouse and the binding table before any of it means anything to the avatar:
|
||||
//!
|
||||
//! - [`look_input`] — the both-button state and the reference's own **camera command word**
|
||||
//! (1.12's `[InputControl+0x4]`, decision 1502), which the auto-follow is armed by. Read
|
||||
//! *before* the look session, because both camera seats want it — including the one on the
|
||||
//! not-driving path, which returns before the axes below are ever computed.
|
||||
//! - [`move_axes`] — the **netted** forward/back and strafe axes plus the modes that select
|
||||
//! between them (mouselook, keyboard turn), and the autorun latch with its cancel set. Netted
|
||||
//! once here so that the direction we move, the speed we pick, the swim amounts and the flags we
|
||||
//! stream can never disagree (decision 0056).
|
||||
//!
|
||||
//! Nothing here touches the avatar: `move_axes` writes exactly one field ([`Player::autorun`]),
|
||||
//! because the autorun latch *is* input state.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{camera, state, CameraControl, LookButton, Player};
|
||||
|
||||
/// What [`look_input`] read off the mouse and the bindings, before the look session runs.
|
||||
pub(super) struct LookInput {
|
||||
/// Vanilla's "both-button run" state (either real button pair, or MOVEANDSTEER).
|
||||
pub both_buttons: bool,
|
||||
/// The camera's input command word — see [`super::camera::follow_cmd`].
|
||||
pub follow_command: u32,
|
||||
}
|
||||
|
||||
/// The mouse/binding state the camera needs, built before the look session so the not-driving
|
||||
/// path (which returns without ever reaching [`move_axes`]) can seat its camera from the same word.
|
||||
pub(super) fn look_input(
|
||||
buttons: &ButtonInput<MouseButton>,
|
||||
binds: &crate::bindings::BindingsState,
|
||||
player: &Player,
|
||||
) -> LookInput {
|
||||
// Both mouse buttons held together = vanilla's "both-button run": the avatar runs forward while
|
||||
// the character steers with the mouse (turns like a right-drag), regardless of which button went
|
||||
// down first. Checked directly here rather than through the controller's single-button look mode.
|
||||
// MOVEANDSTEER (default Middle Mouse) is the same state through a binding — 1.12's own body
|
||||
// runs the identical CameraOrSelectOrMove + TurnOrAction pair a both-button press does.
|
||||
let steer_held = binds.pressed(crate::bindings::cmd::MOVE_AND_STEER);
|
||||
let both_buttons =
|
||||
(buttons.pressed(MouseButton::Left) && buttons.pressed(MouseButton::Right)) || steer_held;
|
||||
|
||||
// The camera's **input command word** (decision 1502) — 1.12's `[InputControl+0x4]`, bit for
|
||||
// bit. The auto-follow is armed by *edges on this word* and its state is classified from it, so
|
||||
// it is built once here, from the same binding state the movement code reads, and handed
|
||||
// to both camera seats. MOVEANDSTEER sets both mouse bits because that is what the reference's
|
||||
// binding does — it runs the identical CameraOrSelectOrMove + TurnOrAction pair.
|
||||
let follow_command = {
|
||||
use camera::follow_cmd as bit;
|
||||
let mut w = 0;
|
||||
let mut set = |on: bool, b: u32| {
|
||||
if on {
|
||||
w |= b;
|
||||
}
|
||||
};
|
||||
set(
|
||||
buttons.pressed(MouseButton::Right) || steer_held,
|
||||
bit::RIGHT_MOUSE,
|
||||
);
|
||||
set(
|
||||
buttons.pressed(MouseButton::Left) || steer_held,
|
||||
bit::LEFT_MOUSE,
|
||||
);
|
||||
// `/follow` is the forward bit in the reference too — the same setter the W key drives
|
||||
// (decision 0890), so it arms the camera exactly like a held W.
|
||||
set(
|
||||
binds.pressed(crate::bindings::cmd::MOVE_FORWARD) || player.follow_forward,
|
||||
bit::FORWARD,
|
||||
);
|
||||
set(
|
||||
binds.pressed(crate::bindings::cmd::MOVE_BACKWARD),
|
||||
bit::BACKWARD,
|
||||
);
|
||||
set(
|
||||
binds.pressed(crate::bindings::cmd::STRAFE_LEFT),
|
||||
bit::STRAFE_LEFT,
|
||||
);
|
||||
set(
|
||||
binds.pressed(crate::bindings::cmd::STRAFE_RIGHT),
|
||||
bit::STRAFE_RIGHT,
|
||||
);
|
||||
set(
|
||||
binds.pressed(crate::bindings::cmd::TURN_LEFT),
|
||||
bit::TURN_LEFT,
|
||||
);
|
||||
set(
|
||||
binds.pressed(crate::bindings::cmd::TURN_RIGHT),
|
||||
bit::TURN_RIGHT,
|
||||
);
|
||||
set(player.autorun, bit::AUTORUN);
|
||||
// The two externally-driven flags, which the reference folds into the camera's own
|
||||
// Track/Fear bits rather than the input word — carried here so one word carries every edge.
|
||||
set(player.server_riding, bit::TRACK);
|
||||
set(player.control_lost, bit::FEAR);
|
||||
w
|
||||
};
|
||||
|
||||
LookInput {
|
||||
both_buttons,
|
||||
follow_command,
|
||||
}
|
||||
}
|
||||
|
||||
/// This frame's **netted** movement axes and the modes that shape them. Every forward/back,
|
||||
/// strafe and turn consumer downstream reads these fields and not the keys.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct MoveAxes {
|
||||
/// Net forward/back: `+1` forward, `-1` back, `0` for a cancelled pair (see
|
||||
/// [`state::forward_axis`]).
|
||||
pub fwd: i32,
|
||||
/// Net strafe, `+` right — Q/E always, A/D only while mouse-looking.
|
||||
pub side: i32,
|
||||
/// Right-mouse (or both-button) held: A/D strafe and the facing tracks the camera.
|
||||
pub mouselook: bool,
|
||||
/// A keyboard turn is being held — and is allowed (a stun kills it).
|
||||
pub turning: bool,
|
||||
/// The reference's `flags & 0xf`, off the *net* axes: W+S neither streams nor moves.
|
||||
pub translating: bool,
|
||||
/// Autorun was toggled **on** this frame — 0445's fifth cast-interrupt term, which the
|
||||
/// flag delta cannot see.
|
||||
pub autorun_armed: bool,
|
||||
/// The raw strafe/turn keys, for the consumers that need the pressed state rather than the
|
||||
/// net axis: the swim amounts and the wire's turn bits.
|
||||
pub strafe_left: bool,
|
||||
pub strafe_right: bool,
|
||||
pub turn_left: bool,
|
||||
pub turn_right: bool,
|
||||
}
|
||||
|
||||
/// Decode the movement keys into [`MoveAxes`], running the autorun latch and its cancel set on
|
||||
/// the way through.
|
||||
pub(super) fn move_axes(
|
||||
binds: &crate::bindings::BindingsState,
|
||||
buttons: &ButtonInput<MouseButton>,
|
||||
player: &mut Player,
|
||||
rig: &CameraControl,
|
||||
both_buttons: bool,
|
||||
stunned: bool,
|
||||
) -> MoveAxes {
|
||||
// ── Autorun ── TOGGLEAUTORUN through the binding table (0997; 1.12 defaults NUMLOCK +
|
||||
// BUTTON4 — the latter is winit's `Forward`, the thumb button this toggle lived on before
|
||||
// the table existed, kept by the codec's BUTTON4 mapping). A latched mode, not a held key:
|
||||
// the keyboard chord is typing-gated at dispatch like every binding, the mouse chord is
|
||||
// not — and the reference agrees, its focus-loss handler releasing every direction bit
|
||||
// while preserving `0x1000` (`0x514490`'s `and eax,0xfffff00f`, VERIFIED).
|
||||
let mut autorun_armed = false;
|
||||
if binds.fired(crate::bindings::cmd::TOGGLE_AUTORUN) {
|
||||
player.autorun = !player.autorun;
|
||||
autorun_armed = player.autorun;
|
||||
}
|
||||
// A mouse whose extra buttons don't land on Back/Forward would otherwise fail silently, and
|
||||
// "nothing happened" is the least debuggable report there is. Name what did arrive.
|
||||
for b in buttons.get_just_pressed() {
|
||||
if let MouseButton::Other(n) = b {
|
||||
info!("mouse: unmapped button Other({n}) — bindings know BUTTON4/BUTTON5 as winit Forward/Back");
|
||||
}
|
||||
}
|
||||
// ── The cancel set ── autorun is NOT simply "held forward" — the thing that makes it its own
|
||||
// mode is what *destroys* it. Six writers clear the bit in the reference; these are the ones
|
||||
// with a benilla analog (VERIFIED, wow-re `rf79-autorun-cancel-set.md`):
|
||||
//
|
||||
// - **A W or S key-DOWN** — unconditional, and the subtle one: the directional handlers look
|
||||
// pure (each pushes only its own bit), but they tail into the shared SET helper `0x514840`,
|
||||
// which does `and [MOVE+4],0xffffefff` under `test cl,0x30` (fwd `0x10` | back `0x20`) at
|
||||
// `0x514a5a`. A per-handler read answers "no" and is wrong about the behaviour. It runs
|
||||
// *before* the axis (`0x5150a7` vs the emitter tail `0x5151a0`), so the axis never sees the
|
||||
// combination. **Key-DOWN only**: the release path `0x514b70` restores nothing, which is why
|
||||
// letting go of S after reversing leaves you standing rather than running again.
|
||||
// - **The transition INTO both-buttons-held** (`0x514a73`, the same helper) — engaging the
|
||||
// both-button run replaces autorun rather than stacking with it.
|
||||
// - **Losing the mover** — death, root/stun, a taxi/charge hand-off. In the reference the
|
||||
// emitter's gate `0x514560` goes down (health `<= 0`, `MOVEMENTFLAGS & 0x1200`, the on-taxi
|
||||
// predicate) and writer #4 `0x514748` clears the bit as a side effect of the next emit; a
|
||||
// level test is the faithful shape, not an edge. (Mechanism VERIFIED; the individual bit
|
||||
// identities behind the gate are INFERRED — see the note's §4.)
|
||||
//
|
||||
// Deliberately absent, each VERIFIED as a *survivor*: a jump, a chat EditBox taking focus, and
|
||||
// a zone change. Mounting is genuinely unsettled in the reference and left alone here.
|
||||
let both_buttons_engaged = (both_buttons
|
||||
&& (buttons.just_pressed(MouseButton::Left) || buttons.just_pressed(MouseButton::Right)))
|
||||
|| binds.just_pressed(crate::bindings::cmd::MOVE_AND_STEER);
|
||||
if state::autorun_cancelled(
|
||||
binds.just_pressed(crate::bindings::cmd::MOVE_FORWARD),
|
||||
binds.just_pressed(crate::bindings::cmd::MOVE_BACKWARD),
|
||||
both_buttons_engaged,
|
||||
player.modes.rooted || player.server_riding,
|
||||
) {
|
||||
player.autorun = false;
|
||||
}
|
||||
let autorun = player.autorun;
|
||||
// ── The forward/back axis ── one net value ([`state::forward_axis`], whose tests pin the
|
||||
// verified state table) read by every forward/back consumer downstream, so the direction we move,
|
||||
// the speed we pick, the swim amounts and the flags we stream can't disagree (decision 0056).
|
||||
//
|
||||
// Zero is the state no "autorun = held forward" reading can produce, and it is reachable:
|
||||
// hold S *first*, then toggle autorun — the toggle pushes X=`0x1000`, so `test cl,0x30` misses
|
||||
// and the bit survives — and the client emits MSG_MOVE_STOP with S still held. The other order
|
||||
// (autorun, then S) destroys the bit at key-down and walks you backward. Same two keys, two
|
||||
// outcomes; that asymmetry is the whole shape of the feature.
|
||||
// `/follow` enters as the FORWARD term, not a fifth source (decision 0890): the reference's
|
||||
// follow pushes the very same move-forward bit `0x100000` the W key does, through the same
|
||||
// setter, so it nets against a held S and diagonals with a strafe exactly like a held W. It
|
||||
// rides the HELD state and not the key-DOWN edge, so it never trips the autorun cancel set
|
||||
// above — which is right: synthesized input is not a keypress.
|
||||
let fwd_axis = state::forward_axis(
|
||||
binds.pressed(crate::bindings::cmd::MOVE_FORWARD) || player.follow_forward,
|
||||
binds.pressed(crate::bindings::cmd::MOVE_BACKWARD),
|
||||
both_buttons,
|
||||
autorun,
|
||||
);
|
||||
// Vanilla turn/strafe control model (decision 0050, VERIFIED wow-5875-re `0x7c5360`): W/S move
|
||||
// forward/back in the facing; **A/D turn the character** (rotate the facing at the turn rate) so
|
||||
// the body faces where it runs — UNLESS right-mouse is held (mouse-look), where A/D strafe and
|
||||
// the facing tracks the camera; **Q/E always strafe**. Movement basis is the *character* facing,
|
||||
// so left-drag (camera-only orbit) doesn't change which way W walks.
|
||||
let mouselook = both_buttons || rig.look == Some(LookButton::Right);
|
||||
// The strafe axis, **netted exactly like `fwd_axis`** — Q/E always strafe, A/D only while
|
||||
// mouse-looking. Netting is not a nicety: the two bits are mutually exclusive on the wire.
|
||||
// Holding both keys used to OR `STRAFE_LEFT | STRAFE_RIGHT` into the flags while the avatar
|
||||
// stood still (the controller's `dir` sum cancels), and vmangos **silently drops** every movement
|
||||
// packet carrying that pair — never relaying it to anyone (decision 0622). Measured: 48 such
|
||||
// packets in one session, 0 received by a watching client, against 0 in the reference
|
||||
// client's entire 1.12.1 capture. That is decision 0056's invariant — the wire mirrors the
|
||||
// avatar's actual motion — violated on this one axis only; the swim branch already nets.
|
||||
let strafe_left = binds.pressed(crate::bindings::cmd::STRAFE_LEFT);
|
||||
let strafe_right = binds.pressed(crate::bindings::cmd::STRAFE_RIGHT);
|
||||
let turn_left = binds.pressed(crate::bindings::cmd::TURN_LEFT);
|
||||
let turn_right = binds.pressed(crate::bindings::cmd::TURN_RIGHT);
|
||||
let side_axis = i32::from(strafe_right) - i32::from(strafe_left)
|
||||
+ if mouselook {
|
||||
i32::from(turn_right) - i32::from(turn_left)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// TURNLEFT/TURNRIGHT turn the facing when not mouse-looking (yaw increases turning left,
|
||||
// matching mouse-left). …and never while stunned: the reference skips the keyboard turn
|
||||
// emitter `0x514f50` entirely (and force-stops an in-flight turn) behind the same
|
||||
// `0x514755` gate. Killing the turn here is also what ends B179's *second* half — the walk
|
||||
// animation a stunned character was still playing was the turn-in-place shuffle, which
|
||||
// `gait` derives from real yaw change.
|
||||
let turning = !mouselook && !stunned && (turn_left || turn_right);
|
||||
// The net translate state — the reference's `flags & 0xf` (its four move bits), read off
|
||||
// the *net* axes, not the keys: W+S streams no direction bit and doesn't translate.
|
||||
let translating = fwd_axis != 0 || side_axis != 0;
|
||||
|
||||
MoveAxes {
|
||||
fwd: fwd_axis,
|
||||
side: side_axis,
|
||||
mouselook,
|
||||
turning,
|
||||
translating,
|
||||
autorun_armed,
|
||||
strafe_left,
|
||||
strafe_right,
|
||||
turn_left,
|
||||
turn_right,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The mover's side of the `WOW_MOVE_TRACE` debug trace ([`benilla_assets::trace`]), three tags:
|
||||
//! The mover's side of the `WOW_MOVE_TRACE` debug trace ([`benilla_assets::trace`]):
|
||||
//!
|
||||
//! - **`move`** — one line per *interesting* frame of the player mover ([`frame`]): a step-down snap,
|
||||
//! a grounded flip, an airborne frame, or any sizeable vertical delta — so a movement-feel report
|
||||
@@ -27,7 +27,9 @@
|
||||
//! compare it against the reference's — the 1.12.1 sniff's client stream is a list of exactly these
|
||||
//! fields, so `grep snd` on a run of mouse-turning and strafing is directly diffable against it.
|
||||
//!
|
||||
//! The anim driver writes its own `anim` lines into the same file, on the same clock.
|
||||
//! The anim driver writes its own `anim` lines into the same file, on the same clock, and the
|
||||
//! transport carry writes `ride` from [`super::ride`] — beside the mechanism it measures, because
|
||||
//! reading it needs the deck's transform and the attach walk, neither of which belongs here.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The kinematic mover step — the walk/fall physics and the step-down snap, split out of the
|
||||
//! `control` system ([`super`] keeps the input/camera/wire glue and the knob table this reads).
|
||||
//! `control` system ([`super::controller`] keeps the frame order; [`super`] the knob table this
|
||||
//! reads).
|
||||
//! One call per frame: [`step`].
|
||||
//!
|
||||
//! Thin kinematic controller (decision 0009) over the **one-sided** mirror of avian's
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! **Posture** — the two things the controller does to how the body *holds* itself, as opposed to
|
||||
//! where it goes: the **stand state** (the `/sit` family and the `X` key) and the **sheath**
|
||||
//! toggle (`Z`). One module because they are one mechanism in the reference and they interlock
|
||||
//! here: entering a stand state ∉ {0 STAND, 2 SIT_CHAIR} force-stows drawn weapons through the
|
||||
//! same anim-layer setter the `Z` key drives, and the sheath toggle's own guard chain refuses on
|
||||
//! the stand state this frame just committed.
|
||||
//!
|
||||
//! Both halves are **silent-refusal** mechanisms — the reference builds no packet and says
|
||||
//! nothing when a press is rejected — which is why the stand half writes the `sit` trace tag
|
||||
//! ([`super::move_trace::posture`]) at every commit *and* every refusal: on screen a granted sit
|
||||
//! and a refused one are the same picture (decisions 0080, 0881; bug B155).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{move_trace, state, BodyQuery, ClientCommand, NetCommands, Player, StandStateRequest};
|
||||
|
||||
/// Run this frame's stand-state decision and the sheath toggle, and return the **committed**
|
||||
/// stand state — the local commit overlaid on the server's echoed byte, which is what the body
|
||||
/// pose and the sheath guard both read (decision 0080c).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn update(
|
||||
player: &mut Player,
|
||||
body: &BodyQuery,
|
||||
binds: &crate::bindings::BindingsState,
|
||||
net: &NetCommands,
|
||||
sheath: &mut MessageWriter<crate::creature_anim::SheathRequest>,
|
||||
asks: &mut MessageReader<StandStateRequest>,
|
||||
moving: bool,
|
||||
turned: bool,
|
||||
) -> u8 {
|
||||
// Stand state (decision 0080c) — a real field, not a local bool: X volunteers
|
||||
// `CMSG_STANDSTATECHANGE` (sit 1 ↔ stand 0) and movement input stands us up; the
|
||||
// server's echo into `UNIT_FIELD_BYTES_1` drives the pose — ours *and* every
|
||||
// observer's. `stand_pending` is the local commit (the client's `SetStandState`
|
||||
// applies immediately and sends, one setter — `0x6127b0`), overlaid on the echoed
|
||||
// byte until it lands so the pose never waits on the round-trip.
|
||||
let stand_byte = body
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|(.., store, _, _, _, _, _)| store.map(|s| s.0.unit_stand_state()))
|
||||
.unwrap_or(0);
|
||||
if player.stand_pending == Some(stand_byte) {
|
||||
player.stand_pending = None; // the echo landed
|
||||
}
|
||||
let stand_state = player.stand_pending.unwrap_or(stand_byte);
|
||||
// The queued asks first (the `/sit` family — decision 0881), then the X key, which is the
|
||||
// reference's own precedence: a queued `SetStandState` ran during the frame's message pass,
|
||||
// the key is read now. The last writer wins, and every one of them lands on the single
|
||||
// commit-and-send below.
|
||||
let mut request_stand = asks.read().last().map(|r| r.state);
|
||||
if binds.fired(crate::bindings::cmd::SIT_OR_STAND) {
|
||||
request_stand = Some(u8::from(stand_state == 0));
|
||||
}
|
||||
// Any movement input stands the avatar back up (the client volunteers the stand — the
|
||||
// server never auto-stands a moving player; verified vmangos MovementHandler). The input
|
||||
// set is byte-pinned (wow-re `standstate-movement-trigger.md`, §5 2026-07-14): the net
|
||||
// input axes (translation), keyboard turn, and jump all reach the guarded stand wrapper
|
||||
// `0x60be30(0)`; a left-drag camera orbit provably does not; sit(1)/chair(2)/sleep(3)
|
||||
// all stand identically (the value-agnostic `GetStandState() != 0` gate). The one open
|
||||
// corner: no static path was found for a pure right-drag MOUSE turn while seated — the
|
||||
// director's ref observation (it stands you up) is the ground truth this keeps; the
|
||||
// byte trigger is flagged LIVE-CAPTURE in the wow-re note.
|
||||
// **A knockback stands you up too** (decision 1702) — the one entry here that is nobody's
|
||||
// input. The reference's knockback apply carries it as a side effect of the launch (the
|
||||
// `0x60e139` block, whose indirect `call [edx+0xa4]` resolves to `GetStandState 0x60be50`),
|
||||
// which is the same guarded wrapper every trigger above reaches. Read off the armed latch
|
||||
// rather than the take-off, because this block runs before the mover: the latch was written
|
||||
// by [`super::wire_in::apply_server_moves`] at the top of the frame and is still there.
|
||||
let knocked_out_of_it = player.knockback.is_some();
|
||||
if (moving || turned || knocked_out_of_it || binds.fired(crate::bindings::cmd::JUMP))
|
||||
&& stand_state != 0
|
||||
&& request_stand.is_none()
|
||||
{
|
||||
request_stand = Some(0);
|
||||
}
|
||||
// The sit-down gate — the client's own, inside the ONE setter `0x5ed430`
|
||||
// ([`state::stand_state_refused`], bug B155): a body the movement layer is already driving
|
||||
// cannot be seated, and **swimming is one of the driving states**, so the press is refused
|
||||
// for as long as we are in the water. Silently, and before the packet — like the reference,
|
||||
// which returns from `SetStandState` without building `CMSG_STANDSTATECHANGE` at all.
|
||||
// Placed on the shared commit below rather than on the X key, so it covers the posture
|
||||
// emotes (`/sit`, `/sleep`, `/kneel`) in the same stroke — their own `Emotes.dbc` gate
|
||||
// does NOT carry the swim bit (`ui_chat::tests::the_posture_emotes_carry_no_swim_suppression_flag`).
|
||||
// The word is the live outbound one, a frame old — the same `[[this+0x118]+0x40]` the cast
|
||||
// gates read (decision 1056), so all three refusals can never disagree about "am I moving".
|
||||
if let Some(s) = request_stand.filter(|&s| state::stand_state_refused(player.move_flags(), s)) {
|
||||
debug!(
|
||||
"stand state {s} refused (move flags {:#x} — the client's `0x5ed430` gate)",
|
||||
player.move_flags()
|
||||
);
|
||||
move_trace::posture("REFUSED", s, stand_state, player.move_flags());
|
||||
request_stand = None;
|
||||
}
|
||||
if let Some(s) = request_stand.filter(|&s| s != stand_state) {
|
||||
move_trace::posture("commit", s, stand_state, player.move_flags());
|
||||
player.stand_pending = Some(s);
|
||||
let _ = net.0.send(ClientCommand::StandStateChange {
|
||||
state: u32::from(s),
|
||||
});
|
||||
// The sit-stow rider (the client's SetStandState → SetSheatheState(0, SNAP) —
|
||||
// wow-re `sheath-policy.md` §4): entering any stand-state ∉ {0 STAND, 2 SIT_CHAIR}
|
||||
// force-stows drawn weapons, through the anim layer's one setter.
|
||||
if s != 0 && s != 2 {
|
||||
if let Ok((e, _, _, _, drv, _, _, _, _, _, _)) = body.single() {
|
||||
if drv.and_then(|d| d.sheath_state()).unwrap_or(0) != 0 {
|
||||
sheath.write(crate::creature_anim::SheathRequest {
|
||||
entity: e,
|
||||
state: 0,
|
||||
ceremony: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let stand_now = player.stand_pending.unwrap_or(stand_byte);
|
||||
// Sheath toggle (Z) — vanilla's draw/stow, through the anim layer's ONE setter
|
||||
// ([`crate::creature_anim::SheathRequest`], decision 0080): walk the *committed*
|
||||
// client-side state (the setter cache — attacking auto-draws and the anim reconcile
|
||||
// force-stows, which a local bool or the raw echo byte would drift from), commit + send
|
||||
// `CMSG_SETSHEATHED` there, and play the ceremony — the manual toggle is the ONLY path
|
||||
// in the whole client that plays it (`bInstant = 0` at the 4 ToggleSheath sites — wow-re
|
||||
// `sheath-policy.md`). No body model yet (no driver) drops the toggle, the client's own
|
||||
// refusal.
|
||||
if binds.fired(crate::bindings::cmd::TOGGLE_SHEATH) {
|
||||
if let Ok((e, _, _, _, Some(drv), store, engaged, _, _, wielded, _)) = body.single() {
|
||||
// The manual toggle's guard chain (decision 0080d) — the guards of the client's
|
||||
// 12-deep silent-refusal chain (`ToggleSheath` `0x5eb480`) whose states exist
|
||||
// today: dead · engaged in combat · not standing (`GetStandState() != 0` —
|
||||
// chairs block the toggle too, unlike the *stow rider's* {0, 2} exemption) ·
|
||||
// mid-ceremony (the 89/90 clip still playing) · MOUNTED (chain check 4,
|
||||
// `UNIT_FIELD_MOUNTDISPLAYID > 0` — wow-re `sheath-policy.md` §2, wired with
|
||||
// 0441's mounts). Stunned / channeling join when those states exist. A refused
|
||||
// press is simply dropped — no message, like the client.
|
||||
let dead = store.is_some_and(|s| s.0.unit_is_dead());
|
||||
let mounted = store.is_some_and(|s| s.0.unit_mount_display_id() != 0);
|
||||
let refused =
|
||||
dead || mounted || engaged || stand_now != 0 || drv.sheath_ceremony_active();
|
||||
if refused {
|
||||
debug!(
|
||||
"sheath toggle refused (dead {dead}, mounted {mounted}, engaged {engaged}, \
|
||||
stand {stand_now}, mid-ceremony {})",
|
||||
drv.sheath_ceremony_active()
|
||||
);
|
||||
} else {
|
||||
// The cycle proper ([`crate::creature_anim::toggle_sheath_next`], byte-read):
|
||||
// melee → ranged → stowed, gated on what is actually worn — never a
|
||||
// two-state flip. `None` = the ref makes no call at all (nothing equipped).
|
||||
let w = wielded.copied().unwrap_or_default();
|
||||
let worn = (w.main.is_some() || w.off.is_some(), w.ranged.is_some());
|
||||
let next =
|
||||
crate::creature_anim::toggle_sheath_next(drv.sheath_state().unwrap_or(0), worn);
|
||||
if let Some(state) = next {
|
||||
sheath.write(crate::creature_anim::SheathRequest {
|
||||
entity: e,
|
||||
state,
|
||||
ceremony: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stand_now
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! **Riding a transport** — the boat/zeppelin/tram platform frame (decision 0438 phase 2, widened
|
||||
//! by 0470's solid cargo). Three acts, in the order the controller runs them:
|
||||
//!
|
||||
//! 1. [`carry`] — *before* any input integrates, recompose the rider from the deck's THIS-frame
|
||||
//! pose. The deck's motion carries a standing player and its yaw delta turns the whole rider
|
||||
//! assembly with it.
|
||||
//! 2. [`update_attachment`] — *after* the mover has stepped, decide whether we are still aboard,
|
||||
//! and re-snapshot the deck-local pose the next frame's [`carry`] recomposes from.
|
||||
//! 3. The `ride` **trace** tag, emitted inside (2) because it needs the same three reads. The
|
||||
//! deck's own motion lives in the boat's transform and the rider's in world space; the
|
||||
//! difference between them is the only thing that says whether the carry composed, and no other
|
||||
//! instrument records it.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{wrap_pi, FlyCam, Player, PlayerRide, TransportQuery};
|
||||
|
||||
/// Recompose the rider from the deck's this-frame pose, before any input integrates.
|
||||
pub(super) fn carry(player: &mut Player, cam: &mut FlyCam, transports: &TransportQuery) {
|
||||
// The platform carry (decision 0438 phase 2): while attached to a transport, recompose the
|
||||
// feet from the boat's THIS-frame pose (the transport tick runs on the Net→Input edge, so it's
|
||||
// fresh) before any input integrates — the deck's motion carries the standing player, and its
|
||||
// per-frame yaw delta turns them with it (applied incrementally so it composes with whatever
|
||||
// mouse-look already wrote to `face_yaw` this frame). A despawned boat (streamed out) detaches
|
||||
// into an ordinary fall from the last world pose.
|
||||
//
|
||||
// The carry is rigid for the WHOLE rider — aim (`face_yaw`), rendered body (`model_yaw`), and
|
||||
// camera (`cam.yaw`) take the same delta, all HERE. Carrying only the aim leaves the standing
|
||||
// body-chase to close the gap frame after frame, and that chase-step is exactly what latches
|
||||
// the turn-in-place foot-shuffle (whose keyframes fire step sounds): a sailing boat's spline
|
||||
// yaw drifts continuously, so the rider shuffled and clacked the whole voyage (director,
|
||||
// 2026-07-17). The deck turning under you is not you turning — the chase and its shuffle only
|
||||
// see input turns.
|
||||
//
|
||||
// The camera's share is unconditional — a deck turn is FRAME motion, not an input turn, so it
|
||||
// never routes through `seat_camera`'s look-session gate (that gate protects the camera from
|
||||
// *keyboard* turns while a drag owns it). Routing it there was the right-drag drift bug
|
||||
// (director, 2026-07-18): during a look session the gate ate the camera's share, and the
|
||||
// right-drag coupling `face_yaw = cam.yaw` (which runs first next frame) then yanked the aim
|
||||
// back to the world-fixed camera — undoing the deck carry, so with the mouse still the scene
|
||||
// swung across the screen and the rider visibly spun against the deck. Carrying all three here
|
||||
// keeps the drag's orbit offset (`cam.yaw − face_yaw`) exactly as the hand left it while the
|
||||
// whole rider assembly turns with the boat — the reference's camera rides the transport-local
|
||||
// player rig the same way.
|
||||
if let Some(ride) = player.ride.as_ref() {
|
||||
match transports.get(ride.entity) {
|
||||
Ok((boat, _, _)) => {
|
||||
let world = boat.translation + boat.rotation * ride.local_pos;
|
||||
let yaw_now = boat.rotation.to_euler(EulerRot::YXZ).0;
|
||||
let mut dyaw = yaw_now - ride.boat_yaw;
|
||||
// `to_euler` wraps to (−π, π]; a boat crossing that seam reads as a ±2π hop.
|
||||
dyaw = (dyaw + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU)
|
||||
- std::f32::consts::PI;
|
||||
player.pos = world;
|
||||
player.face_yaw += dyaw;
|
||||
player.model_yaw = wrap_pi(player.model_yaw + dyaw);
|
||||
cam.yaw += dyaw;
|
||||
if let Some(r) = player.ride.as_mut() {
|
||||
r.boat_yaw = yaw_now;
|
||||
}
|
||||
}
|
||||
Err(_) => player.ride = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach/detach against this frame's walkable support, then re-snapshot the deck-local pose.
|
||||
/// `ground` is the mover's resolved support entity ([`super::mover::Outcome::ground`]).
|
||||
pub(super) fn update_attachment(
|
||||
player: &mut Player,
|
||||
transports: &TransportQuery,
|
||||
child_of: &Query<&ChildOf>,
|
||||
ground: Option<Entity>,
|
||||
grounded: bool,
|
||||
swimming: bool,
|
||||
) {
|
||||
// Transport attach/detach (decision 0438 phase 2). Attach when the walkable support is a
|
||||
// transport's collider — the boat's own hull, OR a deck prop's collider child (solid
|
||||
// cargo, 0470): the walk resolves the support upward through the parent chain to the
|
||||
// Transport that owns it, so standing on a crate is standing on the boat. Detach when
|
||||
// support resolves to world geometry or we enter the water. Airborne keeps the current
|
||||
// attachment — [`carry`] keeps composing, so a jump above the deck is deck-frame
|
||||
// ballistics and lands where it took off (jumping off the side detaches at whatever it
|
||||
// lands on). Then re-snapshot the local pose from this frame's FINAL world pose against
|
||||
// the boat's (unchanged-this-frame) transform, which is what next frame's carry
|
||||
// recomposes from.
|
||||
let owning_transport = |mut e: Entity| {
|
||||
for _ in 0..4 {
|
||||
if let Ok((t, g, _)) = transports.get(e) {
|
||||
return Some((e, t, g));
|
||||
}
|
||||
e = child_of.get(e).ok()?.parent();
|
||||
}
|
||||
None
|
||||
};
|
||||
if swimming {
|
||||
if player.ride.take().is_some() {
|
||||
info!("transport: deboard (entered the water)");
|
||||
}
|
||||
} else if grounded {
|
||||
match ground.and_then(owning_transport) {
|
||||
Some((entity, _, guid)) => {
|
||||
if player.ride.as_ref().map(|r| r.entity) != Some(entity) {
|
||||
info!("transport: board {:#x} (support is its deck)", guid.0);
|
||||
}
|
||||
player.ride = Some(PlayerRide {
|
||||
entity,
|
||||
guid: guid.0,
|
||||
local_pos: Vec3::ZERO, // filled by the snapshot just below
|
||||
boat_yaw: 0.0,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
if player.ride.take().is_some() {
|
||||
info!("transport: deboard (support is world geometry)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let feet = player.pos;
|
||||
// **The ride trace** (`WOW_MOVE_TRACE_TAGS=ride`) — one line per frame while attached, plus
|
||||
// the frame after a detach, because "what happened on the boat" is otherwise unanswerable:
|
||||
// the deck's own motion is in the boat's transform, the rider's in world space, and the
|
||||
// difference between them is the only thing that says whether the carry composed. The
|
||||
// director's report — *stepped off a ledge on a boat and it threw me back across the boat
|
||||
// until I landed* — is a statement about the DECK-relative path, which no other instrument
|
||||
// here records.
|
||||
if benilla_assets::trace::enabled_for("ride") {
|
||||
let boat_pose = player
|
||||
.ride
|
||||
.as_ref()
|
||||
.and_then(|r| transports.get(r.entity).ok())
|
||||
.map(|(t, _, aabb)| {
|
||||
(
|
||||
t.translation,
|
||||
t.rotation.to_euler(EulerRot::YXZ).0,
|
||||
aabb.copied(),
|
||||
)
|
||||
});
|
||||
if let (Some(ride), Some((bpos, byaw, baabb))) = (player.ride.as_ref(), boat_pose) {
|
||||
let local = Quat::from_euler(EulerRot::YXZ, byaw, 0.0, 0.0)
|
||||
.inverse()
|
||||
.mul_vec3(feet - bpos);
|
||||
// The deck's **broad-phase box**, and the gap from its underside to the feet. The
|
||||
// candidate enumeration every mover probe rides
|
||||
// (`SpatialQuery::aabb_intersections_with_aabb`) tests exactly this component —
|
||||
// and avian refreshes it in `PhysicsSchedule` (`FixedPostUpdate`, *before*
|
||||
// `Update`), while `tick_transports` writes the deck's pose *in* `Update`. So the
|
||||
// box is always one frame of deck travel behind the deck, and on a long frame it
|
||||
// is left behind entirely: a positive `gap` means the down-probe cannot even see
|
||||
// the deck the feet are standing on, which is the difference between
|
||||
// `support=deck` and `support=NONE` while `local` still reads (0, 0.05, 0).
|
||||
let aabb_col = match baabb {
|
||||
Some(a) => format!(
|
||||
" | aabbY[{:8.2},{:8.2}] gap{:+7.2}",
|
||||
a.min.y,
|
||||
a.max.y,
|
||||
a.min.y - feet.y,
|
||||
),
|
||||
None => " | aabbY[ absent]".to_string(),
|
||||
};
|
||||
benilla_assets::trace::line(
|
||||
"ride",
|
||||
&format!(
|
||||
"on {:#x} deck({:8.2},{:7.2},{:8.2}) yaw{:+.3} | feet({:8.2},{:7.2},{:8.2}) local({:7.2},{:6.2},{:7.2}) | grounded={} support={} vy={:+6.2}{}",
|
||||
ride.guid,
|
||||
bpos.x,
|
||||
bpos.y,
|
||||
bpos.z,
|
||||
byaw,
|
||||
feet.x,
|
||||
feet.y,
|
||||
feet.z,
|
||||
local.x,
|
||||
local.y,
|
||||
local.z,
|
||||
grounded as u8,
|
||||
match ground.and_then(owning_transport) {
|
||||
Some(_) => "deck",
|
||||
None if ground.is_some() => "world",
|
||||
None => "NONE",
|
||||
},
|
||||
player.vel_y,
|
||||
aabb_col,
|
||||
),
|
||||
);
|
||||
} else if !swimming {
|
||||
// Not riding: only worth a line when something under us *is* a transport, i.e. the
|
||||
// frames where an attach should have happened and did not.
|
||||
if let Some((_, _, guid)) = ground.and_then(owning_transport) {
|
||||
benilla_assets::trace::line(
|
||||
"ride",
|
||||
&format!(
|
||||
"OFF but standing on {:#x} at ({:8.2},{:7.2},{:8.2}) grounded={}",
|
||||
guid.0, feet.x, feet.y, feet.z, grounded as u8
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ride) = player.ride.as_mut() {
|
||||
if let Ok((boat, _, _)) = transports.get(ride.entity) {
|
||||
ride.local_pos = boat.compute_affine().inverse().transform_point3(feet);
|
||||
ride.boat_yaw = boat.rotation.to_euler(EulerRot::YXZ).0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,6 +472,138 @@ pub(super) fn swim_step(
|
||||
}
|
||||
}
|
||||
|
||||
/// What one swim frame produced: the mover outcome the controller's other arms also return, and
|
||||
/// the **presented** pitch — the raw aim, except leveled by the 0499 surface redirect when the
|
||||
/// rest-line cap bit (the body swims flat along the surface, not pitched against it; the wire tail
|
||||
/// streams the same value).
|
||||
pub(super) struct SwimFrame {
|
||||
pub outcome: Outcome,
|
||||
pub pitch: f32,
|
||||
}
|
||||
|
||||
/// Drive one swimming frame: the drunk porpoise, the pitched travel basis, the directional speed
|
||||
/// select, the stroke rate, and [`swim_step`] itself.
|
||||
///
|
||||
/// `basis` is the **level** facing pair `(forward, right)`; `amounts` the netted `(fwd, side)`
|
||||
/// swim translation ([`translate_amounts`]); `wobble` this frame's drunk angle, already zeroed by
|
||||
/// the caller when it must not apply (sober, stunned, or not translating).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn drive_step(
|
||||
player: &mut Player,
|
||||
time: &Time,
|
||||
collide: &benilla_world::collision::WorldCollision<'_, '_>,
|
||||
capsule: &Collider,
|
||||
world: &WorldPoint,
|
||||
surface_y: Option<f32>,
|
||||
basis: (Vec3, Vec3),
|
||||
amounts: (f32, f32),
|
||||
speeds: Option<benilla_protocol::MoveSpeeds>,
|
||||
move_speed: &super::MoveSpeed,
|
||||
wobble: f32,
|
||||
) -> SwimFrame {
|
||||
let (swim_fwd, swim_side) = amounts;
|
||||
// The drunk porpoise (B210): while swimming and moving, the pitch increments by the
|
||||
// wobble ×4.0 every frame (`0x60aabc–0x60ab0a`: flag `0x200000` → `pitch +
|
||||
// wobble·[0x80306c]`, clamped, committed via the pitch pipeline `0x60de70`). Same
|
||||
// wobble as the facing veer above, so the nose and the heading meander together.
|
||||
// The clamp is the callee `0x60aba0`'s FIXED ±π/2 bounds (`0x808acc`/`0x80c5e4`),
|
||||
// NOT the mouselook set's ±89° — the reference carries both (decision 1009 §C4).
|
||||
if wobble != 0.0 {
|
||||
player.mover_pitch = (player.mover_pitch + wobble * super::drunk::SWIM_PITCH_WOBBLE_SCALE)
|
||||
.clamp(-std::f32::consts::FRAC_PI_2, std::f32::consts::FRAC_PI_2);
|
||||
}
|
||||
let mut pitch = player.mover_pitch;
|
||||
// The travel basis (`0x7c5880`, the client's swim velocity direction): the FORWARD axis
|
||||
// is the facing pitched by the swim pitch — `(cosP·horiz-fwd + sinP·up)` — so holding W
|
||||
// with the nose down dives (and aimed up, climbs — the smooth ascend, like the
|
||||
// ref's PitchUp+Forward); the STRAFE axis stays level. There is no vertical
|
||||
// thruster and Space adds nothing here (the verified basis has no separate vertical
|
||||
// input; Space's whole swim role is the jump-exit above).
|
||||
let (sp, cp) = player.mover_pitch.sin_cos();
|
||||
let fwd_axis = basis.0 * cp + Vec3::Y * sp;
|
||||
let v = fwd_axis * swim_fwd + basis.1 * swim_side;
|
||||
let dir3 = v.normalize_or_zero();
|
||||
// Whether the water owns our vertical at all this frame, and where its line sits — the
|
||||
// one source both arms of the constraint read ([`rest_line`]; `None` is GM flight).
|
||||
let rest_line = rest_line(player, surface_y);
|
||||
// Directional swim speed — **VERIFIED** (`0x7c4c90`'s swim arm, the §5's TU-H):
|
||||
// forward or strafe-only → swim; the backward bit `0x2` → `min(swimBack, swim)` —
|
||||
// byte-identical in template to the run arm's `min(runBack, run)`. Vanilla defaults
|
||||
// 4.722/2.5 (vmangos `baseMoveSpeed`).
|
||||
let (swim_speed, swim_back_speed) = match speeds {
|
||||
Some(s) if !move_speed.env_override => (s.swim, s.swim_back),
|
||||
_ => (SWIM_SPEED, SWIM_BACK_SPEED),
|
||||
};
|
||||
let dir_speed = if swim_fwd < 0.0 {
|
||||
swim_back_speed.min(swim_speed)
|
||||
} else {
|
||||
swim_speed
|
||||
};
|
||||
// The stroke's playback-rate numerator is the FLAG-scalar speed — the full
|
||||
// directional speed regardless of pitch, 0 with no translation input — never a
|
||||
// horizontal projection, which would starve a pitched stroke toward a freeze.
|
||||
// **VERIFIED** (TU-I): `0x5fe2f0` divides GetCurrentSpeed (flags + static speed
|
||||
// fields only) by the clip's moveSpeed, the same path for local and observed units.
|
||||
player.swim_stroke_speed = if dir3 == Vec3::ZERO { 0.0 } else { dir_speed };
|
||||
let out = swim_step(
|
||||
player,
|
||||
time,
|
||||
collide,
|
||||
capsule,
|
||||
dir3 * dir_speed,
|
||||
rest_line,
|
||||
|feet| surface_over_feet(world, feet),
|
||||
);
|
||||
// The surface redirect (decisions 0499+0505 — a NAMED DIVERGENCE, see
|
||||
// [`cap_redirect`]): when the rise capped at the rest line, the stroke went
|
||||
// level at full speed — present the *effective* pitch (body pose + wire tail
|
||||
// follow the motion, →0 pinned at the line), while the raw aim stays in
|
||||
// `player.mover_pitch` so a later nose-down dives instantly.
|
||||
if let Some(p) = out.surface_pitch {
|
||||
pitch = p;
|
||||
}
|
||||
let outcome = Outcome {
|
||||
held: false,
|
||||
grounded: out.grounded,
|
||||
jumped: false,
|
||||
// A knockback never resolves here: it clears SWIMMING at the take-off site above
|
||||
// (the reference's `StartFalling 0x7c61f0` clears the bit outright rather than
|
||||
// testing it), so a knocked swimmer is flown by the land mover.
|
||||
knocked: false,
|
||||
air_nudged: false,
|
||||
ground: None, // swimming detaches from any platform frame below
|
||||
};
|
||||
SwimFrame { outcome, pitch }
|
||||
}
|
||||
|
||||
/// The swim translation amounts — read by [`drive_step`] AND the flag build, so the two can never
|
||||
/// disagree (decision 0056: the flags mirror the avatar's motion). W/S, strafe Q/E (+ mouselook
|
||||
/// A/D), and a root cuts both.
|
||||
pub(super) fn translate_amounts(axes: &super::input::MoveAxes, rooted: bool) -> (f32, f32) {
|
||||
// Rooted kills swim translation like the walk `dir` does (decision 0308's regime — the water
|
||||
// arm reads its own axes, so it needs its own cut).
|
||||
if rooted {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let swim_fwd = axes.fwd.signum() as f32;
|
||||
let mut swim_side = 0.0_f32; // +right
|
||||
if axes.strafe_right {
|
||||
swim_side += 1.0;
|
||||
}
|
||||
if axes.strafe_left {
|
||||
swim_side -= 1.0;
|
||||
}
|
||||
if axes.mouselook {
|
||||
if axes.turn_right {
|
||||
swim_side += 1.0;
|
||||
}
|
||||
if axes.turn_left {
|
||||
swim_side -= 1.0;
|
||||
}
|
||||
}
|
||||
(swim_fwd, swim_side)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -53,6 +53,15 @@ pub(super) struct CinematicVoice {
|
||||
}
|
||||
|
||||
impl CinematicVoice {
|
||||
/// Is this owner holding a voice the device is actually mixing? The budget's question — see
|
||||
/// [`drive_narration`]. `Stopped` covers a stream that reached its end on its own, which is
|
||||
/// the ordinary case for a narration shorter than its shot.
|
||||
fn is_live(&self) -> bool {
|
||||
self.handle
|
||||
.as_ref()
|
||||
.is_some_and(|h| !matches!(h.state(), kira::sound::PlaybackState::Stopped))
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
self.shot = None;
|
||||
if let Some(mut h) = self.handle.take() {
|
||||
@@ -67,12 +76,35 @@ pub(super) fn plugin(app: &mut App) {
|
||||
app.add_systems(Update, drive_narration.in_set(WorldStage::Present));
|
||||
}
|
||||
|
||||
/// The narration channel, plus its line in the voice budget.
|
||||
///
|
||||
/// The **budget** half is why this wrapper exists. `SoundOutput::live_voices()` — the number
|
||||
/// [`super::kit::SOFTWARE_CHANNELS`] bounds, and which gates real play decisions, not just a
|
||||
/// readout — summed the kit channels, the zone streams and the glue streams. The narration is a
|
||||
/// *third* long-lived stream owner and it was in none of them, so for the whole length of a
|
||||
/// cinematic the client believed it had one more free voice than it did. `SoundOutput`'s own doc
|
||||
/// already states the law this now follows: "each owner **rewrites its own** every frame from its
|
||||
/// own live handles", because a shared counter with two writers drifts the first time a fade is
|
||||
/// interrupted. So the count is written here, once, from this owner's handle — after the body
|
||||
/// below has settled it, never from a stale read.
|
||||
fn drive_narration(
|
||||
cine: Option<Res<Cinematic>>,
|
||||
mut voice: Local<CinematicVoice>,
|
||||
mut out: NonSendMut<SoundOutput>,
|
||||
mut kits: ResMut<SoundKits>,
|
||||
voice: Local<CinematicVoice>,
|
||||
out: NonSendMut<SoundOutput>,
|
||||
kits: ResMut<SoundKits>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
) {
|
||||
let (mut voice, mut out, mut kits) = (voice, out, kits);
|
||||
narrate(&cine, &mut voice, &mut out, &mut kits, &assets);
|
||||
out.cinematic_streams = usize::from(voice.is_live());
|
||||
}
|
||||
|
||||
fn narrate(
|
||||
cine: &Option<Res<Cinematic>>,
|
||||
voice: &mut CinematicVoice,
|
||||
out: &mut SoundOutput,
|
||||
kits: &mut SoundKits,
|
||||
assets: &Option<Res<WorldAssets>>,
|
||||
) {
|
||||
let playing = cine.as_deref().and_then(Cinematic::playing_shot);
|
||||
let Some((run, index, sound_id)) = playing else {
|
||||
|
||||
@@ -248,16 +248,21 @@ pub(crate) struct SoundOutput {
|
||||
/// here rather than in a resource of its own so the kit player — which already holds `out` —
|
||||
/// can stamp every play on the capture's timeline with no new plumbing.
|
||||
pub(crate) probe: Option<probe::Probe>,
|
||||
/// Live **stream** voices, reported by their owners each frame ([`zone`], [`glue`]).
|
||||
/// Live **stream** voices, reported by their owners each frame ([`zone`], [`glue`],
|
||||
/// [`cinematic`]).
|
||||
///
|
||||
/// These count against the same ceiling as everything else ([`kit::SOFTWARE_CHANNELS`]): the
|
||||
/// reference's music, ambience and liquid loops all land on its uncapped bus 0 and occupy
|
||||
/// FMOD channels exactly like a sword swing does. Two fields rather than one counter because
|
||||
/// each owner **rewrites its own** every frame from its own live handles — a shared counter
|
||||
/// with two writers drifts the first time a fade is interrupted, and a voice budget that
|
||||
/// drifts is worse than none.
|
||||
/// FMOD channels exactly like a sword swing does. A field per owner rather than one shared
|
||||
/// counter because each owner **rewrites its own** every frame from its own live handles — a
|
||||
/// shared counter with several writers drifts the first time a fade is interrupted, and a
|
||||
/// voice budget that drifts is worse than none.
|
||||
pub(crate) zone_streams: usize,
|
||||
pub(crate) glue_streams: usize,
|
||||
/// The cinematic narration's own stream — a third long-lived owner, and for a long time the
|
||||
/// one the budget could not see (a 102-second race intro spent all of it one voice short of
|
||||
/// the truth). Rewritten each frame by [`cinematic::drive_narration`], like its neighbours.
|
||||
pub(crate) cinematic_streams: usize,
|
||||
/// One-shots that lost their slot to a louder newcomer, and plays refused because nothing
|
||||
/// live was quieter than them (decision 1557). Reported by the probe.
|
||||
pub(crate) voices_stolen: u64,
|
||||
@@ -270,7 +275,7 @@ impl SoundOutput {
|
||||
/// Everything the device is currently mixing — kit channels plus the held streams. This is
|
||||
/// the number [`kit::SOFTWARE_CHANNELS`] bounds.
|
||||
pub(crate) fn live_voices(&self) -> usize {
|
||||
self.channels.len() + self.zone_streams + self.glue_streams
|
||||
self.channels.len() + self.zone_streams + self.glue_streams + self.cinematic_streams
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +418,7 @@ impl Plugin for SoundPlugin {
|
||||
probe,
|
||||
zone_streams: 0,
|
||||
glue_streams: 0,
|
||||
cinematic_streams: 0,
|
||||
voices_stolen: 0,
|
||||
voices_denied: 0,
|
||||
copies_dropped: 0,
|
||||
|
||||
@@ -379,7 +379,7 @@ fn zone_audio(
|
||||
// wrongly reused it on a change, leaving a newly-entered zone silent for 3–5 min, and
|
||||
// the 6 s "cold start" that used to sit here was `0x4601f0`'s unreachable arm applied
|
||||
// to an entry that never calls it (1553).
|
||||
if !slot_taken && !config.music_suppressed {
|
||||
if !slot_taken {
|
||||
if let Some(kit) = zone_music_row(&areas.0, music_row).map(|m| m.sounds[phase]) {
|
||||
if kit != 0 {
|
||||
start_music_stream(zone, &mut out, &mut kits, &assets, &config, kit);
|
||||
@@ -452,10 +452,7 @@ fn zone_audio(
|
||||
// 3.0 s resume after a cinematic). The pump is dead while suppressed, which is the reference's
|
||||
// own shape — `0x460040` bails at its first instruction on the flag, so no track is selected,
|
||||
// opened or scheduled for the duration.
|
||||
if !config.music_suppressed
|
||||
&& zone.next_track_at.is_some_and(|t| now >= t)
|
||||
&& zone.music.is_none()
|
||||
{
|
||||
if zone.next_track_at.is_some_and(|t| now >= t) && zone.music.is_none() {
|
||||
zone.next_track_at = None;
|
||||
if let Some(m) = zone_music_row(&areas.0, zone.zone_music) {
|
||||
let kit = m.sounds[phase];
|
||||
@@ -572,6 +569,19 @@ fn start_music_stream(
|
||||
config: &SoundConfig,
|
||||
kit_id: u32,
|
||||
) -> bool {
|
||||
// **Nothing opens the music slot while a cinematic is running** — and the guard belongs here,
|
||||
// at the slot, not at each caller. The reference's music pump dies at its own first
|
||||
// instruction (`0x460040 mov al,[0xb06cc8]; test al,al; jne 0x4600f8`), so suppression is a
|
||||
// property of the slot whatever asked for it. benilla gated two of this function's four
|
||||
// callers, which left the zone **intro fanfare** and `SMSG_PLAY_MUSIC` free to start a track
|
||||
// over a cinematic's narration and play it to the end — a cut only ever happens on the
|
||||
// suppression *edge*, so anything that starts after that edge is never cut. The fanfare was
|
||||
// the worse half: it stamped itself as played on success, so it was consumed for
|
||||
// `MinDelayMinutes` by a shot the player could not hear it under. Refusing here means the
|
||||
// stamp never happens, because it is gated on this returning `true`.
|
||||
if config.music_suppressed {
|
||||
return false;
|
||||
}
|
||||
let Some(mixer_ref) = out.mixer.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -290,10 +290,17 @@ pub(super) fn auto_join_zone_channels(
|
||||
channels: Res<ChannelState>,
|
||||
areas: Option<Res<AreaTableRes>>,
|
||||
world: benilla_world::world_point::WorldPoint,
|
||||
player: Option<Res<crate::player::Player>>,
|
||||
// One param (clippy's argument ceiling), and they belong together: both answer "is the zone
|
||||
// under the player the one this walk may act on?" — the body's own settle, and whether the
|
||||
// view has flown off it for a cinematic.
|
||||
body: (
|
||||
Option<Res<crate::player::Player>>,
|
||||
Option<Res<crate::cinematic::Cinematic>>,
|
||||
),
|
||||
mut walk: ResMut<ZoneChannelWalk>,
|
||||
mut entered: MessageReader<crate::net::EnteredWorldMessage>,
|
||||
) {
|
||||
let (player, cinematic) = (&body.0, &body.1);
|
||||
// World entry arms the walk; the session-end clears disarm it ([`end_session_channels`] and its
|
||||
// disconnect twin, which own the whole fact — walk, confirmed list, VM mirror, edit target).
|
||||
if entered.read().next().is_some() {
|
||||
@@ -302,6 +309,20 @@ pub(super) fn auto_join_zone_channels(
|
||||
if !walk.live {
|
||||
return; // no character session — see `ZoneChannelWalk::live`
|
||||
}
|
||||
// **A cinematic suppresses the rejoin, and it resumes when the shot ends.** Two of the ten
|
||||
// sites that read the reference's cinematic-state cell exist for exactly this — `0x49491e`
|
||||
// (the zone-text update) and `0x5ff566` (a `UPDATEFLAGS` reflex) both skip
|
||||
// `ZoneChannelRefresh` (`0x49a210`) while one runs, and `EndCinematic` calls it once at
|
||||
// `0x48f1d0` (wow-re `ui/scratch/cinematic-camera-law.md` §3.3, the complete 10-site census).
|
||||
//
|
||||
// The walk stays armed, so "rejoin once at the end" is what falling through here the next
|
||||
// frame already does — there is nothing to re-arm. It matters because a race intro flies the
|
||||
// streaming focus hundreds of yards off the body, so `world.area()` changes under a player
|
||||
// who has not moved, and the joins would otherwise fire against zones they are only
|
||||
// *looking* at.
|
||||
if cinematic.as_deref().is_some_and(|c| c.is_playing()) {
|
||||
return;
|
||||
}
|
||||
let (Some(areas), false) = (areas, channels.channels.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -477,11 +477,16 @@ fn main() -> Result<()> {
|
||||
// Report the entities we decoded, with raw WoW coordinates.
|
||||
let self_guid = world.self_guid;
|
||||
println!("\n--- tracked {} entit(ies) ---", world.tracked.len());
|
||||
// Every `EntityKind`, or the dump silently drops a whole class of streamed object. `Corpse`
|
||||
// was missing from here since 1706 gave TYPEID_CORPSE its own variant: a hardcoded list is not
|
||||
// a `match`, so nothing warned, and a streamed body simply never appeared in the census (found
|
||||
// by 1723, alongside the same omission in the --death corpse capture).
|
||||
for kind in [
|
||||
EntityKind::Player,
|
||||
EntityKind::Unit,
|
||||
EntityKind::GameObject,
|
||||
EntityKind::DynamicObject,
|
||||
EntityKind::Corpse,
|
||||
EntityKind::Other,
|
||||
] {
|
||||
let mut group: Vec<(&u64, &Tracked)> = world
|
||||
|
||||
@@ -19,6 +19,9 @@ pub(crate) struct Death {
|
||||
post_revive_query_sent: bool,
|
||||
corpse_gone: Option<bool>,
|
||||
corpse_create: Option<(u64, [f32; 3])>,
|
||||
/// The corpse descriptor's interaction bits at create: `(CORPSE_FIELD_FLAGS, owner, bones,
|
||||
/// lootable, insignia)` — decision 1723's inputs, read off the wire rather than assumed.
|
||||
corpse_flags: Option<(u32, u64, bool, bool, bool)>,
|
||||
}
|
||||
|
||||
impl Probe for Death {
|
||||
@@ -65,19 +68,33 @@ impl Probe for Death {
|
||||
guid,
|
||||
kind,
|
||||
position,
|
||||
fields,
|
||||
..
|
||||
} => {
|
||||
// --death: the corpse streams in as an `Other`-classified create once
|
||||
// we've released (decision 0308 §4). No HIGHGUID_CORPSE classifier
|
||||
// exists in `guid.rs` (a corpse carries no template entry to extract
|
||||
// from its guid, unlike a creature/GameObject), so identify it
|
||||
// positionally instead: the only other wire type `entity_kind` folds
|
||||
// into `Other` is a DynamicObject (ground-effect markers), and nothing
|
||||
// casts one in this scenario — so any `Other` create arriving after
|
||||
// repop is the corpse.
|
||||
// --death: the corpse streams in once we've released (decision 0308 §4).
|
||||
//
|
||||
// **It is `EntityKind::Corpse`, not `Other`** — and this line said `Other` from
|
||||
// 0308 until 1723 found it. 1706 gave TYPEID_CORPSE its own `EntityKind` variant;
|
||||
// the compiler enumerated every `match` on the enum and corrected them, but this
|
||||
// is an `==`, so it stayed silent and the probe's corpse capture quietly stopped
|
||||
// firing: the arc would have bailed "the corpse object never streamed" on a wire
|
||||
// that was perfectly correct. An instrument that fails closed after a refactor is
|
||||
// the expensive kind (the contract §5 — the instruments are part of the codebase).
|
||||
let repop_sent = cx.world.death_arc.as_ref().is_some_and(|a| a.repop_sent);
|
||||
if repop_sent && self.corpse_create.is_none() && *kind == EntityKind::Other {
|
||||
if repop_sent && self.corpse_create.is_none() && *kind == EntityKind::Corpse {
|
||||
self.corpse_create = Some((*guid, *position));
|
||||
// The descriptor bits the client's whole corpse interaction hangs off
|
||||
// (decision 1723): BONES picks the model, DYNAMIC_FLAGS bit 0 is the only
|
||||
// thing that opens the `CMSG_LOOT` route, FLAGS bit 5 is the PvP insignia the
|
||||
// skin leg reads. Printed rather than asserted — what vmangos actually sets on
|
||||
// a plain PvE death is a fact worth having in the log, not a pass/fail.
|
||||
self.corpse_flags = Some((
|
||||
fields.corpse_flags(),
|
||||
fields.corpse_owner().unwrap_or(0),
|
||||
fields.corpse_is_bones(),
|
||||
fields.corpse_lootable(),
|
||||
fields.corpse_pvp_insignia(),
|
||||
));
|
||||
println!(
|
||||
"corpse object streamed: guid {guid:#x} pos ({:.1}, {:.1}, {:.1})",
|
||||
position[0], position[1], position[2]
|
||||
@@ -108,6 +125,7 @@ impl Probe for Death {
|
||||
|
||||
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
|
||||
let self_map = cx.world.self_map;
|
||||
let self_guid = cx.world.self_guid;
|
||||
let arc = cx
|
||||
.world
|
||||
.death_arc
|
||||
@@ -194,6 +212,12 @@ impl Probe for Death {
|
||||
" corpse guid {corpse_guid:#x} pos ({:.1}, {:.1}, {:.1}) ({corpse_dist:.1} yd from death)",
|
||||
corpse_pos[0], corpse_pos[1], corpse_pos[2]
|
||||
);
|
||||
if let Some((flags, owner, bones, lootable, insignia)) = self.corpse_flags {
|
||||
println!(
|
||||
" corpse fields FLAGS {flags:#06x} (bones={bones} insignia={insignia}) DYNFLAGS lootable={lootable} owner {owner:#x}{}",
|
||||
if owner == self_guid { " = us" } else { " ≠ US — the reclaim send would carry the wrong guid" }
|
||||
);
|
||||
}
|
||||
println!(
|
||||
" graveyard ({:.1}, {:.1}, {:.1}) ({graveyard_dist:.1} yd from death)",
|
||||
graveyard_pos[0], graveyard_pos[1], graveyard_pos[2]
|
||||
|
||||
Reference in New Issue
Block a user