a corpse is not a shop, and Darnassus draws through its doorways
- mounted, over a dead wolf, a right-click answered "That merchant doesn't like you." three times: the click was sending `CMSG_LIST_INVENTORY` — a vendor packet — at the corpse, because the unit block was an if/else-if chain whose LAST arm was the NPC-service dispatch, and the mounted-loot fall-through gave it a newly reachable state — dead target, no leg taken, so the click fell into the arm that means "live friendly service NPC", which dispatched on the CURSOR, and a lootable corpse wears the same pouch a vendor does; the reference splits on death first and never rejoins (`0x60bf80`), so the fork is now `Attack / Dead / Service`, matched exhaustively, with a test over all sixteen combinations of its inputs - Darnassus draws through its doorways: the reference's Pass 2 ends in the portal RECURSION (`0x6b3d39 call 0x6b41c0`), not a mark — a doorway admits everything the walk reaches through it, and in a city whose outdoor areas are stitched together by portals that is most of it (standing in a shop, 36 groups the marking pass had culled); the narrowness reject turns out to be the open-world walk's entry gate alone, not the shared volume builder (applying it to both had blanked the containing building's shell for every window in the [0.001, 0.02) NDC band), and the depth cap is `[0x86b6b8]` = 10, not a guessed 64; `WOW_PIN_SITE=darnassus` replays the report offline in 0.2 s - the tab clamp was reading a return the reference's `PanelTemplates_TabResize` does not have, so since the panel kit came off the chain the rule that a tab never grows past its window's drawn right edge raised `attempt to compare number with nil` once per tab per width change on all seven windows built from `CharacterFrameTabButtonTemplate`; it reads the fit back off the art the reference's own verb just wrote (a Texture's width is its authored size, no resolved rect consulted) - footfalls have one gate, one origin, one kernel: the 50 yd camera-distance cut (`0x5fc00a`) is measured from the CAMERA EYE, unconditional (no self exemption — an earlier slice reused a predicate it had just derived for the ring pools, and no RE note ever recorded one), rounded once with a strict compare — and it sits above the decal, the camera shake AND the 25 yd spray, not copied per consumer; the shake's second suspend arm is the shared fly-or-swim spline bit read off the FOLLOWED unit, so under Mind Vision or Eye of Kilrogg the shake frames on the right body and suspends on the right swim state Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -2437,16 +2437,36 @@
|
||||
end
|
||||
|
||||
function BenillaTabButton_OnUpdate(tab)
|
||||
local text = getglobal(tab:GetName() .. "Text")
|
||||
local name = tab:GetName()
|
||||
local text = getglobal(name .. "Text")
|
||||
local w = (text and text:GetStringWidth()) or 0
|
||||
if w <= 0 then return end
|
||||
local pad = tab.benillaTabPadding or 0
|
||||
local room = BenillaTab_Room(tab)
|
||||
if tab.benillaFitWidth == w and tab.benillaFitRoom == room then return end
|
||||
tab.benillaFitWidth, tab.benillaFitRoom = w, room
|
||||
local fitted = PanelTemplates_TabResize(pad, tab, nil, tab.benillaTabMaxWidth)
|
||||
if room and fitted > room then
|
||||
PanelTemplates_TabResize(pad, tab, room)
|
||||
PanelTemplates_TabResize(pad, tab, nil, tab.benillaTabMaxWidth)
|
||||
-- THE CLAMP READS THE FIT BACK OFF THE ART, not off a return value and not off the
|
||||
-- button. `PanelTemplates_TabResize` is the REFERENCE's now (1837 put
|
||||
-- `UIPanelTemplates.lua` on the chain below this file, so its definition wins) and it
|
||||
-- returns nothing; our own copy's benilla-only `return tabWidth` had already stopped
|
||||
-- running, which is what took 1002's clamp off the air silently. `tab:GetWidth()`
|
||||
-- cannot serve either — it reads the last RESOLVED rect, this frame's stale input
|
||||
-- inside a settle, which is the whole reason 1002 added that return.
|
||||
--
|
||||
-- `$parentMiddle` can. `PanelTemplates_TabResize` sets it on its way out, a Texture's
|
||||
-- width is its AUTHORED size, and a region's GetWidth reports the authored value
|
||||
-- without consulting a resolved rect (region::virtual_span) — so it is the number the
|
||||
-- verb just computed, same tick. The tab is that plus its two end caps, which is the
|
||||
-- reference's own `tabWidth = width + sideWidths`.
|
||||
if room then
|
||||
local middle = getglobal(name .. "Middle")
|
||||
local left = getglobal(name .. "Left")
|
||||
local fitted = (middle and middle:GetWidth() or 0)
|
||||
+ 2 * (left and left:GetWidth() or 0)
|
||||
if fitted > room then
|
||||
PanelTemplates_TabResize(pad, tab, room)
|
||||
end
|
||||
end
|
||||
-- No highlight sizing here on purpose — BOTH tab templates anchor their highlight to
|
||||
-- their own two side edges, so it IS the tab by construction (decision 1004).
|
||||
|
||||
@@ -90,11 +90,12 @@
|
||||
//!
|
||||
//! **Lifetime.** A record holds a *snapshot* position and no reference to its source, so a shake
|
||||
//! fully outlives the creature that spawned it. The whole evaluation is skipped — offset zero,
|
||||
//! nothing expiring — while the followed unit is **swimming**.
|
||||
//! nothing expiring — while the **followed unit** (the far-sight subject when there is one, our own
|
||||
//! body otherwise) is **swimming** or riding a **fly-or-swim spline**: the reference's two suspend
|
||||
//! gates, both of which jump the same block.
|
||||
//!
|
||||
//! Not modelled, deliberately: the reference's second wholesale free on `SetTarget(nullptr)`, and
|
||||
//! its destructor's unlink-without-free (a shipped leak — our `Vec` cannot reproduce it). The
|
||||
//! flying-spline (taxi) arm of the skip is noted in [`CameraShakes::evaluate`].
|
||||
//! its destructor's unlink-without-free (a shipped leak — our `Vec` cannot reproduce it).
|
||||
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
@@ -103,10 +104,11 @@ use bevy::prelude::*;
|
||||
use benilla_formats::{CameraShake, CameraShakeCatalog, SpellShakeGroup};
|
||||
|
||||
use crate::creature_anim::{
|
||||
footfall_side, move_flags, AnimSoundEvent, MovementState, SpellKitShake,
|
||||
footfall_culls, footfall_side, move_flags, AnimSoundEvent, MovementState, SpellKitShake,
|
||||
};
|
||||
use crate::entities::{BoneAttach, Creatures};
|
||||
use crate::net::{Embodied, NetEntity, ObjectStore};
|
||||
use crate::net::{Embodied, NetEntity, ObjectStore, Spline};
|
||||
use crate::player::ViewSubject;
|
||||
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
||||
use benilla_protocol::EntityKind;
|
||||
use benilla_world::schedule::WorldStage;
|
||||
@@ -119,9 +121,6 @@ const FULL_DISTANCE_SQ: f32 = 81.0;
|
||||
/// The falloff's half-life base and its per-9-yd exponent divisor.
|
||||
const FALLOFF_BASE: f32 = 0.7;
|
||||
const FALLOFF_SPAN: f32 = 9.0;
|
||||
/// The footstep emitter's own radius, camera→footplant (`0x5fc00a`, `2500 yd²` = 50 yd). **The
|
||||
/// camera, not the player** — the same correction wow-re applied to `dist2-gate.md` this round.
|
||||
const EMIT_DISTANCE_SQ: f32 = 2500.0;
|
||||
/// `CameraShakes.Amplitude` is authored in inches; the client scales it at spawn (`0x511d78`).
|
||||
const INCHES_TO_YARDS: f32 = 1.0 / 36.0;
|
||||
|
||||
@@ -171,13 +170,15 @@ impl CameraShakes {
|
||||
|
||||
/// Retire what has expired and compose this frame's offset.
|
||||
///
|
||||
/// `swimming` is the reference's skip (`0x50ea87`/`0x50ea8b`): while the followed unit swims,
|
||||
/// the whole block is bypassed — the offset is zero **and nothing expires**, so a shake
|
||||
/// resumes mid-flight on surfacing. The sibling arm of that skip, a flying (taxi) spline, is
|
||||
/// not modelled here: we have no flying-spline predicate at the camera seat, and the case is a
|
||||
/// heavy creature's footfall within 50 yd of a taxi path. Recorded rather than guessed.
|
||||
fn evaluate(&mut self, eye: Vec3, facing_yaw: f32, now: f32, swimming: bool) -> Vec3 {
|
||||
if swimming {
|
||||
/// `suspended` is the reference's skip — **two** gates that both jump the same block
|
||||
/// (`0x50ea87` / `0x50ea8b` → `0x50eb01`, wow-re `camera-shake-law.md` §6): the followed unit
|
||||
/// is **swimming** (`[[unit+0x118]+0x40] & 0x200000`), or it is riding a **flying server
|
||||
/// spline** (`MI = [[unit+0x118]+0xa4]` non-null, not done, Flying bit set) — a taxi flight.
|
||||
/// The accumulator is zeroed *before* both gates, so a suspended frame yields the offset zero
|
||||
/// **and expires nothing**: a shake resumes mid-flight on surfacing or landing, rather than
|
||||
/// having quietly aged out while the camera was not reading it.
|
||||
fn evaluate(&mut self, eye: Vec3, facing_yaw: f32, now: f32, suspended: bool) -> Vec3 {
|
||||
if suspended {
|
||||
return Vec3::ZERO;
|
||||
}
|
||||
// Retire on the *unattenuated* clock: distance decides contribution, never lifetime.
|
||||
@@ -386,7 +387,7 @@ fn fire_shakes(
|
||||
// derives it. No marker/joint = the unit origin.
|
||||
let foot = event_point(attach, pose, &joints, &ev.ident)
|
||||
.unwrap_or_else(|| transform.translation());
|
||||
if eye.distance_squared(foot) > EMIT_DISTANCE_SQ {
|
||||
if footfall_culls(eye, foot) {
|
||||
continue;
|
||||
}
|
||||
live.add(*row, foot, now);
|
||||
@@ -461,9 +462,35 @@ fn fire_kit_shakes(
|
||||
}
|
||||
}
|
||||
|
||||
/// The applier's read of the followed unit: its facing (the shake's body frame) and whether it is
|
||||
/// swimming (the reference's skip).
|
||||
type FollowedUnit = (&'static Transform, Option<&'static MovementState>);
|
||||
/// The applier's read of the followed unit: its facing (the shake's body frame), its move flags
|
||||
/// (the swim half of the reference's skip) and its live path (the spline half).
|
||||
type FollowedUnit = (
|
||||
&'static Transform,
|
||||
Option<&'static MovementState>,
|
||||
Option<&'static Spline>,
|
||||
);
|
||||
|
||||
/// Is the whole shake block skipped this frame? The reference's two suspend gates, OR'd — both
|
||||
/// `jmp` past the same block (`0x50ea87` / `0x50ea8b` → `0x50eb01`), on the **followed unit**.
|
||||
///
|
||||
/// **Swimming** is its move flags (`[[unit+0x118]+0x40] & 0x200000`).
|
||||
///
|
||||
/// **The spline gate** is read through to the LIVE path, exactly as the anim selector's `unify`
|
||||
/// does ([`crate::creature_anim`], RF-0057 `0x5fd19c`) — the reference re-reads
|
||||
/// `[[unit+0x118]+0xa4]` every frame rather than trusting a stamped flag, and our stored
|
||||
/// [`MovementState::flying`] is the selector's own derived value, left `false` on the controller's
|
||||
/// component. The [`Spline`]'s *presence* is the reference's "descriptor non-null **and** not done"
|
||||
/// (`sample_splines` removes the component the frame the path completes — the `& 0x4` DONE term),
|
||||
/// and `!grounded` is spline flag `0x200`.
|
||||
///
|
||||
/// **`0x200` is the shared fly-*or*-swim spline bit, not "taxi"** (wow-re `swim-transition.md`) —
|
||||
/// so this arm covers a swimming server path as well as a flight, which is the same reason it
|
||||
/// sits beside a swim gate at all. Naming it "the taxi arm" (as 1540 did, and as this was first
|
||||
/// built) over-specifies what the bit means. A **ground** spline — Charge, knockback, a fleeing
|
||||
/// walk, all of which install the same component — does not suspend anything.
|
||||
fn suspended(mv: Option<&MovementState>, spline: Option<&Spline>) -> bool {
|
||||
mv.is_some_and(|m| m.flags & move_flags::SWIMMING != 0) || spline.is_some_and(|s| !s.grounded)
|
||||
}
|
||||
|
||||
/// Add this frame's shake to the camera's seated eye.
|
||||
///
|
||||
@@ -476,22 +503,40 @@ type FollowedUnit = (&'static Transform, Option<&'static MovementState>);
|
||||
/// the base pose every frame, so the transform this reads is the un-shaken eye rather than last
|
||||
/// frame's shaken one. A zero offset writes nothing at all, preserving the camera's bit-equality
|
||||
/// no-op gate (decision 1362) — a still camera stays bit-stable and its propagation stays quiet.
|
||||
///
|
||||
/// **The body frame and both suspend gates come from the FOLLOWED unit** — `[cam+0x88/0x8c]` in
|
||||
/// the reference, never `0x468550` (the active player). Ordinarily they are the same object; under
|
||||
/// far sight (Mind Vision, Eye of Kilrogg, and possession, which rides the same field) they are
|
||||
/// not, and the camera orbits the subject while our body walks on somewhere else. Reading our own
|
||||
/// body there would frame the shake on the wrong facing and suspend it on the wrong unit's swim
|
||||
/// state. [`ViewSubject`] already resolves exactly this object for the rig.
|
||||
///
|
||||
/// The reference additionally requires the followed object to be **TYPEMASK_UNIT**
|
||||
/// (`0x50e90d`–`0x50e92c`) before reading its `CMovement` at all. That falls out here: the
|
||||
/// fallback is our own body, and a resolved far-sight subject with no [`MovementState`] and no
|
||||
/// [`Spline`] — a GameObject or a DynamicObject — reads as "not suspended", which is what a
|
||||
/// unit-only gate on a non-unit yields.
|
||||
pub(crate) fn apply_camera_shake(
|
||||
mut live: ResMut<CameraShakes>,
|
||||
time: Res<Time>,
|
||||
mut camera: Query<&mut Transform, With<WorldCamera>>,
|
||||
player: Query<FollowedUnit, (With<Embodied>, Without<WorldCamera>)>,
|
||||
subject: Res<ViewSubject>,
|
||||
units: Query<FollowedUnit, Without<WorldCamera>>,
|
||||
body: Query<Entity, (With<Embodied>, Without<WorldCamera>)>,
|
||||
) {
|
||||
let Ok(mut cam) = camera.single_mut() else {
|
||||
return;
|
||||
};
|
||||
let (yaw, swimming) = player.single().map_or((0.0, false), |(t, mv)| {
|
||||
(
|
||||
t.rotation.to_euler(EulerRot::YXZ).0,
|
||||
mv.is_some_and(|m| m.flags & move_flags::SWIMMING != 0),
|
||||
)
|
||||
});
|
||||
let offset = live.evaluate(cam.translation, yaw, time.elapsed_secs(), swimming);
|
||||
let followed = subject
|
||||
.remote
|
||||
.map(|r| r.entity)
|
||||
.or_else(|| body.single().ok());
|
||||
let (yaw, suspended) = followed
|
||||
.and_then(|e| units.get(e).ok())
|
||||
.map_or((0.0, false), |(t, mv, spline)| {
|
||||
(t.rotation.to_euler(EulerRot::YXZ).0, suspended(mv, spline))
|
||||
});
|
||||
let offset = live.evaluate(cam.translation, yaw, time.elapsed_secs(), suspended);
|
||||
if offset != Vec3::ZERO {
|
||||
cam.translation += offset;
|
||||
}
|
||||
@@ -798,8 +843,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Swimming bypasses the whole block: zero offset, and **nothing expires** — the shake resumes
|
||||
/// on surfacing.
|
||||
/// A suspended frame bypasses the whole block: zero offset, and **nothing expires** — the
|
||||
/// shake resumes on surfacing (or on landing off a taxi).
|
||||
#[test]
|
||||
fn swimming_freezes_rather_than_retires() {
|
||||
let mut s = at(FOOTSTEP_1, Vec3::ZERO);
|
||||
@@ -808,4 +853,33 @@ mod tests {
|
||||
assert_eq!(s.evaluate(Vec3::ZERO, 0.0, 10.0, false), Vec3::ZERO);
|
||||
assert!(s.live.is_empty(), "and it retires the moment we surface");
|
||||
}
|
||||
|
||||
/// The two suspend gates are independent and either one alone suspends — and a **ground**
|
||||
/// spline (a charge, a knockback, a fleeing walk) is not one of them: only spline flag
|
||||
/// `0x200`, the shared fly-or-swim bit, is.
|
||||
#[test]
|
||||
fn either_gate_suspends_and_a_ground_spline_does_not() {
|
||||
let swimming = MovementState {
|
||||
flags: move_flags::SWIMMING,
|
||||
..default()
|
||||
};
|
||||
let path = |grounded: bool| Spline {
|
||||
points: vec![[0.0; 3], [1.0, 0.0, 0.0]],
|
||||
start: std::time::Instant::now(),
|
||||
duration: std::time::Duration::from_secs(1),
|
||||
id: 1,
|
||||
grounded,
|
||||
run_mode: true,
|
||||
};
|
||||
|
||||
assert!(!suspended(None, None), "walking about: the shake plays");
|
||||
assert!(!suspended(Some(&default()), None));
|
||||
assert!(suspended(Some(&swimming), None), "swimming");
|
||||
assert!(suspended(None, Some(&path(false))), "a fly-or-swim path");
|
||||
assert!(
|
||||
!suspended(None, Some(&path(true))),
|
||||
"a ground spline still shakes — Charge is not a taxi"
|
||||
);
|
||||
assert!(suspended(Some(&swimming), Some(&path(false))), "both");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,7 +695,8 @@ use driver::{drive_animations, drive_hand_grip};
|
||||
mod events;
|
||||
use events::fire_anim_events;
|
||||
pub(crate) use events::{
|
||||
advance_track, footfall_side, is_footstep_sound, scan_events, AnimSoundEvent, TrackMemory,
|
||||
advance_track, footfall_culls, footfall_side, is_footstep_sound, scan_events, AnimSoundEvent,
|
||||
TrackMemory,
|
||||
};
|
||||
|
||||
/// The `$BTH` breath puffs — a unit's visible cold vapour in a snow zone (B233, decision 1149).
|
||||
|
||||
@@ -48,6 +48,54 @@ pub(crate) fn is_footstep_sound(ident: &[u8; 4]) -> bool {
|
||||
ident == b"$FSD"
|
||||
}
|
||||
|
||||
/// **The footfall handler's own radius**, `2500 yd²` (50 yd) — `[0x80c5b4]`, read at `0x5fc00a`
|
||||
/// inside `0x5fbf70`. This gate stands *above* everything that handler goes on to spawn: the
|
||||
/// footprint decal ([`crate::footprints`]), the footstep camera shake
|
||||
/// ([`crate::camera_shake`]) and the 25 yd spray branch below it all live under it, so it is one
|
||||
/// constant and not one per consumer — they held a copy each until they disagreed about its
|
||||
/// origin (decision 1856).
|
||||
///
|
||||
/// **Measured from the CAMERA EYE, not the local player.** `FUN_004818f0()` returns
|
||||
/// `[[0xb4b2bc]+0x65b8]` — the *active camera* — and its `+0x8/+0xc/+0x10` is the eye; wow-re
|
||||
/// corrected that mislabel in `dist2-gate.md` (2026-08-22, with the camera-dtor proof).
|
||||
///
|
||||
/// **Unconditional** — the local player's own feet are gated like anyone else's. The reference's
|
||||
/// `GUID == local player` compare (`0x5fc042`–`0x5fc06b`) is the decal call's fifth argument, the
|
||||
/// **ring-pool select**, not an early bypass.
|
||||
const FOOTFALL_RADIUS_SQ: f32 = 2500.0;
|
||||
|
||||
/// **Is this footfall out of range?** The handler's distance gate, whole: the kernel, the
|
||||
/// threshold and the compare, because all three are load-bearing and splitting them is what
|
||||
/// let two consumers disagree.
|
||||
///
|
||||
/// The kernel rounds **once, at the end**. The reference is x87: it loads the two `C3Vector`s as f32, and keeps every difference, square
|
||||
/// and sum in 80-bit extended, then `fstp`s the sum to a **single f32** (`0x5fbffe` store,
|
||||
/// `0x5fc007` reload) before the compare. A plain `Vec3::distance_squared` rounds six times
|
||||
/// instead of once and can land on the other side of the branch for a footfall sitting on the
|
||||
/// 50 yd edge.
|
||||
///
|
||||
/// f64 reproduces it exactly and needs no x87: an `f32 − f32` widened first is exact, a 24×24→48
|
||||
/// bit product is exact, and the sum of three ≤50-bit values is exact — which also makes the
|
||||
/// reference's `(dz² + dy²) + dx²` summation order unobservable here, so this does not pretend to
|
||||
/// map its axis names onto ours. Round once on the way out and the f32 handed to the compare is
|
||||
/// bit-identical (wow-re `dist2-gate.md`, the shared kernel + its `fstp`-round-trip exception
|
||||
/// list; `footfall-camera-distance-gate.md` for this call site).
|
||||
///
|
||||
/// The compare is **strict** — bail iff `> FOOTFALL_RADIUS_SQ`, so exactly 50 yd passes, and so
|
||||
/// does a NaN: `test ah,0x41; je` reads ZF and takes the unordered result as "keep", which Rust's
|
||||
/// `>` reproduces for free. It lives here rather than at the call sites so the strictness and the
|
||||
/// NaN edge are stated once instead of re-spelled per consumer.
|
||||
pub(crate) fn footfall_culls(a: Vec3, b: Vec3) -> bool {
|
||||
footfall_dist2(a, b) > FOOTFALL_RADIUS_SQ
|
||||
}
|
||||
|
||||
/// The gate's `dist²`, split out so the tests can see the number the compare is handed.
|
||||
fn footfall_dist2(a: Vec3, b: Vec3) -> f32 {
|
||||
let d = |p: f32, q: f32| p as f64 - q as f64;
|
||||
let (dx, dy, dz) = (d(a.x, b.x), d(a.y, b.y), d(a.z, b.z));
|
||||
(dx * dx + dy * dy + dz * dz) as f32
|
||||
}
|
||||
|
||||
/// The **visual** channel: the foot side a per-foot plant tag names (`$FL0` → `L`), or `None` for
|
||||
/// every other tag — the trigger for footfall-driven *visuals* ([`crate::footprints`]). The ten
|
||||
/// families the dispatcher tables, each with its `0/1/2/3` variants: `$FL/$FR` (front),
|
||||
@@ -277,6 +325,59 @@ pub(crate) fn scan_events(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The gate's edge behaviour, which is the whole reason the kernel is not
|
||||
/// `Vec3::distance_squared`: exactly 50 yd **passes** (the compare is strict), a NaN passes
|
||||
/// with it, and the sum is rounded to f32 exactly once.
|
||||
#[test]
|
||||
fn the_footfall_gate_edge_and_nan_both_pass() {
|
||||
let at = |d: f32| Vec3::new(d, 0.0, 0.0);
|
||||
assert_eq!(
|
||||
footfall_dist2(Vec3::ZERO, at(50.0)),
|
||||
FOOTFALL_RADIUS_SQ,
|
||||
"50 yd is exactly the threshold"
|
||||
);
|
||||
assert!(
|
||||
!footfall_culls(Vec3::ZERO, at(50.0)),
|
||||
"and the edge is kept"
|
||||
);
|
||||
assert!(footfall_culls(Vec3::ZERO, at(50.001)));
|
||||
assert!(
|
||||
!footfall_culls(Vec3::ZERO, Vec3::splat(f32::NAN)),
|
||||
"an unordered compare keeps, exactly as `test ah,0x41; je` does"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Rounding once is not cosmetic** — this pair flips the branch.
|
||||
///
|
||||
/// Two points 50 yd apart out at the map's edge, where the coordinates are large enough that
|
||||
/// f32 differences and squares stop being exact. Rounding six times (each difference, each
|
||||
/// square, each partial sum — what a plain f32 `distance_squared` does) lands on **exactly**
|
||||
/// `2500.0`, which the strict compare *keeps*; the reference's single rounding lands one ulp
|
||||
/// above, which it *culls*. Same two points, opposite answers, and ours has to be the second.
|
||||
///
|
||||
/// The literals are the shortest decimals that round-trip to the intended f32s.
|
||||
#[test]
|
||||
fn a_six_rounding_kernel_lands_on_the_wrong_side_of_the_branch() {
|
||||
let a = Vec3::new(-6157.2476, 16978.16, -14441.062);
|
||||
let b = Vec3::new(-6180.6636, 16935.822, -14453.679);
|
||||
|
||||
let naive = {
|
||||
let d = a - b;
|
||||
d.x * d.x + d.y * d.y + d.z * d.z
|
||||
};
|
||||
assert_eq!(
|
||||
naive, FOOTFALL_RADIUS_SQ,
|
||||
"six roundings land exactly on the threshold, which the strict compare would keep"
|
||||
);
|
||||
|
||||
let ours = footfall_dist2(a, b);
|
||||
assert!(ours > naive, "one rounding lands an ulp above");
|
||||
assert!(
|
||||
footfall_culls(a, b),
|
||||
"so the reference culls this footfall, and so do we"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two channels are disjoint (decision 1080): `$FSD` is the whole sound channel, the
|
||||
/// per-foot side tags the whole visual one. HumanMale's Walk keys one of each family per
|
||||
/// footfall — routing both to sound is exactly the doubled step rate.
|
||||
|
||||
@@ -29,11 +29,15 @@
|
||||
//! - **Caps**: ring pools of **64 local-player + 512 everyone-else** slots, unconditional
|
||||
//! rotation (ring-select = GUID == local player, `0xca05f0`).
|
||||
//! - **Suppressions**: hover (`MOVEFLAG 0x4000_0000`) · stealth (`BYTES_1` byte 3 bit 0x2 —
|
||||
//! NOT death) · player ghost (`PLAYER_FLAGS & 0x10`) · farther than **50 yd** from the local
|
||||
//! player · the terrain flag / printless id / no ground triangles. Water does NOT suppress
|
||||
//! the decal (only the spray branch wades). The reference's `showfootprints` cvar is the
|
||||
//! master toggle (default on) — we are always-on until a settings page wires the knob (the
|
||||
//! cvar-policy line, like the blob shadow's `shadowLOD`).
|
||||
//! NOT death) · player ghost (`PLAYER_FLAGS & 0x10`) · farther than **50 yd** from the
|
||||
//! **camera eye** ([`footfall_culls`] — the handler's own gate, shared with the footstep
|
||||
//! camera shake, and applied to the local player's feet like anyone else's; decision 1856) ·
|
||||
//! the terrain flag / printless id / no ground triangles. Water does NOT suppress
|
||||
//! the decal (only the spray branch wades). The reference's `showfootprints` cvar (default on)
|
||||
//! is the **decal's** toggle and only the decal's: `0x5fc023` skips the decal block alone, so
|
||||
//! the footstep camera shake and the spray branch both still run with prints switched off. We
|
||||
//! are always-on until a settings page wires the knob (the cvar-policy line, like the blob
|
||||
//! shadow's `shadowLOD`).
|
||||
//!
|
||||
//! Draw state per the RE: src-alpha blend, depth-write off, unlit, white vertex RGB with the
|
||||
//! fade in vertex alpha — the ink darkness lives in the texture. Still open there (none
|
||||
@@ -47,7 +51,9 @@ use std::collections::VecDeque;
|
||||
use bevy::platform::collections::HashMap;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::creature_anim::{footfall_side, move_flags, AnimSoundEvent, MovementState};
|
||||
use crate::creature_anim::{
|
||||
footfall_culls, footfall_side, move_flags, AnimSoundEvent, MovementState,
|
||||
};
|
||||
use crate::entities::{BoneAttach, Creatures};
|
||||
use crate::net::{Embodied, NetEntity, ObjectStore};
|
||||
use crate::sound::footsteps::Footsteps;
|
||||
@@ -65,8 +71,6 @@ const LIFETIME: f32 = 6.0;
|
||||
const OWN_CAP: usize = 64;
|
||||
/// Everyone else shares 512 slots (the table's tail), same rotation.
|
||||
const SHARED_CAP: usize = 512;
|
||||
/// Prints farther than this from the local player never spawn (the reference's 2500 yd² gate).
|
||||
const MAX_DISTANCE_SQ: f32 = 2500.0;
|
||||
/// Vertical reach of the projection slab about the planted foot: enough to catch the ground
|
||||
/// through a slightly-lifted foot bone and drape a step edge, small enough not to paint a
|
||||
/// terrace below (the blob shadow's slab is the model box; a print has no box to read).
|
||||
@@ -171,7 +175,7 @@ fn spawn_footprints(
|
||||
// rider's SCALE_X — RE-corrected), and the state gates all read the root.
|
||||
parents: Query<&ChildOf>,
|
||||
roots: Query<RootState>,
|
||||
self_pos: Query<&Transform, With<Embodied>>,
|
||||
camera: Query<&GlobalTransform, With<WorldCamera>>,
|
||||
joints: Query<&GlobalTransform>,
|
||||
footsteps: Option<Res<Footsteps>>,
|
||||
creatures: Option<Res<Creatures>>,
|
||||
@@ -186,6 +190,11 @@ fn spawn_footprints(
|
||||
let (Some(footsteps), Some(creatures), Some(ink)) = (footsteps, creatures, ink) else {
|
||||
return;
|
||||
};
|
||||
// The distance gate's origin — the same read `camera_shake::fire_shakes` makes for the same
|
||||
// gate. No world camera, no world to print into.
|
||||
let Ok(eye) = camera.single().map(|t| t.translation()) else {
|
||||
return;
|
||||
};
|
||||
let now = time.elapsed_secs();
|
||||
for ev in events.read() {
|
||||
// The VISUAL footfall channel: the per-foot side tags only (`$FSD` is the sound handler's,
|
||||
@@ -231,12 +240,9 @@ fn spawn_footprints(
|
||||
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
|
||||
})
|
||||
.unwrap_or_else(|| transform.translation());
|
||||
// The reference's 50-yd radius about the local player (2500 yd², self always passes).
|
||||
if !is_self
|
||||
&& self_pos
|
||||
.single()
|
||||
.is_ok_and(|p| p.translation.distance_squared(foot) > MAX_DISTANCE_SQ)
|
||||
{
|
||||
// The handler's own 50 yd radius about the CAMERA EYE — every unit's feet, the
|
||||
// player's included ([`footfall_culls`]).
|
||||
if footfall_culls(eye, foot) {
|
||||
continue;
|
||||
}
|
||||
// The surface gate, on the SAME terrain type the footstep sound used: the reference
|
||||
|
||||
@@ -132,6 +132,9 @@ pub(crate) use state::{
|
||||
/// in-liquid-but-not-swimming state and the two cannot be different numbers. Read by the creature
|
||||
/// swim marker and the footstep splash slot, which have no `Player` of their own.
|
||||
pub(crate) use swim::{may_swim, swim_enter_depth};
|
||||
/// The camera's followed unit when that is not our own body — read by [`crate::camera_shake`] for
|
||||
/// the shake's body frame and its two suspend gates, which the reference takes off `[cam+0x88]`.
|
||||
pub(crate) use view_subject::ViewSubject;
|
||||
|
||||
/// **`UNIT_FLAG_STUNNED`** — the `UNIT_FIELD_FLAGS` bit that freezes a character's *turning*
|
||||
/// (decision 0872). Not a movement flag and not an aura: the reference reads it straight off the
|
||||
|
||||
@@ -48,6 +48,13 @@ pub(crate) struct ViewSubject {
|
||||
/// A far-sight subject's pose, in the two forms the camera rig asks for.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct RemoteView {
|
||||
/// The subject itself. The rig never needs it — it has the resolved pose — but the systems
|
||||
/// that read the **followed unit's own state** do: the reference's camera driver takes the
|
||||
/// shake's body frame and both of its suspend gates off `[cam+0x88/0x8c]`, the followed unit,
|
||||
/// never off `0x468550` (the active player). Under Mind Vision those are different objects.
|
||||
/// Carrying the entity does not reopen the query conflict this resolver exists to avoid —
|
||||
/// that constraint is the *controller's*, and the controller reads the pose.
|
||||
pub(crate) entity: Entity,
|
||||
/// The subject's feet, world space — what the rig orbits.
|
||||
pub(crate) feet: Vec3,
|
||||
/// Its framing pivot height above those feet (attachment-17 derived, scaled), the same
|
||||
@@ -97,8 +104,9 @@ pub(super) fn publish_view_subject(
|
||||
.and_then(|store| store.0.player_farsight());
|
||||
subject.remote = anchor
|
||||
.and_then(|guid| index.0.get(&guid).copied())
|
||||
.and_then(|entity| poses.get(entity).ok())
|
||||
.map(|(t, pivot, net)| RemoteView {
|
||||
.and_then(|entity| Some((entity, poses.get(entity).ok()?)))
|
||||
.map(|(entity, (t, pivot, net))| RemoteView {
|
||||
entity,
|
||||
feet: t.translation,
|
||||
pivot_height: head_height(pivot, net.map_or(1.0, |n| n.scale)),
|
||||
});
|
||||
|
||||
@@ -583,7 +583,6 @@ pub(super) fn act_on_right_click(
|
||||
target.is_some_and(|s| s.0.unit_flags() & cursor_mode::UNIT_FLAG_SKINNABLE != 0),
|
||||
learned.skinning.is_some(),
|
||||
);
|
||||
let (loot, skin) = (leg == DeadUnitLeg::Loot, leg == DeadUnitLeg::Skin);
|
||||
// The unit twin of the GO/corpse legs' one-line answer (tag `use`). Every refusal on this
|
||||
// ladder is silent from the outside — the range gray, the mounted fall-through, a corpse
|
||||
// somebody else owns — and they are indistinguishable on screen. One run says which.
|
||||
@@ -623,111 +622,138 @@ pub(super) fn act_on_right_click(
|
||||
me.map(|(_, g, _)| g.0),
|
||||
attack,
|
||||
);
|
||||
if attack {
|
||||
// The actor-eligibility block — **silent here**, and that is 1851 correcting 0481. The
|
||||
// click still SELECTED (the commit above already ran, the ref's select-then-refuse order)
|
||||
// and the melee auto-draw and swing still never happen; what changed is that no red
|
||||
// `ERR_ATTACK_*` line shows any more.
|
||||
//
|
||||
// 0481 attached `0x612df0`'s Phase A ladder to this path. The §5 this session dispatched
|
||||
// found that validator has exactly three callers image-wide — pet-attack `0x4bd40d`, the
|
||||
// Attack action/keybind `0x6131aa`, and TryCast `0x6e4efb` — and the world right-click is
|
||||
// none of them: it runs `0x60c247 call 0x5ecb70`, an extent containing no `DisplayError`
|
||||
// at all. So all eight of those red lines belong to the bar and the pet command, never to
|
||||
// the click, and the fix is not to delete six of them but to move where they are asked
|
||||
// for. [`attack_actor_blocked`] is the same ladder with the message removed; the bar keeps
|
||||
// [`attack_actor_refusal`]. (The predicate is still `0x612df0`'s and not `0x5ecb70`'s own
|
||||
// overlapping set — a separate slice — but the SILENCE is the verified law, and the swing
|
||||
// is suppressed either way.)
|
||||
if crate::ui_action::attack_actor_blocked(self_store, me.map(|(_, g, _)| g.0)).is_some() {
|
||||
// refused — selection stands, no swing, and nothing is said
|
||||
} else {
|
||||
debug!("right-click attack: {guid:#x}");
|
||||
// The right-click's own StartAttack, through the one seam (auto-draw + the swing +
|
||||
// the auto-repeat cancel — `0x5ecb70`'s whole body). The commit above may already
|
||||
// have re-pointed the swing at this guid, in which case the ref's `0x5eccda` is the
|
||||
// thing that suppresses a second send, so pass it the real lock state and let the
|
||||
// seam decide: `swung` means the re-swing already went out and the lock is ours, so
|
||||
// no stop is in flight any more.
|
||||
let engaged = me.is_some_and(|(_, _, e)| e);
|
||||
seam.start(guid, engaged || outcome.swung, false);
|
||||
match unit_branch(attack, dead_fork, leg) {
|
||||
UnitBranch::Attack => {
|
||||
// The actor-eligibility block — **silent here**, and that is 1851 correcting 0481. The
|
||||
// click still SELECTED (the commit above already ran, the ref's select-then-refuse order)
|
||||
// and the melee auto-draw and swing still never happen; what changed is that no red
|
||||
// `ERR_ATTACK_*` line shows any more.
|
||||
//
|
||||
// 0481 attached `0x612df0`'s Phase A ladder to this path. The §5 this session dispatched
|
||||
// found that validator has exactly three callers image-wide — pet-attack `0x4bd40d`, the
|
||||
// Attack action/keybind `0x6131aa`, and TryCast `0x6e4efb` — and the world right-click is
|
||||
// none of them: it runs `0x60c247 call 0x5ecb70`, an extent containing no `DisplayError`
|
||||
// at all. So all eight of those red lines belong to the bar and the pet command, never to
|
||||
// the click, and the fix is not to delete six of them but to move where they are asked
|
||||
// for. [`attack_actor_blocked`] is the same ladder with the message removed; the bar keeps
|
||||
// [`attack_actor_refusal`]. (The predicate is still `0x612df0`'s and not `0x5ecb70`'s own
|
||||
// overlapping set — a separate slice — but the SILENCE is the verified law, and the swing
|
||||
// is suppressed either way.)
|
||||
if crate::ui_action::attack_actor_blocked(self_store, me.map(|(_, g, _)| g.0)).is_some()
|
||||
{
|
||||
// refused — selection stands, no swing, and nothing is said
|
||||
} else {
|
||||
debug!("right-click attack: {guid:#x}");
|
||||
// The right-click's own StartAttack, through the one seam (auto-draw + the swing +
|
||||
// the auto-repeat cancel — `0x5ecb70`'s whole body). The commit above may already
|
||||
// have re-pointed the swing at this guid, in which case the ref's `0x5eccda` is the
|
||||
// thing that suppresses a second send, so pass it the real lock state and let the
|
||||
// seam decide: `swung` means the re-swing already went out and the lock is ours, so
|
||||
// no stop is in flight any more.
|
||||
let engaged = me.is_some_and(|(_, _, e)| e);
|
||||
seam.start(guid, engaged || outcome.swung, false);
|
||||
}
|
||||
}
|
||||
} else if loot {
|
||||
// A dead unit carrying UNIT_DYNFLAG_LOOTABLE (the Pickup loot cursor, decision 0084): open
|
||||
// its loot (`CMSG_LOOT`). Range-gated like the interact branch — the cursor grays a corpse
|
||||
// beyond the melee interact reach (`unable`), and we don't send then (no auto-approach yet).
|
||||
// No EmoteTalk: looting is not an NPC interaction, the corpse plays no talk.
|
||||
//
|
||||
// **The unit leg's own stand-state gate** (`0x60bfb7 call [playerVtbl+0xa4]`, non-zero →
|
||||
// `0x60c007 push 0x85; call DisplayError 0x496720`) — the exact instruction pair the corpse
|
||||
// leg carries at `0x5d6c3b`/`0x496720(0x85)`, in the same fork, one test after the lootable
|
||||
// one. The corpse leg's comment has said since 1729 that "the unit loot branch's own copy of
|
||||
// it is unverified here"; it is the same `push 0x85` and the same virtual, read off the same
|
||||
// dispatcher this session's `0x60bf98` mounted gate came out of. So the caveat retires and
|
||||
// the two legs say the same thing: sitting, kneeling or asleep, the click raises the
|
||||
// client-local red `ERR_LOOT_NOTSTANDING` and sends nothing.
|
||||
if self_store.is_some_and(|s| s.0.unit_stand_state() != 0) {
|
||||
debug!("right-click loot: refused, not standing ({guid:#x})");
|
||||
ui_error_keys
|
||||
.0
|
||||
.push(crate::ui_action::UiError::key("ERR_LOOT_NOTSTANDING"));
|
||||
} else if !cursor.unable {
|
||||
debug!("right-click loot: {guid:#x}");
|
||||
let _ = seam.net.0.send(ClientCommand::Loot { guid });
|
||||
// The kneel is client-predicted AT THE SEND: the real client's `CMSG_LOOT` sender
|
||||
// (`0x5df253`) sets the loot-target latch `[player+0x1d28]` and plays Loot 50 before
|
||||
// any server response (decision 0515). Arm the latch the anim driver's loot leg
|
||||
// reads for the self unit; the release/refusal drops it.
|
||||
loot_latch.0 = Some(guid);
|
||||
UnitBranch::Dead(DeadUnitLeg::Loot) => {
|
||||
// A dead unit carrying UNIT_DYNFLAG_LOOTABLE (the Pickup loot cursor, decision 0084): open
|
||||
// its loot (`CMSG_LOOT`). Range-gated like the interact branch — the cursor grays a corpse
|
||||
// beyond the melee interact reach (`unable`), and we don't send then (no auto-approach yet).
|
||||
// No EmoteTalk: looting is not an NPC interaction, the corpse plays no talk.
|
||||
//
|
||||
// **The unit leg's own stand-state gate** (`0x60bfb7 call [playerVtbl+0xa4]`, non-zero →
|
||||
// `0x60c007 push 0x85; call DisplayError 0x496720`) — the exact instruction pair the corpse
|
||||
// leg carries at `0x5d6c3b`/`0x496720(0x85)`, in the same fork, one test after the lootable
|
||||
// one. The corpse leg's comment has said since 1729 that "the unit loot branch's own copy of
|
||||
// it is unverified here"; it is the same `push 0x85` and the same virtual, read off the same
|
||||
// dispatcher this session's `0x60bf98` mounted gate came out of. So the caveat retires and
|
||||
// the two legs say the same thing: sitting, kneeling or asleep, the click raises the
|
||||
// client-local red `ERR_LOOT_NOTSTANDING` and sends nothing.
|
||||
if self_store.is_some_and(|s| s.0.unit_stand_state() != 0) {
|
||||
debug!("right-click loot: refused, not standing ({guid:#x})");
|
||||
ui_error_keys
|
||||
.0
|
||||
.push(crate::ui_action::UiError::key("ERR_LOOT_NOTSTANDING"));
|
||||
} else if !cursor.unable {
|
||||
debug!("right-click loot: {guid:#x}");
|
||||
let _ = seam.net.0.send(ClientCommand::Loot { guid });
|
||||
// The kneel is client-predicted AT THE SEND: the real client's `CMSG_LOOT` sender
|
||||
// (`0x5df253`) sets the loot-target latch `[player+0x1d28]` and plays Loot 50 before
|
||||
// any server response (decision 0515). Arm the latch the anim driver's loot leg
|
||||
// reads for the self unit; the release/refusal drops it.
|
||||
loot_latch.0 = Some(guid);
|
||||
}
|
||||
}
|
||||
} else if skin {
|
||||
// A dead SKINNABLE corpse the loot leg declined (`0x60c01f`): cast our known Skinning spell
|
||||
// at it — the unit-side mirror of the GO lock split (0239; decision 0437's gathering
|
||||
// finish). The spell comes from the reference's own learn-time latch
|
||||
// ([`crate::ui_action::LearnedAbilities`] = `[0xb700e4]`, decision 0752), which is the same
|
||||
// thing the classifier gated the Skin cursor on. Ordinarily that means an unlootable
|
||||
// corpse; **while mounted it also means a still-lootable one**, and the cast then meets the
|
||||
// cast family's own mounted gate (`0x6094f0`'s reason `0x39`) and says "You are mounted" —
|
||||
// the one leg of this dispatcher where the rider is told anything at all.
|
||||
// Range rides the cursor's melee-reach gray, like loot.
|
||||
if !cursor.unable {
|
||||
if let Some(spell_id) = learned.skinning {
|
||||
let def = go_inputs
|
||||
.spells
|
||||
.as_ref()
|
||||
.and_then(|s| s.catalog.get(spell_id));
|
||||
if crate::ui_action::cast_mounted_refusal(self_mounted, def) {
|
||||
debug!("right-click skin: refused locally — mounted (0x39)");
|
||||
cast_errors.push_local(spell_id, 0x39);
|
||||
} else {
|
||||
debug!("right-click skin: {guid:#x} (spell {spell_id})");
|
||||
let _ = seam.net.0.send(ClientCommand::CastSpell {
|
||||
spell_id,
|
||||
target: Some(guid),
|
||||
});
|
||||
UnitBranch::Dead(DeadUnitLeg::Skin) => {
|
||||
// A dead SKINNABLE corpse the loot leg declined (`0x60c01f`): cast our known Skinning spell
|
||||
// at it — the unit-side mirror of the GO lock split (0239; decision 0437's gathering
|
||||
// finish). The spell comes from the reference's own learn-time latch
|
||||
// ([`crate::ui_action::LearnedAbilities`] = `[0xb700e4]`, decision 0752), which is the same
|
||||
// thing the classifier gated the Skin cursor on. Ordinarily that means an unlootable
|
||||
// corpse; **while mounted it also means a still-lootable one**, and the cast then meets the
|
||||
// cast family's own mounted gate (`0x6094f0`'s reason `0x39`) and says "You are mounted" —
|
||||
// the one leg of this dispatcher where the rider is told anything at all.
|
||||
// Range rides the cursor's melee-reach gray, like loot.
|
||||
if !cursor.unable {
|
||||
if let Some(spell_id) = learned.skinning {
|
||||
let def = go_inputs
|
||||
.spells
|
||||
.as_ref()
|
||||
.and_then(|s| s.catalog.get(spell_id));
|
||||
if crate::ui_action::cast_mounted_refusal(self_mounted, def) {
|
||||
debug!("right-click skin: refused locally — mounted (0x39)");
|
||||
cast_errors.push_local(spell_id, 0x39);
|
||||
} else {
|
||||
debug!("right-click skin: {guid:#x} (spell {spell_id})");
|
||||
let _ = seam.net.0.send(ClientCommand::CastSpell {
|
||||
spell_id,
|
||||
target: Some(guid),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !cursor.unable {
|
||||
// An in-range friendly service NPC (the cursor already gated friendly + service + range):
|
||||
// route the interact by the classified kind (decision 0081). The Buy kind is shared by
|
||||
// banker and auctioneer (both classify to Buy(3), low-bit-first — a gossip-flagged banker
|
||||
// already classified to Speak and routes through the gossip menu), so the dispatch reads
|
||||
// the NPC's own flags to split them (decision 0604).
|
||||
let npc_flags = stores
|
||||
.get(entity)
|
||||
.map(|(s, _)| s.0.unit_npc_flags())
|
||||
.unwrap_or(0);
|
||||
if let Some(cmd) = interact_command(cursor.kind, guid, npc_flags) {
|
||||
debug!("right-click interact: {guid:#x} ({:?})", cursor.kind);
|
||||
let _ = seam.net.0.send(cmd);
|
||||
// Talk at the NPC. The gesture's own anim carries WeaponFlags `0x10`, so the
|
||||
// per-animation sheath reconcile stows a drawn weapon — a committed change that
|
||||
// persists after the talk (decisions 0080/0081; no sheath wiring here).
|
||||
if let Some((_, my_guid, _)) = me {
|
||||
gestures.push(my_guid.0, crate::creature_anim::Gesture::Talk);
|
||||
// **The silent leg — and it is TERMINAL.** That is the whole of decision 1858. A dead
|
||||
// unit whose fork declined both legs (a rider over a lootable corpse; a corpse someone
|
||||
// else killed; a skinnable one without the skill) is DONE: the reference reaches its
|
||||
// NPC-service dispatch only down the ALIVE branch (`0x60c162`), never off the back of
|
||||
// this fork.
|
||||
//
|
||||
// benilla wrote the fork as an `if/else if` chain whose LAST arm was the service send, so
|
||||
// "the dead fork chose nothing" fell through into "this is a live service NPC" — and that
|
||||
// arm dispatches on the CURSOR kind, which over a lootable corpse is `Pickup`, the mode
|
||||
// the vendor pouch also uses ([`interact_command`]). The click therefore sent
|
||||
// `CMSG_LIST_INVENTORY` at a dead wolf, and vmangos answers a non-vendor with
|
||||
// `SELL_ERR_CANT_FIND_VENDOR` (`ItemHandler.cpp:701-710` → `GetNPCIfCanInteractWith` →
|
||||
// `CanInteractWithNPC`'s npc-flag and `IsAlive` tests), which this client renders as the
|
||||
// red **"That merchant doesn't like you."** on an animal corpse.
|
||||
UnitBranch::Dead(DeadUnitLeg::Nothing) => {
|
||||
debug!("right-click unit {guid:#x}: dead fork took no leg — nothing sent");
|
||||
}
|
||||
UnitBranch::Service if !cursor.unable => {
|
||||
// An in-range friendly service NPC (the cursor already gated friendly + service + range):
|
||||
// route the interact by the classified kind (decision 0081). The Buy kind is shared by
|
||||
// banker and auctioneer (both classify to Buy(3), low-bit-first — a gossip-flagged banker
|
||||
// already classified to Speak and routes through the gossip menu), so the dispatch reads
|
||||
// the NPC's own flags to split them (decision 0604).
|
||||
let npc_flags = stores
|
||||
.get(entity)
|
||||
.map(|(s, _)| s.0.unit_npc_flags())
|
||||
.unwrap_or(0);
|
||||
if let Some(cmd) = interact_command(cursor.kind, guid, npc_flags) {
|
||||
debug!("right-click interact: {guid:#x} ({:?})", cursor.kind);
|
||||
let _ = seam.net.0.send(cmd);
|
||||
// Talk at the NPC. The gesture's own anim carries WeaponFlags `0x10`, so the
|
||||
// per-animation sheath reconcile stows a drawn weapon — a committed change that
|
||||
// persists after the talk (decisions 0080/0081; no sheath wiring here).
|
||||
if let Some((_, my_guid, _)) = me {
|
||||
gestures.push(my_guid.0, crate::creature_anim::Gesture::Talk);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The classifier's own range gray on a LIVE service NPC — outside the 5.5556 yd service
|
||||
// reach the click sends nothing (there is no auto-approach yet), exactly as the loot and
|
||||
// skin legs ride the same `unable`.
|
||||
UnitBranch::Service => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1012,6 +1038,39 @@ fn dead_unit_leg(
|
||||
}
|
||||
DeadUnitLeg::Nothing
|
||||
}
|
||||
/// **Which branch of `0x60bea0` a right-click on a unit takes** — the dispatcher's own top-level
|
||||
/// fork, named so it cannot be fallen out of.
|
||||
///
|
||||
/// The reference splits on the target's death *first* and never rejoins: the dead side runs
|
||||
/// [`dead_unit_leg`]'s three-way ladder and returns, and the NPC-service dispatch hangs off the
|
||||
/// ALIVE side alone (`0x60c162`'s `CanInteract` → the service send). An `if/else if` chain that
|
||||
/// ends in the service arm gets that wrong in one specific, invisible way — a dead target whose
|
||||
/// ladder chose [`DeadUnitLeg::Nothing`] slides into the service arm — which is exactly the bug
|
||||
/// decision 1858 was written for. This enum exists so the compiler, not a comment, is what keeps
|
||||
/// the two sides apart: the call site matches it exhaustively, so a new [`DeadUnitLeg`] variant
|
||||
/// cannot silently acquire a service send.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum UnitBranch {
|
||||
/// The Attack cursor's own leg — `0x60c247 call 0x5ecb70` (select, then swing).
|
||||
Attack,
|
||||
/// The dead fork (`0x60bf75`), and its chosen leg. **Terminal in every variant.**
|
||||
Dead(DeadUnitLeg),
|
||||
/// The alive branch (`0x60c162`): `CanInteract`, then the NPC-service packet.
|
||||
Service,
|
||||
}
|
||||
|
||||
/// [`UnitBranch`], from the two facts that pick it. `attack` first, matching the chain the
|
||||
/// dispatcher's callers see: the Attack cursor kind is only ever classified for a LIVE hostile,
|
||||
/// so the order is a formality on real data and a defined answer on impossible data.
|
||||
fn unit_branch(attack: bool, dead_fork: bool, leg: DeadUnitLeg) -> UnitBranch {
|
||||
if attack {
|
||||
UnitBranch::Attack
|
||||
} else if dead_fork {
|
||||
UnitBranch::Dead(leg)
|
||||
} else {
|
||||
UnitBranch::Service
|
||||
}
|
||||
}
|
||||
|
||||
/// The interact packet a right-click on a friendly service NPC sends, by its classified
|
||||
/// [`cursor_mode::CursorKind`] (decision 0081). A **vendor-only** NPC (Pickup) opens the stock list
|
||||
@@ -1405,6 +1464,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// **The dead fork is TERMINAL** — decision 1858, and the reason a mounted right-click on a
|
||||
/// Young Wolf answered *"That merchant doesn't like you."*
|
||||
///
|
||||
/// [`dead_unit_leg`] was right; where its `Nothing` WENT was not. The dispatcher's arms were
|
||||
/// an `if/else if` chain ending in the NPC-service send, so a dead target that took no leg
|
||||
/// slid into the arm that dispatches on the CURSOR kind — and a lootable corpse's cursor is
|
||||
/// `Pickup`, the very mode a vendor shows. The click sent `CMSG_LIST_INVENTORY` at the
|
||||
/// corpse, and vmangos answers a non-vendor with `SELL_ERR_CANT_FIND_VENDOR`, which is
|
||||
/// `ERR_VENDOR_HATES_YOU`. Nothing about that was visible in [`dead_unit_leg`], which is why
|
||||
/// 1851's two tests passed over it: they asserted the LEG, and the bug was in the ROUTING.
|
||||
///
|
||||
/// So this asserts the property rather than the instance — over a dead target, no combination
|
||||
/// of the fork's four inputs may produce [`UnitBranch::Service`].
|
||||
#[test]
|
||||
fn the_dead_fork_never_reaches_the_service_dispatch() {
|
||||
for mounted in [false, true] {
|
||||
for lootable in [false, true] {
|
||||
for skinnable in [false, true] {
|
||||
for know_skinning in [false, true] {
|
||||
let leg = dead_unit_leg(mounted, true, lootable, skinnable, know_skinning);
|
||||
assert_eq!(
|
||||
unit_branch(false, true, leg),
|
||||
UnitBranch::Dead(leg),
|
||||
"a dead target escaped the fork (mounted={mounted} \
|
||||
lootable={lootable} skinnable={skinnable} know={know_skinning})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The exact shape the director hit: mounted, over a lootable corpse with no skinning.
|
||||
let leg = dead_unit_leg(true, true, true, false, false);
|
||||
assert_eq!(
|
||||
unit_branch(false, true, leg),
|
||||
UnitBranch::Dead(DeadUnitLeg::Nothing)
|
||||
);
|
||||
// ...and what that used to fall into. The pouch over a corpse and the pouch over a vendor
|
||||
// are one cursor mode, so the old last arm read the corpse as a shop.
|
||||
assert!(matches!(
|
||||
interact_command(cursor_mode::CursorKind::Pickup, 0x42, 0),
|
||||
Some(ClientCommand::ListInventory { .. })
|
||||
));
|
||||
// A LIVE unit still reaches the service dispatch — the fix must not shut the door on the
|
||||
// branch that is supposed to send.
|
||||
assert_eq!(
|
||||
unit_branch(false, false, DeadUnitLeg::Nothing),
|
||||
UnitBranch::Service
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interact_routes_vendor_direct_and_gossip_universal() {
|
||||
|
||||
@@ -520,3 +520,61 @@ fn tab_resize_falls_back_to_this_when_no_tab_is_passed() {
|
||||
let width = s.eval::<f64>("return ProbeTab:GetWidth()").unwrap();
|
||||
assert!(width > 0.0, "the tab got a width, got {width}");
|
||||
}
|
||||
|
||||
/// **The window tab's structural clamp, driven the way the client drives it — from `OnUpdate`.**
|
||||
///
|
||||
/// 1002's law is that a tab may never grow past its window's drawn right edge, whatever fit the
|
||||
/// instance asked for. `BenillaTabButton_OnUpdate` enforces it by comparing the fitted width
|
||||
/// against the room, and it used to read that width from `PanelTemplates_TabResize`'s return —
|
||||
/// a benilla-only line on OUR copy of that verb.
|
||||
///
|
||||
/// 1837 put `Interface\FrameXML\UIPanelTemplates.lua` on the chain BELOW `UiPanels.xml`, so the
|
||||
/// reference's `PanelTemplates_TabResize` is the one that runs, and it returns nothing. The
|
||||
/// comparison became `nil > number`: a raise, once per tab per width change, with the clamp
|
||||
/// silently off the air and the tab already sized by the unclamped first call.
|
||||
///
|
||||
/// The existing tab test drives `OnLoad` and never ticks, which is why nothing caught it.
|
||||
#[test]
|
||||
fn a_tab_never_grows_past_its_windows_right_edge() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness();
|
||||
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
|
||||
// A deliberately narrow window and a long label: the natural fit does not fit.
|
||||
let doc = benilla_ui::framexml::parse(
|
||||
r#"<Ui>
|
||||
<Frame name="ProbeWindow">
|
||||
<Size><AbsDimension x="160" y="200"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
<Frames>
|
||||
<Button name="ProbeTab" inherits="CharacterFrameTabButtonTemplate"
|
||||
text="A Very Long Tab Label Indeed">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-10"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Button>
|
||||
</Frames>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let report = benilla_ui::loader::load_in(&s, &doc, "test", &|_: &str| None);
|
||||
assert!(report.errors.is_empty(), "load: {:?}", report.errors);
|
||||
|
||||
// Two ticks: the first lands the measure, the second settles against it.
|
||||
s.tick(0.1);
|
||||
s.tick(0.1);
|
||||
assert!(
|
||||
s.errors().is_empty(),
|
||||
"the settle must not raise — the clamp reads the fit back, not a return value: {:?}",
|
||||
s.errors()
|
||||
);
|
||||
|
||||
let (left, width) = s
|
||||
.eval::<(f64, f64)>("return ProbeTab:GetLeft(), ProbeTab:GetWidth()")
|
||||
.unwrap();
|
||||
let right = s.eval::<f64>("return ProbeWindow:GetRight()").unwrap();
|
||||
assert!(
|
||||
left + width <= right + 0.5,
|
||||
"the tab ran past its window: left {left} + width {width} > right {right}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,8 +74,13 @@
|
||||
//! [`Frustum::from_clip_from_world`] extracts the identical 6 planes. That keeps us on one
|
||||
//! plane-extraction implementation instead of a private corner-ray port.
|
||||
//!
|
||||
//! Windows narrower than [`MIN_WINDOW_NDC`] in either axis are dropped, matching the reference's
|
||||
//! `[0x8029d0]` reject.
|
||||
//! Windows narrower than [`MIN_WINDOW_NDC`] in either axis are dropped **on this walk** — the
|
||||
//! reference's reject is the first thing `0x682fa0` does, before it touches a bucket, and it is
|
||||
//! non-strict (`>= 0.01` of the 0..1 screen passes). It belongs to the walk, not to the volume:
|
||||
//! `0x682930`, the builder the containing building's own Pass 2 shares, contains no comparison at
|
||||
//! all, so a doorway too narrow to admit a hillside can still admit a wall. That asymmetry is the
|
||||
//! reference's, and reproducing it is [`scene_window_frustum`] vs [`window_frustum`] (decision
|
||||
//! 1853).
|
||||
//!
|
||||
//! ## What is gated so far, and what is knowingly not
|
||||
//!
|
||||
@@ -202,25 +207,24 @@ impl Plugin for ExteriorCullPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sub-frustum for one NDC window rect.
|
||||
/// The sub-frustum for one NDC window rect — the **construction alone**, no narrowness gate.
|
||||
///
|
||||
/// **Two callers, deliberately one construction** (decision 1826): this module gates the open world
|
||||
/// through these rects, and `crate::wmo_portal`'s Pass 2 gates the containing building's own
|
||||
/// exterior groups through the same ones. The reference likewise builds one volume per window and
|
||||
/// hands it to both (`0x682930` for `0x6b3b20`'s Pass 2, and the copy at `0xc7cb7c` for
|
||||
/// `0x682fa0`), so a doorway too narrow to admit a hillside cannot admit a wall either.
|
||||
/// `0x682fa0`). What 1826 also copied across, and should not have, was the *reject*: it belongs to
|
||||
/// the open-world walk's entry, not to the volume — see [`scene_window_frustum`]. So a doorway too
|
||||
/// narrow to admit a hillside **can** still admit a wall, which is the reference's own asymmetry.
|
||||
///
|
||||
/// An NDC rect maps to the full screen by a scale+offset **on clip space** — `clip.x` scaled by
|
||||
/// `2/(x1-x0)` plus `clip.w` times `-(x0+x1)/(x1-x0)`, and likewise in y — so pre-multiplying
|
||||
/// `clip_from_world` by that matrix yields a projection whose 6 extracted planes bound exactly the
|
||||
/// window's sub-frustum. Depth rows are untouched: the window narrows the view laterally and inherits
|
||||
/// the camera's own near/far.
|
||||
pub(crate) fn window_frustum(rect: Rect, clip_from_world: &Mat4) -> Option<Frustum> {
|
||||
pub(crate) fn window_frustum(rect: Rect, clip_from_world: &Mat4) -> Frustum {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
if w < MIN_WINDOW_NDC || h < MIN_WINDOW_NDC {
|
||||
return None;
|
||||
}
|
||||
// Column-major: `Mat4::from_cols` takes columns, so the `w`-column carries the offsets.
|
||||
let rect_to_ndc = Mat4::from_cols(
|
||||
Vec4::new(2.0 / w, 0.0, 0.0, 0.0),
|
||||
@@ -228,9 +232,30 @@ pub(crate) fn window_frustum(rect: Rect, clip_from_world: &Mat4) -> Option<Frust
|
||||
Vec4::new(0.0, 0.0, 1.0, 0.0),
|
||||
Vec4::new(-(x0 + x1) / w, -(y0 + y1) / h, 0.0, 1.0),
|
||||
);
|
||||
Some(Frustum::from_clip_from_world(
|
||||
&(rect_to_ndc * *clip_from_world),
|
||||
))
|
||||
Frustum::from_clip_from_world(&(rect_to_ndc * *clip_from_world))
|
||||
}
|
||||
|
||||
/// [`window_frustum`] behind the **open-world walk's own entry gate** — and nothing else's.
|
||||
///
|
||||
/// `0x682fa0` opens with the gate, before it touches a bucket: `fld [edi+8]; fsub [edi]; fcomp
|
||||
/// [0x8029d0]; test ah,5; jnp -> ret` on x, then the same on y. `[0x8029d0]` is `0.01` and the rects
|
||||
/// are `[0,1]` screen fraction, so the walk runs iff **both extents are `>= 0.01`** — 0.02 in our NDC,
|
||||
/// non-strict, and a window under it draws no terrain, no ADT doodads, no world WMO placement, no
|
||||
/// liquid, no far band and no outdoor unit at all.
|
||||
///
|
||||
/// **The gate is the walk's, not the volume's** (RE 2026-09-02, `wow-5875-re`
|
||||
/// `models/scratch/wmo-pass2-window-reseed.md`): a compare-class census over the whole of
|
||||
/// `0x682930` — the builder both consumers share — returns **zero** compares, and Pass 2's own body
|
||||
/// `[0x6b3c73, 0x6b3d6f)` has seven, none of them on the rect. Decision 1826 read the reject as
|
||||
/// part of the shared construction and applied it to both, which silently culled the containing
|
||||
/// building's whole exterior shell for every window between the recursion's own collapse guard
|
||||
/// (`0.001` NDC, `0x6b44b1`) and this one. Decision 1853.
|
||||
pub(crate) fn scene_window_frustum(rect: Rect, clip_from_world: &Mat4) -> Option<Frustum> {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
if x1 - x0 < MIN_WINDOW_NDC || y1 - y0 < MIN_WINDOW_NDC {
|
||||
return None;
|
||||
}
|
||||
Some(window_frustum(rect, clip_from_world))
|
||||
}
|
||||
|
||||
/// This frame's exterior gate — the window worklist turned into clip volumes, built once and asked
|
||||
@@ -265,7 +290,7 @@ impl ExteriorGate {
|
||||
Self::Windows(
|
||||
rects
|
||||
.iter()
|
||||
.filter_map(|r| window_frustum(*r, &clip_from_world))
|
||||
.filter_map(|r| scene_window_frustum(*r, &clip_from_world))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -436,7 +461,7 @@ fn apply_exterior_cull(
|
||||
// would be arbitrary — `ExteriorGate::build`'s own law.
|
||||
let full_view = cam.iter().next().and_then(|(cam_t, proj)| {
|
||||
let clip_from_world = proj.get_clip_from_view() * cam_t.to_matrix().inverse();
|
||||
window_frustum([-1.0, -1.0, 1.0, 1.0], &clip_from_world)
|
||||
scene_window_frustum([-1.0, -1.0, 1.0, 1.0], &clip_from_world)
|
||||
});
|
||||
let in_view = |gt: &GlobalTransform, bound: &Aabb| {
|
||||
full_view
|
||||
@@ -565,7 +590,7 @@ mod tests {
|
||||
/// clipping the world.
|
||||
#[test]
|
||||
fn the_full_screen_window_is_the_whole_frustum() {
|
||||
let f = window_frustum([-1.0, -1.0, 1.0, 1.0], &clip_from_world()).expect("full screen");
|
||||
let f = window_frustum([-1.0, -1.0, 1.0, 1.0], &clip_from_world());
|
||||
assert!(admits(&f, Vec3::new(0.0, 0.0, -10.0)), "straight ahead");
|
||||
// At 90° fov the frustum edge is |x| = |z|; well inside it must pass, and behind must not.
|
||||
assert!(admits(&f, Vec3::new(5.0, 0.0, -10.0)), "right of centre");
|
||||
@@ -578,7 +603,7 @@ mod tests {
|
||||
/// one side of the view must not let the world in on the other.
|
||||
#[test]
|
||||
fn a_half_screen_window_rejects_the_other_half() {
|
||||
let f = window_frustum([0.1, -1.0, 1.0, 1.0], &clip_from_world()).expect("right half");
|
||||
let f = window_frustum([0.1, -1.0, 1.0, 1.0], &clip_from_world());
|
||||
assert!(admits(&f, Vec3::new(5.0, 0.0, -10.0)), "right: admitted");
|
||||
assert!(
|
||||
!admits(&f, Vec3::new(-5.0, 0.0, -10.0)),
|
||||
@@ -596,7 +621,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_narrow_window_rejects_a_chunk_of_ground_but_never_a_whole_tile_of_it() {
|
||||
// A doorway on the RIGHT of the view; the ground of interest is 200 yd to the LEFT.
|
||||
let f = window_frustum([0.6, -1.0, 1.0, 1.0], &clip_from_world()).expect("a right doorway");
|
||||
let f = window_frustum([0.6, -1.0, 1.0, 1.0], &clip_from_world());
|
||||
let ground = Vec3::new(-200.0, -2.0, -200.0);
|
||||
|
||||
const CELL: f32 = 33.333 / 2.0;
|
||||
@@ -620,16 +645,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference rejects a window narrower than a hundredth of the screen (`[0x8029d0]`); a
|
||||
/// collapsed rect must produce no frustum at all rather than a degenerate one whose planes are
|
||||
/// NaN (which `intersects_obb` would answer arbitrarily).
|
||||
/// The OPEN-WORLD walk rejects a window narrower than a hundredth of the screen
|
||||
/// (`0x682fa0`'s entry compares against `[0x8029d0]`) — and it is non-strict, so a window
|
||||
/// exactly at the threshold draws. The reject also keeps a collapsed rect from producing a
|
||||
/// degenerate frustum whose planes are NaN (which `intersects_obb` would answer arbitrarily).
|
||||
#[test]
|
||||
fn a_collapsed_window_is_dropped_not_degenerate() {
|
||||
assert!(window_frustum([0.5, -1.0, 0.5, 1.0], &clip_from_world()).is_none());
|
||||
assert!(window_frustum([-1.0, 0.2, 1.0, 0.2001], &clip_from_world()).is_none());
|
||||
// Just over the threshold still builds.
|
||||
fn the_scene_walk_drops_a_window_under_a_hundredth_of_the_screen() {
|
||||
assert!(scene_window_frustum([0.5, -1.0, 0.5, 1.0], &clip_from_world()).is_none());
|
||||
assert!(scene_window_frustum([-1.0, 0.2, 1.0, 0.2001], &clip_from_world()).is_none());
|
||||
// `>= 0.01` of a 0..1 screen passes — the client's `jnp` is parity, not a strict `>`.
|
||||
assert!(
|
||||
window_frustum([0.0, -1.0, MIN_WINDOW_NDC + 0.001, 1.0], &clip_from_world()).is_some()
|
||||
scene_window_frustum([0.0, -1.0, -1.0 + MIN_WINDOW_NDC, 1.0], &clip_from_world())
|
||||
.is_none(),
|
||||
"a rect with a NEGATIVE extent is nonsense and must not build a frustum"
|
||||
);
|
||||
assert!(
|
||||
scene_window_frustum([0.0, -1.0, MIN_WINDOW_NDC, 1.0], &clip_from_world()).is_some()
|
||||
);
|
||||
}
|
||||
|
||||
/// …and the volume builder itself has no such gate, because `0x682930` has no compares at all:
|
||||
/// Pass 2 draws the containing building's own shell through a window the open world cannot use
|
||||
/// (decision 1853). The band is `[0.001, 0.02)` NDC — above the recursion's collapse guard,
|
||||
/// below the scene walk's entry.
|
||||
#[test]
|
||||
fn pass_twos_builder_takes_a_window_the_scene_walk_rejects() {
|
||||
let narrow = [0.0, -1.0, 0.01, 1.0];
|
||||
assert!(scene_window_frustum(narrow, &clip_from_world()).is_none());
|
||||
let f = window_frustum(narrow, &clip_from_world());
|
||||
// fov 90 / aspect 1, so NDC x = world x / -z: the sliver's axis at z = -10 is x = 0.05.
|
||||
assert!(
|
||||
admits(&f, Vec3::new(0.05, 0.0, -10.0)),
|
||||
"the sliver still bounds a real volume: a point straight down its axis is inside"
|
||||
);
|
||||
assert!(
|
||||
!admits(&f, Vec3::new(5.0, 0.0, -10.0)),
|
||||
"…and it is still a sliver: NDC x = 0.5 is far outside it"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,18 @@ const SHADOWFANG: Site = Site {
|
||||
uid: 218202,
|
||||
};
|
||||
|
||||
/// Darnassus — the 2026-09-02 report's site: one placement, 104 groups, **50 of them exterior**, and
|
||||
/// the city's outdoor areas stitched to each other and to every shop by portals. That shape is what
|
||||
/// makes it the discriminating subject for Pass 2: standing in a shop, the doorway admits the one
|
||||
/// outdoor group beyond it, and everything else the director can see through that doorway is reached
|
||||
/// only by WALKING ON from there (decision 1853). `.go xyz 10188.36 2348.35 1328.96 1` is the pin.
|
||||
const DARNASSUS: Site = Site {
|
||||
wmo: r"world\wmo\kalimdor\darnassis\darnassis.wmo",
|
||||
map: "Kalimdor",
|
||||
tile: (27, 12),
|
||||
uid: 352798,
|
||||
};
|
||||
|
||||
/// Eye height above a floor point for both the standing player and the seated camera samples.
|
||||
const EYE_HEIGHT: f32 = 1.7;
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ use bevy::math::{Mat4, Vec3};
|
||||
use super::super::seed::down_ray_claim;
|
||||
use super::super::EXTERIOR_LIT;
|
||||
use super::{
|
||||
load_subject, Site, BLACKSMITH, DEADMINES, EXTERIOR, FARGODEEP, GOLDSHIRE, IRONFORGE,
|
||||
SHADOWFANG, UNDERCITY,
|
||||
load_subject, Site, BLACKSMITH, DARNASSUS, DEADMINES, EXTERIOR, FARGODEEP, GOLDSHIRE,
|
||||
IRONFORGE, SHADOWFANG, UNDERCITY,
|
||||
};
|
||||
|
||||
/// The named subjects `WOW_PIN_SITE` selects — every [`Site`] the harness carries, so a report at a
|
||||
@@ -25,6 +25,7 @@ const NAMED_SITES: &[(&str, Site)] = &[
|
||||
("undercity", UNDERCITY),
|
||||
("ironforge", IRONFORGE),
|
||||
("shadowfang", SHADOWFANG),
|
||||
("darnassus", DARNASSUS),
|
||||
];
|
||||
|
||||
/// The pin family's shared env targeting: `WOW_PIN_SITE=<name>` picks a [`NAMED_SITES`] subject
|
||||
@@ -722,8 +723,164 @@ fn shadowfang_sees_true_interiors_that_are_off_the_fog_chain() {
|
||||
|
||||
assert_eq!(
|
||||
off_chain,
|
||||
vec![1, 2, 13],
|
||||
"three true-interior rooms are in frame across the courtyards and none is on the chain: \
|
||||
this is the population a unit's fog gate has to answer for"
|
||||
vec![1, 2, 13, 68],
|
||||
"four true-interior rooms are in frame across the courtyards and none is on the chain: \
|
||||
this is the population a unit's fog gate has to answer for. It was three until 1853 — \
|
||||
g68 sits behind an exterior group no portal joins to the seed's half of the graph, so it \
|
||||
arrives only once Pass 2 WALKS ON from the window that admits that group instead of \
|
||||
merely marking it. The gate's verdict on all four is identical; what changed is how many \
|
||||
rooms the cull lets the director see at once"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Decision 1853's fixture — Pass 2 WALKS ON from the window, it does not merely mark.**
|
||||
///
|
||||
/// The reference's Pass 2 ends its per-group predicate in `0x6b3d39 call 0x6b41c0` — the portal
|
||||
/// recursion, the same entry point Pass 1 uses — not in the callback draw `0x6b4160` (that is Pass
|
||||
/// 3's, and only Pass 3's). Decision 1826 shipped Pass 2 as a *marking* pass: a window admitted an
|
||||
/// exterior group's own MOGI box and stopped there. Everything the walk would have reached THROUGH
|
||||
/// that group — the next courtyard, the shop behind it, the terrace one portal further on — stayed
|
||||
/// culled, and from inside a Darnassus shop that is most of the city.
|
||||
///
|
||||
/// Darnassus is the discriminating subject because it is one placement with 104 groups, 50 of them
|
||||
/// exterior, and its outdoor areas are stitched to each other and to every shop by portals — so the
|
||||
/// walk-on has somewhere to go. A building whose shell is one disconnected group (the Goldshire inn)
|
||||
/// cannot tell the two implementations apart at all.
|
||||
///
|
||||
/// The measurement is the **gain**: groups the Pass-2 walk entered that no window admitted directly
|
||||
/// and Pass 1 never reached. Under the marking implementation the gain is zero by construction.
|
||||
#[test]
|
||||
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
|
||||
fn darnassus_pass_two_walks_on_from_the_window() {
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Splits the flood's `entered` steps into Pass 1's and Pass 2's, and records which groups a
|
||||
/// window admitted directly — everything needed to name what the walk-on, and only the walk-on,
|
||||
/// delivered.
|
||||
#[derive(Default)]
|
||||
struct WalkOn {
|
||||
in_pass2: bool,
|
||||
seen_pass1: HashSet<usize>,
|
||||
roots: HashSet<usize>,
|
||||
reached: HashSet<usize>,
|
||||
}
|
||||
impl super::super::FloodTrace for WalkOn {
|
||||
fn seed(&mut self, seeds: super::super::DownRaySeeds) {
|
||||
self.seen_pass1.extend(seeds.in_group);
|
||||
self.seen_pass1.extend(seeds.across);
|
||||
}
|
||||
fn entered(
|
||||
&mut self,
|
||||
_from: usize,
|
||||
_portal: u16,
|
||||
to: usize,
|
||||
_rect: super::super::Rect,
|
||||
_on_plane: bool,
|
||||
) {
|
||||
if self.in_pass2 {
|
||||
self.reached.insert(to);
|
||||
} else {
|
||||
self.seen_pass1.insert(to);
|
||||
}
|
||||
}
|
||||
fn pass2(&mut self, group: usize, _flags: u32, _window: usize, admitted: bool) {
|
||||
self.in_pass2 = true;
|
||||
if admitted {
|
||||
self.roots.insert(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let site = DARNASSUS;
|
||||
let subject = load_subject(site.wmo, Some(&site));
|
||||
let placed = subject.placed.as_ref().expect("Darnassus has a placement");
|
||||
let nav = &subject.model.group_nav;
|
||||
|
||||
// Every true interior with a doorway is a pose: stand on its floor, look out through its first
|
||||
// portal. That is the director's own description of the report — "standing here looking out
|
||||
// through the doorway" — swept over the whole city instead of one spot.
|
||||
let mut poses = 0usize;
|
||||
let mut total_gain = 0usize;
|
||||
let mut best: Option<(usize, usize, usize)> = None; // (gain, group, exterior drawn)
|
||||
for (gi, g) in nav.iter().enumerate() {
|
||||
if g.flags & (EXTERIOR | EXTERIOR_LIT) != 0 || g.ref_count == 0 {
|
||||
continue;
|
||||
}
|
||||
let eye = [
|
||||
(g.bbox_min[0] + g.bbox_max[0]) * 0.5,
|
||||
(g.bbox_min[1] + g.bbox_max[1]) * 0.5,
|
||||
g.bbox_min[2] + super::EYE_HEIGHT,
|
||||
];
|
||||
// The run is only evidence if the seed actually names the room we meant to stand in — a
|
||||
// bbox centre can sit in a wall, over a stairwell, or above a mezzanine floor.
|
||||
if subject.seeds(eye).in_group != Some(gi) {
|
||||
continue;
|
||||
}
|
||||
let r = &subject.model.portal_refs[g.ref_start as usize];
|
||||
let Some(verts) = super::super::portal_poly(
|
||||
&subject.model.portal_vertices,
|
||||
&subject.model.portal_infos[r.portal as usize],
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let n = verts.len() as f32;
|
||||
let look = [
|
||||
verts.iter().map(|v| v[0]).sum::<f32>() / n,
|
||||
verts.iter().map(|v| v[1]).sum::<f32>() / n,
|
||||
verts.iter().map(|v| v[2]).sum::<f32>() / n,
|
||||
];
|
||||
if look == eye {
|
||||
continue;
|
||||
}
|
||||
let eye_bevy = placed.world_from_local.transform_point3(wow_to_bevy(eye));
|
||||
let look_bevy = placed.world_from_local.transform_point3(wow_to_bevy(look));
|
||||
let clip = Mat4::perspective_rh(crate::view::CAM_FOVY, 16.0 / 9.0, 0.1, 1000.0)
|
||||
* Mat4::look_at_rh(eye_bevy, look_bevy, Vec3::Y);
|
||||
let mut tap = WalkOn::default();
|
||||
let pvs = crate::wmo_portal::compute_pvs_traced(
|
||||
&subject.model,
|
||||
eye,
|
||||
subject.terrain_z(eye),
|
||||
&clip,
|
||||
&placed.world_from_local,
|
||||
&mut tap,
|
||||
);
|
||||
let gain: HashSet<usize> = tap
|
||||
.reached
|
||||
.difference(&tap.roots)
|
||||
.copied()
|
||||
.filter(|g| !tap.seen_pass1.contains(g))
|
||||
.collect();
|
||||
let ext_drawn = nav
|
||||
.iter()
|
||||
.zip(&pvs.visible)
|
||||
.filter(|(n, v)| **v && n.flags & EXTERIOR != 0)
|
||||
.count();
|
||||
println!(
|
||||
"g{gi:03} windows={} walk steps={} exterior drawn={ext_drawn}/50 walk-on gain={} {:?}",
|
||||
pvs.windows.len(),
|
||||
pvs.iters,
|
||||
gain.len(),
|
||||
{
|
||||
let mut v: Vec<usize> = gain.iter().copied().collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
);
|
||||
poses += 1;
|
||||
total_gain += gain.len();
|
||||
if best.is_none_or(|(b, _, _)| gain.len() > b) {
|
||||
best = Some((gain.len(), gi, ext_drawn));
|
||||
}
|
||||
}
|
||||
println!("poses={poses} total walk-on gain={total_gain} best={best:?}");
|
||||
assert!(
|
||||
poses >= 20,
|
||||
"the sweep needs the real city, got {poses} poses"
|
||||
);
|
||||
assert!(
|
||||
total_gain > 0,
|
||||
"Pass 2 must WALK ON from a window-admitted exterior group (0x6b3d39 -> 0x6b41c0); a \
|
||||
marking pass scores exactly zero here"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
//!
|
||||
//! Exterior shells: a building's outer-shell group (an inn's walls/roof) often carries **no portals**, so
|
||||
//! the interior flood can't reach it. The client draws exterior groups from inside via a separate
|
||||
//! *deferred-window* pass, **gated** on the flood having reached a doorway onto the outdoors. We
|
||||
//! reproduce that gate (`wow-5875-re` `wmo-insideleg-phase3.md`): reach a portal onto an exterior group ⇒
|
||||
//! draw the exterior shell (frustum-culled); reach none ⇒ leave it culled (you can't see out).
|
||||
//! *deferred-window* pass, **gated** on the flood having reached a doorway onto the outdoors
|
||||
//! (`wow-5875-re` `wmo-insideleg-phase3.md`): reach none and the whole pass is skipped — you can't
|
||||
//! see out. Reach one and, **per window**, every `0x8` group visible in a frustum clipped to that
|
||||
//! doorway becomes a **flood root of its own** (`0x6b3d39 call 0x6b41c0` — the same recursion, not a
|
||||
//! draw), carrying that window's rect onward through the graph. So a doorway shows you the courtyard
|
||||
//! beyond it *and* what the courtyard's own portals show, narrowing all the way (decision 1853).
|
||||
//!
|
||||
//! Seam: this module only **computes** the per-group visible set ([`WmoPortalInstance::visible`]). The
|
||||
//! single `Visibility` authority ([`crate::debug_panel`]'s `apply_model_visibility`, decision 0025)
|
||||
@@ -116,8 +119,13 @@ const CALLBACK_PASS: u32 = 0x10000;
|
||||
/// the reference's own designers left room for is far more likely to be OUR bug than the data's
|
||||
/// (decision 1148: one Trade District room once opened eleven windows onto Elwynn).
|
||||
const WORKLIST_CAP: usize = 16;
|
||||
/// Recursion depth cap (the client carries one) — a backstop against pathological portal cycles.
|
||||
const DEPTH_CAP: u32 = 64;
|
||||
/// Recursion depth cap — the client's own, `[0xcb004c]` seeded from `[0x86b6b8]` = **10**, refreshed
|
||||
/// by every `depth = 0` entry (so Pass 2's re-seeds each get a fresh ten hops, RE 2026-09-02). Ours
|
||||
/// was a guessed backstop of 64 until the byte read; dropping it to the real number changes nothing
|
||||
/// measurable in any audited building — Darnassus's walk-on gain is identical at 10 and 64, and all
|
||||
/// 13 real-data sweeps are unmoved — which is the answer you want from a cap: it is a cycle
|
||||
/// backstop, not a visibility term.
|
||||
const DEPTH_CAP: u32 = 10;
|
||||
/// Hard cap on flood iterations — a runaway-graph backstop; real WMOs settle far below this.
|
||||
const MAX_ITERS: u32 = 1 << 16;
|
||||
|
||||
@@ -305,7 +313,7 @@ pub struct WmoRoom {
|
||||
pub struct CameraInteriorClaim(pub Option<InteriorClaim>);
|
||||
|
||||
/// See [`CameraInteriorClaim`].
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub struct InteriorClaim {
|
||||
/// The room the camera EYE is in — the placement + its current group.
|
||||
pub room: WmoRoom,
|
||||
@@ -470,6 +478,18 @@ fn compute_wmo_pvs(
|
||||
inst.set_changed();
|
||||
}
|
||||
if let (Some(text), Some(log)) = (&mut dump_text, &log) {
|
||||
// Which building this trace belongs to — the dump lists every placement in range, and
|
||||
// an id alone cannot tell two night-elf houses apart. The origin can.
|
||||
let o = world_from_local.translation;
|
||||
text.push_str(&format!(
|
||||
"placement @ world ({:.1}, {:.1}, {:.1}) — {} deferred window(s) {:?}, {} walk steps\n",
|
||||
o.x,
|
||||
o.y,
|
||||
o.z,
|
||||
fresh.windows.len(),
|
||||
fresh.windows,
|
||||
fresh.iters
|
||||
));
|
||||
text.push_str(&log.text);
|
||||
text.push_str(&format!("visible: {:?}\n\n", inst.visible));
|
||||
}
|
||||
@@ -565,7 +585,12 @@ fn compute_wmo_pvs(
|
||||
if *camera_windows != want_windows {
|
||||
*camera_windows = want_windows;
|
||||
}
|
||||
if let Some(text) = dump_text {
|
||||
if let Some(mut text) = dump_text {
|
||||
// The frame's published verdict, after every placement has had its say: which room claimed
|
||||
// the camera and what the open world is allowed to draw through. The per-placement traces
|
||||
// above say what each flood found; only this says what the scene actually got.
|
||||
text.push_str(&format!("CAMERA CLAIM: {:?}\n", camera_claim.0));
|
||||
text.push_str(&format!("EXTERIOR WINDOWS: {:?}\n", *camera_windows));
|
||||
match std::fs::write(PROBE_DUMP_PATH, &text) {
|
||||
Ok(()) => info!("wmo cull trace written to {PROBE_DUMP_PATH}"),
|
||||
Err(e) => warn!("wmo cull trace: cannot write {PROBE_DUMP_PATH}: {e}"),
|
||||
@@ -624,7 +649,7 @@ pub(crate) trait FloodTrace {
|
||||
/// One Pass-2 verdict: an EXTERIOR group tested against the whole deferred worklist. Without
|
||||
/// this, "why is that wall still drawn?" has no reading at all — the flood's own hop lines
|
||||
/// never mention a group no portal reaches, which is exactly the population Pass 2 decides.
|
||||
fn pass2(&mut self, _group: usize, _flags: u32, _windows: usize, _admitted: bool) {}
|
||||
fn pass2(&mut self, _group: usize, _flags: u32, _window: usize, _admitted: bool) {}
|
||||
/// One Pass-3 verdict: a `0x10000` callback-pass group against the full frustum.
|
||||
fn pass3(&mut self, _group: usize, _flags: u32, _admitted: bool) {}
|
||||
/// The flood produced more deferred windows than the reference's fixed worklist holds — a
|
||||
@@ -658,9 +683,9 @@ impl<A: FloodTrace, B: FloodTrace> FloodTrace for (&mut A, &mut B) {
|
||||
self.0.entered(from, portal, to, rect, on_plane);
|
||||
self.1.entered(from, portal, to, rect, on_plane);
|
||||
}
|
||||
fn pass2(&mut self, group: usize, flags: u32, windows: usize, admitted: bool) {
|
||||
self.0.pass2(group, flags, windows, admitted);
|
||||
self.1.pass2(group, flags, windows, admitted);
|
||||
fn pass2(&mut self, group: usize, flags: u32, window: usize, admitted: bool) {
|
||||
self.0.pass2(group, flags, window, admitted);
|
||||
self.1.pass2(group, flags, window, admitted);
|
||||
}
|
||||
fn pass3(&mut self, group: usize, flags: u32, admitted: bool) {
|
||||
self.0.pass3(group, flags, admitted);
|
||||
@@ -699,9 +724,9 @@ impl<T: FloodTrace> FloodTrace for Option<T> {
|
||||
t.entered(from, portal, to, rect, on_plane);
|
||||
}
|
||||
}
|
||||
fn pass2(&mut self, group: usize, flags: u32, windows: usize, admitted: bool) {
|
||||
fn pass2(&mut self, group: usize, flags: u32, window: usize, admitted: bool) {
|
||||
if let Some(t) = self {
|
||||
t.pass2(group, flags, windows, admitted);
|
||||
t.pass2(group, flags, window, admitted);
|
||||
}
|
||||
}
|
||||
fn pass3(&mut self, group: usize, flags: u32, admitted: bool) {
|
||||
@@ -770,10 +795,155 @@ pub(crate) struct GroupPvs {
|
||||
/// [`ExteriorWindows`] (what of the open world draws), which the reference likewise feeds from
|
||||
/// one list — `0x681070` `rep movsd`s the very same records into `0xc7cb7c`.
|
||||
pub(crate) windows: Vec<Rect>,
|
||||
/// How many stack steps every run of the walk took this frame — Pass 1 plus one Pass-2 replay
|
||||
/// per window. Instrument only (the dump prints it): Pass 2 re-floods from every window-admitted
|
||||
/// exterior group, so this is the number to watch if a dense root ever gets expensive, and the
|
||||
/// number that would pin it against [`MAX_ITERS`] if a portal cycle ever ran away.
|
||||
pub(crate) iters: u32,
|
||||
}
|
||||
|
||||
/// [`compute_pvs`] with a [`FloodTrace`] tap on every decision — the same single flood; the probe dump
|
||||
/// and the audit harness pass a recorder, the per-frame path passes `()`.
|
||||
/// One step on the portal walk's stack: the group to enter, the group it was entered FROM (so the
|
||||
/// entry portal is never re-crossed), the working screen rect, the recursion depth, and whether the
|
||||
/// path that got here kept an unbroken interior chain from the seed.
|
||||
type Step = (usize, usize, Rect, u32, bool);
|
||||
|
||||
/// The portal walk `0x6b41c0`, and the per-FRAME state every run of it shares.
|
||||
///
|
||||
/// The inside leg runs this walk **twice** (`wow-5875-re` `models/scratch/wmo-insideleg-phase3.md`,
|
||||
/// §5 1v1 VERIFIED): once from the camera's own group (Pass 1, `0x6b3c05 call 0x6b41c0`) and again
|
||||
/// from every exterior group a deferred window admits (Pass 2, `0x6b3d39 call 0x6b41c0` — **the same
|
||||
/// recursion**, not a draw call). Holding the walk in one place is what makes the second run actually
|
||||
/// be the same walk: the visible set, the fog lane, the per-portal window stamp and the runaway
|
||||
/// backstop are all per-frame, not per-pass.
|
||||
struct Flood<'a> {
|
||||
model: &'a WmoModel,
|
||||
eye_local: [f32; 3],
|
||||
clip_from_world: &'a Mat4,
|
||||
world_from_local: &'a Affine3A,
|
||||
visible: Vec<bool>,
|
||||
interior_fog: Vec<bool>,
|
||||
/// The **deferred exterior-window worklist** Pass 1 leaves behind — the client's `[0xcbe324]`
|
||||
/// records, reset to empty at `0x6b3b51` and filled by the recursion at `0x6b4541`.
|
||||
windows: Vec<Rect>,
|
||||
/// The client's per-frame stamp on the PORTAL record (`0x6b4505`, `[esi+0x18]` vs `[0xca8014]`):
|
||||
/// a portal contributes **at most one** window per frame, however many paths the flood reaches it
|
||||
/// by. It guards the store alone — the recursion runs regardless — so this is a dedup, never a
|
||||
/// skip. First push wins, which our stack walk resolves the same way the reference's does.
|
||||
portal_pushed: Vec<bool>,
|
||||
/// Runaway backstop, counted across every run of the walk in this frame (see [`MAX_ITERS`]).
|
||||
iters: u32,
|
||||
}
|
||||
|
||||
impl Flood<'_> {
|
||||
/// Drain `stack`, entering each step's group and crossing its portals.
|
||||
///
|
||||
/// `record_windows` is the client's inside-leg flag `[0xcde5ac]` — set for Pass 1 only
|
||||
/// (`0x6b3b47`, cleared `0x6b3c1a`), which is why Pass 1 is the ONLY run that records deferred
|
||||
/// windows: the push itself is guarded on it (`0x6b4511`).
|
||||
fn walk<T: FloodTrace>(&mut self, stack: &mut Vec<Step>, record_windows: bool, trace: &mut T) {
|
||||
let model = self.model;
|
||||
let nav = &model.group_nav;
|
||||
let eye_local = self.eye_local;
|
||||
while let Some((g, came, rect, depth, chain)) = stack.pop() {
|
||||
self.iters += 1;
|
||||
if self.iters > MAX_ITERS || g >= nav.len() {
|
||||
continue;
|
||||
}
|
||||
self.visible[g] = true;
|
||||
// The interior-fog gate ([`GroupPvs::interior_fog`]): this group draws with the
|
||||
// building's own MFOG triple iff the walk that reached it kept an unbroken interior
|
||||
// chain from the seed AND the group is itself a true interior. ORed over paths — a
|
||||
// group two walks reach takes the lane if EITHER kept the chain.
|
||||
//
|
||||
// The second term is the DRAWER ROUTER folded in (`0x6b3fe8` picks interior-vs-exterior
|
||||
// on the same `flags & 0x48`): an exterior/exterior-lit group is drawn by `0x6b4f10`,
|
||||
// which pushes no fog at all, so `[0xca7f00]` cannot reach its surfaces however the walk
|
||||
// got there — and that is true of the SEED's own group too when the camera stands in a
|
||||
// courtyard. Folding it here rather than ANDing it in each of the three consuming lanes
|
||||
// keeps one definition of "which fog does this room wear".
|
||||
self.interior_fog[g] |= chain && nav[g].flags & (EXTERIOR | EXTERIOR_LIT) == 0;
|
||||
if depth >= DEPTH_CAP {
|
||||
continue;
|
||||
}
|
||||
let nav_g = &nav[g];
|
||||
// The chain breaks BELOW an exterior/exterior-lit group, never at the seed: the seed
|
||||
// forces the lane on for its own group (`0x6b3b4c`/`0x6b3df5`), and a `flags & 0x48`
|
||||
// group is the anchor the walk's chain breaks at (`0x6b424f` → `0x6b42c6`).
|
||||
let chain_on = chain && nav_g.flags & (EXTERIOR | EXTERIOR_LIT) == 0;
|
||||
let start = nav_g.ref_start as usize;
|
||||
let end = (start + nav_g.ref_count as usize).min(model.portal_refs.len());
|
||||
for r in &model.portal_refs[start..end] {
|
||||
let neighbour = r.group as usize;
|
||||
// Never re-cross the entry portal, and skip the "no group" sentinel.
|
||||
if r.group == u16::MAX || neighbour == came {
|
||||
continue;
|
||||
}
|
||||
let Some(info) = model.portal_infos.get(r.portal as usize) else {
|
||||
continue;
|
||||
};
|
||||
// Front-side test: the camera must be on the `side`-oriented half-space to enter.
|
||||
// The threshold IS zero — skip iff `d' < 0` strictly, enter on exactly-0 (the
|
||||
// client's `DAT_007ffd74 = 0.0`) — and it ALWAYS runs: the recorded on-plane "skip
|
||||
// the side test" flag is dead code in the client (`wmo-portal-audit.md` Q1
|
||||
// correction).
|
||||
let mut d = info.plane[0] * eye_local[0]
|
||||
+ info.plane[1] * eye_local[1]
|
||||
+ info.plane[2] * eye_local[2]
|
||||
+ info.plane[3];
|
||||
if r.side < 0 {
|
||||
d = -d;
|
||||
}
|
||||
if d < 0.0 {
|
||||
trace.side_fail(g, r.portal, neighbour, d);
|
||||
continue;
|
||||
}
|
||||
// The eye standing in this portal's plane (inside its polygon) gets the full-screen
|
||||
// rect (the client's `0x6b46f0` special case). Without this, a camera crossing a
|
||||
// doorway clips the room ahead to nothing for a frame.
|
||||
let on_plane = eye_on_portal(&model.portal_vertices, info, eye_local);
|
||||
// Project the portal polygon to a screen rect; intersect with the carried rect.
|
||||
// Either collapsing to zero area kills this branch (the cathedral case).
|
||||
let prect = if on_plane {
|
||||
FULL_SCREEN
|
||||
} else {
|
||||
match portal_screen_rect(
|
||||
model,
|
||||
info,
|
||||
self.clip_from_world,
|
||||
self.world_from_local,
|
||||
) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
trace.rect_none(g, r.portal, neighbour);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let Some(inter) = intersect_rect(rect, prect) else {
|
||||
trace.rect_collapse(g, r.portal, neighbour);
|
||||
continue;
|
||||
};
|
||||
trace.entered(g, r.portal, neighbour, inter, on_plane);
|
||||
// Reaching a portal onto an exterior group records a deferred window — the client's
|
||||
// push at `0x6b44f8`(test `0x8`)/`0x6b4539`(count)/`0x6b4541`(rect).
|
||||
// [`opens_a_window`] is the predicate, shared with nothing else because there IS
|
||||
// nothing else: this is the one place the worklist is built.
|
||||
if record_windows && opens_a_window(nav, neighbour) {
|
||||
if let Some(stamp) = self.portal_pushed.get_mut(r.portal as usize) {
|
||||
if !*stamp {
|
||||
*stamp = true;
|
||||
self.windows.push(inter);
|
||||
}
|
||||
}
|
||||
}
|
||||
stack.push((neighbour, g, inter, depth + 1, chain_on));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`compute_pvs`] with a [`FloodTrace`] tap on every decision — the same flood the frame runs;
|
||||
/// the probe dump and the audit harness pass a recorder, the per-frame path passes `()`.
|
||||
fn compute_pvs_traced<T: FloodTrace>(
|
||||
model: &WmoModel,
|
||||
eye_local: [f32; 3],
|
||||
@@ -783,8 +953,17 @@ fn compute_pvs_traced<T: FloodTrace>(
|
||||
trace: &mut T,
|
||||
) -> GroupPvs {
|
||||
let nav = &model.group_nav;
|
||||
let mut visible = vec![false; nav.len()];
|
||||
let mut interior_fog = vec![false; nav.len()];
|
||||
let mut flood = Flood {
|
||||
model,
|
||||
eye_local,
|
||||
clip_from_world,
|
||||
world_from_local,
|
||||
visible: vec![false; nav.len()],
|
||||
interior_fog: vec![false; nav.len()],
|
||||
windows: Vec::new(),
|
||||
portal_pushed: vec![false; model.portal_infos.len()],
|
||||
iters: 0,
|
||||
};
|
||||
|
||||
// Seed: the down-ray's set — the camera's current group AND, when the nearest crossing was a
|
||||
// portal, the group on its other side. Each is an independent flood ROOT with the full screen
|
||||
@@ -792,9 +971,8 @@ fn compute_pvs_traced<T: FloodTrace>(
|
||||
// over the whole set — `wow-5875-re` `wmo-portal-audit.md` Q2; seeding only the containing group
|
||||
// under-floods at exactly the near-portal moment the second root exists to cover). Over no surface
|
||||
// (open air) or an exterior one ⇒ the outside leg, seeding each EXTERIOR group full-screen (Bevy
|
||||
// then frustum-culls the genuinely off-screen ones by their Aabb). Stack item: (group, came_from,
|
||||
// working screen rect, depth, interior-chain intact).
|
||||
let mut stack: Vec<(usize, usize, Rect, u32, bool)> = Vec::new();
|
||||
// then frustum-culls the genuinely off-screen ones by their Aabb).
|
||||
let mut stack: Vec<Step> = Vec::new();
|
||||
let seeds = down_ray_seeds(model, eye_local, terrain_z);
|
||||
trace.seed(seeds);
|
||||
// **The inside-leg flag** — the client's `[0xcde5ac]`, set only for Pass 1 of `0x6b3b20`
|
||||
@@ -829,146 +1007,65 @@ fn compute_pvs_traced<T: FloodTrace>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The deferred-window worklist Pass 1 leaves behind — the client's `[0xcbe324]` records, reset to
|
||||
// empty at `0x6b3b51` and filled by the recursion at `0x6b4541`. Pass 2 replays it below.
|
||||
let mut windows: Vec<Rect> = Vec::new();
|
||||
// The client's per-frame stamp on the PORTAL record (`0x6b4505`, `[esi+0x18]` vs `[0xca8014]`):
|
||||
// a portal contributes **at most one** window per frame, however many paths the flood reaches it
|
||||
// by. It guards the store alone — the recursion runs regardless — so this is a dedup, never a
|
||||
// skip. First push wins, which our stack walk resolves the same way the reference's does.
|
||||
let mut portal_pushed = vec![false; model.portal_infos.len()];
|
||||
let mut iters = 0u32;
|
||||
while let Some((g, came, rect, depth, chain)) = stack.pop() {
|
||||
iters += 1;
|
||||
if iters > MAX_ITERS || g >= nav.len() {
|
||||
continue;
|
||||
}
|
||||
visible[g] = true;
|
||||
// The interior-fog gate ([`GroupPvs::interior_fog`]): this group draws with the building's
|
||||
// own MFOG triple iff the walk that reached it kept an unbroken interior chain from the
|
||||
// seed AND the group is itself a true interior. ORed over paths — a group two walks reach
|
||||
// takes the lane if EITHER kept the chain.
|
||||
//
|
||||
// The second term is the DRAWER ROUTER folded in (`0x6b3fe8` picks interior-vs-exterior on
|
||||
// the same `flags & 0x48`): an exterior/exterior-lit group is drawn by `0x6b4f10`, which
|
||||
// pushes no fog at all, so `[0xca7f00]` cannot reach its surfaces however the walk got
|
||||
// there — and that is true of the SEED's own group too when the camera stands in a
|
||||
// courtyard. Folding it here rather than ANDing it in each of the three consuming lanes
|
||||
// keeps one definition of "which fog does this room wear".
|
||||
interior_fog[g] |= chain && nav[g].flags & (EXTERIOR | EXTERIOR_LIT) == 0;
|
||||
if depth >= DEPTH_CAP {
|
||||
continue;
|
||||
}
|
||||
let nav_g = &nav[g];
|
||||
// The chain breaks BELOW an exterior/exterior-lit group, never at the seed: the seed forces
|
||||
// the lane on for its own group (`0x6b3b4c`/`0x6b3df5`), and a `flags & 0x48` group is the
|
||||
// anchor the walk's chain breaks at (`0x6b424f` → `0x6b42c6`).
|
||||
let chain_on = chain && nav_g.flags & (EXTERIOR | EXTERIOR_LIT) == 0;
|
||||
let start = nav_g.ref_start as usize;
|
||||
let end = (start + nav_g.ref_count as usize).min(model.portal_refs.len());
|
||||
for r in &model.portal_refs[start..end] {
|
||||
let neighbour = r.group as usize;
|
||||
// Never re-cross the entry portal, and skip the "no group" sentinel.
|
||||
if r.group == u16::MAX || neighbour == came {
|
||||
continue;
|
||||
}
|
||||
let Some(info) = model.portal_infos.get(r.portal as usize) else {
|
||||
continue;
|
||||
};
|
||||
// Front-side test: the camera must be on the `side`-oriented half-space to enter. The
|
||||
// threshold IS zero — skip iff `d' < 0` strictly, enter on exactly-0 (the client's
|
||||
// `DAT_007ffd74 = 0.0`) — and it ALWAYS runs: the recorded on-plane "skip the side test"
|
||||
// flag is dead code in the client (`wmo-portal-audit.md` Q1 correction).
|
||||
let mut d = info.plane[0] * eye_local[0]
|
||||
+ info.plane[1] * eye_local[1]
|
||||
+ info.plane[2] * eye_local[2]
|
||||
+ info.plane[3];
|
||||
if r.side < 0 {
|
||||
d = -d;
|
||||
}
|
||||
if d < 0.0 {
|
||||
trace.side_fail(g, r.portal, neighbour, d);
|
||||
continue;
|
||||
}
|
||||
// The eye standing in this portal's plane (inside its polygon) gets the full-screen rect
|
||||
// (the client's `0x6b46f0` special case). Without this, a camera crossing a doorway clips
|
||||
// the room ahead to nothing for a frame.
|
||||
let on_plane = eye_on_portal(&model.portal_vertices, info, eye_local);
|
||||
// Project the portal polygon to a screen rect; intersect with the carried rect. Either
|
||||
// collapsing to zero area kills this branch (the cathedral case).
|
||||
let prect = if on_plane {
|
||||
FULL_SCREEN
|
||||
} else {
|
||||
match portal_screen_rect(model, info, clip_from_world, world_from_local) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
trace.rect_none(g, r.portal, neighbour);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let Some(inter) = intersect_rect(rect, prect) else {
|
||||
trace.rect_collapse(g, r.portal, neighbour);
|
||||
continue;
|
||||
};
|
||||
trace.entered(g, r.portal, neighbour, inter, on_plane);
|
||||
// Reaching a portal onto an exterior group records a deferred window — the client's
|
||||
// push at `0x6b44f8`(test `0x8`)/`0x6b4539`(count)/`0x6b4541`(rect). [`opens_a_window`]
|
||||
// is the predicate, shared with nothing else because there IS nothing else: this is the
|
||||
// one place the worklist is built.
|
||||
if inside_leg && opens_a_window(nav, neighbour) {
|
||||
if let Some(stamp) = portal_pushed.get_mut(r.portal as usize) {
|
||||
if !*stamp {
|
||||
*stamp = true;
|
||||
windows.push(inter);
|
||||
}
|
||||
}
|
||||
}
|
||||
stack.push((neighbour, g, inter, depth + 1, chain_on));
|
||||
}
|
||||
}
|
||||
// **Pass 1 — the interior flood** (`0x6b3bd4..0x6b3c10`), the only run that records windows.
|
||||
flood.walk(&mut stack, inside_leg, trace);
|
||||
|
||||
// **Pass 2 — the deferred exterior-window replay** (`0x6b3c73..0x6b3d6f`), carved at the bytes in
|
||||
// `wow-5875-re` `models/scratch/wmo-insideleg-phase3.md` (§5 1v1, VERIFIED). Gated on the worklist
|
||||
// being non-empty (`0x6b3c87 jbe 0x6b3d6f` — a sealed room draws no exterior shell at all), and
|
||||
// then, **per window**, every group is admitted on `0x8 ∧ visible against a frustum clipped to
|
||||
// THAT window` (`0x6b3d13` / `0x6b3d22`) — never against the full view. That is what draws the
|
||||
// portal-disconnected shells (an inn's outer walls carry no portals, so the flood alone never
|
||||
// reaches them) without drawing the ones no doorway shows.
|
||||
// THAT window` (`0x6b3d13` / `0x6b3d22`) — never against the full view.
|
||||
//
|
||||
// **And an admitted group is FLOODED FROM, not merely marked** (`0x6b3d39 call 0x6b41c0` — the
|
||||
// portal recursion, the identical entry point Pass 1 uses; the callback draw `0x6b4160` is Pass
|
||||
// 3's and only Pass 3's). Decision 1826 shipped this as a marking pass, which is why a city
|
||||
// whose outdoor areas are stitched together by portals — Darnassus is one WMO with 104 groups,
|
||||
// 50 of them exterior — lost every shell the doorway's own rect did not directly touch. The
|
||||
// window admits the *first* group; the walk carries that window's rect onward through the
|
||||
// graph, narrowing at each portal exactly as Pass 1 does.
|
||||
//
|
||||
// We used to mark EVERY `0x8` group visible the moment any window existed, against the full
|
||||
// frustum, with a comment calling it "a benign over-draw". It is not benign: it is the
|
||||
// building's own far side drawing through its own walls, and decision 0784 had already flagged
|
||||
// this exact line — *"the exemption is per placement, not per group … if a case turns up where
|
||||
// it is not, this is the line to revisit"*. The case is the carve itself. Decision 1826.
|
||||
// it is not, this is the line to revisit"*. The case is the carve itself.
|
||||
//
|
||||
// The sub-frustum is [`crate::exterior_cull::window_frustum`] — the SAME construction, and the
|
||||
// same too-narrow-window reject, that gates the open world through these very rects. One
|
||||
// definition, so a doorway that cannot admit a hillside cannot admit a wall either.
|
||||
if windows.len() > WORKLIST_CAP {
|
||||
trace.worklist_over_cap(windows.len());
|
||||
// The sub-frustum is [`crate::exterior_cull::window_frustum`] — the same construction the open
|
||||
// world is gated by, and **deliberately not** its narrowness reject. That reject is the
|
||||
// entry gate of the scene walk `0x682fa0`, not part of the volume builder `0x682930` (which
|
||||
// contains no compare at all); 1826 applied it here too and blanked the containing building's
|
||||
// whole shell for every window in the `[0.001, 0.02)` NDC band — above the recursion's own
|
||||
// collapse guard, below the scene walk's. So a doorway too narrow to admit a hillside can
|
||||
// still admit a wall, which is the reference's own asymmetry.
|
||||
if flood.windows.len() > WORKLIST_CAP {
|
||||
trace.worklist_over_cap(flood.windows.len());
|
||||
}
|
||||
if !windows.is_empty() {
|
||||
let frusta: Vec<Frustum> = windows
|
||||
.iter()
|
||||
.filter_map(|r| crate::exterior_cull::window_frustum(*r, clip_from_world))
|
||||
.collect();
|
||||
// The worklist is fixed once Pass 1 ends (`[0xcde5ac]` is clear from here on), so replaying it
|
||||
// by value keeps the borrow honest without copying anything that can still change.
|
||||
let worklist = std::mem::take(&mut flood.windows);
|
||||
for (wi, rect) in worklist.iter().enumerate() {
|
||||
let frustum = crate::exterior_cull::window_frustum(*rect, clip_from_world);
|
||||
// Every MOGI group is a candidate against every window — the reference's inner loop carries
|
||||
// no "already drawn" short-circuit, and one that skipped a visible group would also skip the
|
||||
// walk THROUGH it, which is the half of Pass 2 that reaches the rest of the city.
|
||||
let mut roots: Vec<Step> = Vec::new();
|
||||
for (gi, g) in nav.iter().enumerate() {
|
||||
// `¬0x10000 ∧ 0x8` — the client's `0x6b3d0b`/`0x6b3d13`, in that order. A callback-pass
|
||||
// group is Pass 3's alone (below); admitting it here too would be the double-submit the
|
||||
// reference's two predicates exist to prevent.
|
||||
if visible[gi] || g.flags & (EXTERIOR | CALLBACK_PASS) != EXTERIOR {
|
||||
if g.flags & (EXTERIOR | CALLBACK_PASS) != EXTERIOR {
|
||||
continue;
|
||||
}
|
||||
let aabb = group_aabb(g);
|
||||
let admitted = frusta
|
||||
.iter()
|
||||
.any(|f| f.intersects_obb(&aabb, world_from_local, true, true));
|
||||
trace.pass2(gi, g.flags, frusta.len(), admitted);
|
||||
visible[gi] |= admitted;
|
||||
let admitted = frustum.intersects_obb(&group_aabb(g), world_from_local, true, true);
|
||||
trace.pass2(gi, g.flags, wi, admitted);
|
||||
if admitted {
|
||||
roots.push((gi, usize::MAX, *rect, 0, false));
|
||||
}
|
||||
}
|
||||
flood.walk(&mut roots, false, trace);
|
||||
}
|
||||
flood.windows = worklist;
|
||||
|
||||
// **Pass 3 — the callback pass** (`0x6b3d6f..0x6b3dbc`). Two things make it unlike Pass 2, and
|
||||
// both matter: it tests the group against the **full camera frustum** (no window narrowing —
|
||||
// there is no `0x682930` call on this path), and it is the `jbe` **fall-through**, so it runs
|
||||
@@ -996,18 +1093,19 @@ fn compute_pvs_traced<T: FloodTrace>(
|
||||
// diverge.
|
||||
let base = Frustum::from_clip_from_world(clip_from_world);
|
||||
for (gi, g) in nav.iter().enumerate() {
|
||||
if visible[gi] || g.flags & CALLBACK_PASS == 0 {
|
||||
if flood.visible[gi] || g.flags & CALLBACK_PASS == 0 {
|
||||
continue;
|
||||
}
|
||||
let aabb = group_aabb(g);
|
||||
let admitted = base.intersects_obb(&aabb, world_from_local, true, true);
|
||||
trace.pass3(gi, g.flags, admitted);
|
||||
visible[gi] |= admitted;
|
||||
flood.visible[gi] |= admitted;
|
||||
}
|
||||
GroupPvs {
|
||||
visible,
|
||||
interior_fog,
|
||||
windows,
|
||||
visible: flood.visible,
|
||||
interior_fog: flood.interior_fog,
|
||||
windows: flood.windows,
|
||||
iters: flood.iters,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1443,6 +1541,13 @@ mod tests {
|
||||
"a portal-disconnected shell 30 yd off the doorway's axis is culled: no window shows \
|
||||
it, and drawing it is exactly the over-draw 1826 removed"
|
||||
);
|
||||
assert!(
|
||||
pvs.visible[4],
|
||||
"1853: an admitted shell is FLOODED FROM, not merely marked — g4 is an interior behind \
|
||||
g2 with no edge to the seed's half of the graph, so the walk through g2's own back \
|
||||
door is its only route into the PVS (`0x6b3d39 call 0x6b41c0`). A marking Pass 2 \
|
||||
leaves it culled, which from a Darnassus shop is most of the city"
|
||||
);
|
||||
|
||||
// The control: widen the window to the whole screen (stand IN the doorway, which takes the
|
||||
// on-plane full-screen branch) and the side shell comes back — proving the cull above is the
|
||||
@@ -1578,13 +1683,27 @@ mod tests {
|
||||
[0.0, 0.5, 0.0],
|
||||
[0.0, 0.5, 2.0],
|
||||
[0.0, -0.5, 2.0],
|
||||
// p1, the back door of the disconnected shell g2 — g4's only way in.
|
||||
[6.0, -0.5, 0.0],
|
||||
[6.0, 0.5, 0.0],
|
||||
[6.0, 0.5, 2.0],
|
||||
[6.0, -0.5, 2.0],
|
||||
],
|
||||
portal_infos: vec![
|
||||
WmoPortalInfo {
|
||||
start_vertex: 0,
|
||||
count: 4,
|
||||
plane: [1.0, 0.0, 0.0, 0.0],
|
||||
},
|
||||
WmoPortalInfo {
|
||||
start_vertex: 4,
|
||||
count: 4,
|
||||
plane: [1.0, 0.0, 0.0, -6.0],
|
||||
},
|
||||
],
|
||||
portal_infos: vec![WmoPortalInfo {
|
||||
start_vertex: 0,
|
||||
count: 4,
|
||||
plane: [1.0, 0.0, 0.0, 0.0],
|
||||
}],
|
||||
// g0 -> g1 and back. `side = -1` puts the eye at x < 0 on the entering half-space.
|
||||
// Then g2 -> g4 and back, through p1: g2 has no edge to the seed's side of the graph, so
|
||||
// the ONLY route to g4 is Pass 2 admitting g2 and then walking on through it.
|
||||
portal_refs: vec![
|
||||
WmoPortalRef {
|
||||
portal: 0,
|
||||
@@ -1596,14 +1715,25 @@ mod tests {
|
||||
group: 0,
|
||||
side: 1,
|
||||
},
|
||||
WmoPortalRef {
|
||||
portal: 1,
|
||||
group: 4,
|
||||
side: -1,
|
||||
},
|
||||
WmoPortalRef {
|
||||
portal: 1,
|
||||
group: 2,
|
||||
side: 1,
|
||||
},
|
||||
],
|
||||
group_nav: vec![
|
||||
nav(0x2000, [-10.0, -10.0, 0.0], [0.0, 10.0, 3.0], 0, 1),
|
||||
nav(EXTERIOR, [1.0, -0.5, 0.0], [5.0, 0.5, 2.0], 1, 1),
|
||||
nav(EXTERIOR, [2.0, -0.5, 0.0], [6.0, 0.5, 2.0], 0, 0),
|
||||
nav(EXTERIOR, [2.0, -0.5, 0.0], [6.0, 0.5, 2.0], 2, 1),
|
||||
nav(EXTERIOR, [2.0, 30.0, 0.0], [6.0, 34.0, 2.0], 0, 0),
|
||||
nav(0x2000, [7.0, -0.5, 0.0], [11.0, 0.5, 2.0], 3, 1),
|
||||
],
|
||||
group_collision_tris: vec![floor(0.0), Vec::new(), Vec::new(), Vec::new()],
|
||||
group_collision_tris: vec![floor(0.0), Vec::new(), Vec::new(), Vec::new(), Vec::new()],
|
||||
..portal_model()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,10 +144,10 @@ impl FloodTrace for TraceLog {
|
||||
if on_plane { " (in-plane)" } else { "" }
|
||||
));
|
||||
}
|
||||
fn pass2(&mut self, group: usize, flags: u32, windows: usize, admitted: bool) {
|
||||
fn pass2(&mut self, group: usize, flags: u32, window: usize, admitted: bool) {
|
||||
self.text.push_str(&format!(
|
||||
" pass2 g{group:02} EXT flags {flags:#07x} vs {windows} window(s): {}\n",
|
||||
if admitted { "DRAWN" } else { "culled" }
|
||||
" pass2 g{group:02} EXT flags {flags:#07x} vs window w{window}: {}\n",
|
||||
if admitted { "FLOOD ROOT" } else { "culled" }
|
||||
));
|
||||
}
|
||||
fn pass3(&mut self, group: usize, flags: u32, admitted: bool) {
|
||||
|
||||
Reference in New Issue
Block a user