From 5f822218b23aeb67c282c2cf59735e8cd9ef079e Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 31 Aug 2026 20:12:30 +0700 Subject: [PATCH] nothing plays over a cinematic, and a lootable corpse sparkles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/creature_anim/spell_visual.rs | 46 +- crates/benilla-app/src/player.rs | 1827 +---------------- crates/benilla-app/src/player/body_pose.rs | 125 ++ crates/benilla-app/src/player/camera.rs | 59 + crates/benilla-app/src/player/controller.rs | 992 +++++++++ crates/benilla-app/src/player/flags.rs | 196 ++ crates/benilla-app/src/player/input.rs | 259 +++ crates/benilla-app/src/player/move_trace.rs | 6 +- crates/benilla-app/src/player/mover.rs | 3 +- crates/benilla-app/src/player/posture.rs | 164 ++ crates/benilla-app/src/player/ride.rs | 207 ++ crates/benilla-app/src/player/swim.rs | 132 ++ crates/benilla-app/src/sound/cinematic.rs | 38 +- crates/benilla-app/src/sound/mod.rs | 18 +- crates/benilla-app/src/sound/zone.rs | 20 +- crates/benilla-app/src/ui_chat/channels.rs | 23 +- .../src/bin/benilla-world/main.rs | 5 + .../src/bin/benilla-world/probes/death.rs | 42 +- 18 files changed, 2385 insertions(+), 1777 deletions(-) create mode 100644 crates/benilla-app/src/player/body_pose.rs create mode 100644 crates/benilla-app/src/player/controller.rs create mode 100644 crates/benilla-app/src/player/flags.rs create mode 100644 crates/benilla-app/src/player/input.rs create mode 100644 crates/benilla-app/src/player/posture.rs create mode 100644 crates/benilla-app/src/player/ride.rs diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs index aab8b0af..880cca21 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual.rs @@ -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` 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>, + // 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>, visuals: Option>, mut fx: MessageWriter, mut armed: Local, @@ -1259,8 +1266,16 @@ pub(super) fn arm_loot_fx( } else { changed.iter().collect::>() }; - 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) { diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index f749d292..b5aa5514 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -18,6 +18,15 @@ //! the camera is ([`land`]). //! //! Movement is a thin kinematic capsule controller over avian's `MoveAndSlide` (decision 0009). +//! +//! **This file is the map, not the machine.** It holds the plugin and its ordering edges, the +//! shared types the concern modules trade in ([`Player`], [`BodyQuery`], [`TransportQuery`], +//! [`StandStateRequest`]), and the two `UNIT_FIELD_FLAGS` bits with more than one reader. The +//! per-frame system itself is [`controller::control`], and every phase it runs that is a concern +//! of its own has a file beside it: [`input`] decodes the keys, [`wire_in`] applies what the +//! server said, [`ride`] carries us on a deck, [`posture`] holds the body, [`mover`]/[`swim`] +//! move it, [`flags`] builds the move-flag word, [`gait`] + [`body_pose`] render it, [`camera`] +//! seats the eye, [`movement_net`] streams it. use bevy::input::mouse::AccumulatedMouseMotion; use bevy::prelude::*; @@ -33,7 +42,11 @@ use benilla_world::interact::{WorldClick, WorldRightClick, WorldRightPress}; use benilla_world::schedule::WorldStage; mod arc; +// Writing the frame onto the body we drive — pose, MovementState, the counter-twist gap. +mod body_pose; pub(crate) mod camera; +// The per-frame controller itself — the one system, split out so the root stays the map. +mod controller; mod world_focus; // The remembered camera pose (decision 1131) — it lives inside `player/` so it can read the rig's // own `pub(super)` fields instead of widening them for a module outside. @@ -41,14 +54,22 @@ mod camera_saved; mod drunk; // Which single unit the client embodies (decision 1277) — the `Embodied` marker's owner. mod embody; +// This frame's move-flag word — the one bitset the animation, the wire and the local gates all +// read (decision 0056). +mod flags; mod follow; mod gait; +// This frame's decoded input — the netted movement axes and the camera's command word +// (decisions 0056, 1502). Everything downstream reads the axes, never the keys. +mod input; // The land-here affordance (free-fly's other half). `pub(crate)` for its `LandHere` message, which // the debug panel's button writes. pub(crate) mod land; mod move_trace; mod movement_net; +// The stand state + the sheath toggle — how the body HOLDS itself (decisions 0080, 0881). +mod posture; // The kinematic mover step. `pub(crate)` because the grounded walk resolve is **not** the local // player's alone: a remote mover's dead-reckon (`crate::net::motion::remote`) runs its extrapolated // step through the very same code, the way the reference runs every mover through one controller @@ -56,6 +77,8 @@ mod movement_net; pub(crate) mod mover; /// The spyglass zoom — aura 76, a client-local camera override with no wire half at all (B151). mod scoped_view; +// Riding a transport — the platform-frame carry/attach (decisions 0438, 0470). +mod ride; mod server_ride; mod setup; mod state; @@ -71,11 +94,14 @@ mod wire_in; // render-alpha field, so the unit-lane material-alpha compose (`entities::apply_unit_mat_alpha`) // orders itself before it and lets that documented override stand. pub(crate) use camera::apply_self_model_fade; +// The controller system, named unqualified here so the plugin's ordering edges (and the ones +// sibling modules declare against it) read as they always have. +use controller::control; // `/follow` (decision 0890): chat asks with the message, `crate::target` resolves the subject into // the state, and this module owns the motion. use camera::{ - apply_zoom_scroll, model_pivot_height, run_look_session, seat_camera, CameraProbe, FlyCam, - LookButton, CAM_COLLISION_RADIUS, CAM_DIST_DEFAULT, + apply_zoom_scroll, model_pivot_height, run_look_session, CameraProbe, FlyCam, LookButton, + CAM_COLLISION_RADIUS, CAM_DIST_DEFAULT, }; pub(crate) use camera::{head_height, CameraControl, CameraPivot}; pub(crate) use follow::{FollowRequest, FollowState}; @@ -133,10 +159,10 @@ pub(crate) const UNIT_FLAG_IN_COMBAT: u32 = 0x0008_0000; /// Ask for a **stand state** — the client's `SetStandState(newState)` (`0x5ed430`: send /// `CMSG_STANDSTATECHANGE` + apply locally through `0x6127b0`), as a message so every path that -/// wants a posture funnels through the ONE setter in [`control`] (the [`crate::creature_anim:: +/// wants a posture funnels through the ONE setter in [`posture`] (the [`crate::creature_anim:: /// SheathRequest`] posture, decision 0080). /// -/// Two senders today: the `X` key reads the toggle inline in [`control`], and the **posture emotes** +/// Two senders today: the `X` key reads the toggle inline in [`posture`], and the **posture emotes** /// (`/sit`, `/sleep`, `/kneel`, `/stand`, `/lay`) send this — `DoEmote`'s `EmoteSpecProc == 1` /// branch calls the same `0x5ed430` the key does (wow-re `object-layer/scratch/emote-posture- /// gate.md` §1, decision 0881). Routing them here is what makes `/sit` sit at all: the *server* @@ -149,6 +175,62 @@ pub(crate) struct StandStateRequest { pub(crate) state: u8, } +/// **The body in our hands** — normally our own streamed avatar, and a possessed creature while we +/// hold its reins (decision 1277). The controller reads its server pose to take control, then +/// drives its transform (feet position + facing) and feeds its movement to the animation selector +/// via `MovementState`. Its model is attached by the entity renderer through the same path as any +/// other unit (0041), which is exactly why a creature needs nothing special: everything downstream +/// reads the body's own pivot, speeds, scale and descriptor off the entity. +/// +/// An alias because three modules take it — [`control`], [`posture`] (read-only, for the sheath +/// toggle's guard chain) and [`body_pose`] (the frame's one writer) — and one eleven-column tuple +/// written out three times is how a query silently drifts from its readers. +pub(super) type BodyQuery<'w, 's> = Query< + 'w, + 's, + ( + Entity, + &'static mut Transform, + Option<&'static mut MovementState>, + Option<&'static CameraPivot>, + Option<&'static crate::creature_anim::AnimDriver>, + Option<&'static crate::net::ObjectStore>, + Has, + Option<&'static crate::net::UnitSpeeds>, + Option<&'static mut BodyTwist>, + // What is worn in each hand and in the ranged slot — the Z toggle's cycle reads it + // (the ref's three `GetWeapon(0/1/2)` calls before the state walk). + Option<&'static crate::creature_anim::Wielded>, + // The **raw** `OBJECT_FIELD_SCALE_X`, for the camera pivot's target height — not the + // transform's, which is the 2 s-eased render scale (see [`camera::head_height`]). + Option<&'static crate::net::NetEntity>, + ), + (With, Without), +>; + +/// The armed transports — the platform-frame carry/attach ([`ride`], decision 0438 phase 2). The +/// `Without`s only disjoint the borrows against the body and the camera. +/// +/// `ColliderAabb` rides along for the **ride trace only**, and it is the column that separates +/// "the deck moved" from "the deck's collider moved": avian refreshes it in `PhysicsSchedule` +/// (i.e. `FixedPostUpdate`, *before* `Update`), while `tick_transports` writes the deck's pose *in* +/// `Update` — so the box the broad phase prunes against is always one frame behind the deck it +/// belongs to, and the down-probe's candidate set is whatever that stale box still overlaps. +pub(super) type TransportQuery<'w, 's> = Query< + 'w, + 's, + ( + &'static Transform, + &'static crate::net::Guid, + Option<&'static avian3d::prelude::ColliderAabb>, + ), + ( + With, + Without, + Without, + ), +>; + /// The player controller's **ordering handle**. Anything that must write the aim or the camera rig /// before the controller reads them orders against this — the two scripted probe drivers do /// (`capture::probe_look` / `capture::probe_cam`, decision 1174). A set rather than the `control` @@ -354,1740 +436,3 @@ fn mirror_mover_collision_height( } } } - -/// 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 -/// [`camera::head_height`] for the byte citation and why the distinction is visible. -fn body_pivot_target( - pivot: Option<&CameraPivot>, - net: Option<&crate::net::NetEntity>, -) -> Option { - pivot.map(|p| model_pivot_height(p, net.map_or(1.0, |n| n.scale))) -} - -/// 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)] -fn control( - time: Res