From f9e75a79fcc05fde10268ec71b6adf9de3cd6c6b Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 31 Aug 2026 20:18:16 +0700 Subject: [PATCH] the ghost world gets its own sky, and a corpse cannot steer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a dead player's sky is DeathClouds now — `lightSkyboxID` was named in the schema and read by nobody and `LightSkybox.dbc` was never loaded at all, so a ghost kept the ordinary gradient dome; the shipped data has exactly 5 of 426 LightParams rows carrying a skybox id, every one of them DeathClouds.mdx, reached from the death profile (slot 4) of all 374 Light rows and gated on the ghost bit, with a hardcoded weight of 1.0 that skips the WMO skybox outright; it resolves off the same (map, camera position) seed the atmosphere uses, so the ghost sky and the death-profile fog it hangs over switch on the same frame — and the lane sheds its `wmo_sky` name for `skybox`, no longer being WMO-only - a corpse cannot steer: the reference's two movement-input predicates share a precondition benilla never modelled — `UNIT_FIELD_HEALTH > 0` — so at zero health the keyboard turn and the right-drag close together, exactly as they do inside an Ice Block, while the ghost (health 1) keeps moving; the wow-re §5 round then corrected three byte trails (the mouse turn has its own eight-conjunct predicate, the supposed on-taxi test is the Knockdown animation lockout — set on exactly one of 208 AnimationData rows — and the "charm bit" is the far-sight latch) and added three verified health gates we lacked: the jump IS health-gated, sit and stand are refused while the body reads dead (a feigner too), and a follow tears down only when BOTH predicates drop — we had that wrong in both directions - the walk-gait cross-check folds back confirming `min(walk, run)` and the speed-only animation channel (by a 2,304-function closure census), and finds the trap we lacked: every incoming spline re-authors the gait, and the spline's RUNMODE flag is an INVERSE — a spline marked as a run clears walk mode, one not so marked SETS it — so a charge, taxi, knockback or flee hand-off leaves the body in whatever gait its path was authored with, applied once on the spline's start edge; the post-worldport zero-walk fallback is retracted on a re-run (the ctor's zero is a transient thirty instructions wide, never observable), leaving the plain minimum the bytes compute — the better failure mode too, since a fallback makes a walker silently RUN if a walk speed ever goes missing, where the transcription makes them visibly stop Co-Authored-By: Claude Fable 5 --- .../src/creature_anim/select/tests.rs | 1 + crates/benilla-app/src/death.rs | 16 +- crates/benilla-app/src/debug_panel/world.rs | 2 +- crates/benilla-app/src/net.rs | 41 ++- crates/benilla-app/src/net/apply.rs | 2 + crates/benilla-app/src/net/apply/objects.rs | 3 +- crates/benilla-app/src/net/motion/spline.rs | 17 ++ crates/benilla-app/src/net/motion/tests.rs | 27 +- crates/benilla-app/src/pipe_warm/menagerie.rs | 2 +- crates/benilla-app/src/player/controller.rs | 85 ++++-- crates/benilla-app/src/player/flags.rs | 29 +- crates/benilla-app/src/player/follow.rs | 33 ++- crates/benilla-app/src/player/input.rs | 34 ++- crates/benilla-app/src/player/posture.rs | 27 +- crates/benilla-app/src/player/server_ride.rs | 59 ++++ crates/benilla-app/src/player/state.rs | 253 ++++++++++++++++-- crates/benilla-app/src/player/swim.rs | 12 +- crates/benilla-app/src/player/world_focus.rs | 4 + crates/benilla-app/tests/world_api_wall.rs | 2 +- crates/benilla-assets/src/wmo.rs | 2 +- crates/benilla-formats/src/light.rs | 77 +++++- .../benilla-formats/src/light/atmosphere.rs | 12 + crates/benilla-formats/src/models/wmo/root.rs | 4 +- crates/benilla-formats/tests/chain.rs | 48 +++- .../src/bin/benilla-world/world.rs | 1 + crates/benilla-protocol/src/events.rs | 3 + crates/benilla-protocol/src/events/decode.rs | 2 + .../src/messages/monster_move.rs | 12 + .../benilla-protocol/src/messages/packet.rs | 4 + .../src/messages/update_object/movement.rs | 6 + crates/benilla-world/src/clouds/layer.rs | 6 +- crates/benilla-world/src/clouds/mod.rs | 2 +- crates/benilla-world/src/lib.rs | 2 +- crates/benilla-world/src/model_render.rs | 2 +- .../benilla-world/src/model_render/batch.rs | 2 +- crates/benilla-world/src/shaders.rs | 2 +- crates/benilla-world/src/sky.rs | 10 +- crates/benilla-world/src/sky_order.rs | 2 +- .../src/{wmo_sky.rs => skybox.rs} | 73 +++-- crates/benilla-world/src/sun/mod.rs | 8 +- crates/benilla-world/src/wmo_portal/mod.rs | 2 +- crates/benilla-world/src/world_census.rs | 4 +- crates/benilla-world/src/world_plugins.rs | 4 +- 43 files changed, 793 insertions(+), 146 deletions(-) rename crates/benilla-world/src/{wmo_sky.rs => skybox.rs} (88%) diff --git a/crates/benilla-app/src/creature_anim/select/tests.rs b/crates/benilla-app/src/creature_anim/select/tests.rs index 0deda68f..86c9dd66 100644 --- a/crates/benilla-app/src/creature_anim/select/tests.rs +++ b/crates/benilla-app/src/creature_anim/select/tests.rs @@ -922,6 +922,7 @@ fn unify_stamps_flying_from_the_live_spline_on_every_leg() { duration: Duration::from_secs(10), id: 1, grounded, + run_mode: true, }; // Self leg: the controller's stored component says nothing about the ride — the live // Spline does (the client reads the active CMovement's spline flags at select time). diff --git a/crates/benilla-app/src/death.rs b/crates/benilla-app/src/death.rs index fdffb00d..ad453594 100644 --- a/crates/benilla-app/src/death.rs +++ b/crates/benilla-app/src/death.rs @@ -493,11 +493,17 @@ fn drive_death_look( } } -/// `WOW_GHOST_PROBE=1|0` — pin the ghost **screen pass** on or off without dying, so the -/// ghost-world look (and everything that must NOT inherit it — the portrait bakes, decision 1481) -/// can be A/B'd from a capture instead of from a corpse run. It overrides only this look; the -/// death arc's state machine, its events and its UI are untouched and still follow the wire. -fn ghost_probe() -> Option { +/// `WOW_GHOST_PROBE=1|0` — pin the **ghost world** on or off without dying, so the ghost-world look +/// (and everything that must NOT inherit it — the portrait bakes, decision 1481) can be A/B'd from a +/// capture instead of from a corpse run. +/// +/// It drives all three halves of that look together, because they are one state: this screen pass, +/// the death light (`LightParams` slot 4) and the DeathClouds sky, the latter two through +/// [`Viewer::ghost`](benilla_world::view::Viewer::ghost), whose only readers they are. It pinned +/// the screen pass alone until the sky landed, which made an A/B of "the ghost world" quietly a +/// third of one. The death arc's state machine, its events and its UI stay untouched and still +/// follow the wire. +pub(crate) fn ghost_probe() -> Option { static PROBE: std::sync::OnceLock> = std::sync::OnceLock::new(); *PROBE.get_or_init(|| match std::env::var("WOW_GHOST_PROBE").ok()?.trim() { "1" | "true" => Some(true), diff --git a/crates/benilla-app/src/debug_panel/world.rs b/crates/benilla-app/src/debug_panel/world.rs index 94231458..4f71380f 100644 --- a/crates/benilla-app/src/debug_panel/world.rs +++ b/crates/benilla-app/src/debug_panel/world.rs @@ -41,7 +41,7 @@ pub(super) struct WorldReadout<'w, 's> { /// [`benilla_world::exterior_cull`] runs on. See the readout below for why they are worth a line. room: Res<'w, benilla_world::wmo_portal::CameraInteriorClaim>, windows: Res<'w, benilla_world::wmo_portal::ExteriorWindows>, - skybox: Res<'w, benilla_world::wmo_sky::CameraWmoSkybox>, + skybox: Res<'w, benilla_world::skybox::CameraSkybox>, streamer: Res<'w, benilla_world::terrain_stream::TerrainStreamer>, self_guid: Res<'w, crate::net::SelfGuid>, names: ResMut<'w, crate::names::NameCache>, diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 1907ecbb..7db77bd4 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -233,10 +233,16 @@ pub(crate) struct UnitSpeeds(pub(crate) MoveSpeeds); /// the smaller (ties/NaN → the forward field). At vanilla values the min is always the back field, /// so the clamp is only visible under a server that force-sets a back speed above its forward one. /// -/// A **zero** `walk` is the ctor state, not a rate (`0x7c48e8`–`0x7c4906` zeroes all six speed -/// fields and only the create block or a `SMSG_FORCE_WALK_SPEED_CHANGE` fills them), so a mover -/// whose speeds have not landed falls back to the run arm rather than freezing solid — the same -/// treatment `turn_rate` already gets in [`crate::player`]'s controller. +/// **There is no zero-`walk` fallback here, and that is a settled question rather than an +/// oversight** (decision 1759). The ctor does zero all six speed fields (`0x7c48e8`–`0x7c4906`), +/// and 1752 guarded against a mover reaching this with `walk == 0` by falling back to the run arm. +/// The wow-re §5 then reported that the local player's create skips the speed-block apply, so the +/// zero was live after every world port — and then **retracted it**: the guard it found +/// (`0x5ff070`) has two callers, and the post-create `0x5fad50` passes 0, so the block is applied +/// for our own unit exactly as for a remote one (`0x5fad98 xor edi,edi`, unwritten to the push at +/// `0x5fb131`). The self-skip belongs to the *redundant*-create path `0x466350`, reached only when +/// the object-manager lookup HIT. The ctor's zero is a transient inside one call and is never +/// observable, so this is the plain minimum the bytes are. pub(crate) fn current_speed(s: &MoveSpeeds, flags: u32) -> f32 { use crate::creature_anim::move_flags as f; if flags & f::ANY_MOVE == 0 { @@ -249,7 +255,7 @@ pub(crate) fn current_speed(s: &MoveSpeeds, flags: u32) -> f32 { s.swim }; } - if flags & f::WALK_MODE != 0 && s.walk > 0.0 { + if flags & f::WALK_MODE != 0 { return s.walk.min(s.run); } if flags & f::BACKWARD != 0 { @@ -2553,13 +2559,19 @@ mod tests { assert_eq!(current_speed(&s, f::BACKWARD | f::SWIMMING), 4.0); } - /// The two guards at the ends of the cascade. No direction bit → 0.0 (`0x7c4c99 test dl,0xf`), - /// never a stale speed: a mover standing still is standing however its fields read, and the - /// turn/pitch/mode bits are not translation. And a **zero walk speed is the ctor state, not a - /// rate** — a mover whose speed block has not landed falls back to the run arm rather than - /// freezing solid the moment somebody toggles walk (the same treatment `turn_rate` gets). + /// The guard at the TOP of the cascade: no direction bit → 0.0 (`0x7c4c99 test dl,0xf`), never + /// a stale speed. A mover standing still is standing however its fields read, and the + /// turn/pitch/mode bits are not translation — walk mode least of all, being precisely a mode + /// and not a motion. + /// + /// The bottom of the cascade has **no** guard, and decision 1759 is why: 1752 shipped a + /// zero-`walk` fallback to the run arm, on the reading that a mover could reach here before + /// its speed block landed. The wow-re §5 retracted the basis for it — the local player's + /// create applies the speed block like anyone else's — so the fallback is gone. The zero is + /// asserted rather than merely no longer asserted against, so that re-adding the guard is a + /// deliberate act with a record to argue against, not a plausible-looking tidy-up. #[test] - fn no_direction_bit_is_zero_and_an_unfilled_walk_speed_is_not_a_freeze() { + fn a_mode_is_not_a_motion_and_a_zero_walk_speed_is_a_zero() { use crate::creature_anim::move_flags as f; let s = vanilla(); assert_eq!(current_speed(&s, 0), 0.0); @@ -2576,8 +2588,13 @@ mod tests { }; assert_eq!( current_speed(&unfilled, f::FORWARD | f::WALK_MODE), + 0.0, + "no fallback: `min(walk, run)` with walk 0 is 0 (decision 1759)" + ); + assert_eq!( + current_speed(&unfilled, f::FORWARD), 7.0, - "walk 0.0 is the ctor, not a rate — fall back rather than freeze" + "…and the run arm is untouched by it" ); } diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 7bb95f4e..5ab055c5 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -712,6 +712,7 @@ pub(super) fn apply_net_updates( stop, duration_ms, flying, + run_mode, } => objects::monster_move( guid, start, @@ -721,6 +722,7 @@ pub(super) fn apply_net_updates( stop, duration_ms, flying, + run_mode, &mut commands, &index, &mut transforms, diff --git a/crates/benilla-app/src/net/apply/objects.rs b/crates/benilla-app/src/net/apply/objects.rs index 927e9160..c3172c21 100644 --- a/crates/benilla-app/src/net/apply/objects.rs +++ b/crates/benilla-app/src/net/apply/objects.rs @@ -481,6 +481,7 @@ pub(super) fn monster_move( stop: bool, duration_ms: u32, flying: bool, + run_mode: bool, commands: &mut Commands, index: &GuidIndex, transforms: &mut Query<&mut Transform>, @@ -542,7 +543,7 @@ pub(super) fn monster_move( crate::creature_anim::NockLatch, crate::creature_anim::RangedHold, )>(); - match monster_move_spline(path, spline_id, stop, duration_ms, flying) { + match monster_move_spline(path, spline_id, stop, duration_ms, flying, run_mode) { // A moving path: sample_splines drives the transform along every waypoint. Some(spline) => { commands.entity(e).insert(spline).remove::(); diff --git a/crates/benilla-app/src/net/motion/spline.rs b/crates/benilla-app/src/net/motion/spline.rs index 8ab5f6c3..b67518fc 100644 --- a/crates/benilla-app/src/net/motion/spline.rs +++ b/crates/benilla-app/src/net/motion/spline.rs @@ -31,6 +31,17 @@ pub(crate) struct Spline { /// [`ground_clamp_creatures`] snaps this unit onto benilla's terrain. `false` for a flying path, /// which keeps the spline's own Z. pub(crate) grounded: bool, + /// The path's `SPLINEFLAG_RUNMODE` bit — **a run when set, a walk when clear**, and the clear + /// case is the one that does something: the real client's `SMSG_MONSTER_MOVE` commit + /// `0x7c6a50` feeds this bit to `CMovement::SetRunMode 0x7c71c0` (`0x7c6ac2 and edi,0x100`; + /// `0x7c6acb call`), whose argument is *run*, so **a spline without RUNMODE sets + /// `MOVEFLAG_WALK_MODE` on the unit it moves.** Every incoming spline re-authors the bit + /// (wow-re `collision/scratch/walk-mode-law.md` §5.2; decision 1758). + /// + /// Only the body **we drive** reads it ([`crate::player::server_ride`]) — a creature's own + /// gait is derived from [`Spline::speed`], the path's arc length over its duration, so its + /// walk bit would select nothing. + pub(crate) run_mode: bool, } /// **A spline the server ended by decree**, carrying the id the acknowledgement owes (decision @@ -229,6 +240,7 @@ pub(in crate::net) fn monster_move_spline( stop: bool, duration_ms: u32, flying: bool, + run_mode: bool, ) -> Option { if stop || duration_ms == 0 || path.len() < 2 { return None; @@ -239,6 +251,7 @@ pub(in crate::net) fn monster_move_spline( duration: Duration::from_millis(u64::from(duration_ms)), id: spline_id, grounded: !flying, + run_mode, }) } @@ -272,6 +285,7 @@ pub(in crate::net) fn create_spline(spline: CreateSpline) -> Option { duration: Duration::from_millis(u64::from(spline.duration_ms)), id: spline.id, grounded: !spline.flying, + run_mode: spline.run_mode, }) } @@ -1044,6 +1058,7 @@ mod tests { duration: Duration::from_secs(10), id: 7, grounded, + run_mode: true, }; let now = Instant::now(); (spline, now) @@ -1139,6 +1154,7 @@ mod tests { duration_ms: 12_000, flying: false, cyclic: false, + run_mode: true, }) .expect("a live walk"); let (pos, facing, _) = s.sample(Instant::now()); @@ -1161,6 +1177,7 @@ mod tests { duration_ms, flying: false, cyclic: false, + run_mode: true, }; let straight = vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]]; assert!(create_spline(spline(5_000, 5_000, straight.clone())).is_none()); diff --git a/crates/benilla-app/src/net/motion/tests.rs b/crates/benilla-app/src/net/motion/tests.rs index 42f64396..ecfd3b3e 100644 --- a/crates/benilla-app/src/net/motion/tests.rs +++ b/crates/benilla-app/src/net/motion/tests.rs @@ -342,6 +342,7 @@ fn spline_interpolates_constant_speed_and_faces_travel() { duration: Duration::from_secs(4), id: 0, grounded: true, + run_mode: true, }; let close = |a: [f32; 3], b: [f32; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 0.05); @@ -381,6 +382,7 @@ fn spline_travel_pitch_is_the_segment_climb_angle() { duration: Duration::from_secs(4), id: 0, grounded: true, + run_mode: true, }; let (_, f, pitch) = s.sample(start + Duration::from_secs(1)); assert!(f.unwrap().abs() < 1e-3, "facing is the horizontal heading"); @@ -400,7 +402,7 @@ fn monster_move_carries_every_waypoint() { [10.0, 10.0, 0.0], [10.0, 10.0, 5.0], ]; - let s = monster_move_spline(path.clone(), 42, false, 2000, false) + let s = monster_move_spline(path.clone(), 42, false, 2000, false, true) .expect("a moving monster-move yields a spline"); assert_eq!( s.points, path, @@ -426,6 +428,7 @@ fn monster_move_flying_spline_is_not_grounded() { false, 2000, true, + true, ) .expect("a flying monster-move still yields a spline"); assert!( @@ -437,7 +440,15 @@ fn monster_move_flying_spline_is_not_grounded() { #[test] fn monster_move_stop_clears_the_spline() { assert!( - monster_move_spline(vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], 0, true, 2000, false).is_none(), + monster_move_spline( + vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + 0, + true, + 2000, + false, + true + ) + .is_none(), "a Stop move snaps and clears, never builds a path" ); } @@ -445,7 +456,15 @@ fn monster_move_stop_clears_the_spline() { #[test] fn monster_move_zero_duration_clears_the_spline() { assert!( - monster_move_spline(vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], 0, false, 0, false).is_none(), + monster_move_spline( + vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + 0, + false, + 0, + false, + true + ) + .is_none(), "a zero-duration move would divide by ~0 when sampled; treat as stationary" ); } @@ -453,7 +472,7 @@ fn monster_move_zero_duration_clears_the_spline() { #[test] fn monster_move_without_a_travelable_path_clears_the_spline() { assert!( - monster_move_spline(vec![[1.0, 2.0, 3.0]], 0, false, 2000, false).is_none(), + monster_move_spline(vec![[1.0, 2.0, 3.0]], 0, false, 2000, false, true).is_none(), "a single point is nowhere to travel — no spline" ); } diff --git a/crates/benilla-app/src/pipe_warm/menagerie.rs b/crates/benilla-app/src/pipe_warm/menagerie.rs index 9b705470..6be052be 100644 --- a/crates/benilla-app/src/pipe_warm/menagerie.rs +++ b/crates/benilla-app/src/pipe_warm/menagerie.rs @@ -440,7 +440,7 @@ pub(super) fn spawn_menagerie( } // The WMO-skybox rows (decision 1264): the STATIC PLAIN layout only, and only on the world - // camera. `wmo_sky::build_skybox` inserts POSITION + NORMAL + UV_0 — `layouts[0]` exactly — a + // camera. `skybox::build_skybox` inserts POSITION + NORMAL + UV_0 — `layouts[0]` exactly — a // skybox is never skinned (the asteroid belts' bones are a deferral, not a shipped lane), never // vertex-coloured (M2 carries no MOCV), never far-twinned, and never reaches a portrait booth. { diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index e026275a..1e3a8b29 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -205,24 +205,41 @@ pub(super) fn control( // 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 + // **Dead** is read in the same pass and for the same reason: the reference's two movement-input + // predicates share a precondition (`0x5144e0`) whose second term is `[[mover+0x110]+0x40] > 0` + // — UNIT_FIELD_HEALTH, the `jle` at `0x5144f8` — so a body at zero health answers *no* to both + // "may I translate?" (`0x514560`) and "am I not stunned?" (`0x5145b0`). **A corpse is stunned** + // as far as the input tick is concerned, and that is the whole reason the reference will not + // let you spin your own body on the ground (decision 1753). Off the MOVER's descriptor, not + // ours — `esi` in `0x5144e0` is whatever we are driving (1277) — and a ghost is not dead by it, + // because the server puts a released player's health at 1 (0308). + let (stunned, dead) = body .single() .ok() .and_then(|(.., store, _, _, _, _, _)| { - store.map(|s| s.0.unit_flags() & UNIT_FLAG_STUNNED != 0) + store.map(|s| { + ( + s.0.unit_flags() & UNIT_FLAG_STUNNED != 0, + s.0.unit_is_dead(), + ) + }) }) - .unwrap_or(false); + .unwrap_or((false, false)); + // `0x5145b0`, evaluated here because the first thing it suppresses is the mouse turn below. Its + // translate sibling waits until after `apply_server_moves`, where the root edge it reads lands. + let may_turn = state::may_turn(dead, stunned); // 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`). + // fraction), and zero whenever the turn predicate is down — the reference's wobble sits behind + // the same input-allowed chain as the turn emitters (`0x60aa47` → `0x5145b0`), so a stun stops + // it and, through the precondition that predicate shares, so does death. 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 { + if !may_turn { 0.0 } else { drunk::wobble(time.elapsed().as_millis() as u32, f) @@ -265,7 +282,25 @@ pub(super) fn control( // 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 { + // + // **Death enters through the same door** (decision 1753) — but by its own byte trail, not the + // keyboard's, and the two are worth keeping apart. The reference's right-drag lives in + // `0x514400`, off the mouse-MOVE handler `0x492c00`, and its body hand-off + // `0x51447b call 0x5103e0` is gated at `0x514474` by a THIRD predicate, `0x5145e0`, whose first + // act is to call the stun test `0x5145b0` — which fails the precondition `0x5144e0` it shares + // with the translate gate at zero health, and short-circuits the rest. The two commit gates + // further down, `0x5151b0` (yaw) and `0x515250` (pitch), carry their own health tests as well, + // and a closed census of all nine call sites of the facing setters `0x60de30`/`0x60de70` finds + // every route health-gated. So the mouse and the keys do NOT share `0x514755`; they share + // `0x5144e0`, and `may_turn` is where that term lives on our side (wow-re §6.4). + // + // That is the line this file was missing: benilla modelled the server's root on death (0308), + // and a root deliberately leaves turning live (0872), so a right-drag went on spinning the body + // on the ground. Note the reference has **nothing to restore** — it never writes the facing at + // all, because `0x50fee0` (the camera rotate at `0x514446`) runs before any object lookup and + // the hand-off simply does not run. Ours writes and puts back, which is indistinguishable + // downstream and keeps the approved camera path intact — a shape difference, not a copy. + if !may_turn || player.control_lost || player.reseat { player.face_yaw = yaw_before_look; } let mouse_turned = player.face_yaw != yaw_before_look; @@ -410,7 +445,18 @@ pub(super) fn control( } // 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); + // `0x514560`, evaluated now — after `apply_server_moves`, so a root edge that landed this + // frame is already in `player.modes` and not read a frame late. + let may_translate = state::may_translate(dead, player.modes.rooted); + let axes = input::move_axes( + binds, + &buttons, + &mut player, + &rig, + both_buttons, + may_translate, + may_turn, + ); let input::MoveAxes { fwd: fwd_axis, side: side_axis, @@ -486,14 +532,16 @@ pub(super) fn control( -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 + // **The translate predicate down: translation intent dies here — and under a plain root, + // 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 { + // Block freezes completely while Frost Nova lets you turn. **Death is the third way this + // predicate goes down** (1753), and unlike the root it takes the pivot with it. + if !may_translate { dir = Vec3::ZERO; } let moving = dir != Vec3::ZERO; @@ -566,7 +614,13 @@ pub(super) fn control( // 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; + // + // **And death is refused here too** — `may_translate` and not `!rooted`, which corrects + // what 1753 first shipped on the reading that `0x513cee`'s `test ch,0x12` was the whole + // gate. `Jump 0x513bd0` inlines *both* `0x5144e0` and `0x514560`: a health test at + // `0x513cbc` (`jle 0x513d43`), a second at `0x513cde`, the root mask, and stand state + // `!= 7` at `0x513cf3` — which is `may_translate`, term for term (wow-re §6.7). + let mut want_jump = binds.fired(crate::bindings::cmd::JUMP) && may_translate; // 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 @@ -628,7 +682,7 @@ pub(super) fn control( // 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) + swim::translate_amounts(&axes, !may_translate) } else { (0.0, 0.0) }; @@ -824,7 +878,8 @@ pub(super) fn control( jumped, held, air_nudged, - stunned, + may_translate, + may_turn, now, launch_y, ); diff --git a/crates/benilla-app/src/player/flags.rs b/crates/benilla-app/src/player/flags.rs index 4f0b0b60..77a43f64 100644 --- a/crates/benilla-app/src/player/flags.rs +++ b/crates/benilla-app/src/player/flags.rs @@ -42,7 +42,10 @@ pub(super) fn this_frame( jumped: bool, held: bool, air_nudged: bool, - stunned: bool, + // This frame's two movement-input predicates — what the reference's `0x514560` and `0x5145b0` + // answered ([`state::may_translate`], [`state::may_turn`]). Both go down on death (1753). + may_translate: bool, + may_turn: bool, now: f32, launch_y: f32, ) -> FrameFlags { @@ -187,10 +190,12 @@ pub(super) fn this_frame( if player.knock_arc { move_flags_now = (move_flags_now & !move_flags::BACKWARD) | move_flags::FORWARD; } - // 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); + // The two incapacitate suppressions — the translate predicate down drops the direction bits, + // the turn predicate down 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). Death drops both, through the precondition + // the two predicates share (decision 1753), so a corpse streams a bare word. + move_flags_now = state::incapacitated_flags(move_flags_now, !may_translate, !may_turn); // 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). @@ -268,7 +273,10 @@ mod tests { false, held, false, - stunned, + // The two predicates, as this case's root and stun leave them — the body is alive, + // so each is simply its own term (decision 1753). + !rooted, + !stunned, 1.0, 0.0, ) @@ -313,7 +321,8 @@ mod tests { false, false, false, - false, + true, + true, 1.0, 0.0, ); @@ -343,7 +352,8 @@ mod tests { false, false, false, - false, + true, + true, 1.0, 0.0, ); @@ -369,7 +379,8 @@ mod tests { false, false, false, - false, + true, + true, 1.0, 0.0, ); diff --git a/crates/benilla-app/src/player/follow.rs b/crates/benilla-app/src/player/follow.rs index d15a125b..d692bdf9 100644 --- a/crates/benilla-app/src/player/follow.rs +++ b/crates/benilla-app/src/player/follow.rs @@ -249,7 +249,7 @@ fn steer(face: f32, bearing: f32, dt: f32) -> f32 { /// Everything the cancel set reads. Bundled so [`steer_follow`]'s own parameter list stays about /// the motion. #[derive(bevy::ecs::system::SystemParam)] -pub(super) struct FollowInput<'w> { +pub(super) struct FollowInput<'w, 's> { buttons: Res<'w, ButtonInput>, /// The binding dispatch (0997) — the movement commands' press edges, wherever they are bound. /// It already carries the typing gate this struct used to hold `UiKeyboardCapture` for @@ -259,9 +259,12 @@ pub(super) struct FollowInput<'w> { /// which is deliberate and harmless: the cancel then lands on the frame *after* the look /// begins, versus the reference's same-frame commit. rig: Res<'w, CameraControl>, + /// The **mover's** descriptor block, for the one term of the cancel set that is not an input: + /// its health. See [`FollowInput::mover_dead`]. + mover: Query<'w, 's, &'static crate::net::ObjectStore, With>, } -impl FollowInput<'_> { +impl FollowInput<'_, '_> { /// The press edge of any movement command (0997: wherever the six are bound today). The turn /// pair are turn commands normally and strafe under mouse-look; either way they are a /// movement start and either way they cancel, so the distinction the controller draws does @@ -279,6 +282,26 @@ impl FollowInput<'_> { .iter() .any(|&c| self.binds.just_pressed(c)) } + + /// Has the input tick reached its **teardown leg** — the one that cancels click-to-move and the + /// follow and fires `AUTOFOLLOW_END` ([`super::state::input_torn_down`])? Not an input, but the + /// member of the cancel set that is a *state*, and the one benilla had wrong in both directions. + /// + /// `rf86-autofollow-cancel-set.md` §5 named the mechanism as the health test at `0x5144f8`, and + /// this file passed the **root** instead. wow-re's §6.3 sharpens it: `0x5146d6 call 0x60fb60` + /// is reached only when *both* predicates are down (`0x5146c3` and `0x5146ce` both not taken), + /// so a pure ROOT — which benilla cancelled on — does **not** end a follow in the reference, and + /// death — which benilla did not cancel on, so a follow taken into death kept steering the + /// corpse's facing every frame — does. Ice Block, root and stun together, ends it as well. + fn input_torn_down(&self, rooted: bool) -> bool { + self.mover.single().is_ok_and(|s| { + super::state::input_torn_down( + s.0.unit_is_dead(), + rooted, + s.0.unit_flags() & super::UNIT_FLAG_STUNNED != 0, + ) + }) + } } /// Drive the follow: cancel if this frame's input says so, then re-resolve the followee, steer the @@ -314,7 +337,11 @@ pub(super) fn steer_follow( input.buttons.just_pressed(MouseButton::Forward) && !player.autorun, both_engaged, input.rig.look == Some(LookButton::Right), - player.modes.rooted || player.server_riding(), + // The input tick on its teardown leg, or the body handed to a server spline. The ride term + // is benilla's own and predates this: it is not one of `0x5144e0`'s conjuncts (conjunct 4 is + // the Knockdown lockout, not a taxi test — wow-re §6.2), and nothing in this round bears on + // it either way, so it is left exactly as it stood. + input.input_torn_down(player.modes.rooted) || player.server_riding(), ) { info!("follow: cancelled by the player's own movement input"); follow.stop(); diff --git a/crates/benilla-app/src/player/input.rs b/crates/benilla-app/src/player/input.rs index 470970ec..b4f921b7 100644 --- a/crates/benilla-app/src/player/input.rs +++ b/crates/benilla-app/src/player/input.rs @@ -136,7 +136,10 @@ pub(super) fn move_axes( player: &mut Player, rig: &CameraControl, both_buttons: bool, - stunned: bool, + // The reference's two movement-input predicates this frame ([`state::may_translate`], + // [`state::may_turn`]) — `0x514560` and `0x5145b0`. Both go down on death (decision 1753). + may_translate: bool, + may_turn: 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 @@ -169,11 +172,17 @@ pub(super) fn move_axes( // 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.) + // - **Losing the mover** — death, or a root/stun. In the reference the emitter's gate + // `0x514560` goes down (health `<= 0`, `MOVEMENTFLAGS & 0x1200`, stand state 7) 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. The `death` half of that sentence was prose only until decision 1753: + // the term passed here was the root alone, so dying with autorun latched kept the bit. It is + // [`state::may_translate`] now — the gate itself. + // + // **This list used to say "a taxi/charge hand-off", on rf79 §4's parked reading that + // `0x60f5b0` is an on-taxi predicate. It is not** — wow-re §6.2 reads it as + // `AnimationData.dbc` column 3 bit `0x80`, set on exactly one of 208 shipped rows, id 121 + // `Knockdown`. The ride term below is benilla's own and is kept on its own merits. // // 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. @@ -184,7 +193,7 @@ pub(super) fn move_axes( 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, + !may_translate || player.server_riding, ) { player.autorun = false; } @@ -234,12 +243,15 @@ pub(super) fn move_axes( 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 + // matching mouse-left). …and never while [`state::may_turn`] is down: the reference skips the + // keyboard turn emitter `0x514f50` entirely (and force-stops an in-flight turn) behind the // `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); + // `gait` derives from real yaw change. A **dead** body is down the same gate, through the + // precondition `0x5144e0` that both predicates share (decision 1753): health `<= 0` fails it, + // so as far as `0x5145b0` is concerned a corpse is stunned — which is why our corpses could + // be spun with A/D until 1753, and why one term fixes the keys and the mouse together. + let turning = !mouselook && may_turn && (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; diff --git a/crates/benilla-app/src/player/posture.rs b/crates/benilla-app/src/player/posture.rs index d3aa0f38..a0786b0b 100644 --- a/crates/benilla-app/src/player/posture.rs +++ b/crates/benilla-app/src/player/posture.rs @@ -34,11 +34,22 @@ pub(super) fn update( // 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 + let (stand_byte, reads_dead) = body .single() .ok() - .and_then(|(.., store, _, _, _, _, _)| store.map(|s| s.0.unit_stand_state())) - .unwrap_or(0); + .and_then(|(.., store, _, _, _, _, _)| { + // `SetStandState`'s own first two guards, read together with the byte they gate: + // health ≤ 0, **or** `UNIT_DYNAMIC_FLAGS & 0x20` — a feigner, whose health never moved. + // Deliberately NOT `unit_reads_dead`, which folds in stand state 7 as a third term the + // setter does not test (wow-re `local-move-input-gate.md` §6.7; decision 1753). + store.map(|s| { + ( + s.0.unit_stand_state(), + s.0.unit_is_dead() || s.0.unit_dynflag_dead(), + ) + }) + }) + .unwrap_or((0, false)); if player.stand_pending == Some(stand_byte) { player.stand_pending = None; // the echo landed } @@ -76,16 +87,20 @@ pub(super) fn update( // 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, + // for as long as we are in the water — and a body that reads dead is refused in EITHER + // direction, which is the setter's own first guard (decision 1753). 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)) { + if let Some(s) = + request_stand.filter(|&s| state::stand_state_refused(reads_dead, player.move_flags(), s)) + { debug!( - "stand state {s} refused (move flags {:#x} — the client's `0x5ed430` gate)", + "stand state {s} refused (dead {reads_dead}, move flags {:#x} — the client's \ + `0x5ed430` gate)", player.move_flags() ); move_trace::posture("REFUSED", s, stand_state, player.move_flags()); diff --git a/crates/benilla-app/src/player/server_ride.rs b/crates/benilla-app/src/player/server_ride.rs index a901b95c..8a645a60 100644 --- a/crates/benilla-app/src/player/server_ride.rs +++ b/crates/benilla-app/src/player/server_ride.rs @@ -101,6 +101,19 @@ pub(super) fn drive_self_ride( spline.speed(), spline.duration.as_millis(), ); + // **A spline re-authors the walk gait, and its RUNMODE bit is inverted** (decision + // 1758, wow-re `walk-mode-law.md` §5.2). The reference's `SMSG_MONSTER_MOVE` commit + // `0x7c6a50` does this unconditionally for every incoming spline: `0x7c6ac2 and + // edi,0x100` takes the path's own `SPLINEFLAG_RUNMODE` and `0x7c6acb` hands it to + // `CMovement::SetRunMode 0x7c71c0`, whose argument is *run* — so a path the server + // did NOT mark as a run leaves the body in walk mode when the ride ends, and a path + // it did mark clears any walk the player had toggled. The two `0x100`s are inverses. + // + // On the START edge only, matching the byte site: the commit runs once per spline, + // not per sampled frame. It writes the same latch the keybind does, so the wire + // announces it through the ordinary flag differ on the frame the ride hands back. + player.walking = !spline.run_mode; + super::move_trace::gait("spline", player.walking, false, player.modes.rooted, true); } let yaw = yaw_of(transform.rotation); player.pos = transform.translation; @@ -186,6 +199,15 @@ mod tests { /// A one-system app riding a 2-point self-spline, `Player` mid-strafe as at charge engage. fn ride_app() -> (App, Entity, crossbeam_channel::Receiver) { + ride_app_with(true, false) + } + + /// …parameterised by the spline's `SPLINEFLAG_RUNMODE` and the walk gait the player had + /// toggled before the ride took over. + fn ride_app_with( + run_mode: bool, + walking: bool, + ) -> (App, Entity, crossbeam_channel::Receiver) { let mut app = App::new(); app.add_systems(Update, drive_self_ride); let (tx, rx) = crossbeam_channel::unbounded(); @@ -195,6 +217,7 @@ mod tests { // Stale pre-ride momentum: a strafe was held when the spline took over. horiz_vel: Vec3::new(5.0, 0.0, 0.0), vel_y: -2.0, + walking, ..Default::default() }); // Mid-strafe counter-twist: the aim sits 90° off the rendered root. @@ -210,6 +233,7 @@ mod tests { duration: Duration::from_secs(600), // far from ending during the test id: 77, grounded: true, + run_mode, }, twist, Embodied, @@ -218,6 +242,41 @@ mod tests { (app, entity, rx) } + /// **A spline re-authors the walk gait, inverted from its own RUNMODE bit** (decision 1758). + /// The reference does this in the `SMSG_MONSTER_MOVE` commit `0x7c6a50`, unconditionally for + /// every incoming spline: `0x7c6ac2 and edi,0x100` takes `SPLINEFLAG_RUNMODE` and `0x7c6acb` + /// hands it to `SetRunMode 0x7c71c0`, whose argument is *run*. The two `0x100`s are inverses, + /// which is the whole reason this is a test and not an obvious line of code — reading it the + /// natural way round gets the behaviour exactly backwards in both directions. + #[test] + fn a_spline_re_authors_the_walk_gait_from_its_inverted_runmode_bit() { + // A path the server did NOT mark as a run leaves the body walking — even though the + // player was running when it took over. + let (mut app, _e, _rx) = ride_app_with(false, false); + app.update(); + assert!( + app.world().resource::().walking, + "a spline without RUNMODE forces walk mode ON" + ); + // …and a run path clears a walk the player had toggled. + let (mut app, _e, _rx) = ride_app_with(true, true); + app.update(); + assert!( + !app.world().resource::().walking, + "a spline WITH RUNMODE clears the toggled walk" + ); + // The start edge only: the commit runs once per spline, not once per sampled frame, so a + // toggle taken mid-ride survives the rest of it. + let (mut app, _e, _rx) = ride_app_with(true, true); + app.update(); + app.world_mut().resource_mut::().walking = true; + app.update(); + assert!( + app.world().resource::().walking, + "re-authored on the START edge, not every frame" + ); + } + #[test] fn engaging_a_ride_mid_strafe_unwinds_the_counter_twist() { // The ride owns the pose: `control` (the normal gap owner) is parked behind the ride diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index 7440e06f..cc592c3c 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -462,6 +462,106 @@ impl MoveModes { } } +/// **The precondition both movement-input predicates evaluate first** — the reference's +/// `0x5144e0`, called at the head of `0x514560` (at `0x514568`) and of `0x5145b0` (at `0x5145b8`). +/// It answers *"is there a mover, and is it in a state where input means anything at all"*. Quoted +/// in full and re-derived in wow-re `ui/scratch/local-move-input-gate.md` **§6.2**, its five +/// conjuncts are: +/// +/// 1. the mover object resolves (`0x5144e7`); +/// 2. **`[[mover+0x110]+0x40] > 0` — UNIT_FIELD_HEALTH**, signed i32, strictly greater, the `jg` at +/// `0x5144fd`. (The field identity is **VERIFIED** three independent ways in §6.1 — the +/// descriptor-base arithmetic, the `UnitHealth` registrar pair `.data 0x850510 → 0x5174d0`, and +/// the low-health warning's `fild [eax+0x40]; fidiv [eax+0x58]`. `rf79`'s INFERRED label is +/// retired.) +/// 3. the CMovement-aux gate `[[mover+0x118]+0xa4]` null-or-`&4` (`0x514516`); +/// 4. `!0x60f5b0(obj)` — the **KNOCKDOWN animation lockout**, and *not* an on-taxi predicate: it +/// returns `AnimationData.dbc` column 3 bit `0x80`, which the shipped table sets on exactly one +/// of 208 rows, id 121 `Knockdown` (§6.2's census). It contributes nothing while dead; +/// 5. `!(IsActivePlayer(mover) && [mover+0x1c70] & 1)` — the **far-sight ENGAGED latch** +/// (`0x5ee3f6` sets it, `0x5ee4c8` clears it, both inside the far-sight machine `0x5ee290`), and +/// *not* a charm bit: **while your view is out on a far-sight object you may not drive your own +/// body** — though you may still drive a *possessed* one, since `IsActivePlayer` is then false. +/// +/// Conjuncts 4 and 5 are **not modelled here** — named, verified, and unbuilt (decision 1753's tail); +/// they are their own behaviours with their own retests, not part of the death gate. Conjunct 1 is +/// structural: [`super::controller::control`] returns before the axes on `control_lost` and on +/// `reseat`, the frames where the mover would not resolve. Conjunct 3 has no benilla analog yet. +/// +/// **The health term is the one this function exists for.** benilla modelled the server's root on +/// death (0308) and nothing else, and a root deliberately leaves turning live (0872), so a corpse on +/// the ground could still be spun with the turn keys or a right-drag. That is decision 1753. +/// +/// `dead` is health `== 0` off the **mover's** descriptor +/// ([`benilla_protocol::messages::update_object::ObjectFields::unit_is_dead`]) — the mover's and +/// not ours, because `0x5144e0` reads `[esi+0x110]` where `esi` is whatever we are driving +/// (decision 1277's possessed creature). **A ghost is not dead by this test:** the server sets a +/// released player's health to 1 (0308 §the release), so `0x5144fd`'s `jg` is taken and the ghost +/// gets every input back. Nothing in the input path, the emitters or the send gates reads the ghost +/// bit at all — a band census finds **zero** `PLAYER`-block reads in the whole InputControl and +/// CMovement address ranges (§6.6). The ghost bit *is* read in the collision layer (it adds trace +/// mask bit `0x8000` at `0x631658`), which is a separate, unbuilt finding. +fn mover_input_ready(dead: bool) -> bool { + !dead +} + +/// **`0x514560` — "may this unit translate?"**, the ROOT predicate. Consumed by the input tick at +/// `0x5146c1` (`test bl,bl`), the sole gate on the forward/back emitter `0x514da0` and the strafe +/// emitter `0x514e80`. Its own terms past the shared precondition are `MOVEMENTFLAGS & 0x1200` +/// (`0x51458c test dh,0x12` — our `rooted`) and stand state `!= 7` (`0x514591`), which vmangos +/// never writes and which is therefore inert here. +/// +/// Note the health term is tested **twice** — once inside `0x5144e0` and again at `0x51457c` on the +/// predicate's own path. Death is not a corner of this gate; it is its first question. +pub(crate) fn may_translate(dead: bool, rooted: bool) -> bool { + mover_input_ready(dead) && !rooted +} + +/// **`0x5145b0` — "is this unit not stunned?"**, the STUN predicate. Consumed at `0x514755` +/// (`je 0x51479c`), which skips the turn emitter `0x514f50` and the pitch emitter `0x515010` +/// outright *and* force-stops either already in flight. Each emitter has exactly one caller, +/// immediately behind that gate, and no data or vtable reference anywhere in the image — so while +/// this predicate is false **no keyboard or mouse turn can reach `CMovement::StartTurn 0x7c6d90` +/// by any route** (VERIFIED, wow-re `local-move-input-gate.md` §4). +/// +/// Its own term past the shared precondition is `UNIT_FIELD_FLAGS & 0x40000` — a descriptor read, +/// not an aura and not a movement flag (decision 0872). **And because the precondition is shared, +/// a dead body is "stunned" as far as this predicate is concerned**: that single fact is why the +/// reference refuses to turn a corpse. +/// +/// **The MOUSE turn reaches the same answer down a different path** (§6.4, and this corrects what +/// 1753 first wrote). The right-drag handler is `0x514400`, called from the mouse-MOVE handler +/// `0x492c00`; its body hand-off `0x51447b call 0x5103e0` is gated at `0x514474` by a *third* +/// predicate, **`0x5145e0`**, whose first act is to call this one — so the health term arrives +/// through `0x5145b0` → `0x5144e0` and short-circuits before `0x5145e0` reaches its own remaining +/// conjuncts (`[input+4] & 1`, and `GetStandState() == 0` via the CGPlayer vtable slot `0x5ed570`). +/// The refusal is then **triple-redundant**: the two downstream commit gates `0x5151b0` (yaw, at +/// `0x5151e6`) and `0x515250` (pitch, at `0x515283`) each carry their own health test. A closed +/// census of all nine call sites of the body-facing setters `0x60de30`/`0x60de70` — neither of which +/// has any dword reference image-wide — finds every one health-gated. +/// +/// So one term in one place really does fix the keys and the mouse together, but not because they +/// share `0x514755`: they share `0x5144e0`. +pub(crate) fn may_turn(dead: bool, stunned: bool) -> bool { + mover_input_ready(dead) && !stunned +} + +/// **The input tick's teardown leg** — `0x5146d6 call 0x60fb60(0, 1)`, which cancels click-to-move +/// and `/follow` and fires `AUTOFOLLOW_END` (event `0x170`). +/// +/// It is reached only when **BOTH** predicates are down: `0x5146c3 jne` not taken (`bl == 0`) *and* +/// `0x5146ce jne` not taken (`[ebp+0xf] == 0`). That is the correction wow-re's §6.3 makes to +/// `rf86-autofollow-cancel-set.md` §5, which named the health test alone — necessary, but not enough +/// to name the leg. **A pure ROOT does not cancel a follow** (translate down, turn still up), and +/// neither does a pure stun; **death does**, because it takes both. Ice Block, which grants root and +/// stun together, does too. +/// +/// benilla had the root alone in this position and so cancelled on a Frost Nova, which the reference +/// does not (decision 1753). +pub(crate) fn input_torn_down(dead: bool, rooted: bool, stunned: bool) -> bool { + !may_translate(dead, rooted) && !may_turn(dead, stunned) +} + /// **The two incapacitate suppressions, applied to a freshly built move-flag word** (decision 0880) /// — the last step of [`super::control`]'s per-frame rebuild, before the word drives the animation /// and goes on the wire. @@ -490,13 +590,18 @@ impl MoveModes { /// is touched: the granted modes ride on (drop one and the server forgets it), SWIMMING survives a /// root exactly as `0xffe07f00` preserves `0x200000`, and FALLING is already gone by the time we get /// here — the root ended the arc ([`super::mover`]'s anchor). -pub(crate) fn incapacitated_flags(flags: u32, rooted: bool, stunned: bool) -> u32 { +/// +/// The two arguments are therefore not "rooted" and "stunned" but **the two predicates being down** +/// ([`may_translate`], [`may_turn`]) — a wider set by exactly one member: **death drops both**, +/// through the precondition they share ([`mover_input_ready`], decision 1753). A corpse streams +/// neither a direction bit nor a turn bit. +pub(crate) fn incapacitated_flags(flags: u32, translate_gated: bool, turn_gated: bool) -> u32 { use crate::creature_anim::move_flags as f; let mut out = flags; - if rooted { + if translate_gated { out &= !f::ANY_MOVE; } - if stunned { + if turn_gated { out &= !(f::TURN_LEFT | f::TURN_RIGHT); } out @@ -1162,8 +1267,17 @@ pub(super) fn autorun_cancelled( /// `ui_chat::tests::the_posture_emotes_carry_no_swim_suppression_flag`. (It has a *separate*, /// louder gate that fires on movement rather than water — `0x4000` → `ERR_NOEMOTEWHILERUNNING` — /// which benilla does not model; see [`crate::ui_chat::input::emote_send_eligible`].) -pub(super) fn stand_state_refused(move_flags: u32, new_state: u8) -> bool { +pub(super) fn stand_state_refused(reads_dead: bool, move_flags: u32, new_state: u8) -> bool { use crate::creature_anim::move_flags as f; + // **A body that reads dead is refused outright, in EITHER direction** — the same setter's first + // two guards, ahead of the stand-up asymmetry below and of the movement word: `0x5ed4a9 cmp + // [eax+0x40],ebx` / `0x5ed4ac jle 0x5ed566` (UNIT_FIELD_HEALTH ≤ 0), then `0x5ed4b2`–`0x5ed4bd` + // on `UNIT_DYNAMIC_FLAGS & 0x20` — so a **feigner** is refused too, at unchanged health (wow-re + // `local-move-input-gate.md` §6.7; decision 1753). A corpse cannot sit, and it cannot stand up + // either, which is why this sits above the `new_state == 0` exit rather than beside it. + if reads_dead { + return true; + } // The stand-up asymmetry: never gated (`0x5ed4f0 je 0x5ed501`). if new_state == 0 { return false; @@ -1180,26 +1294,49 @@ mod stand_state_tests { use super::stand_state_refused; use crate::creature_anim::move_flags as f; + /// **A body that reads dead is refused in EITHER direction** — `SetStandState`'s first two + /// guards, ahead of the stand-up asymmetry and of the movement word: health ≤ 0 at `0x5ed4ac`, + /// and `UNIT_DYNAMIC_FLAGS & 0x20` at `0x5ed4b2`–`0x5ed4bd`, which catches a **feigner** whose + /// health never moved (decision 1753, wow-re §6.7). + #[test] + fn a_body_that_reads_dead_can_neither_sit_nor_stand() { + for state in [0u8, 1, 2, 3, 8] { + assert!( + stand_state_refused(true, 0, state), + "stand state {state} refused on a body that reads dead — standing up included, \ + which is the one case the movement-word gate below would have let through" + ); + } + assert!( + !stand_state_refused(false, 0, 1), + "and the same still body, alive, is granted its sit — the guard is the death, not the \ + standing still" + ); + } + /// B155's exact press: swimming, X pressed, the client refuses — and the same swimmer standing /// up is not refused, which is the asymmetry that makes the water escapable. #[test] fn a_swimmer_cannot_sit_but_can_always_stand() { // Floating still, no stroke: SWIMMING alone is enough. - assert!(stand_state_refused(f::SWIMMING, 1), "sit refused mid-swim"); assert!( - stand_state_refused(f::SWIMMING | f::FORWARD, 1), + stand_state_refused(false, f::SWIMMING, 1), + "sit refused mid-swim" + ); + assert!( + stand_state_refused(false, f::SWIMMING | f::FORWARD, 1), "swimming forward too" ); // …and every seat shape the posture emotes can ask for. for state in [1u8, 2, 8] { assert!( - stand_state_refused(f::SWIMMING, state), + stand_state_refused(false, f::SWIMMING, state), "state {state} refused mid-swim" ); } // Standing up is never gated — `newState == 0` skips the word entirely. - assert!(!stand_state_refused(f::SWIMMING | f::FORWARD, 0)); - assert!(!stand_state_refused(f::ANY_MOVE | f::SWIMMING, 0)); + assert!(!stand_state_refused(false, f::SWIMMING | f::FORWARD, 0)); + assert!(!stand_state_refused(false, f::ANY_MOVE | f::SWIMMING, 0)); } /// On dry land the gate is the *translation* test, not a blanket "any flag": sitting while @@ -1207,20 +1344,26 @@ mod stand_state_tests { /// `0x20000f`. (The control that says this is a real mask rather than a swim special-case.) #[test] fn sitting_is_refused_while_translating_and_allowed_while_merely_turning() { - assert!(!stand_state_refused(0, 1), "standing still: sit granted"); + assert!( + !stand_state_refused(false, 0, 1), + "standing still: sit granted" + ); for bit in [f::FORWARD, f::BACKWARD, f::STRAFE_LEFT, f::STRAFE_RIGHT] { - assert!(stand_state_refused(bit, 1), "translating: sit refused"); + assert!( + stand_state_refused(false, bit, 1), + "translating: sit refused" + ); } for bit in [f::TURN_LEFT, f::TURN_RIGHT] { assert!( - !stand_state_refused(bit, 1), + !stand_state_refused(false, bit, 1), "turning in place: sit granted" ); } // Mode bits are not movement: a rooted or water-walking body may still sit. for bit in [f::ROOT, f::WATER_WALKING, f::FALLING, f::WALK_MODE] { assert!( - !stand_state_refused(bit, 1), + !stand_state_refused(false, bit, 1), "mode bit {bit:#x} is not a move" ); } @@ -1234,21 +1377,21 @@ mod stand_state_tests { fn sleep_takes_both_tests_so_it_is_the_strictest_posture() { // The extra test, SLEEP's alone. assert!( - stand_state_refused(f::TURN_LEFT, 3), + stand_state_refused(false, f::TURN_LEFT, 3), "turning: /sleep refused" ); - assert!(stand_state_refused(f::TURN_RIGHT, 3)); + assert!(stand_state_refused(false, f::TURN_RIGHT, 3)); assert!( - !stand_state_refused(f::TURN_LEFT, 1), + !stand_state_refused(false, f::TURN_LEFT, 1), "turning blocks ONLY the sleep — a sit is granted" ); // …and the shared one it falls into, which 1581's either/or wrongly skipped. assert!( - stand_state_refused(f::SWIMMING, 3), + stand_state_refused(false, f::SWIMMING, 3), "swimming: /sleep refused" ); assert!( - stand_state_refused(f::FORWARD, 3), + stand_state_refused(false, f::FORWARD, 3), "walking: /sleep refused" ); // So SLEEP's effective mask is exactly `0x20003f` — the one-shot route's own constant. @@ -1258,10 +1401,10 @@ mod stand_state_tests { "`0x20003f`, byte-verified at 0x5fe6dc as well as 0x5ed4e6+0x5ed4f8" ); // Standing up is still ungated from SLEEP, which is what makes it escapable. - assert!(!stand_state_refused(f::ROUTE_COMMITTED_MOVE, 0)); + assert!(!stand_state_refused(false, f::ROUTE_COMMITTED_MOVE, 0)); // FALLING is in neither mask: a standing jump does not block a posture. assert!( - !stand_state_refused(f::FALLING, 3), + !stand_state_refused(false, f::FALLING, 3), "falling: /sleep granted" ); } @@ -1355,8 +1498,8 @@ mod autorun_tests { #[cfg(test)] mod move_mode_tests { use super::{ - incapacitated_flags, MoveModes, Player, FEATHER_TERMINAL_VELOCITY, GRAVITY, - HOVER_CLIMB_RATE, HOVER_HEIGHT, TERMINAL_VELOCITY, + incapacitated_flags, input_torn_down, may_translate, may_turn, MoveModes, Player, + FEATHER_TERMINAL_VELOCITY, GRAVITY, HOVER_CLIMB_RATE, HOVER_HEIGHT, TERMINAL_VELOCITY, }; use crate::creature_anim::move_flags as f; use benilla_protocol::MoveMode; @@ -1585,6 +1728,72 @@ mod move_mode_tests { assert!(!ungranted.take_wire_jump(), "no opcode, no jump"); } + /// **Death is both a root and a stun, and it is the only state that is both** (decision 1753) + /// — the truth table of the reference's two movement-input predicates, whose shared + /// precondition `0x5144e0` is what makes a corpse unturnable. + /// + /// The last two assertions are the bug this test exists for: benilla only ever modelled the + /// server's root on death, and a pure root leaves the pivot live *on purpose*, so a dead body + /// could be spun with the turn keys or a right-drag. The predicate that stops it must not need + /// the root, the stun, or the server's cooperation. + #[test] + fn death_drops_both_movement_input_predicates() { + // (dead, rooted, stunned) -> (may_translate, may_turn) + let gate = |dead, rooted, stunned| (may_translate(dead, rooted), may_turn(dead, stunned)); + + assert_eq!( + gate(false, false, false), + (true, true), + "alive and free: both predicates pass and every input applies" + ); + assert_eq!( + gate(false, true, false), + (false, true), + "a PURE root (Frost Nova) takes translation and leaves the pivot — `0x514560` alone" + ); + assert_eq!( + gate(false, false, true), + (true, false), + "a stun takes the pivot and, by itself, nothing else — `0x5145b0` alone" + ); + assert_eq!( + gate(true, false, false), + (false, false), + "DEAD, with neither a root nor a stun granted anywhere: both predicates fail their \ + shared precondition `0x5144e0` on health at `0x5144f8`. The turn half is the bug — a \ + corpse is stunned as far as the input tick is concerned, so it cannot be spun with \ + the turn keys or a right-drag, and it does not need the server's root to say so." + ); + assert_eq!( + gate(true, true, true), + (false, false), + "and death is not additive with either: it is already both" + ); + } + + /// **The teardown leg needs BOTH predicates down** (`0x5146d6 call 0x60fb60`) — wow-re §6.3, + /// which sharpens `rf86-autofollow-cancel-set.md` §5. benilla had the root in this position and + /// so ended a follow on a Frost Nova, which the reference does not. + #[test] + fn only_both_predicates_down_tears_the_follow_down() { + assert!( + !input_torn_down(false, true, false), + "a PURE root does NOT end a follow — translate is down, the turn is still up" + ); + assert!( + !input_torn_down(false, false, true), + "and neither does a pure stun — the emitter never consults `0x5145b0`" + ); + assert!( + input_torn_down(true, false, false), + "DEATH ends it, with nothing granted: it takes both predicates down by itself" + ); + assert!( + input_torn_down(false, true, true), + "and so does Ice Block, which is root and stun at once" + ); + } + /// **The two incapacitate suppressions take exactly their own bits** (decision 0880) — the /// difference between a pure root and a stun, which is the whole distinction B179 was drawing, /// expressed on the one word that drives both the animation and the wire. diff --git a/crates/benilla-app/src/player/swim.rs b/crates/benilla-app/src/player/swim.rs index aa2c9fc0..45afce86 100644 --- a/crates/benilla-app/src/player/swim.rs +++ b/crates/benilla-app/src/player/swim.rs @@ -587,10 +587,14 @@ pub(super) fn drive_step( /// 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 { +pub(super) fn translate_amounts( + axes: &super::input::MoveAxes, + translate_gated: bool, +) -> (f32, f32) { + // The translate predicate being down (`0x514560`: a root, or **death** through the precondition + // it shares with the turn gate — decision 1753) 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 translate_gated { return (0.0, 0.0); } let swim_fwd = axes.fwd.signum() as f32; diff --git a/crates/benilla-app/src/player/world_focus.rs b/crates/benilla-app/src/player/world_focus.rs index 7e8038d0..72a3cce0 100644 --- a/crates/benilla-app/src/player/world_focus.rs +++ b/crates/benilla-app/src/player/world_focus.rs @@ -44,6 +44,10 @@ pub(super) fn publish_viewer( ), Err(_) => (0.0, false), }; + // The ghost A/B override (`WOW_GHOST_PROBE`) lands here rather than beside the screen pass it + // used to pin alone: this flag is what the death light and the DeathClouds sky both read, so + // overriding it is what makes the probe mean "the ghost world" rather than "the ghost filter". + let ghost = crate::death::ghost_probe().unwrap_or(ghost); let body = match player.as_deref() { Some(p) if p.active && !p.detached => Viewer { at: Some(p.pos), diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index a0620077..dee03b1a 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -65,6 +65,7 @@ const ENGINE_ROOTS: &[&str] = &[ "schedule", "sky", "sky_order", + "skybox", "sun", "surface", "terrain", @@ -74,7 +75,6 @@ const ENGINE_ROOTS: &[&str] = &[ "wdl", "weather", "wmo_portal", - "wmo_sky", "world_census", "world_plugins", "world_point", diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index f6435edf..0c986481 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -77,7 +77,7 @@ pub struct WmoModel { /// ROUND 5 (`0x69de20`). pub fogs: Vec, /// The root's **MOSB** skybox model (`.m2`), drawn as the sky backdrop while the camera stands in - /// a group whose flags carry `0x40000` — see `crate::wmo_sky` in the app for the gate and + /// a group whose flags carry `0x40000` — see `crate::skybox` in the app for the gate and /// `benilla_formats::WmoGroupInfo::show_skybox` for how that bit was identified. `None` for all /// but a handful of roots (`benilla-extract skyboxscan`); carrying it here costs one `Option` /// per building and saves the sky lane a second root parse. diff --git a/crates/benilla-formats/src/light.rs b/crates/benilla-formats/src/light.rs index b517be05..e798691c 100644 --- a/crates/benilla-formats/src/light.rs +++ b/crates/benilla-formats/src/light.rs @@ -22,7 +22,7 @@ use std::collections::HashMap; use anyhow::{Context, Result}; use benilla_dbc::{FieldType, Schema, SchemaField}; -use crate::dbc::{f32_at, parse, u32_at}; +use crate::dbc::{f32_at, parse, str_at, u32_at}; use crate::Chain; mod atmosphere; // the resolved `Atmosphere` output type + the DBC row/field → meaning constants @@ -33,7 +33,7 @@ use atmosphere::{ FB_CLOUD_DENSITY, FB_FOG_END, FB_FOG_START_MULT, IB_AMBIENT, IB_CLOUD_GBASE, IB_CLOUD_SLOPE, IB_CLOUD_SUN, IB_DIFFUSE, IB_FOG_COLOR, IB_OCEAN_DEEP, IB_OCEAN_SHALLOW, IB_RIVER_DEEP, IB_RIVER_SHALLOW, IB_SKY0, IB_SUN_COLOR, LP_GLOW, LP_HIGHLIGHT, LP_OCEAN_DEEP_ALPHA, - LP_OCEAN_SHALLOW_ALPHA, LP_WATER_DEEP_ALPHA, LP_WATER_SHALLOW_ALPHA, + LP_OCEAN_SHALLOW_ALPHA, LP_SKYBOX, LP_WATER_DEEP_ALPHA, LP_WATER_SHALLOW_ALPHA, }; pub use atmosphere::{ZERO_KEY_COLOR, ZERO_KEY_SCALAR}; use tables::{band_schema, load_bands, load_float_bands, sample_color, sample_float, Band, DAY}; @@ -42,6 +42,7 @@ const LIGHT: &str = "DBFilesClient\\Light.dbc"; const INT_BAND: &str = "DBFilesClient\\LightIntBand.dbc"; const FLOAT_BAND: &str = "DBFilesClient\\LightFloatBand.dbc"; const LIGHT_PARAMS: &str = "DBFilesClient\\LightParams.dbc"; +const LIGHT_SKYBOX: &str = "DBFilesClient\\LightSkybox.dbc"; /// World-unit scale factor on Light.dbc positions/radii (stored as yards × 36). const POS_SCALE: f32 = 36.0; @@ -198,6 +199,16 @@ pub struct LightCatalog { /// `LightParams.dbc` id → water-blend alphas `[waterShallow, waterDeep, oceanShallow, oceanDeep]` /// (fields 5–8). The from-above swatch's per-zone depth-alpha ramp endpoints. light_params_water_alpha: HashMap, + /// `LightParams.dbc` id → `lightSkyboxID` (field [`LP_SKYBOX`]), kept only for the non-zero rows + /// — 5 of 426 in the shipped chain, every one of them the ghost sky. + light_params_skybox: HashMap, + /// `LightSkybox.dbc` id → the model it names, normalised to its chain path (`crate::models`' + /// `model_path`: lowercased, `.mdx` → the physical `.m2`) so both skybox sources hand the render + /// lane the same spelling and one model can never build twice under two names. + /// + /// Six rows in 5875; only id 3 (`DeathClouds.mdx`) is reachable from `LightParams` — the other + /// five are the WMO **MOSB** skyboxes, which this table does not drive (`crate::skybox`). + skyboxes: HashMap, } fn light_schema() -> Schema { @@ -311,7 +322,12 @@ impl LightCatalog { FLOAT_BAND, band_schema("LightFloatBand", FieldType::Float32), )?; - let (light_params_glow, light_params_highlight, light_params_water_alpha) = { + let ( + light_params_glow, + light_params_highlight, + light_params_water_alpha, + light_params_skybox, + ) = { let bytes = chain .read_file(LIGHT_PARAMS) .with_context(|| format!("reading {LIGHT_PARAMS}"))?; @@ -319,6 +335,7 @@ impl LightCatalog { let mut glow = HashMap::with_capacity(rs.records().len()); let mut highlight = HashMap::with_capacity(rs.records().len()); let mut water_alpha = HashMap::with_capacity(rs.records().len()); + let mut skybox = HashMap::new(); for r in rs.records() { let Some(id) = u32_at(r, 0) else { continue }; if let Some(g) = f32_at(r, LP_GLOW) { @@ -338,8 +355,33 @@ impl LightCatalog { ) { water_alpha.insert(id, [ws, wd, os, od]); } + // `lightSkyboxID`: 0 means "no skybox", which is 421 of the 426 rows — store only + // the live ones so a lookup miss and an explicit zero are the same answer. + match u32_at(r, LP_SKYBOX) { + Some(0) | None => {} + Some(sky) => { + skybox.insert(id, sky); + } + } } - (glow, highlight, water_alpha) + (glow, highlight, water_alpha, skybox) + }; + // `LightSkybox.dbc` — 6 records × 2 fields (`ID`, `Name`), the model each skybox id names. + let skyboxes = { + let bytes = chain + .read_file(LIGHT_SKYBOX) + .with_context(|| format!("reading {LIGHT_SKYBOX}"))?; + let mut schema = Schema::new("LightSkybox"); + schema.add_field(SchemaField::new("ID", FieldType::UInt32)); + schema.add_field(SchemaField::new("Name", FieldType::String)); + let rs = parse(&bytes, schema, "LightSkybox")?; + let mut m = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let (Some(id), Some(path)) = (u32_at(r, 0), str_at(&rs, r, 1)) { + m.insert(id, crate::models::model_path(&path)); + } + } + m }; Ok(LightCatalog { lights, @@ -348,6 +390,8 @@ impl LightCatalog { light_params_glow, light_params_highlight, light_params_water_alpha, + light_params_skybox, + skyboxes, }) } @@ -443,6 +487,31 @@ impl LightCatalog { } } + /// The **ghost sky** for this position: the model `LightSkybox.dbc` names for the death + /// profile, or `None` when the chain's data does not name one. + /// + /// The caller gates on the `PLAYER_FLAGS` ghost bit — that bit is the *whole* condition, and it + /// is the reference's too. Byte-VERIFIED (wow-re `lighting/scratch/wmo-skybox.md` §3 + + /// `death-light.md`): the DBC skybox slot `[0xce9bb4]` is filled inside `dn_color_table_build + /// 0x6d2260` at `0x6d26cb`, **gated on the override cell `[0xce9bb0] != -1`**, which only + /// `0x6d4620` writes and only the ghost-bit selector `0x5de9c0` calls (`mov ecx,4` — param slot + /// [`SLOT_DEATH`]). So: ghost ⇒ this skybox; alive ⇒ none, with no other path into the table. + /// + /// **Read straight off slot 4, with no clear-slot fallback** — unlike the atmosphere's + /// `atmo_of`, which degrades an unset slot to [`SLOT_CLEAR`]. The reference resolves the row + /// with `0x6d6ab0(row, 4)` = `[row+0x2c]` and simply finds nothing if it is 0; degrading here + /// would instead read a slot that, in shipped data, never names a skybox at all. + /// + /// Resolved through [`pick_light`](Self::pick_light) so the sky comes from the same zone row + /// the water tint does. On 5875 data the choice is unobservable — all 374 `Light` rows reach + /// skybox **3** (`DeathClouds.mdx`) through slot 4 — but a chain that ever varied it would then + /// vary sky and zone together rather than drifting apart. + pub fn ghost_skybox(&self, map: u32, pos: [f32; 3]) -> Option<&str> { + let light = self.pick_light(map, pos)?; + let id = self.light_params_skybox.get(&light.params[SLOT_DEATH])?; + self.skyboxes.get(id).map(String::as_str) + } + /// Faithful **area-light blend** (step 2 isolation): replicates the real client's /// `calculateLightParamBlends` + the sequential lerp in `getLightResultsFromDB`. Start from the /// continent's global `(0,0,0)` light, then for every local light sphere covering `pos` (sorted by diff --git a/crates/benilla-formats/src/light/atmosphere.rs b/crates/benilla-formats/src/light/atmosphere.rs index 1402ddc5..d823e28a 100644 --- a/crates/benilla-formats/src/light/atmosphere.rs +++ b/crates/benilla-formats/src/light/atmosphere.rs @@ -51,6 +51,18 @@ pub(super) const LP_GLOW: usize = 4; /// Stored as 0.0/1.0. pub(super) const LP_HIGHLIGHT: usize = 1; +/// `LightParams.dbc` field index of **lightSkyboxID** (record byte +0x08 = field index 2) — the +/// `LightSkybox.dbc` row whose model replaces the whole celestial pass while it is active. +/// +/// **In 5875 this field is the ghost sky and nothing else.** Byte-VERIFIED (wow-re +/// `lighting/scratch/wmo-skybox.md` §3): the DBC skybox slot `[0xce9bb4]` is filled inside +/// `dn_color_table_build 0x6d2260` at `0x6d26cb` **gated on `[0xce9bb0] != -1`** — the ghost +/// override cell, written only by `0x6d4620` off the `PLAYER_FLAGS` ghost bit (`death-light.md`). +/// The shipped data agrees exactly: of 426 `LightParams` rows only **5** carry a non-zero id, all +/// of them **3 = `DeathClouds.mdx`**, and all 374 `Light.dbc` rows reach one through param slot +/// **4** alone (slots 0–3 are uniformly 0). So there is no live non-ghost skybox to miss here. +pub(super) const LP_SKYBOX: usize = 2; + /// `LightParams.dbc` field indices of the **water-blend alphas** — the from-above swatch's depth-alpha /// ramp endpoints (`swatch.a = lerp(shallow, deep, V)`; the runtime reads these into `gWorldLight+0x114..` /// via `FUN_006b6b60`). Layout follows the glow shift: glow is field 4 (+0x10), so these are fields 5–8 diff --git a/crates/benilla-formats/src/models/wmo/root.rs b/crates/benilla-formats/src/models/wmo/root.rs index b94b10fa..6f0fb612 100644 --- a/crates/benilla-formats/src/models/wmo/root.rs +++ b/crates/benilla-formats/src/models/wmo/root.rs @@ -173,7 +173,7 @@ pub fn parse_wmo_root(bytes: &[u8]) -> Result { let group_infos = parse_wmo_group_infos(bytes); let portals = parse_wmo_portals(bytes); let fogs = parse_wmo_fogs(bytes); - let skybox = parse_wmo_skybox(bytes); + let skybox = parse_skybox(bytes); Ok(WmoRoot { parsed, doodads, @@ -188,7 +188,7 @@ pub fn parse_wmo_root(bytes: &[u8]) -> Result { /// Parse the root's **MOSB** chunk — a single null-terminated model path, or the 4 zero bytes that /// stand for "no skybox" (which is what almost every root carries). Returned normalized to `.m2`, the /// extension the loaders take; the chunk itself names the authoring-time `.mdx`. -fn parse_wmo_skybox(bytes: &[u8]) -> Option { +fn parse_skybox(bytes: &[u8]) -> Option { let mosb = find_wmo_chunk(bytes, b"BSOM")?; let end = mosb.iter().position(|&b| b == 0).unwrap_or(mosb.len()); let raw = std::str::from_utf8(&mosb[..end]).ok()?.trim(); diff --git a/crates/benilla-formats/tests/chain.rs b/crates/benilla-formats/tests/chain.rs index aaf7e8dc..9e341ebc 100644 --- a/crates/benilla-formats/tests/chain.rs +++ b/crates/benilla-formats/tests/chain.rs @@ -1266,7 +1266,7 @@ fn tombstoned_paths_never_fall_through_to_the_base_copy() { } /// The WMO **skybox** pair, against the shipped Stratholme files (`crate::models::wmo`, -/// `benilla::wmo_sky`): a root's MOSB model and the per-group `0x40000` gate that asks for it. +/// `benilla::skybox`): a root's MOSB model and the per-group `0x40000` gate that asks for it. /// /// The sibling roots are the whole point of the fixture. `Stratholme_B` is the burning **city** — it /// names `StratholmeSkybox.m2` *and* 61 of its 83 groups set the bit, which is why the reference's @@ -1276,7 +1276,7 @@ fn tombstoned_paths_never_fall_through_to_the_base_copy() { /// halves going quiet, and the two together are what keeps the gate off the chunk alone — four 1.12 /// roots name a skybox no group ever asks for (`benilla-extract skyboxscan`). #[test] -fn reads_the_wmo_skybox_and_its_per_group_gate() { +fn reads_the_skybox_and_its_per_group_gate() { let data = benilla_formats::wow_data_or_skip!(); let mut chain = open_chain(&data).expect("open vanilla patch chain"); @@ -1409,3 +1409,47 @@ fn wmo_ground_type_is_a_terrain_type_id() { ); assert_eq!(multi, 121, "roots authoring more than one surface"); } + +/// The **ghost sky** — `LightParams.lightSkyboxID` → `LightSkybox.dbc` → a model the chain can +/// actually read. Pins all three joints of a path that was parsed and then dropped on the floor: +/// the field was named in the schema and had zero consumers, so nothing caught that the table it +/// indexes was never loaded at all. +/// +/// The reference reaches this table only through the ghost override (`0x6d26cb`, gated on +/// `[0xce9bb0] != -1`; wow-re `lighting/scratch/wmo-skybox.md` §3 + `death-light.md`), and the +/// shipped data matches that gate exactly: 5 of 426 `LightParams` rows carry a non-zero id, all of +/// them 3 = `DeathClouds.mdx`, reached from param slot **4** of every one of the 374 `Light` rows. +/// So the answer is the same everywhere, which is what the spread of positions below asserts — +/// including Deeprun Tram, the shipped map with no `Light` row of its own (it must still find the +/// sky through the zero-match fallback row, not come back bare). +#[test] +fn resolves_the_ghost_skybox_everywhere() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let cat = benilla_formats::LightCatalog::load(&mut chain).expect("load Light DBCs"); + + const DEATH_CLOUDS: &str = "environments\\stars\\deathclouds.m2"; + for (map, pos, what) in [ + (0u32, [-8949.95f32, -132.49, 83.5], "Northshire, Azeroth"), + (1, [1629.0, -4373.0, 31.0], "Orgrimmar, Kalimdor"), + ( + 369, + [0.0, 0.0, 0.0], + "Deeprun Tram — no Light row of its own", + ), + ] { + assert_eq!( + cat.ghost_skybox(map, pos), + Some(DEATH_CLOUDS), + "the ghost sky must resolve at {what}" + ); + } + + // The path is normalised to what the chain actually holds: the DBC authors `.mdx`, the archive + // ships `.m2`. Reading it here is the half that a name-only assert would miss — the render lane + // hands this exact string to `load_m2_mesh`. + let bytes = chain + .read_file(DEATH_CLOUDS) + .expect("the ghost sky model the DBC names must be readable from the chain"); + assert_eq!(&bytes[..4], b"MD20", "DeathClouds is an M2"); +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/world.rs b/crates/benilla-protocol/src/bin/benilla-world/world.rs index da7efe5f..e5a128a3 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/world.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/world.rs @@ -490,6 +490,7 @@ impl World { stop, duration_ms, flying, + .. } => { let mine = *guid == self.self_guid; if mine { diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 8f083f32..83ec0313 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -343,6 +343,9 @@ pub enum SessionEvent { /// `true` ⇒ a 3-D flight path (keep the spline's Z); `false` ⇒ a ground walk whose Z the app /// re-derives from the terrain under the unit (see the renderer's creature ground-clamp). flying: bool, + /// `SPLINEFLAG_RUNMODE` — the path is travelled at run speed. Its **absence** forces + /// `MOVEFLAG_WALK_MODE` on for the unit the spline moves (decision 1758). + run_mode: bool, }, /// Same-map teleport (`MSG_MOVE_TELEPORT_ACK`): the app snaps our player + echoes the ack. Teleport { diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 80293385..9d7c8f04 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -636,6 +636,7 @@ pub fn decode(packet: ServerPacket) -> Vec { stop, duration_ms, flying, + run_mode, } => vec![SessionEvent::MonsterMove { guid, start: v3(start), @@ -645,6 +646,7 @@ pub fn decode(packet: ServerPacket) -> Vec { stop, duration_ms, flying, + run_mode, }], ServerPacket::PlayerMove { guid, diff --git a/crates/benilla-protocol/src/messages/monster_move.rs b/crates/benilla-protocol/src/messages/monster_move.rs index af429b92..43153bc4 100644 --- a/crates/benilla-protocol/src/messages/monster_move.rs +++ b/crates/benilla-protocol/src/messages/monster_move.rs @@ -25,6 +25,13 @@ const MONSTER_MOVE_STOP: u8 = 0x1; /// its Z. The client gates on this same bit (`0x6018f0: test ah,0x2`). It is also `Mask_CatmullRom` — /// flying paths interpolate (and wire-encode) as a curve, ground paths as straight segments. const SPLINE_FLAG_FLYING: u32 = 0x200; +/// `SPLINEFLAG_RUNMODE` — the path is travelled at run speed. Its **absence** is what matters: +/// the real client feeds this bit straight into `CMovement::SetRunMode 0x7c71c0` +/// (`0x7c6ac2 and edi,0x100` → `0x7c6acb call 0x7c71c0`, inside the `SMSG_MONSTER_MOVE` commit +/// `0x7c6a50`), and that setter's argument is *run* — so a spline **without** this bit **sets** +/// `MOVEFLAG_WALK_MODE` on the unit it moves. The two `0x100`s are inverses of each other +/// (wow-re `collision/scratch/walk-mode-law.md` §5.2; benilla decision 1758). +const SPLINE_FLAG_RUNMODE: u32 = 0x100; /// Parse an `SMSG_MONSTER_MOVE` body into [`ServerPacket::MonsterMove`]. The head (mover, `start`, spline /// id, `moveType` + its final facing) is always present; a **stop** (`moveType 1`) ends there (the client @@ -62,11 +69,15 @@ pub(super) fn read_monster_move(r: &mut &[u8]) -> io::Result { stop: true, duration_ms: 0, flying: false, + // The stop form carries no spline-flags dword at all, so there is no run/walk verdict + // in it — and it builds no path, so nothing downstream reads this. + run_mode: true, } } else { let spline_flags = read_u32_le(r)?; let duration_ms = read_u32_le(r)?; let flying = spline_flags & SPLINE_FLAG_FLYING != 0; + let run_mode = spline_flags & SPLINE_FLAG_RUNMODE != 0; // The decoded waypoints *after* the start (see [`read_monster_move_spline`]): a ground path's // first packed point re-encodes `start` (quantized), so drop it and anchor the path with the // exact wire `start`; a flying path sends only post-start points, kept whole. The result is the @@ -89,6 +100,7 @@ pub(super) fn read_monster_move(r: &mut &[u8]) -> io::Result { stop: false, duration_ms, flying, + run_mode, } }) } diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 8f14e55d..3196ec1a 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -146,6 +146,10 @@ pub enum ServerPacket { /// The path is a 3-D **flight** (`SPLINE_FLAG_FLYING` = `Mask_CatmullRom`): keep the spline's own Z. /// Clear ⇒ a ground walk whose Z the client re-derives from the terrain (the app terrain-clamps it). flying: bool, + /// The path's `SPLINEFLAG_RUNMODE` bit — and its **absence** is the load-bearing half: a + /// spline without it forces `MOVEFLAG_WALK_MODE` **on** for the unit it moves (see + /// [`super::monster_move`]'s `SPLINE_FLAG_RUNMODE`). + run_mode: bool, }, /// A relayed *player* movement packet (`MSG_MOVE_*`, see [`super::parse::is_movement_relay`]): the mover's /// authoritative pose plus its live CMovement `moveFlags`. The app snaps the entity to `position` diff --git a/crates/benilla-protocol/src/messages/update_object/movement.rs b/crates/benilla-protocol/src/messages/update_object/movement.rs index 9e43a428..a121ae85 100644 --- a/crates/benilla-protocol/src/messages/update_object/movement.rs +++ b/crates/benilla-protocol/src/messages/update_object/movement.rs @@ -49,6 +49,9 @@ const SPLINE_FLAG_FINAL_ANGLE: u32 = 0x4_0000; /// (`MoveSplineFlag.h:48,77`), the same split [`crate::ServerPacket::MonsterMove`] reads: set ⇒ a 3-D /// flight path that keeps its own Z, clear ⇒ a ground walk whose Z the client re-derives from terrain. const SPLINE_FLAG_FLYING: u32 = 0x200; +/// `SPLINEFLAG_RUNMODE` — see [`super::super::monster_move`]'s copy for why its *absence* is what +/// matters: a spline without it forces walk mode on for the unit it moves. +const SPLINE_FLAG_RUNMODE: u32 = 0x100; /// `MoveSplineFlag::Cyclic` (`MoveSplineFlag.h:59`) — the path loops back on itself forever. const SPLINE_FLAG_CYCLIC: u32 = 0x10_0000; @@ -86,6 +89,8 @@ pub struct CreateSpline { /// `MoveSplineFlag::Cyclic` — the server loops this path forever. The app rides it once; a looping /// follow is unbuilt (the same gap `MonsterMove`'s cyclic paths have). pub cyclic: bool, + /// `MoveSplineFlag::Runmode` — and its absence forces walk mode on (decision 1758). + pub run_mode: bool, } /// The decoded fields a movement block carries: the pose (from its `LIVING`/`HAS_POSITION` flag), @@ -199,6 +204,7 @@ impl MovementBlock { duration_ms, flying: spline_flags & SPLINE_FLAG_FLYING != 0, cyclic: spline_flags & SPLINE_FLAG_CYCLIC != 0, + run_mode: spline_flags & SPLINE_FLAG_RUNMODE != 0, }); } } else if update_flag & UPDATE_FLAG_HAS_POSITION != 0 { diff --git a/crates/benilla-world/src/clouds/layer.rs b/crates/benilla-world/src/clouds/layer.rs index b4e62261..a4f57f32 100644 --- a/crates/benilla-world/src/clouds/layer.rs +++ b/crates/benilla-world/src/clouds/layer.rs @@ -178,7 +178,7 @@ pub(super) fn setup_cloud_layer( /// The sky-dome visibility toggle (the clouds hide with the rest of the sky). All color state /// lives in the texture — the kernel's `0x6cfb00` port owns it. /// -/// A WMO skybox ([`crate::wmo_sky`]) hides the clouds too: `CSky::Render`'s one shared boolean skips +/// A WMO skybox ([`crate::skybox`]) hides the clouds too: `CSky::Render`'s one shared boolean skips /// **all six** sky elements together (`0x6d4a3b test edi,edi; je`), so the painted art is the whole /// sky rather than a backdrop the procedural clouds keep drawing over. The art carries its own cloud /// banks; layering ours on top lifted the painted zenith out of its near-black. @@ -188,12 +188,12 @@ pub(super) fn setup_cloud_layer( /// under the surface there is no sky, only the murk. pub(super) fn apply_cloud_visibility( debug: Res, - wmo_skybox: Res, + skybox: Res, underwater: Res, mut dome: Query<&mut Visibility, With>, ) { if let Ok(mut vis) = dome.single_mut() { - *vis = if debug.lighting.disable_sky_dome || wmo_skybox.0.is_some() || underwater.0.any() { + *vis = if debug.lighting.disable_sky_dome || skybox.0.is_some() || underwater.0.any() { Visibility::Hidden } else { Visibility::Inherited diff --git a/crates/benilla-world/src/clouds/mod.rs b/crates/benilla-world/src/clouds/mod.rs index 40335581..a3def438 100644 --- a/crates/benilla-world/src/clouds/mod.rs +++ b/crates/benilla-world/src/clouds/mod.rs @@ -83,7 +83,7 @@ impl Plugin for CloudsPlugin { // or one frame draws both (the ordering `crate::sky`'s gate already takes) — // and after the submersion verdict, for the same one-frame-agreement reason. layer::apply_cloud_visibility - .after(crate::wmo_sky::WmoSkyResolve) + .after(crate::skybox::SkyboxResolve) .after(crate::liquid::SubmersionVerdict), ), ) diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs index 410f1524..42cdc53e 100644 --- a/crates/benilla-world/src/lib.rs +++ b/crates/benilla-world/src/lib.rs @@ -167,6 +167,7 @@ pub mod schedule; mod shaders; pub mod sky; pub mod sky_order; +pub mod skybox; pub mod static_gx; pub mod static_merge; pub mod sun; @@ -179,7 +180,6 @@ pub mod water_fx; pub mod wdl; pub mod weather; pub mod wmo_portal; -pub mod wmo_sky; pub mod world_census; pub mod world_map; pub mod world_plugins; diff --git a/crates/benilla-world/src/model_render.rs b/crates/benilla-world/src/model_render.rs index 9f5b79b2..8799948d 100644 --- a/crates/benilla-world/src/model_render.rs +++ b/crates/benilla-world/src/model_render.rs @@ -111,7 +111,7 @@ pub struct MatKey { /// z-write on, drawn before its model's colour batches. Its own key axis so a twin can never /// dedupe onto a colour material. zfill: bool, - /// This batch is part of a **WMO skybox** ([`crate::wmo_sky`]) — the building-owned painted sky, + /// This batch is part of a **WMO skybox** ([`crate::skybox`]) — the building-owned painted sky, /// drawn on this lane like any other M2 since decision 1264. Its own key axis because the lane /// changes pipeline state ([`SKY_DEPTH_MARKER`]: forced far depth, no early-Z) and sort rung /// ([`skybox_sort_bias`]), so a skybox batch must never dedupe onto the identical-looking world diff --git a/crates/benilla-world/src/model_render/batch.rs b/crates/benilla-world/src/model_render/batch.rs index 99ceb78c..f6f2a902 100644 --- a/crates/benilla-world/src/model_render/batch.rs +++ b/crates/benilla-world/src/model_render/batch.rs @@ -102,7 +102,7 @@ impl M2BatchMaterials<'_> { } /// One material for a batch of a **WMO skybox** — the painted sky a building owns - /// ([`crate::wmo_sky`]), which is an ordinary M2 and is drawn as one (decision 1264). + /// ([`crate::skybox`]), which is an ordinary M2 and is drawn as one (decision 1264). /// /// Everything that makes it a *skybox* rather than a doodad is here, and it is only three /// things. Blend mode, sidedness, the UNLIT and UNFOGGED bits, the alpha-key reference, the diff --git a/crates/benilla-world/src/shaders.rs b/crates/benilla-world/src/shaders.rs index 4384b628..b4be64cc 100644 --- a/crates/benilla-world/src/shaders.rs +++ b/crates/benilla-world/src/shaders.rs @@ -77,7 +77,7 @@ mod tests { `shaders::plugin`, or was registered from a file that is not directly under src/" ); } - // 6 since decision 1264 retired `wmo_skybox.wgsl` (a WMO skybox draws on the shared + // 6 since decision 1264 retired `skybox.wgsl` (a WMO skybox draws on the shared // model lane, whose forced-far-depth branch lives in `benilla_assets`' // `wow_model.wgsl`); 7 since 1429 added `static_gx.wgsl` (the B1 retained pass). assert_eq!(found, 7, "the engine's shader set changed size"); diff --git a/crates/benilla-world/src/sky.rs b/crates/benilla-world/src/sky.rs index 3df28419..ca84c984 100644 --- a/crates/benilla-world/src/sky.rs +++ b/crates/benilla-world/src/sky.rs @@ -122,9 +122,9 @@ impl Plugin for SkyPlugin { ( update_sky_colors, // The dome stands down for a WMO skybox, so its gate must read the SETTLED - // resolve, not whichever side of it the executor picked (`crate::wmo_sky`). + // resolve, not whichever side of it the executor picked (`crate::skybox`). apply_sky_visibility - .after(crate::wmo_sky::WmoSkyResolve) + .after(crate::skybox::SkyboxResolve) .after(crate::liquid::SubmersionVerdict), ), ) @@ -245,7 +245,7 @@ fn follow_camera( } /// Hide/show the dome from the debug panel's "disable sky dome" toggle — and stand it down entirely -/// while a **WMO skybox** owns the backdrop ([`crate::wmo_sky`]), or while the eye is **submerged**: +/// while a **WMO skybox** owns the backdrop ([`crate::skybox`]), or while the eye is **submerged**: /// a building whose group asks for its MOSB model replaces this gradient, it does not layer over it, /// and underwater the reference skips the whole sky pass (the scene driver's `0x6812a4` submerged /// test gates `CSky::Render 0x6d4940` — stars, discs, gradient band and cloud dome together; @@ -253,14 +253,14 @@ fn follow_camera( /// backdrop (the row-7 fog colour — submerged, the murk — or black via "black backdrop") shows through. fn apply_sky_visibility( debug: Res, - wmo_skybox: Res, + skybox: Res, underwater: Res, mut dome: Query<&mut Visibility, With>, ) { let Ok(mut vis) = dome.single_mut() else { return; }; - let want = if debug.lighting.disable_sky_dome || wmo_skybox.0.is_some() || underwater.0.any() { + let want = if debug.lighting.disable_sky_dome || skybox.0.is_some() || underwater.0.any() { Visibility::Hidden } else { Visibility::Visible diff --git a/crates/benilla-world/src/sky_order.rs b/crates/benilla-world/src/sky_order.rs index 782f22aa..1612360a 100644 --- a/crates/benilla-world/src/sky_order.rs +++ b/crates/benilla-world/src/sky_order.rs @@ -74,7 +74,7 @@ /// Stars — the first celestial draw (`0x6d4a3f`): everything else in the sky paints over them, so /// theirs is the ladder's LOWEST rung (see the sign law above). pub(crate) const STARS_BIAS: f32 = -1.0e6; -/// **The WMO skybox** ([`crate::wmo_sky`]) — the building-owned painted sky, which replaces this +/// **The WMO skybox** ([`crate::skybox`]) — the building-owned painted sky, which replaces this /// whole ladder while it draws, so its rung never competes with one above. It needs a rung at all /// because a skybox is **an ordinary M2 and not an opaque backdrop**, which is what this module /// asserted until decision 1264: `CavernsOfTimeSky.m2` is 21 batches across four blend modes — a diff --git a/crates/benilla-world/src/wmo_sky.rs b/crates/benilla-world/src/skybox.rs similarity index 88% rename from crates/benilla-world/src/wmo_sky.rs rename to crates/benilla-world/src/skybox.rs index 1b4fdd6f..9f25ca9a 100644 --- a/crates/benilla-world/src/wmo_sky.rs +++ b/crates/benilla-world/src/skybox.rs @@ -1,5 +1,15 @@ -//! The **WMO skybox** — the authored sky a building swaps in for the `Light.dbc` gradient dome while -//! the camera stands in one of its own groups. +//! The **skybox** — an authored sky model that stands in for the `Light.dbc` gradient dome, drawn +//! camera-anchored in the sky slice. +//! +//! **Two sources feed one lane, and the DBC source wins.** A *building* asks for its own painted sky +//! (the **MOSB**/`0x40000` mechanism derived below); the *death profile* asks for the ghost sky +//! (`LightSkybox.dbc` via `LightParams.lightSkyboxID`, [`benilla_formats::LightCatalog::ghost_skybox`]). +//! The reference keeps these in separate slots — the WMO's is loop slot A, the DBC's is the single +//! slot B — and slot B, whose weight is hardcoded `1.0` whenever it is filled, makes +//! `0x6d4ac1`–`0x6d4acc` skip loop A entirely: **an active DBC skybox suppresses the WMO one +//! outright** (byte-VERIFIED, wow-re `lighting/scratch/wmo-skybox.md` §3–4). Here that is one +//! `Option` with the DBC source taken first, because a resolve that produced both would have to +//! decide anyway and the binary has already decided. //! //! A WMO root can name a skybox model in its **MOSB** chunk, and a group can ask for it with group //! flag **`0x40000`**. Both halves matter, and the flag is tested **on the groups the portal flood @@ -75,7 +85,7 @@ use bevy::prelude::*; use crate::model_render::M2BatchMaterials; use crate::view::WorldCamera; -use benilla_assets::coords::wow_to_bevy; +use benilla_assets::coords::{bevy_to_wow, wow_to_bevy}; use benilla_assets::WmoModel; use benilla_assets::{LockRecover, WorldAssets}; @@ -88,12 +98,12 @@ const SHOW_SKYBOX: u32 = 0x40000; /// the [`crate::sky`] gradient dome is the backdrop. Resolved from [`CameraInteriorClaim`]: the same /// down-ray seed that already names the camera's room for the MFOG fog resolve. #[derive(Resource, Default, PartialEq, Eq)] -pub struct CameraWmoSkybox(pub Option); +pub struct CameraSkybox(pub Option); /// Marks one batch of a built skybox model, tagged with the model path it belongs to (a session can /// walk through more than one skybox building, and the built entities are cached, not rebuilt). #[derive(Component)] -struct WmoSkyboxPart(String); +struct SkyboxPart(String); /// This batch **spins**: every one of its vertices is wholly weighted to one parentless /// rotation-only bone, so the authored motion is a rigid transform about that bone's pivot and needs @@ -125,21 +135,21 @@ struct SkyboxSpin { #[derive(Resource, Default)] struct BuiltSkyboxes(HashSet); -/// Ordering handle: [`CameraWmoSkybox`] is settled for the frame after this set. `crate::sky`'s dome +/// Ordering handle: [`CameraSkybox`] is settled for the frame after this set. `crate::sky`'s dome /// gate hangs off it, because the two backdrops must agree *within* a frame — one reading a stale /// resource is a frame with both drawn or neither. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) struct WmoSkyResolve; +pub(crate) struct SkyboxResolve; /// The WMO-skybox subsystem: resolve which model the camera's room wants, build it on first need, /// then show exactly that one and pin it to the camera. -pub(crate) struct WmoSkyPlugin; +pub(crate) struct SkyboxPlugin; -impl Plugin for WmoSkyPlugin { +impl Plugin for SkyboxPlugin { fn build(&self, app: &mut App) { // No `MaterialPlugin` of its own any more: a skybox batch is a `WowModelMaterial` like every // other model batch, and that plugin is already loaded. - app.init_resource::() + app.init_resource::() .init_resource::() .add_systems( Update, @@ -150,7 +160,7 @@ impl Plugin for WmoSkyPlugin { // move that changes rooms lands a frame late, or not at all on the frame it // matters, and the backdrop flickers between the painted sky and the gradient. .after(crate::wmo_portal::WmoPvsSet) - .in_set(WmoSkyResolve), + .in_set(SkyboxResolve), ) // Camera-anchored placement runs post-propagation off the SAME-frame camera pose — the // slot decision 0504 moved every camera-anchored shell into. @@ -173,11 +183,36 @@ impl Plugin for WmoSkyPlugin { /// placement. The reference draws the painted sky there regardless, because 61 of the 83 groups the /// flood reaches from group 39 do carry the bit. (Verified on the asset: BFS over `Stratholme_B`'s /// MOPR reaches 82 of 83 groups from group 39, 61 of them flagged.) +#[allow(clippy::too_many_arguments)] fn resolve_camera_skybox( instances: Query<&crate::wmo_portal::WmoPortalInstance>, wmos: Res>, - mut want: ResMut, + sampler: Option>, + viewer: Res, + current_map: Option>, + cam: Query<&GlobalTransform, With>, + mut want: ResMut, ) { + // **The ghost sky first — it wins outright.** Slot B is filled at weight 1.0 and that makes the + // reference skip loop A entirely (module header, wow-re §3–4), so a dead player under + // Stratholme's painted sky sees DeathClouds, not the building's. + // + // Resolved from the SAME (map, camera position) seed the atmosphere uses, so the sky and the + // fog it hangs over come from one zone row. The ghost bit is `PLAYER_FLAGS` bit 0x10 — the same + // one `lighting::resolve` reads to select param slot 4 — so the two switch on the same frame. + if viewer.ghost { + let ghost_sky = sampler.as_ref().and_then(|s| { + let pos = bevy_to_wow(cam.single().ok()?.translation()); + let map = current_map.as_ref().map_or(0, |m| m.0); + s.0.ghost_skybox(map, pos) + }); + if let Some(sky) = ghost_sky { + if want.0.as_deref() != Some(sky) { + want.0 = Some(sky.to_owned()); + } + return; + } + } // `min()` rather than "first match": `Query` iteration order is not stable across frames, and a // tie would otherwise alternate two backdrops frame to frame. Five roots qualify — Stratholme_B // and the four Caverns of Time shells — and the tie-break is live code, not a formality: the @@ -215,7 +250,7 @@ fn resolve_camera_skybox( /// Stratholme's gate must not re-decode art. fn build_skybox( mut commands: Commands, - want: Res, + want: Res, mut built: ResMut, world_assets: Option>, mut meshes: ResMut>, @@ -250,12 +285,12 @@ fn build_skybox( let subs = match subs { Ok(subs) if !subs.is_empty() => subs, Ok(_) => { - warn!("WMO skybox '{path}' has no render batches — keeping the gradient dome"); + warn!("skybox '{path}' has no render batches — keeping the gradient dome"); built.0.insert(path.to_string()); return; } Err(e) => { - warn!("WMO skybox '{path}' failed to load, keeping the gradient dome: {e:#}"); + warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}"); built.0.insert(path.to_string()); return; } @@ -301,7 +336,7 @@ fn build_skybox( MeshMaterial3d(material), Transform::default(), Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one - WmoSkyboxPart(path.to_string()), + SkyboxPart(path.to_string()), )) .id(); if let Some(spin) = sole_bone(sub).and_then(|b| spins.get(&b)) { @@ -339,8 +374,8 @@ fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option { /// writer for these entities (the gradient dome's own gate lives in [`crate::sky`], which reads the /// same resource — one authority per entity class, decision 0025). fn apply_skybox_visibility( - want: Res, - mut parts: Query<(&WmoSkyboxPart, &mut Visibility)>, + want: Res, + mut parts: Query<(&SkyboxPart, &mut Visibility)>, ) { for (part, mut vis) in &mut parts { let show = want.0.as_deref() == Some(part.0.as_str()); @@ -393,7 +428,7 @@ fn follow_camera( cam: Query<&GlobalTransform, With>, mut parts: Query< (&mut Transform, &mut GlobalTransform, Option<&SkyboxSpin>), - (With, Without), + (With, Without), >, ) { let Some(cam_gt) = cam.iter().next() else { diff --git a/crates/benilla-world/src/sun/mod.rs b/crates/benilla-world/src/sun/mod.rs index 804164ea..c91e935c 100644 --- a/crates/benilla-world/src/sun/mod.rs +++ b/crates/benilla-world/src/sun/mod.rs @@ -132,13 +132,13 @@ impl Plugin for SunPlugin { .add_systems( Update, apply_celestial_visibility - .after(crate::wmo_sky::WmoSkyResolve) + .after(crate::skybox::SkyboxResolve) .after(crate::liquid::SubmersionVerdict), ); } } -/// Hide the sky pass's own elements while a WMO skybox owns the sky ([`crate::wmo_sky`]) — or while +/// Hide the sky pass's own elements while a WMO skybox owns the sky ([`crate::skybox`]) — or while /// the eye is **submerged**. /// /// `CSky::Render` gates its six draws on **one shared boolean** — `0x6d49cd` sets it, a filled skybox @@ -163,13 +163,13 @@ impl Plugin for SunPlugin { /// own authority (decision 0025) reading these same resources. #[allow(clippy::type_complexity)] fn apply_celestial_visibility( - wmo_skybox: Res, + skybox: Res, underwater: Res, mut suns: Query<(&SunSprite, &mut Visibility), Without>, mut moons: Query<(&MoonSprite, &mut Visibility), Without>, mut stars: Query<&mut Visibility, (With, Without, Without)>, ) { - let suppressed = wmo_skybox.0.is_some() || underwater.0.any(); + let suppressed = skybox.0.is_some() || underwater.0.any(); // `Inherited`, not `Visible`: these sprites hang off the celestial rig and must keep deferring to // it when the skybox stands down, exactly as the cloud dome's gate does. let want = |in_sky_pass: bool| { diff --git a/crates/benilla-world/src/wmo_portal/mod.rs b/crates/benilla-world/src/wmo_portal/mod.rs index d8cc0c93..13923eda 100644 --- a/crates/benilla-world/src/wmo_portal/mod.rs +++ b/crates/benilla-world/src/wmo_portal/mod.rs @@ -174,7 +174,7 @@ impl WmoGroupVis { /// one asymmetric arm: a rider outlives its building by the frame between the placement /// despawning and the cascade reaching it, and one frame of an additive strip — or a torch — /// hanging where a building used to be is a bright artefact, where one frame of a missing one is -/// nothing at all. [`crate::wmo_sky`]'s skybox resolve refuses on the same asymmetry, and 1276 is +/// nothing at all. [`crate::skybox`]'s skybox resolve refuses on the same asymmetry, and 1276 is /// the general rule: an unknown with a testable answer must be tested, but where the error /// directions are lopsided, take the cheap one. pub fn room_admits(room: Option<&WmoGroupVis>, instance: Option<&WmoPortalInstance>) -> bool { diff --git a/crates/benilla-world/src/world_census.rs b/crates/benilla-world/src/world_census.rs index 812bbac4..8dc5632e 100644 --- a/crates/benilla-world/src/world_census.rs +++ b/crates/benilla-world/src/world_census.rs @@ -53,8 +53,8 @@ pub struct WorldCensus<'w, 's> { /// The visibility authority's own pose (see [`CensusReport::pvs_eye`]). cull_probe: Option>, /// Which backdrop is drawing — the gradient dome, or a building's own MOSB sky - /// ([`crate::wmo_sky`]). Optional for the same reason as the portal terms above. - skybox: Option>, + /// ([`crate::skybox`]). Optional for the same reason as the portal terms above. + skybox: Option>, /// The ribbon lane's own verdict — the third effect population, counted nowhere else. ribbons: Option>, mats: Res<'w, Assets>, diff --git a/crates/benilla-world/src/world_plugins.rs b/crates/benilla-world/src/world_plugins.rs index 626af726..96f5875e 100644 --- a/crates/benilla-world/src/world_plugins.rs +++ b/crates/benilla-world/src/world_plugins.rs @@ -9,7 +9,7 @@ //! //! **The order below is load-bearing and is the client's own**, so the two binaries stay diffable //! (it was `worldview::engine`, "the cut line written down", before it was this). Two edges inside -//! it are documented dependencies rather than taste: `WmoSkyPlugin` registers after `SkyPlugin`, +//! it are documented dependencies rather than taste: `SkyboxPlugin` registers after `SkyPlugin`, //! whose dome it stands down, and `PerfPlugin` after the debug panel, whose egui context it //! needs (both instruments, and both the client's to add in that order now). `ArtScopePlugin` before `AssetPlugin` is *not* one — its own comment says so — but it //! costs nothing to keep. @@ -163,7 +163,7 @@ impl PluginGroup for WorldPlugins { .add(crate::sky::SkyPlugin) // WMO skybox: the authored sky a building's `0x40000` group swaps in for that gradient // (Stratholme's burning city) — registered after SkyPlugin, whose dome it stands down. - .add(crate::wmo_sky::WmoSkyPlugin) + .add(crate::skybox::SkyboxPlugin) // Cloud coverage: the reference's procedural field — glare occlusion (occ1) + the // visible layer. .add(crate::clouds::CloudsPlugin)