the ghost world gets its own sky, and a corpse cannot steer

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 20:18:16 +07:00
co-authored by Claude Fable 5
parent 03c70a7035
commit f9e75a79fc
43 changed files with 793 additions and 146 deletions
@@ -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).
+11 -5
View File
@@ -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<bool> {
/// `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<bool> {
static PROBE: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
*PROBE.get_or_init(|| match std::env::var("WOW_GHOST_PROBE").ok()?.trim() {
"1" | "true" => Some(true),
+1 -1
View File
@@ -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>,
+29 -12
View File
@@ -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"
);
}
+2
View File
@@ -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,
+2 -1
View File
@@ -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::<SplineStopped>();
@@ -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<Spline> {
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<Spline> {
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());
+23 -4
View File
@@ -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"
);
}
@@ -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.
{
+70 -15
View File
@@ -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,
);
+20 -9
View File
@@ -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,
);
+30 -3
View File
@@ -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<MouseButton>>,
/// 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<crate::net::Embodied>>,
}
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();
+23 -11
View File
@@ -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;
+21 -6
View File
@@ -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());
@@ -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<ClientCommand>) {
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<ClientCommand>) {
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::<Player>().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::<Player>().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::<Player>().walking = true;
app.update();
assert!(
app.world().resource::<Player>().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
+231 -22
View File
@@ -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.
+8 -4
View File
@@ -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;
@@ -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),
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -77,7 +77,7 @@ pub struct WmoModel {
/// ROUND 5 (`0x69de20`).
pub fogs: Vec<WmoFog>,
/// 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.
+73 -4
View File
@@ -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<u32, [f32; 4]>,
/// `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<u32, u32>,
/// `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<u32, String>,
}
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
@@ -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
@@ -173,7 +173,7 @@ pub fn parse_wmo_root(bytes: &[u8]) -> Result<WmoRoot> {
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<WmoRoot> {
/// 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<String> {
fn parse_skybox(bytes: &[u8]) -> Option<String> {
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();
+46 -2
View File
@@ -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");
}
@@ -490,6 +490,7 @@ impl World {
stop,
duration_ms,
flying,
..
} => {
let mine = *guid == self.self_guid;
if mine {
+3
View File
@@ -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 {
@@ -636,6 +636,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
stop,
duration_ms,
flying,
run_mode,
} => vec![SessionEvent::MonsterMove {
guid,
start: v3(start),
@@ -645,6 +646,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
stop,
duration_ms,
flying,
run_mode,
}],
ServerPacket::PlayerMove {
guid,
@@ -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<ServerPacket> {
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<ServerPacket> {
stop: false,
duration_ms,
flying,
run_mode,
}
})
}
@@ -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`
@@ -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 {
+3 -3
View File
@@ -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<DebugState>,
wmo_skybox: Res<crate::wmo_sky::CameraWmoSkybox>,
skybox: Res<crate::skybox::CameraSkybox>,
underwater: Res<crate::liquid::Underwater>,
mut dome: Query<&mut Visibility, With<CloudDome>>,
) {
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
+1 -1
View File
@@ -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),
),
)
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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");
+5 -5
View File
@@ -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<DebugState>,
wmo_skybox: Res<crate::wmo_sky::CameraWmoSkybox>,
skybox: Res<crate::skybox::CameraSkybox>,
underwater: Res<crate::liquid::Underwater>,
mut dome: Query<&mut Visibility, With<Sky>>,
) {
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
+1 -1
View File
@@ -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
@@ -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<String>);
pub struct CameraSkybox(pub Option<String>);
/// 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<String>);
/// 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::<CameraWmoSkybox>()
app.init_resource::<CameraSkybox>()
.init_resource::<BuiltSkyboxes>()
.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<Assets<WmoModel>>,
mut want: ResMut<CameraWmoSkybox>,
sampler: Option<Res<crate::lighting::LightSampler>>,
viewer: Res<crate::view::Viewer>,
current_map: Option<Res<crate::world_map::CurrentMap>>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
mut want: ResMut<CameraSkybox>,
) {
// **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<CameraWmoSkybox>,
want: Res<CameraSkybox>,
mut built: ResMut<BuiltSkyboxes>,
world_assets: Option<ResMut<WorldAssets>>,
mut meshes: ResMut<Assets<Mesh>>,
@@ -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<u16> {
/// 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<CameraWmoSkybox>,
mut parts: Query<(&WmoSkyboxPart, &mut Visibility)>,
want: Res<CameraSkybox>,
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<WorldCamera>>,
mut parts: Query<
(&mut Transform, &mut GlobalTransform, Option<&SkyboxSpin>),
(With<WmoSkyboxPart>, Without<WorldCamera>),
(With<SkyboxPart>, Without<WorldCamera>),
>,
) {
let Some(cam_gt) = cam.iter().next() else {
+4 -4
View File
@@ -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<crate::wmo_sky::CameraWmoSkybox>,
skybox: Res<crate::skybox::CameraSkybox>,
underwater: Res<crate::liquid::Underwater>,
mut suns: Query<(&SunSprite, &mut Visibility), Without<MoonSprite>>,
mut moons: Query<(&MoonSprite, &mut Visibility), Without<SunSprite>>,
mut stars: Query<&mut Visibility, (With<StarDome>, Without<SunSprite>, Without<MoonSprite>)>,
) {
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| {
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -53,8 +53,8 @@ pub struct WorldCensus<'w, 's> {
/// The visibility authority's own pose (see [`CensusReport::pvs_eye`]).
cull_probe: Option<Res<'w, crate::wmo_portal::WmoCullProbe>>,
/// 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<Res<'w, crate::wmo_sky::CameraWmoSkybox>>,
/// ([`crate::skybox`]). Optional for the same reason as the portal terms above.
skybox: Option<Res<'w, crate::skybox::CameraSkybox>>,
/// The ribbon lane's own verdict — the third effect population, counted nowhere else.
ribbons: Option<Res<'w, crate::ribbons::RibbonVerdict>>,
mats: Res<'w, Assets<WowModelMaterial>>,
+2 -2
View File
@@ -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)