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:
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -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| {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user