opening a chest kneels you, and outdoors elects what it draws

- the loot kneel finally fires on a chest (the reported gap): a chest never sends `CMSG_LOOT`, so the client-local loot latch gains its second arm on `SMSG_SPELL_GO` with the loot response as an admission gate — you kneel before the window opens and hold it while it is up, like the reference
- the outdoor draw election lands: the 1.12 client elects every scene object per frame, and a unit behind you or in a room the portal flood can't see is neither drawn, animated nor particle-ticked — at the Goldshire pin drawn submeshes go 365 → 49 and live particles 569 → 138, camera stamp bit-identical; `WOW_NO_DRAW_ELECTION=1` reverts. No ms pricing yet — counts only, the machine was shared
- standing-NPC facing and docked transports go write-on-change instead of dirtying their transforms every frame
- the options window gets a ground: a black fill inside the rope drops the world's bleed-through onto the page from 40% to 12%, so the small text reads against snow and noon water

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-21 17:59:10 +07:00
co-authored by Claude Fable 5
parent 441cd33338
commit b32dc59723
25 changed files with 1539 additions and 173 deletions
@@ -60,6 +60,9 @@
through, the reference screenshot's actual mechanism; 0978 misread that ground as
parchment) + UI-DialogBox-Border rope edge 32, insets 11/12/12/11. Title plate
UI-DialogBox-Header at the options frames' 300x68 TOP (0,+12) seat, title on it.
ON TOP of that tile, inside the rope, a plain black 0.7 fill (1476) takes the
composite ground to 0.88: at the bare tile the world reached the option labels
hard enough to break them over bright terrain. The read-through stays, dimmed.
· the era's one dark Options_InnerFrame panel → TWO 1.14 OptionsFrameListTemplate
boxes: tooltip-border backdrops with the FILL AT ALPHA 0 (outlines — the ground
shows through) and the border tinted LIGHTGRAY_FONT_COLOR 0.6, tops at -52 (1.14's
@@ -1525,6 +1528,37 @@
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
</Backdrop>
<Layers>
<Layer level="BACKGROUND">
<!-- THE GROUND DIM (1476) — a plain black fill over the tiled ground, because
the tile alone is not a readable page. UI-DialogBox-Background decodes to a
uniform (0,0,0,153) — pure black at 0.6 — so 40% of the world reaches the
text: over snow, noon water or a lit sky the page ground lands near mid-grey
and the option labels stop reading (the director's report). 0.6 is right for
a dialog you glance at; the options window is a page you READ, and the era's
own answer is an OPAQUE FlatPanelBackgroundTemplate panel — so darkening
toward it is the era structure 0985 already gives us, wearing a plain colour
fill, which 0985 names as an art byte we may author.
Alpha 0.7 over the 0.6 tile composites to 0.88 (the world reaches the page at
0.12), not 1.0: the faint read-through is the window's character, and 0981's
"the reference was never parchment" finding stands — this dims the ground, it
does not replace it.
SEAT: inset 15 on all four sides, NOT the backdrop's own 11/12/12/11 bg
insets. Regions sort AFTER the frame's own draw slot, which is where the
backdrop's bg AND its 8 border pieces both live (extract.rs) — so this fill
paints OVER the rope, and at the bg insets it would smear 0.7 black across
the border's inner half. The rope's ink is measured, not guessed: every edge
slice of UI-DialogBox-Border is opaque only in its outer ~11 units and dead
by texel 16 of 32, and the corner slices' ink ends by 14 (decoded). 15 clears
every inked texel and leaves at most a 1-unit lighter hairline inside the
rope, where the rope reads as frame anyway. -->
<Texture name="$parentGroundDim">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="15" y="-15"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-15" y="15"/></Offset></Anchor>
</Anchors>
<Color r="0" g="0" b="0" a="0.7"/>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- The carved title plate at the 1.14 options frames' own seat: 300x68 at
TOP (0,+12) — OptionsFrameTemplates.xml l.244-255 (GameMenuFrame's plate
+2
View File
@@ -86,6 +86,7 @@ mod probe_binder;
mod probe_book;
mod probe_castcancel;
mod probe_charcreate;
mod probe_chest;
mod probe_crossing;
mod probe_mail;
mod probe_melee;
@@ -105,6 +106,7 @@ pub(crate) use probe_binder::ProbeBinderPlugin;
pub(crate) use probe_book::ProbeBookPlugin;
pub(crate) use probe_castcancel::ProbeCastCancelPlugin;
pub(crate) use probe_charcreate::ProbeCharCreatePlugin;
pub(crate) use probe_chest::ProbeChestPlugin;
pub(crate) use probe_crossing::ProbeCrossingPlugin;
pub(crate) use probe_mail::ProbeMailPlugin;
pub(crate) use probe_melee::ProbeMeleePlugin;
@@ -0,0 +1,391 @@
//! The chest live probe (`WOW_PROBE_CHEST=1`) — **B84's instrument**: does the player kneel at an
//! open chest?
//!
//! Goudy, 2026-07-26 (`#bugs` `1530708187173359646`): *"No looting/opening animation when using a
//! chest"*, with a benilla/reference pair at the same Mirror Lake Orchard chest — the reference
//! gnome kneeling over the open loot window, ours standing bolt upright. Decision 0515 had shipped
//! the kneel keyed to the `CMSG_LOOT` send alone and recorded a chest's silence as *correct*; 1471
//! is the correction (the real client also arms the latch in `OnLootResponse 0x5eb900`, and a chest
//! never sends `CMSG_LOOT` at all).
//!
//! **A kneel is a number here, not a picture** (`method.md` step 4: timing and pose questions are
//! settled with instruments). The probe reads the self unit's own
//! [`AnimDriver::active_anim`][crate::creature_anim::AnimDriver::active_anim] — the id the base
//! selector actually landed on — so the verdict is `50` (`Loot`) or it is not, with no screenshot
//! and no eye in the loop. Three readings, in order, which is what makes it a *regression* test and
//! not a one-sided assertion:
//!
//! 1. **BEFORE** — parked at the chest, window shut: the base anim must be `0` (`Stand`). This is
//! the control; a probe that only ever sees `50` cannot tell a fix from a stuck kneel.
//! 2. **OPEN** — the chest used on the click's own route, the loot window up: must be `50`.
//! 3. **AFTER** — `CloseLoot()` through the live VM: back to `0`.
//!
//! Nothing here re-decides what packet a chest takes. The probe resolves the object through
//! [`crate::target::click::resolve_go_action`] — the same lock chain the mouse runs (0239/0752) —
//! and sends whichever arm it names, so a chest whose `Lock.dbc` row changes shape still gets the
//! packet the real click would send. (Every world chest carries a lock: `Lock.dbc` 43 is one
//! `LockType 13 "Open Kneeling"` slot, 57 is `5 "Open"` + `6 "Treasure (DND)"`, all `Skill 0`, so
//! the "Opening" spell every character knows satisfies them and the live arm is `OpenLock`.)
//!
//! The DONE line reports the two mechanism halves separately — `latch-while-open` (predicate A: a
//! loot session is open) and `kneel-while-open` (predicate B: this *kind* of target is knelt at,
//! decision 1477). They are not the same question, and the whole of B84's correction lives in the
//! gap: a fishing bobber arms the latch exactly as a chest does and must still read `kneel=false`.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_CHEST=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `method.md`
//! "The local vmangos server"). `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]` aims it at a different
//! object; the default is a live `Battered Chest` spawn (`gameobject.guid` 26756, template 2843) in
//! the Mirror Lake stretch of Elwynn, the same corner of the world the report came from. A chest is
//! a respawning spawn point: if someone emptied that one it will be missing for its respawn timer,
//! which the probe reports as a scan failure rather than as a passing run.
//!
//! Grep `PROBE_CHEST:` for the verdict; the probe self-exits when it lands.
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::creature_anim::AnimDriver;
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::target::click::{resolve_go_action, GoAction};
use crate::target::lock::GoLockInputs;
use crate::ui_loot::{LootKneel, LootLatch, LootState};
/// The probe's default object: the `Worn Wooden Chest` at `gameobject` 3998644 (template 1765,
/// `Lock.dbc` 43 → `LockType 13 "Open Kneeling"`, loot 1570) below Northshire in Elwynn.
///
/// **Not** the reporter's own Mirror Lake `Battered Chest`, deliberately, and the reason is the
/// trap this probe fell into first: every ordinary world chest — that one included — is a
/// **spawn-pool** member (`pool_gameobject`, one of pool 20004's many points spawned at a time), so
/// a fixed coordinate finds nothing most of the time and the probe reads "no chest" where the real
/// answer is "not this point today". 3998644 is unpooled with a 25 s respawn, so it is there every
/// run and it comes back between runs. It is the same GameObject type, the same lock family and the
/// same `SMSG_LOOT_RESPONSE`-only session as the reported object.
const CHEST_AT: [f32; 4] = [-9474.37, 111.25, 57.03, 0.0];
/// `GAMEOBJECT_TYPE_CHEST` — the strategy type a chest (and a herb/mining node) carries.
const GO_TYPE_CHEST: i32 = 3;
/// `AnimationData.dbc` ids the verdict is written in: the kneel we want, and the Stand we must
/// return to. Named rather than inlined so the report reads as animations, not magic numbers.
const ANIM_LOOT: u16 = 50;
const ANIM_STAND: u16 = 0;
/// Scan radius around the landing spot, in yards.
const SCAN_RANGE: f32 = 25.0;
/// Frames each window samples. The base anim is a settled per-frame state, so this is about
/// outlasting a transient, not about statistics.
const SAMPLE_FRAMES: usize = 60;
const SETTLE_SECS: f64 = 5.0;
const SCAN_TIMEOUT_SECS: f64 = 25.0;
const LOOT_TIMEOUT_SECS: f64 = 15.0;
const CLOSE_TIMEOUT_SECS: f64 = 10.0;
/// The streamed-object columns the scan and the lock resolve both read — a `type` alias so the
/// one `Query` stays inside clippy's complexity bar.
type NearbyObject = (
&'static Guid,
&'static NetEntity,
&'static ObjectStore,
&'static Transform,
Option<&'static crate::go_anim::GoAnim>,
);
pub(crate) struct ProbeChestPlugin;
impl Plugin for ProbeChestPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ChestProbe>()
.add_systems(Update, chest_probe);
}
}
#[derive(Resource, Default)]
struct ChestProbe {
phase: Phase,
/// The chest's guid, once the scan finds it.
chest: Option<u64>,
/// The base anim ids seen in each window, in frame order.
before: Vec<Option<u16>>,
open: Vec<Option<u16>>,
after: Vec<Option<u16>>,
/// The two mechanism readings taken when the OPEN window finished — reported beside the pose
/// so a failure says *which* half broke. They are deliberately separate: predicate A (a loot
/// session is open — the latch) and predicate B (this KIND of target is knelt at — decision
/// 1477). A fishing bobber is `latch=Some(...) kneel=false`, and that is correct.
latch_open: Option<u64>,
kneel_open: bool,
fails: u32,
exited: bool,
}
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
Wait,
/// `.go` issued; letting the world stream the chest in.
Settling {
sent_at: f64,
},
/// Sampling with the chest SHUT — the control window.
Before,
/// The use packet is out; waiting for the server to open a loot window.
WaitLoot {
since: f64,
},
/// Sampling with the loot window OPEN — the reported condition.
Open,
/// `CloseLoot()` called; waiting for the window to actually go.
WaitClosed {
since: f64,
},
/// Sampling after the close — the "and it stands back up" half.
After,
Done,
}
/// Where the probe is aimed: `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]`, else [`CHEST_AT`]. Anything
/// unparseable falls back to the default rather than failing the run — the common value is `1`.
fn target() -> [f32; 4] {
let Ok(raw) = std::env::var("WOW_PROBE_CHEST") else {
return CHEST_AT;
};
let parts: Vec<f32> = raw
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
match parts.len() {
3 => [parts[0], parts[1], parts[2], CHEST_AT[3]],
4 => [parts[0], parts[1], parts[2], parts[3]],
_ => CHEST_AT,
}
}
/// Whether the loot window is up, asked of the **live UI** rather than of our own state: the
/// window is what the reporter saw, and `LootFrame` shows only once the feed has fired
/// `LOOT_OPENED` off the wire. Falls back to the app-side session when the frame can't be read.
fn window_open(script: &UiScript, loot: &LootState) -> bool {
script
.eval::<bool>("return LootFrame:IsShown() and 1 or nil")
.unwrap_or_else(|_| loot.source().is_some())
}
/// One window's verdict line: what the base slot held, how steadily, and whether it is the id the
/// reference shows. `expect` is the id every frame of the window must carry.
fn report(label: &str, expect: u16, seen: &[Option<u16>]) -> u32 {
let held = seen.iter().filter(|a| **a == Some(expect)).count();
// What it held instead, most-common first — the useful half of a failure.
let mut other: Vec<String> = Vec::new();
for a in seen.iter().filter(|a| **a != Some(expect)) {
let name = a.map_or_else(|| "none".to_string(), |id| id.to_string());
if !other.contains(&name) {
other.push(name);
}
}
if held == seen.len() && !seen.is_empty() {
info!(
"PROBE_CHEST: {label:<6} PASS — base anim {expect} on all {}/{} frames",
held,
seen.len()
);
0
} else {
error!(
"PROBE_CHEST: {label:<6} FAIL — base anim {expect} on only {}/{} frames (also saw: {})",
held,
seen.len(),
if other.is_empty() {
"nothing".to_string()
} else {
other.join(", ")
}
);
1
}
}
#[allow(clippy::too_many_arguments)]
fn chest_probe(
time: ProbeClock,
mut probe: ResMut<ChestProbe>,
script: Option<NonSendMut<UiScript>>,
self_q: Query<&AnimDriver, With<SelfPlayer>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
player: Res<Player>,
objects: Query<NearbyObject, Without<SelfPlayer>>,
mut go_inputs: GoLockInputs,
actions: Res<crate::ui_action::PlayerActions>,
loot: Res<LootState>,
latch: Res<LootLatch>,
kneel: Res<LootKneel>,
net: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
) {
let Ok(driver) = self_q.single() else {
return; // not in-world yet
};
let Some(script) = script else {
return; // no UI VM this build — nothing this probe can drive
};
let now = time.elapsed_secs_f64();
let anim = driver.active_anim();
match probe.phase {
Phase::Wait => {
let [x, y, z, map] = target();
info!(
"PROBE_CHEST: heading to the chest ({x} {y} {z} map {map}) — B84's object, \
GameObject type {GO_TYPE_CHEST}"
);
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {map}"),
});
probe.phase = Phase::Settling { sent_at: now };
}
Phase::Settling { sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
}
let me = player.pos;
let chest = objects.iter().find(|(_, net_e, store, tf, _)| {
net_e.kind == EntityKind::GameObject
&& store.0.gameobject_type_id() == GO_TYPE_CHEST
&& tf.translation.distance(me) < SCAN_RANGE
});
if let Some((guid, ..)) = chest {
info!(
"PROBE_CHEST: chest {:#x} in range — sampling {SAMPLE_FRAMES} frames with it SHUT",
guid.0
);
probe.chest = Some(guid.0);
probe.phase = Phase::Before;
} else if now - sent_at > SCAN_TIMEOUT_SECS {
error!(
"PROBE_CHEST: FAIL — no type-{GO_TYPE_CHEST} GameObject within {SCAN_RANGE} yd \
in {SCAN_TIMEOUT_SECS}s. A chest is a respawning spawn point: if it was looted \
recently it is simply not there yet. This is NOT a passing run"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Before => {
probe.before.push(anim);
if probe.before.len() < SAMPLE_FRAMES {
return;
}
let Some(guid) = probe.chest else {
probe.phase = Phase::Done;
return;
};
// The click's own route (0239/0752): ask the shared lock chain what the right-click
// would send, then send exactly that.
let go = objects
.iter()
.find(|(g, ..)| g.0 == guid)
.map(|(_, _, store, _, anim)| (store, crate::go_anim::go_state(anim, store)));
let me_store = self_store.single().ok();
match resolve_go_action(guid, &mut go_inputs, &actions.spells, go, me_store, &net) {
GoAction::Use => {
info!("PROBE_CHEST: lockless — CMSG_GAMEOBJ_USE on {guid:#x}");
let _ = net.0.send(ClientCommand::GameObjUse { guid });
}
GoAction::OpenLock(spell_id) => {
info!("PROBE_CHEST: opener cast {spell_id} at {guid:#x} (the live arm)");
let _ = net.0.send(ClientCommand::CastSpellGameObject {
spell_id,
go_guid: guid,
});
}
GoAction::OpenByKey { .. } | GoAction::Refuse(_) => {
error!(
"PROBE_CHEST: FAIL — the lock chain refuses this object (key/unmet). Aim \
the probe at a chest this character can actually open"
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
}
}
probe.phase = Phase::WaitLoot { since: now };
}
Phase::WaitLoot { since } => {
if window_open(&script, &loot) {
info!(
"PROBE_CHEST: loot window up on {:#x} — sampling {SAMPLE_FRAMES} frames OPEN",
loot.source().unwrap_or_default()
);
probe.phase = Phase::Open;
} else if now - since > LOOT_TIMEOUT_SECS {
error!(
"PROBE_CHEST: FAIL — no loot window within {LOOT_TIMEOUT_SECS}s of the use; \
the anim question can't be asked at all"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Open => {
probe.open.push(anim);
if probe.open.len() >= SAMPLE_FRAMES {
probe.latch_open = latch.0;
probe.kneel_open = kneel.0;
info!("PROBE_CHEST: closing the window through the live VM's own CloseLoot()");
let _ = script.eval::<()>("CloseLoot()");
probe.phase = Phase::WaitClosed { since: now };
}
}
Phase::WaitClosed { since } => {
if !window_open(&script, &loot) {
info!("PROBE_CHEST: window closed — sampling {SAMPLE_FRAMES} frames AFTER");
probe.phase = Phase::After;
} else if now - since > CLOSE_TIMEOUT_SECS {
error!(
"PROBE_CHEST: FAIL — the loot window never closed within {CLOSE_TIMEOUT_SECS}s"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::After => {
probe.after.push(anim);
if probe.after.len() >= SAMPLE_FRAMES {
probe.phase = Phase::Done;
}
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
let mut fails = probe.fails;
fails += report("BEFORE", ANIM_STAND, &probe.before);
fails += report("OPEN", ANIM_LOOT, &probe.open);
fails += report("AFTER", ANIM_STAND, &probe.after);
info!(
"PROBE_CHEST: DONE chest={:#x} latch-while-open={:?} kneel-while-open={} fail={fails}",
probe.chest.unwrap_or_default(),
probe.latch_open.map(|g| format!("{g:#x}")),
probe.kneel_open,
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_CHEST: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
@@ -117,9 +117,14 @@ type ScreenParams<'w, 's> = (
);
#[derive(SystemParam)]
struct SamplePin<'w> {
struct SamplePin<'w, 's> {
map: Option<Res<'w, benilla_world::world_map::CurrentMap>>,
body: Option<Res<'w, crate::player::Player>>,
/// The eye the leg was measured through — pose-stamped like `sys_busy_pct` time-stamps load,
/// and for the same reason: two legs are only comparable if this matches. 1475's bring-up
/// lost an hour to a probe whose camera sat first-person pitched 26° down — a per-character
/// saved camera file, invisible in every count — and the census read as a regression hunt.
cam: Query<'w, 's, &'static GlobalTransform, With<benilla_world::view::WorldCamera>>,
}
/// Wait for in-world + the delay, uncap, warm, sample, print, exit — the live twin of the
@@ -339,6 +344,17 @@ fn drive_live_fps(
}
_ => String::new(),
};
let cam_pose = pin
.cam
.iter()
.next()
.map(|gt| {
let [x, y, z] = benilla_assets::coords::bevy_to_wow(gt.translation());
let (yaw, pitch, _) =
gt.to_scale_rotation_translation().1.to_euler(EulerRot::YXZ);
format!(" cam={x:.1},{y:.1},{z:.1} cam_yaw={yaw:.2} cam_pitch={pitch:.2}")
})
.unwrap_or_default();
let gate = match (seen.room.as_deref(), seen.windows.as_deref()) {
(Some(room), Some(w)) => format!(" room={room} windows={w}"),
_ => String::new(),
@@ -379,7 +395,7 @@ fn drive_live_fps(
seen.mats, seen.meshes, seen.images, seen.uv_anims, seen.tint_anims,
);
println!(
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present}{display}{gpu_line} occluded_frames={}{at_pin}{gate}{sky}{ribbons}{culled}",
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present}{display}{gpu_line} occluded_frames={}{at_pin}{cam_pose}{gate}{sky}{ribbons}{culled}",
v.len(),
at(0.50),
at(0.95),
+9 -2
View File
@@ -57,8 +57,10 @@ pub(crate) use twist::{wrap_pi, BodyTwist};
/// The unit animation-LOD gate (decision 0448): park an off-frustum rig's per-bone pose
/// evaluation — the clocks, the driver state machine, and the event tracks keep running, so
/// off-screen combat stays audible (0075) and a re-appearing unit snaps to the absolute-clock
/// pose. A modernization, not fidelity — the reference animates every in-range unit (wow-re
/// `unit-anim-visibility-gate.md`).
/// pose. Shipped as a modernization; wow-re's 2026-08-13 election correction
/// (`outdoor-object-pass-election.md`) made it the faithful direction instead — decision 1473
/// records that, and what still diverges (parked rigs keep drawing; the reference keeps only
/// `MORE_AUDIBLE` creatures audible off-screen).
mod lod;
/// The unit's wielded weapon classes — `(item class, item subclass)` per hand, `None` for an empty
@@ -1058,6 +1060,11 @@ impl Plugin for CreatureAnimPlugin {
)
.chain()
.after(WorldStage::Net)
// After predicate B's recompute (decision 1477): the loot leg's self trigger
// is that boolean, and the reference has no gap between arming the latch and
// posing — its chest arm force-plays Loot 50 in the same handler. Without
// this edge the kneel lands a frame late at every loot window.
.after(crate::ui_loot::resolve_loot_kneel)
// After Input so a sheath request (the Z toggle) executes the same frame.
.after(WorldStage::Input)
// Before the entity-visuals chain: [`VisualSheath`] writes must be applied
+27 -15
View File
@@ -332,10 +332,11 @@ pub(super) fn drive_animations(
// client-local loot-target latch, and the frame clock.
aux: (
Option<Res<EmoteSounds>>,
// The loot-target latch (decision 0515): the SELF unit's kneel trigger — armed at the
// `CMSG_LOOT` send, dropped at release/refusal — read by the loot leg below. `Option`
// for the headless test worlds that don't build the loot seam.
Option<Res<crate::ui_loot::LootLatch>>,
// Predicate B's standing answer (decision 1477): whether the currently latched loot
// target is one the SELF unit kneels at — the loot leg's whole self trigger, since it is
// false while the latch is cold. `Option` for the headless test worlds that don't build
// the loot seam.
Option<Res<crate::ui_loot::LootKneel>>,
// The frame clock — the key-bone cross-fade's only time source (decision 0878): its
// retiring node is *frozen* on the clip's final frame, so unlike the wound's decay there
// is no playback clock to read the λ window off.
@@ -348,7 +349,7 @@ pub(super) fn drive_animations(
// diff-only filter; see the trace block after the mode machine).
mut anim_trace_last: Local<std::collections::HashMap<Entity, String>>,
) {
let (emote_sounds, loot_latch, time) = aux;
let (emote_sounds, loot_kneel, time) = aux;
let dt = time.delta_secs();
// This frame's one-shot PLAY CALLS (swings + anim-emotes), gathered per unit and replayed
// in the client's call order below ([`PlaySeq`] stamps — the net drain stamps packet order,
@@ -422,9 +423,11 @@ pub(super) fn drive_animations(
let catalog = anim_data.as_deref().map(|d| &d.0);
// `WOW_ANIM_COST=1` — the memo-sizing counter (1370 item 9's bracket): counts the frame's
// RESTING rows, the shape an input-memo fast path would skip. Sizing only — no skip is
// taken here (an `AnimParked` filter is law-barred: 0448's off-screen-events law means a
// parked unit's driver must keep arming clips; the memo is the only safe shape, built when
// this number says it pays).
// taken here. (An `AnimParked` filter was law-barred by 0448's off-screen-events law; that
// law's premise — "the reference keeps every off-screen unit audible" — fell with wow-re's
// election correction, which keeps only `MORE_AUDIBLE` creatures ticking. Until 1473's
// event-gate leg lands as its own record, the driver still arms clips for every parked
// unit, and the memo remains the only safe skip shape.)
let anim_cost = anim_cost_enabled();
let (mut cost_rows, mut cost_resting) = (0u32, 0u32);
for (
@@ -743,12 +746,21 @@ pub(super) fn drive_animations(
}
// The loot kneel's trigger (see [`select::LOOT`]; byte-verified, wow-re
// `loot-anim-leg.md` — the 2026-07-18 §5 fold-back, decision 0515): the predicate
// (`0x6126b0`) SPLITS on IsActivePlayer — the **self** unit kneels off the client-local
// loot-target latch ([`crate::ui_loot::LootLatch`], armed at the `CMSG_LOOT` send, so
// the kneel is client-predicted with no round-trip), a **remote** unit off its mirrored
// descriptor: `UNIT_FLAG_LOOTING` (0x400) set AND the `0x10000000` bit clear. Never
// mounted (the leg's `[+0xdc]==0` gate). Movement outranks by CHAIN POSITION, not an
// `loot-anim-leg.md` §5/§8 — decisions 0515 / 1471 / 1477): the leg needs **two**
// predicates, and both SPLIT on IsActivePlayer.
//
// **Self.** Predicate A (`0x6126b0`) is "a loot session is open" — the client-local
// loot-target latch. Predicate B (`0x612710`) is the per-object-CLASS filter: which
// *kind* of target the character kneels at. Only B is read here, because
// [`crate::ui_loot::LootKneel`] already folds in A (it is false whenever the latch is
// cold). Keeping them apart is the correction 1477 makes to 1471: a fishing bobber arms
// the latch exactly like a chest does, and the reference still does not kneel at it —
// the difference is entirely B's `GAMEOBJECT_TYPE_ID == 17` test.
//
// **Remote.** The mirrored descriptor: `UNIT_FLAG_LOOTING` (0x400) set AND the
// `0x10000000` bit clear.
//
// Never mounted (the leg's `[+0xdc]==0` gate). Movement outranks by CHAIN POSITION, not an
// in-leg gate — the core ground selector claims first on any direction bit (`[9e8]&0xf`)
// — transcribed as the `ANY_MOVE` test here; a stationary *swimmer* with the trigger up
// kneels mid-tread, as the bytes order it (locomotion's swim block needs a direction
@@ -756,7 +768,7 @@ pub(super) fn drive_animations(
let looting = !mounted
&& mv.flags & move_flags::ANY_MOVE == 0
&& if is_self {
loot_latch.as_ref().is_some_and(|l| l.0.is_some())
loot_kneel.as_ref().is_some_and(|k| k.0)
} else {
store.is_some_and(|s| {
let f = s.0.unit_flags();
@@ -1082,15 +1082,18 @@ fn unit_flag_looting_kneels_stationary_units_only() {
/// The loot kneel, SELF half (decision 0515 — the byte predicate `0x6126b0` splits on
/// IsActivePlayer): the local player's kneel rides the client-local loot-target latch
/// ([`crate::ui_loot::LootLatch`], the `[player+0x1d28]` mirror) — NOT its descriptor flag — so
/// it starts the frame the `CMSG_LOOT` send arms the latch (client-predicted, before any server
/// response reaches the descriptor) and ends the frame the latch drops.
/// (predicate B's standing answer over the `[player+0x1d28]` latch — [`crate::ui_loot::LootKneel`],
/// decision 1477) — NOT its descriptor flag — so it starts the frame the arm lands
/// (client-predicted, before any server response reaches the descriptor) and ends the frame it
/// drops. *Which* latched objects set that boolean is predicate B's own table, tested where it
/// lives (`ui_loot::tests::predicate_b_decides_which_loot_targets_are_knelt_at`); this test is
/// about the leg reading self and remote from two different places.
#[test]
fn the_self_kneel_rides_the_loot_latch_not_the_flag() {
use benilla_protocol::messages::ObjectFields;
let mut app = app();
app.init_resource::<crate::ui_loot::LootLatch>();
app.init_resource::<crate::ui_loot::LootKneel>();
let model = ModelAnimations {
graph: Handle::default(),
clips: vec![clip(0, 1, true), clip(50, 2, false), clip(5, 3, true)],
@@ -1124,17 +1127,17 @@ fn the_self_kneel_rides_the_loot_latch_not_the_flag() {
"the self unit ignores its own descriptor flag"
);
// The CMSG_LOOT send arms the latch: the kneel is client-predicted the same frame cycle.
// The arm lands on a kneelable target: the kneel is client-predicted the same frame cycle.
app.world_mut()
.resource_mut::<crate::ui_loot::LootLatch>()
.0 = Some(0x42);
.resource_mut::<crate::ui_loot::LootKneel>()
.0 = true;
app.update();
assert_eq!(gait(&app), Some(50), "the armed latch kneels the self unit");
// The release/refusal drops the latch: straight back to Stand, no wire round-trip needed.
app.world_mut()
.resource_mut::<crate::ui_loot::LootLatch>()
.0 = None;
.resource_mut::<crate::ui_loot::LootKneel>()
.0 = false;
app.update();
assert_eq!(
gait(&app),
+25 -87
View File
@@ -4,19 +4,26 @@
//! the query's comment for why the old `With<AnimDriver>` filter left every animated GO
//! sampling off-view).
//!
//! The reference has NO view cull on unit skeletons — every in-range, non-hidden unit is fully
//! animated every frame (wow-re `unit-anim-visibility-gate.md`: worklist admission is the CGUnit
//! render vtable chain, no frustum/occlusion/distance test — the 0364 §1 dispatch's verdict). So
//! this gate is a *modernization*, not a fidelity copy, and it preserves the verdict's two
//! observables:
//! 0448 shipped this as a modernization against a reference that "has NO view cull on unit
//! skeletons" — a verdict wow-re REFUTED on 2026-08-13 (`outdoor-object-pass-election.md`: every
//! scene object, units included, is frustum/horizon/room-elected each frame, and a pass-2 unit
//! is neither drawn, nor animated, nor event-ticked — except creatures flagged `MORE_AUDIBLE`,
//! re-linked for tick only). So this gate turned out to be the *faithful* direction, not a
//! departure; what still diverges is decision 1473's ledger — parked rigs keep DRAWING (the
//! election never reached draw submission), and observable (ii) keeps every unit audible where
//! the reference keeps only the flagged ones. The two observables preserved here:
//!
//! - **(i) the absolute-clock snap** — a re-appearing unit shows the pose "now" dictates. Nothing
//! here pauses an [`AnimationPlayer`]: Bevy's `advance_animations` keeps ticking every seek
//! clock, so waking is just sampling again — there is no frozen state to resume from.
//! - **(ii) off-screen combat stays audible** — the event scanner
//! - **(i) the absolute-clock snap** — faithful (`0x714260` samples the world clock): a
//! re-appearing unit shows the pose "now" dictates. Nothing here pauses an [`AnimationPlayer`]:
//! Bevy's `advance_animations` keeps ticking every seek clock, so waking is just sampling
//! again — there is no frozen state to resume from.
//! - **(ii) off-screen combat stays audible — for EVERY unit**, which is now known to be MORE
//! than the reference grants (it silences all but `MORE_AUDIBLE` creatures): the event scanner
//! ([`super::events::fire_anim_events`]) reads the player's seek, not the bones, and the driver
//! state machine keeps arming clips for parked rigs; `$CSS`/`$CAH`/`$HIT` (0075), footsteps,
//! `$BWP`/`$BWR`, and the `$CSL`-keyed missile release (0430) all fire regardless of the camera.
//! `$BWP`/`$BWR`, and the `$CSL`-keyed missile release (0430) all fire regardless of the
//! camera. Narrowing this to the flagged set is 1473's event-gate leg — its own record when it
//! lands.
//!
//! The park mechanism is the [`AnimParked`] marker alone (decision 0712 — the evaluator took over
//! from `animate_targets`, and the old per-joint `AnimatedBy` repoint died with the targets): the
@@ -50,13 +57,13 @@ use bevy::camera::primitives::{Frustum, Sphere as CullSphere};
use bevy::ecs::entity::EntityHashMap;
use bevy::prelude::*;
use benilla_assets::{WmoGroupNav, WmoModel, WmoPortalRef};
use benilla_assets::WmoModel;
use crate::entities::mount::MountBody;
use crate::net::Embodied;
use crate::target::SelectionRadius;
use benilla_world::view::WorldCamera;
use benilla_world::wmo_portal::{UnitWmoRoom, WmoPortalInstance, WmoRoom};
use benilla_world::wmo_portal::{room_pvs_visible, UnitWmoRoom, WmoPortalInstance};
use benilla_world::rig_anim::{AnimParked, RigPose};
@@ -195,48 +202,11 @@ pub(super) fn gate_rig_animation(
out_since.retain(|e, _| rigs.contains(*e));
}
/// The room leg's resolve chain: unit claim → placement instance → model → PVS bits. Fail-open
/// at every seam (no claim, despawned placement, still-loading model) — a lookup miss must never
/// park a drawable rig.
fn room_pvs_visible(
room: Option<&UnitWmoRoom>,
instances: &Query<&WmoPortalInstance>,
wmos: &Assets<WmoModel>,
) -> bool {
let Some(WmoRoom { instance, group }) = room.and_then(|r| r.room()) else {
return true;
};
let Ok(inst) = instances.get(instance) else {
return true;
};
let Some(model) = wmos.get(&inst.handle) else {
return true;
};
room_visible(&inst.visible, &model.group_nav, &model.portal_refs, group)
}
/// Is the claimed group — or any group one portal hop from it — in the PVS? The one-hop union is
/// the doorway-straddle guard: a body can extend past its feet's room only through a portal
/// opening, so the neighbour set bounds everything of the unit that could be on screen. Indices
/// out of range read visible (fail-open, [`benilla_world::wmo_portal::WmoGroupVis::drawn_by`]'s
/// convention); a group with no portal refs at all can only be seen with the camera inside it
/// (the flood cannot reach it), which the direct bit already answered.
fn room_visible(visible: &[bool], nav: &[WmoGroupNav], refs: &[WmoPortalRef], group: u16) -> bool {
let vis = |g: usize| visible.get(g).copied().unwrap_or(true);
if vis(group as usize) {
return true;
}
let Some(n) = nav.get(group as usize) else {
return true;
};
let Some(hops) = refs.get(n.ref_start as usize..(n.ref_start as usize + n.ref_count as usize))
else {
return true;
};
hops.iter().any(|r| vis(r.group as usize))
}
/// Register the gate: [`gate_rig_animation`] before the frame's pose evaluation.
///
/// The room predicate itself lives in [`benilla_world::wmo_portal::room_pvs_visible`] since 1475:
/// the body draw election asks the identical question of the identical claim, and two private
/// copies of one election term is exactly the drift 1473 catalogued.
pub(super) fn plugin(app: &mut App) {
app.add_systems(PostUpdate, gate_rig_animation.before(AnimationSystems));
}
@@ -249,7 +219,9 @@ mod tests {
use benilla_assets::{
bone_target_id, AnimClip, ModelAnimations, PoseBone, PoseClip, PoseSource, PoseTrack,
WmoGroupNav, WmoPortalRef,
};
use benilla_world::wmo_portal::WmoRoom;
use super::super::{events::fire_anim_events, AnimDriver, AnimSoundEvent};
use super::*;
@@ -532,40 +504,6 @@ mod tests {
}
}
/// The room predicate's whole truth table: direct bit, the one-hop straddle guard, the
/// all-dark park, the sealed room, and both fail-open seams (group past every table, a
/// ref slice past the refs vec).
#[test]
fn room_visible_covers_the_hop_guard_and_fails_open() {
// Groups 0 ↔ 1 share one portal; group 2 is sealed (no refs).
let navs = vec![nav(0, 1), nav(1, 1), nav(2, 0)];
let refs = vec![pref(1), pref(0)];
assert!(
room_visible(&[true, false, false], &navs, &refs, 0),
"direct PVS bit"
);
assert!(
room_visible(&[true, false, false], &navs, &refs, 1),
"own bit dark but the neighbour lit — the doorway-straddle guard keeps it live"
);
assert!(
!room_visible(&[false, false, true], &navs, &refs, 0),
"own room and every hop dark ⇒ parked"
);
assert!(
!room_visible(&[true, true, false], &navs, &refs, 2),
"a sealed room's own bit decides — no hops exist to save it"
);
assert!(
room_visible(&[false], &navs, &refs, 9),
"a group past the PVS table reads visible (fail-open)"
);
assert!(
room_visible(&[false], &[nav(7, 2)], &refs, 0),
"a ref slice past the refs vec reads visible (fail-open)"
);
}
/// The room leg end to end: an IN-FRUSTUM rig whose claimed room (and its one hop) is
/// outside the PVS parks past the hysteresis window; setting the room's bit wakes it and its
/// bones sample again — the same instant-wake law as the frustum leg.
+17 -8
View File
@@ -57,14 +57,23 @@ pub(super) const STEALTH_STAND: u16 = 120;
pub(super) const MOUNT: u16 = 91;
/// Loot (50) — the kneel-and-rummage held while a unit's loot window is open. Byte-verified end
/// to end (wow-re `loot-anim-leg.md`, the 2026-07-18 §5; decision 0515): the `0x5fd8b0` chain's
/// loot leg `0x5fd260` → 0x32 = 50, chain order locomotion → **LOOT** → standState →
/// combat/channel (movement outranks by position — the leg itself reads no movement state), leg
/// gates `[+0xdc]==0` (never mounted) + the `[+0xd58]&0x40` enable. The **trigger predicate**
/// (`0x6126b0`) splits on IsActivePlayer: the **self** unit keys the client-local loot-target
/// latch (`[player+0x1d28]`, ours [`crate::ui_loot::LootLatch`]) — armed at the `CMSG_LOOT` send,
/// so the kneel is client-predicted — while a **remote** unit keys its descriptor:
/// [`UNIT_FLAG_LOOTING`] set and [`UNIT_FLAG_LOOT_SUPPRESS`] clear. The clip is authored
/// to end (wow-re `loot-anim-leg.md`, the 2026-07-18 §5 + the 08-21 §5 trio; decisions 0515 /
/// 1471 / 1477): the `0x5fd8b0` chain's loot leg `0x5fd260` → 0x32 = 50, chain order locomotion →
/// **LOOT** → standState → combat/channel (movement outranks by position — the leg itself reads no
/// movement state), leg gates `[+0xdc]==0` (never mounted) + the `[+0xd58]&0x40` enable.
///
/// The leg needs **two** predicates, and both split on IsActivePlayer:
/// - **A `0x6126b0` — is a session open.** Self: the client-local loot-target latch
/// (`[player+0x1d28]`, ours [`crate::ui_loot::LootLatch`]) — armed at the `CMSG_LOOT` send for a
/// corpse, at `SMSG_SPELL_GO` for a chest, and at an admitted `SMSG_LOOT_RESPONSE` otherwise.
/// Remote: [`UNIT_FLAG_LOOTING`] set and [`UNIT_FLAG_LOOT_SUPPRESS`] clear.
/// - **B `0x612710` — is that KIND of target knelt at.** Self: a per-object-class filter, ours
/// [`crate::ui_loot::LootKneel`]. It is why a fishing bobber arms the latch and still does not
/// kneel, and 1471 shipped without it.
///
/// One consequence worth stating plainly, because it looks like a bug: on vmangos, other players
/// never see us kneel at a chest. `Player::SendLoot` sets `UNIT_FLAG_LOOTING` only for
/// `LOOT_CORPSE`, and the remote half has nothing else to read. The clip is authored
/// **clamp** (HumanMale: one 0.5 s sequence, no variations): kneel down, freeze in the rummage
/// pose; the rise back is the ordinary cross-fade to Stand when the trigger drops. Weapons stow
/// for free — row 50 carries the `WeaponFlags & 4` force-stow the per-animation sheath
+8
View File
@@ -113,6 +113,7 @@ impl Plugin for DevProbesPlugin {
"WOW_PROBE_CHAT",
"WOW_PROBE_KEY",
"WOW_PROBE_LUA",
"WOW_PROBE_CHEST",
"WOW_RIG",
"WOW_LIVE_FPS",
]
@@ -259,6 +260,13 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE_BOOK").is_ok() {
app.add_plugins(crate::capture::ProbeBookPlugin);
}
// The chest live probe: `WOW_PROBE_CHEST=1` parks at a real chest spawn, opens it on the
// click's own route and reports the self unit's base anim id before/during/after — B84's
// instrument, the numeric answer to "does the player kneel at a chest" (decision 1471;
// see `capture::ProbeChestPlugin`).
if std::env::var("WOW_PROBE_CHEST").is_ok() {
app.add_plugins(crate::capture::ProbeChestPlugin);
}
// The cast-cancel live probe: `WOW_PROBE=castcancel` hearths and presses W mid-cast — the
// local self-cancel's end-to-end timing instrument (see `capture::ProbeCastCancelPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("castcancel") {
+15 -11
View File
@@ -245,17 +245,21 @@ pub(super) fn spawn_part(
child.insert(benilla_world::rig_palette::RigPart(dress.unit));
}
if dress.rigged && part.skinned_mesh.is_some() {
// A streamed entity's M2 is **never view-culled** — the reference registers entity render
// records with effectively-infinite bounds and the one frustum cull in its machinery is
// map-doodad-only (wow-re `unit-anim-visibility-gate.md` §2/§4). Bevy's bind-pose `Aabb` is
// the wrong stand-in (the duel flag plants itself 9 yd below it) and is stomped anyway by
// `calculate_bounds` on any `Mesh3d` change. So the renderer gets the faithful
// `NoFrustumCulling`, and the `Aabb` beside it serves ONE master: the mouseover picker
// (`target/hover.rs`) — the armed idle's authored CAaBox when it has one, else the bind
// box, read from the part's build-time bound (decision 0834: the static mesh is
// `RENDER_WORLD`-only, so its main-world data — which the old `compute_aabb` fallback
// read — is gone after extract). Both `calculate_bounds` queries skip `NoFrustumCulling`
// entities, so this box survives.
// A skinned entity part is never PART-culled: the reference tests ONE sphere per scene
// object, never per batch, so the view cull belongs to the body ROOT's election
// (`exterior_cull`, 1270) and `NoFrustumCulling` keeps Bevy's per-part test out of its
// way. (0648 originally justified the marker as "the reference never view-culls entities
// at all", off a ≈1e7 render-bounds reading; wow-re's `outdoor-object-pass-election.md`
// refuted that 2026-08-13 — those fields are a position cache, and units ARE
// frustum/horizon/room-elected per frame. Decision 1473 records the correction and owns
// the outdoor half of the election.) Bevy's bind-pose `Aabb` is the wrong stand-in for
// picking (the duel flag plants itself 9 yd below it) and is stomped anyway by
// `calculate_bounds` on any `Mesh3d` change. So the `Aabb` beside the marker serves ONE
// master: the mouseover picker (`target/hover.rs`) — the armed idle's authored CAaBox
// when it has one, else the bind box, read from the part's build-time bound (decision
// 0834: the static mesh is `RENDER_WORLD`-only, so its main-world data — which the old
// `compute_aabb` fallback read — is gone after extract). Both `calculate_bounds` queries
// skip `NoFrustumCulling` entities, so this box survives.
let picker_aabb = dress.idle_aabb.or(part.aabb);
if let Some(aabb) = picker_aabb {
child.insert((aabb, NoFrustumCulling));
+11 -1
View File
@@ -769,6 +769,7 @@ pub(super) fn apply_net_updates(
&mut ui_actions.2,
&mut ui_actions.6,
&mut ui_actions.7,
&mut loot_latch,
),
SessionEvent::Chat(m) => {
chat::chat(m, &mut chat_log, &social, &net_commands, &mut server_said)
@@ -934,7 +935,15 @@ pub(super) fn apply_net_updates(
loot_type,
gold,
items,
} => loot_response(guid, loot_type, gold, items, &mut loot),
} => loot_response(
guid,
loot_type,
gold,
items,
&mut loot,
&mut loot_latch,
&net_commands,
),
SessionEvent::LootError { guid, error } => {
loot_error(guid, error, &mut ui_actions.4, &mut loot_latch)
}
@@ -1130,6 +1139,7 @@ pub(super) fn apply_net_updates(
&mut ui_actions.15,
&mut audio.7,
&mut audio.10,
&mut loot_latch,
(
&mut ui_actions.10,
ui_actions.11.as_deref(),
+189 -1
View File
@@ -12,20 +12,68 @@ use bevy::prelude::*;
use super::super::SelfGuid;
use crate::items::Items;
use crate::net::ClientCommand;
use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps};
use crate::ui_action::{UiError, UiErrorKeys};
use crate::ui_items::{EquipError, EquipErrors};
use crate::ui_loot::{LootErrors, LootLatch, LootState};
use crate::ui_loot_roll::LootRolls;
/// A loot window opened (`SMSG_LOOT_RESPONSE`'s normal shape), answering our `CMSG_LOOT`.
/// The wire `loot_type` values `SMSG_LOOT_RESPONSE`'s **cold-latch** admission accepts
/// (`0x5eb94b`/`0x5eb953`/`0x5eb95b`) — vmangos `LootType`'s `PICKPOCKETING(2)` · `FISHING(3)` ·
/// `DISENCHANTING(4)`. The alphabet the bytes are drawing is *"the server started this session"*;
/// `CORPSE(1)` is the client's own, and it is the one value a cold latch refuses.
const SERVER_STARTED_LOOT: [u8; 3] = [2, 3, 4];
/// A loot window opened (`SMSG_LOOT_RESPONSE`'s normal shape) — the answer to our `CMSG_LOOT`, or
/// to anything else that opened a session: a chest, herb node, mining vein or fishing bobber all
/// reach the window through an `OPEN_LOCK` cast, never through `CMSG_LOOT` (vmangos
/// `Spell::SendLoot` → `Player::SendLoot`).
///
/// **This handler is an admission gate, not an unconditional open** (wow-re `loot-anim-leg.md` §7,
/// byte-verified; decision 1477 correcting 1471). `0x5eb924`:
///
/// ```text
/// ACCEPT ⇔ (latch != 0 && latch == pkt.guid) || (latch == 0 && loot_type ∈ {2,3,4})
/// ```
///
/// Everything else is **refused** at `0x5eb963`: no window, a `CMSG_LOOT_RELEASE` bounced back for
/// the *packet's* guid, and the latch cleared — **not** guid-matched, so an unsolicited response
/// for B drops a live latch on A. The design the bytes state is a two-way handshake: type 1 means
/// "I asked for this", and the client pre-armed at its own `CMSG_LOOT` send, so a type-1 answer
/// with nothing latched is an answer to a question we never asked.
///
/// The accepted arm writes the latch verbatim (`0x5ebb60`) and — verified over the whole success
/// path — calls **neither** the base-anim recompute nor the Loot-50 force-play. A chest is already
/// kneeling by now: its arm was `SMSG_SPELL_GO` (§6, [`super::spells`]), one packet earlier.
pub(super) fn loot_response(
guid: u64,
loot_type: u8,
gold: u32,
items: Vec<LootItem>,
loot: &mut LootState,
latch: &mut LootLatch,
net: &crate::net::NetCommands,
) {
// `0x5eb924`–`0x5eb95b`, transcribed.
let accept = match latch.0 {
Some(latched) => latched == guid,
None => SERVER_STARTED_LOOT.contains(&loot_type),
};
if !accept {
// `0x5eb963`: bounce it. On this server nothing produces a refusal — a corpse always
// pre-arms and a chest/fishing answer carries type 2/3 — so this arm is inert today and
// carried for the faithful shape (and for a server that answers differently).
debug!(
"net: loot response {guid:#x} type {loot_type} REFUSED (latch {:?})",
latch.0
);
if loot_type != 0 {
let _ = net.0.send(ClientCommand::LootRelease { guid });
}
latch.0 = None; // NOT guid-matched — `0x5eb9d2` clears whatever was there
return;
}
// The loot window opens (decision 0084): fill LootState from the wire; the feed
// ([`crate::ui_loot`]) resolves rows + fires LOOT_OPENED next frame. `loot_type` rides along
// for `IsFishingLoot()` (decision 1086).
@@ -34,6 +82,11 @@ pub(super) fn loot_response(
items.len()
);
loot.open(guid, loot_type, gold, items);
// `0x5ebb60` — the packet's guid, verbatim. Re-arming a corpse's already-matching latch is a
// no-op; a chest re-arms what `SMSG_SPELL_GO` armed; a **fishing** bobber arms here for the
// first time and still does not kneel, because the pose is predicate B's call, not the
// latch's ([`crate::ui_loot::LootKneel`]).
latch.0 = Some(guid);
}
/// A fishing verdict with no loot window (`SMSG_FISH_ESCAPED` / `SMSG_FISH_NOT_HOOKED`, both
@@ -191,6 +244,7 @@ pub(super) fn item_template(entry: u32, info: Option<ItemInfo>, items: &mut Item
/// [`PendingItemOps::clear_by_failure`]. This site has no `UiScript` to fire `ITEM_LOCK_CHANGED`
/// through, so the transitioned slots queue in [`LockClearedByFailure`] for the container feed
/// (`ui_items::feed::feed_containers`) to drain and fire next time it runs.
#[allow(clippy::too_many_arguments)] // one dispatch arm's full input set
pub(super) fn inventory_failure(
reason: u8,
required_level: Option<u32>,
@@ -199,6 +253,7 @@ pub(super) fn inventory_failure(
equip_errors: &mut EquipErrors,
pending: &mut PendingItemOps,
lock_cleared: &mut LockClearedByFailure,
latch: &mut LootLatch,
) {
debug!("net: inventory failure {reason:#04x} (item {item_guid:#x}, bag slot {bag_slot})");
equip_errors.0.push(EquipError {
@@ -207,6 +262,13 @@ pub(super) fn inventory_failure(
bag_slot,
});
lock_cleared.0.extend(pending.clear_by_failure(item_guid));
// The sixth loot-latch clear (`0x5e3a84`, wow-re `loot-anim-leg.md` §5; decision 1477): when
// the packet's **first item guid** is the object we are looting, the session ends here. It is
// how an item-container loot (a lockbox) closes when the move out of it fails — the one clear
// the 1471 census was missing. Guid-matched, as the bytes are.
if item_guid != 0 {
latch.clear_for(item_guid);
}
}
#[cfg(test)]
@@ -242,6 +304,132 @@ mod tests {
assert_eq!(g("ERR_FISH_ESCAPED"), "Your fish got away!");
}
/// A GameObject guid (HIGHGUID_GAMEOBJECT in the high dword) — a chest.
const CHEST: u64 = 0xF110_0000_0000_1234;
/// A creature guid — a corpse.
const CORPSE: u64 = 0xF130_0000_0000_00AB;
/// A `NetCommands` plus its receiver, so a test can read what the handler actually sent.
fn net() -> (
crate::net::NetCommands,
crossbeam_channel::Receiver<ClientCommand>,
) {
let (tx, rx) = crossbeam_channel::unbounded();
(crate::net::NetCommands(tx), rx)
}
/// **B84 / decisions 1471 + 1477.** A chest never sends `CMSG_LOOT`, so the corpse branch's
/// arm-at-the-send (0515) never fires for it. `SMSG_SPELL_GO` is its real arm; the response's
/// own arm is the second half, and on a **cold** latch it admits only the server-started
/// types 2/3/4 (`0x5eb94b`–`0x5eb95b`). A chest's answer is wire type 2, so it opens and arms.
#[test]
fn a_cold_latch_admits_a_server_started_loot_and_arms_on_it() {
let (net, rx) = net();
let mut loot = LootState::default();
let mut latch = LootLatch::default();
assert_eq!(latch.0, None, "no CMSG_LOOT was sent, so nothing armed it");
loot_response(CHEST, 2, 0, Vec::new(), &mut loot, &mut latch, &net);
assert_eq!(latch.0, Some(CHEST), "the open window is the loot session");
assert_eq!(loot.source(), Some(CHEST), "…and the window opened");
assert!(
rx.try_recv().is_err(),
"an accepted response bounces nothing"
);
// The ordinary close path still ends it — the latch is guid-matched, and a chest guid is
// no different from a corpse one there.
loot_release_response(CHEST, &mut loot, &mut latch);
assert_eq!(latch.0, None, "the release ends the session");
}
/// The corpse path is untouched: the send already armed the latch with the same guid, so the
/// response takes the **GUID-match** branch (`0x5eb93e`) and its re-arm is a no-op.
#[test]
fn a_matching_latch_admits_any_loot_type() {
let (net, _rx) = net();
let mut loot = LootState::default();
let mut latch = LootLatch(Some(CORPSE)); // the `CMSG_LOOT` send, client-predicted
loot_response(CORPSE, 1, 0, Vec::new(), &mut loot, &mut latch, &net);
assert_eq!(latch.0, Some(CORPSE));
assert_eq!(loot.source(), Some(CORPSE));
}
/// **The refusal arm (`0x5eb963`, decision 1477).** `loot_type == 1` means "you asked for
/// this" — and a cold latch says we did not. The real client opens no window, bounces a
/// `CMSG_LOOT_RELEASE` for the *packet's* guid, and clears. 1471 shipped an unconditional
/// arm, which is exactly this case's divergence.
#[test]
fn a_cold_latch_refuses_a_corpse_typed_response_and_bounces_it() {
let (net, rx) = net();
let mut loot = LootState::default();
let mut latch = LootLatch::default();
loot_response(CORPSE, 1, 0, Vec::new(), &mut loot, &mut latch, &net);
assert_eq!(
loot.source(),
None,
"no window for an unasked-for corpse answer"
);
assert_eq!(latch.0, None, "…and nothing latched");
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::LootRelease { guid }) if guid == CORPSE),
"the refusal releases the object the PACKET named"
);
}
/// The refuse arm's clear is **not** guid-matched (`0x5eb9d2`): an unsolicited response for B
/// drops a live latch on A, and releases B. Faithfully odd, and byte-verified.
#[test]
fn a_refusal_drops_whatever_latch_was_live_not_just_a_matching_one() {
let (net, rx) = net();
let mut loot = LootState::default();
let mut latch = LootLatch(Some(CORPSE));
loot_response(CHEST, 1, 0, Vec::new(), &mut loot, &mut latch, &net);
assert_eq!(latch.0, None, "A's latch is dropped by B's refusal");
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::LootRelease { guid }) if guid == CHEST),
"…and it is B that gets released"
);
}
/// The sixth clear (`0x5e3a84`): an inventory-move failure whose first item guid IS the loot
/// target ends that session. Guid-matched, so an unrelated bag failure leaves it alone.
#[test]
fn an_inventory_failure_on_the_looted_object_clears_the_latch() {
const LOCKBOX: u64 = 0x4000_0000_0000_0007;
let mut errs = EquipErrors::default();
let mut pending = PendingItemOps::default();
let mut cleared = LockClearedByFailure::default();
let mut latch = LootLatch(Some(LOCKBOX));
// An unrelated item's failure must not end the session.
inventory_failure(
2,
None,
0x1234,
0,
&mut errs,
&mut pending,
&mut cleared,
&mut latch,
);
assert_eq!(latch.0, Some(LOCKBOX));
inventory_failure(
2,
None,
LOCKBOX,
0,
&mut errs,
&mut pending,
&mut cleared,
&mut latch,
);
assert_eq!(latch.0, None);
}
const ME: u64 = 0x0000_0000_0000_002A;
const THEM: u64 = 0x0000_0000_0000_00FF;
@@ -285,6 +285,10 @@ pub(super) fn spell_start(
}
}
/// `GAMEOBJECT_TYPE_ID` 3 — `CHEST`, the only GO type whose `OPEN_LOCK` cast arms the loot-target
/// latch (`0x6e830c`; DOOR(0)/BUTTON(1) are skipped explicitly and every other type falls out).
const GO_TYPE_CHEST: i32 = 3;
/// The cast launched (`SMSG_SPELL_GO`): hit/miss lists + (for a ranged spell) the ammo display id
/// for the projectile visual. The server schedules the *damage* itself off `Spell.dbc` Speed —
/// nothing about missile travel rides this packet; the client (and we) rebuild the flight
@@ -314,6 +318,8 @@ pub(super) fn spell_go(
queued_melee: &mut QueuedMeleeSpell,
text: &mut MessageWriter<crate::combat_text::CombatTextSpawn>,
go_lid: &mut MessageWriter<crate::go_anim::GoLidOpen>,
// The client-local loot-target latch — armed here for a chest (decision 1477, §6 above).
loot_latch: &mut crate::ui_loot::LootLatch,
// The cooldown store + what its start laws read (grouped: one arm-body concern). The last
// member is the PET's store — the reference inserts into two banks from this one handler
// ([`pet_go_cooldown`]).
@@ -342,6 +348,37 @@ pub(super) fn spell_go(
let (cooldowns, spells, items, net_commands, pet_bar) = cooldown_ctx;
let now = Instant::now();
let display = spells.and_then(|s| s.catalog.get(spell_id));
// **A chest's loot-target arm** (wow-re `loot-anim-leg.md` §6, byte-verified §5 trio; decision
// 1477). `Spell_C::HandleSpellGo 0x6e7a70` reaches `0x6e831b call SetLootTarget 0x5ed5f0`,
// which writes `[player+0x1d28]` and force-plays Loot 50 — so **this packet, not the loot
// response, is when the reference starts kneeling at a chest**. Its gates, transcribed:
// caster is the local player (`0x6e81b6`), the spell carries `SPELL_EFFECT_OPEN_LOCK` (0x21)
// or `OPEN_LOCK_ITEM` (0x3b) (`0x6e81f9`/`0x6e8202`), and the single target resolves as a
// GameObject whose `GAMEOBJECT_TYPE_ID` is **CHEST(3)** (`0x6e82df`–`0x6e830c`; DOOR/BUTTON
// are skipped explicitly, everything else falls out).
//
// One named divergence: the reference reads its target off the hit list and requires
// `hitCount == 1` (`0x6e82c1`). vmangos writes a GameObject target into `SpellCastTargets`,
// not the hit list, so we key on the packet's `go_target` — a single guid by construction,
// which is the same condition arrived at from the shape of our wire rather than from a count.
//
// No force-play is needed here: our loot leg is recomputed every frame from
// [`crate::ui_loot::LootKneel`], where the reference recomputes only on events and therefore
// has to kick the pose by hand.
if self_guid.0 == Some(caster) {
if let Some(go_guid) = go_target {
let is_open_lock = display.is_some_and(|d| d.open_lock.is_some());
let is_chest = index
.0
.get(&go_guid)
.and_then(|&e| stores.get(e).ok())
.is_some_and(|store| store.0.gameobject_type_id() == GO_TYPE_CHEST);
if is_open_lock && is_chest {
debug!("net: spell go — chest {go_guid:#x} becomes the loot target (kneel arm)");
loot_latch.0 = Some(go_guid);
}
}
}
// Our own launch completes the cast bar (a shown bar fills green and fades; auto-repeat
// shots — GO with no bar showing — no-op in the reference Lua) and opens the in-flight guard
// (spell-id-keyed, so a triggered proc's GO mid-cast doesn't unblock the running cast early).
@@ -965,6 +1002,7 @@ mod tests {
&mut queued_melee,
&mut text,
&mut go_lid,
&mut crate::ui_loot::LootLatch::default(),
(
&mut cooldowns,
None,
@@ -1114,6 +1152,7 @@ mod tests {
&mut queued_melee,
&mut text,
&mut go_lid,
&mut crate::ui_loot::LootLatch::default(),
(
&mut cooldowns,
Some(&spells),
@@ -1249,6 +1288,7 @@ mod tests {
&mut queued_melee,
&mut text,
&mut go_lid,
&mut crate::ui_loot::LootLatch::default(),
(
&mut cooldowns,
None,
+75 -1
View File
@@ -304,7 +304,17 @@ pub(in crate::net) fn drive_display_facing(
// *goal*, not a freeze, which is what swings an NPC back when you close its window.
let goal = goal.unwrap_or(state.wire);
let new = filter_step(cur, goal, &mut state.hist);
tf.rotation = Quat::from_rotation_y(new);
// Write-on-change only (1473): a settled unit's `new` is bitwise `goal.rem_euclid(TAU)`
// every frame (the dead-band leg of `filter_step` returns the goal verbatim, never a
// re-derivation from `cur`), so this compare goes quiet one frame after settling. The
// unconditional write it replaces marked every standing NPC's `Transform` changed every
// frame — re-propagating its whole subtree and defeating each downstream `Changed` gate
// for the entire idle crowd (1445's swim gate, the water-side classifier's moved sweep,
// mesh re-extract) — the same defect `face_billboards` fixed for cards.
let rot = Quat::from_rotation_y(new);
if tf.rotation != rot {
tf.rotation = rot;
}
// The turn-shuffle latch (decision 0123): while the ease still has meaningful yaw to
// cover, the anim layer sees a signed turning step; settled drops it below.
let remaining = crate::creature_anim::wrap_pi(goal - new);
@@ -568,6 +578,70 @@ mod tests {
);
}
/// The rotation write is a dead-band, not a metronome: once a governed unit settles, its
/// `Transform` stops being marked changed. This is the regression fence for 1473's defeat
/// finding — an unconditional same-value write re-propagated every standing NPC's subtree
/// every frame and silently un-gated each downstream `Changed` consumer (1445's swim gate,
/// the water-side classifier's moved sweep, mesh re-extract).
#[test]
fn a_settled_unit_stops_dirtying_its_transform() {
#[derive(Resource, Default)]
struct Dirty(usize);
fn spy(q: Query<Entity, (Changed<Transform>, With<NetEntity>)>, mut out: ResMut<Dirty>) {
out.0 = q.iter().count();
}
let mut app = App::new();
app.init_resource::<GuidIndex>()
.init_resource::<crate::ui_session::InteractNpc>()
.init_resource::<Dirty>()
.add_systems(Update, (drive_display_facing, spy).chain());
// Us OFF the vendor's axis (bearing ≈ 2.60 rad), deliberately: a goal at exactly ±π sits
// on the yaw wrap, where the client's unfolded-delta dead-band (its own byte-verified
// quirk) never snaps — the ease still goes bitwise-quiet there, but only via float
// underflow, ~16 pumps later. The representative case snaps inside the band in ~9.
app.world_mut().spawn((
SelfPlayer,
ActiveMover,
Transform::from_translation(wow_to_bevy([0.0, 3.0, 0.0])),
));
let npc = app
.world_mut()
.spawn((
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
ObjectStore::default(),
Transform {
translation: wow_to_bevy([5.0, 0.0, 0.0]),
rotation: Quat::from_rotation_y(0.0),
..default()
},
))
.id();
// Open a window on it: the vendor legitimately turns, dirtying the transform — the
// control that proves the spy sees real writes.
app.world_mut()
.resource_mut::<crate::ui_session::InteractNpc>()
.0 = Some(npc);
app.update(); // seeding frame (spawn itself also reads as Changed here)
app.update();
assert!(
app.world().resource::<Dirty>().0 > 0,
"a turning unit dirties its transform"
);
// The turn settles inside the dead-band in ~9 pumps; run past it, then check steady state.
for _ in 0..16 {
app.update();
}
assert_eq!(
app.world().resource::<Dirty>().0,
0,
"a settled unit must stop dirtying its transform"
);
}
/// A coincident pair yields no bearing — a degenerate `atan2` would spin the unit to 0.
#[test]
fn a_coincident_pair_has_no_bearing() {
+2 -2
View File
@@ -427,7 +427,7 @@ pub(super) fn act_on_right_click(
/// The right-click action a hovered GameObject resolves to (decisions 0239 / 0545 / 0752) — chosen
/// by its lock, through the shared chain in [`super::lock`].
enum GoAction {
pub(crate) enum GoAction {
/// No lock (or no lock data): `CMSG_GAMEOBJ_USE` — door / lever / quest object / mailbox /
/// unlocked chest.
Use,
@@ -478,7 +478,7 @@ enum KeyFact {
/// `Spell.cpp:7892`), and only `CMSG_USE_ITEM` supplies it. That was recorded here as the server's
/// gap; it was ours, and wow-re's note says so in the same breath — "the client never sends a bare
/// CMSG_CAST_SPELL for a key lock".
fn resolve_go_action(
pub(crate) fn resolve_go_action(
guid: u64,
inputs: &mut GoLockInputs,
known: &std::collections::BTreeSet<u32>,
+5 -1
View File
@@ -46,7 +46,11 @@ use benilla_world::interact::{WorldClick, WorldRightClick};
use benilla_world::schedule::WorldStage;
mod by_name;
mod click;
// `pub(crate)` for the chest live probe alone (decision 1471): it resolves a GameObject's
// right-click action through the very same [`click::resolve_go_action`] the mouse does, rather
// than re-deciding lockless-vs-opener itself — an instrument that guesses the packet is testing
// its own guess (`probe_book`'s pattern: drive the click's own route, never a parallel one).
pub(crate) mod click;
// `pub(crate)` for the hover inspector's interact-gate line (`interact/inspect.rs`): the overlay
// states the very predicate the cursor runs ([`cursor_mode::go_highlightable`]) rather than a
// re-derivation of it, so the readout can never drift from the behaviour it is reporting on.
+89 -6
View File
@@ -384,22 +384,34 @@ fn tick_transports(
sample.map,
);
}
// Write-on-change only, on every component here (1473): a docked leg samples the
// identical pose each frame, and the unconditional writes this replaces re-dirtied the
// deck's whole subtree — transform propagation over every submesh plus a `Visibility`
// re-propagation — for every docked elevator on the map, every frame.
if sample.map != current_map.0 {
*visibility = Visibility::Hidden;
visibility.set_if_neq(Visibility::Hidden);
continue;
}
*visibility = Visibility::Inherited;
transform.translation = benilla_assets::coords::wow_to_bevy(sample.pos);
transform.rotation = Quat::from_rotation_y(sample.heading);
visibility.set_if_neq(Visibility::Inherited);
let translation = benilla_assets::coords::wow_to_bevy(sample.pos);
let heading = Quat::from_rotation_y(sample.heading);
if transform.translation != translation || transform.rotation != heading {
transform.translation = translation;
transform.rotation = heading;
}
// Mirror the pose into avian's own components: its Transform→Position sync runs in the
// physics schedule (before Update), so without this the mover's same-frame casts would
// see the deck one frame behind — ~0.5 yd astern at cruise. The broad-phase AABB still
// lags a step, which is harmless (a rider stands deep inside the boat's AABB).
if let Some(mut p) = position {
p.0 = transform.translation;
if p.0 != transform.translation {
p.0 = transform.translation;
}
}
if let Some(mut r) = rotation {
r.0 = transform.rotation;
if r.0 != transform.rotation {
r.0 = transform.rotation;
}
}
}
}
@@ -456,3 +468,74 @@ fn compose_riders(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::ElevatorKeyframe;
/// A docked lift goes QUIET: `tick_transports` writes pose and visibility on change only
/// (1473) — the unconditional writes it replaces re-dirtied every docked elevator's whole
/// subtree (transform + `Visibility` propagation over all its submeshes), map-wide, every
/// frame. A two-frame pause path samples the identical pose forever, so after the first
/// arming write nothing may be marked changed.
#[test]
fn a_docked_lift_stops_dirtying_its_transform_and_visibility() {
#[derive(Resource, Default)]
struct Dirty {
transforms: usize,
visibilities: usize,
}
fn spy(
t: Query<Entity, (Changed<Transform>, With<Transport>)>,
v: Query<Entity, (Changed<Visibility>, With<Transport>)>,
mut out: ResMut<Dirty>,
) {
out.transforms = t.iter().count();
out.visibilities = v.iter().count();
}
let mut app = App::new();
app.insert_resource(CurrentMap(0))
.init_resource::<Dirty>()
.add_systems(Update, (tick_transports, spy).chain());
app.world_mut().spawn((
Guid(0xF110_0000_0000_0001),
Transport {
drive: Drive::Lift(Lift {
frames: Arc::new(vec![
ElevatorKeyframe {
time_ms: 0,
pos: [0.0; 3],
},
ElevatorKeyframe {
time_ms: 1000,
pos: [0.0; 3],
},
]),
period_ms: 1000,
base_pos: [10.0, 20.0, 30.0],
quat: [0.0, 0.0, 0.0, 1.0],
yaw: 0.5,
map: 0,
}),
was_moving: false,
},
TransportAnchor {
progress_ms: 0,
at: Instant::now(),
},
Transform::default(),
Visibility::default(),
));
app.update(); // arming frame: the first pose write lands (and spawn reads as Changed)
for _ in 0..3 {
app.update();
}
let dirty = app.world().resource::<Dirty>();
assert_eq!(
(dirty.transforms, dirty.visibilities),
(0, 0),
"a docked lift must stop dirtying its transform and visibility"
);
}
}
+173 -10
View File
@@ -296,19 +296,99 @@ pub(crate) struct LootConfig {
}
/// The client-local **loot-target latch** — the mirror of the real client's `[player+0x1d28]`
/// guid (wow-re `loot-anim-leg.md`, the 2026-07-18 §5, decision 0515): set the instant
/// `CMSG_LOOT` goes out (`0x5df253`, the same site that client-predicts the kneel before any
/// server response) and cleared on release/close (`0x48f2c9`/`0x5ec0d4`). This — **not** the
/// mirrored `UNIT_FLAG_LOOTING` descriptor bit — is what the local player's own kneel rides
/// (the byte predicate `0x6126b0` splits: self → this latch, remote → the flag), so our kneel
/// starts at the click and ends at the release with no wire round-trip on either edge. Ours
/// clears **guid-matched** (release response / refusal / our own release sends) — the safe
/// generalization of the client's clears under our corpse-switch race, where the *old* window's
/// release response lands after the *new* loot request armed the latch — plus unconditionally at
/// session teardown.
/// guid (wow-re `loot-anim-leg.md` §5, the 2026-08-21 §5 trio; decisions 0515 / 1471 / **1477**).
/// It says *a loot session is open on this object*, and it is read by far more than the kneel: the
/// loot cursor, the re-loot lock-out (`0x5ec110`), `CMSG_LOOT_MONEY`'s gate, auto-loot.
///
/// **Whether we kneel at it is a separate question** — [`LootKneel`], the client's predicate B
/// `0x612710`. Arming the latch is not arming the pose; conflating the two is what 1471 got wrong.
///
/// **The arms** (the real client has five; we model the three our wire can produce):
/// - the `CMSG_LOOT` send (`0x5df253`/`0x5df40d`) — a **corpse** or player bones, armed at the
/// click, so the kneel is client-predicted with no round-trip;
/// - **`SMSG_SPELL_GO`** for an `OPEN_LOCK` cast that lands on a **chest**
/// (`0x6e831b → SetLootTarget 0x5ed5f0`) — this, not the loot response, is a chest's real arm,
/// and it is why the reference is already kneeling by the time the window opens;
/// - `CGPlayer_C::OnLootResponse 0x5eb900`, through its **admission gate** — see
/// [`crate::net::apply::loot::loot_response`]. Not unconditional: a `loot_type == 1` response
/// against a cold latch is *refused and bounced*.
///
/// Not modelled: the `CMSG_OPEN_ITEM` arm (`0x5edcc0`, a lockbox). It would change no pose —
/// predicate B refuses an ITEM — only the cursor and the re-loot lock-out; named in 1477 as a gap.
///
/// Cleared on release/close (`0x48f2c9`/`0x5ec0d4`). Ours clears **guid-matched** (release
/// response / refusal / our own release sends) — the safe generalization of the client's clears
/// under our corpse-switch race, where the *old* window's release response lands after the *new*
/// loot request armed the latch — plus unconditionally at session teardown.
#[derive(Resource, Default)]
pub(crate) struct LootLatch(pub(crate) Option<u64>);
/// **Predicate B `0x612710`, the local-player branch** — whether the object the [`LootLatch`]
/// currently names is one the character *kneels at* (wow-re `loot-anim-leg.md` §8, byte-verified
/// §5 trio 2026-08-21; decision 1477). The loot leg `0x5fd260` needs predicate A (a session is
/// open) **and** this one, and the split is the whole reason a fishing bobber does not kneel while
/// a chest does — the latch is armed identically for both.
///
/// The byte table, transcribed:
///
/// | latched object | kneels? |
/// |---|---|
/// | GameObject, any type but 17 (a chest, a herb node, a `FISHINGHOLE` 25) | yes |
/// | GameObject type **17 `FISHINGNODE`** — a fishing bobber (`0x612772`) | **no** |
/// | Unit with `UNIT_FIELD_HEALTH <= 0` — a creature corpse | yes |
/// | Unit with health **> 0** — pickpocketing a live target (`0x61278c`) | **no** |
/// | Item — a lockbox, a disenchant (`0x612797`, `!(TYPEMASK & 2)`) | **no** |
/// | a guid the object manager cannot resolve (`0x612732`) | **no** |
///
/// Recomputed by [`resolve_loot_kneel`] each frame, **between the net drain and the anim driver**.
/// That ordering is load-bearing, not tidiness: the reference does not poll this at all — it
/// force-plays Loot 50 *at* the chest arm (`0x5ed619`, in the same `SMSG_SPELL_GO` handler that
/// writes the latch), so the pose is up on the arming frame. Scheduling this system loose cost
/// exactly one frame of Stand at the open, which the chest probe caught as 59/60.
#[derive(Resource, Default)]
pub(crate) struct LootKneel(pub(crate) bool);
/// `GAMEOBJECT_TYPE_ID` 17 — `FISHINGNODE`, the bobber. The one GameObject type predicate B names
/// explicitly, and the reason "the latch is armed" is not the same question as "we kneel"
/// (`0x612772`; the test exists for exactly this case).
const GO_TYPE_FISHINGNODE: i32 = 17;
/// Recompute [`LootKneel`] from the latched object — predicate B `0x612710`'s local branch.
/// `pub(crate)` so [`crate::creature_anim`]'s driver chain can order itself after it (see
/// [`LootKneel`]: the reference's pose is up on the arming frame, so ours must be too).
pub(crate) fn resolve_loot_kneel(
latch: Res<LootLatch>,
index: Res<crate::net::GuidIndex>,
objects: Query<(&crate::net::NetEntity, &crate::net::ObjectStore)>,
mut kneel: ResMut<LootKneel>,
) {
let allowed = latch
.0
.and_then(|guid| index.0.get(&guid).copied())
.and_then(|e| objects.get(e).ok())
.is_some_and(|(net_entity, store)| match net_entity.kind {
// `0x612764`: a GameObject kneels unless it is the bobber.
benilla_protocol::EntityKind::GameObject => {
store.0.gameobject_type_id() != GO_TYPE_FISHINGNODE
}
// `0x61278c`: a unit kneels only once its `UNIT_FIELD_HEALTH` is not positive — read
// straight off the descriptor, as the bytes do (an unsent field is 0 in the client's
// descriptor array too, so `unwrap_or(0)` *is* the faithful read; this deliberately
// does not go through `unit_is_dead`, whose extra `max_health > 0` guard the
// reference has no counterpart for).
benilla_protocol::EntityKind::Unit | benilla_protocol::EntityKind::Player => {
store.0.unit_health().unwrap_or(0) == 0
}
// An ITEM never kneels (`0x612797`), and we stream no item entities anyway — the
// resolve above already answers `false` for a lockbox latch. Everything else
// (DynamicObject, Other) is not a loot target the reference reaches here.
_ => false,
});
if kneel.0 != allowed {
kneel.0 = allowed;
}
}
impl LootLatch {
/// Drop the latch if it still points at `guid` (a release/refusal for that loot session).
pub(crate) fn clear_for(&mut self, guid: u64) {
@@ -326,6 +406,7 @@ impl Plugin for UiLootPlugin {
.init_resource::<LootErrors>()
.init_resource::<LootConfig>()
.init_resource::<LootLatch>()
.init_resource::<LootKneel>()
.add_systems(
Update,
(
@@ -333,6 +414,10 @@ impl Plugin for UiLootPlugin {
// after it so a click's intent goes out the same frame (mirrors ui_merchant).
feed_loot.before(UiInput),
drain_loot.after(UiInput),
// Predicate B, per frame, after the net drain that arms the latch. The anim
// driver then orders itself after THIS (`crate::creature_anim`), closing the
// arm→pose gap to zero frames, as the reference's force-play does.
resolve_loot_kneel.after(benilla_world::schedule::WorldStage::Net),
),
);
}
@@ -724,6 +809,84 @@ fn drain_loot(
mod tests {
use super::*;
use benilla_protocol::messages::loot_type;
use benilla_protocol::messages::ObjectFields;
use benilla_protocol::EntityKind;
/// Descriptor field indices the predicate-B table reads.
const F_GO_TYPE_ID: u16 = 21;
const F_UNIT_HEALTH: u16 = 22;
/// Drive [`resolve_loot_kneel`] once over a world holding one latched object, and report what
/// predicate B said. `None` for `object` = the latch names a guid that does not resolve.
fn kneels_at(object: Option<(EntityKind, &[(u16, u32)])>) -> bool {
const GUID: u64 = 0xF110_0000_0000_0042;
let mut app = App::new();
app.init_resource::<LootLatch>()
.init_resource::<LootKneel>()
.init_resource::<crate::net::GuidIndex>()
.add_systems(Update, resolve_loot_kneel);
app.world_mut().resource_mut::<LootLatch>().0 = Some(GUID);
if let Some((kind, fields)) = object {
let e = app
.world_mut()
.spawn((
crate::net::NetEntity {
kind,
display_id: None,
scale: 1.0,
},
crate::net::ObjectStore(ObjectFields::from_pairs(fields)),
))
.id();
app.world_mut()
.resource_mut::<crate::net::GuidIndex>()
.0
.insert(GUID, e);
}
app.update();
app.world().resource::<LootKneel>().0
}
/// **Predicate B `0x612710`, the local branch** (wow-re `loot-anim-leg.md` §8; decision 1477).
/// The whole row set, because the *point* of this predicate is that arming the latch is not
/// the same question as kneeling: a fishing bobber and a chest arm it identically, and only
/// one of them is knelt at. Without this filter, 1471's response-arm gave benilla a kneel at
/// a bobber, over a lockbox, and while pickpocketing — none of which the reference does.
#[test]
fn predicate_b_decides_which_loot_targets_are_knelt_at() {
// A GameObject that is not the bobber — a chest, a herb node, a FISHINGHOLE(25).
assert!(kneels_at(Some((
EntityKind::GameObject,
&[(F_GO_TYPE_ID, 3)]
))));
assert!(kneels_at(Some((
EntityKind::GameObject,
&[(F_GO_TYPE_ID, 25)]
))));
// `0x612772` — GAMEOBJECT_TYPE_ID 17 FISHINGNODE, the one type named explicitly.
assert!(!kneels_at(Some((
EntityKind::GameObject,
&[(F_GO_TYPE_ID, 17)]
))));
// `0x61278c` — a corpse kneels, a live target (pickpocketing) does not.
assert!(kneels_at(Some((EntityKind::Unit, &[(F_UNIT_HEALTH, 0)]))));
assert!(!kneels_at(Some((EntityKind::Unit, &[(F_UNIT_HEALTH, 1)]))));
// `0x612732` — a guid the object manager cannot resolve (an item latch is this, for us).
assert!(!kneels_at(None));
}
/// A cold latch is not a kneel — predicate A's half, folded into the same resource so the
/// anim driver reads one boolean.
#[test]
fn a_cold_latch_never_kneels() {
let mut app = App::new();
app.init_resource::<LootLatch>()
.init_resource::<LootKneel>()
.init_resource::<crate::net::GuidIndex>()
.add_systems(Update, resolve_loot_kneel);
app.update();
assert!(!app.world().resource::<LootKneel>().0);
}
fn item(slot: u8, entry: u32, count: u32) -> LootItem {
LootItem {
@@ -6,7 +6,8 @@
//! cite; 0989: the directed cuts — steppers and the corner X gone, the whole bar live via
//! the engine's track-press law, the search box at the era's verbatim seat; 0992: the
//! dropdown row shape on the 1.12 kit — Environment Detail — and the Nameplates page's
//! three UnitName* rows).
//! three UnitName* rows; 1476: the ground dim — a black fill over the 0.6 tile, seated clear
//! of the rope's ink, because a 60% veil is not a page you can read over bright terrain).
//!
//! What these guard: the file loads clean inside the real neighbourhood (Fonts + UiPanels +
//! GameMenuFrame); the menu's Options button is the door in (menu down, options up, on the ref's
@@ -295,6 +296,77 @@ fn the_selected_row_wears_the_gold_wash_and_hover_runs_blue() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The GROUND DIM (1476) — the plain black fill that turns 0.6 of veil into a page you can
/// read. Two laws here, both silently breakable by hand, and neither about the taste number:
/// the fill must draw **above** the backdrop's tiled ground (a region sorts after its frame's
/// own draw slot, which is the whole reason a region *can* dim it), and it must sit clear of
/// the rope's **ink** — the border's four edge slices are dead from texel 16 of 32 and the
/// corners' ink ends by 14 (decoded from `UI-DialogBox-Border`), so anything inset 14 or more
/// darkens the page without touching the frame. At the backdrop's own 11/12/12/11 bg insets it
/// would smear across the rope's inner half, which is exactly the mistake this pins.
#[test]
fn the_ground_dim_draws_over_the_tile_and_clear_of_the_rope() {
let mut s = harness();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.resolve();
let edge = |frame: &str, side: &str| -> f32 {
s.eval(&format!("return {frame}:Get{side}()")).unwrap()
};
let (fl, fr, ft, fb) = (
edge("OptionsFrame", "Left"),
edge("OptionsFrame", "Right"),
edge("OptionsFrame", "Top"),
edge("OptionsFrame", "Bottom"),
);
let (dl, dr, dt, db) = (
edge("OptionsFrameGroundDim", "Left"),
edge("OptionsFrameGroundDim", "Right"),
edge("OptionsFrameGroundDim", "Top"),
edge("OptionsFrameGroundDim", "Bottom"),
);
for (name, inset) in [
("left", dl - fl),
("right", fr - dr),
("top", ft - dt),
("bottom", db - fb),
] {
assert!(
inset >= 14.0,
"the dim must clear the rope's ink on the {name} — inset {inset}"
);
}
// Draw order: `extract` returns ascending z, so a later index draws later. The dim has to
// land after the tiled ground or it dims nothing.
let quads = s.extract();
let ground = quads
.iter()
.position(|q| match &q.content {
QuadContent::Backdrop { path, .. } => path.contains("UI-DialogBox-Background"),
_ => false,
})
.expect("the window still wears the 1.14 dialog ground");
let dim = quads
.iter()
.position(|q| {
let Some(r) = q.rect else { return false };
matches!(
&q.content,
QuadContent::Texture { path: None, color: Some(c), .. }
if c[0] == 0.0 && c[1] == 0.0 && c[2] == 0.0 && c[3] > 0.0 && c[3] < 1.0
) && (r.left - dl).abs() < 0.5
&& (r.top - dt).abs() < 0.5
})
.expect("the black ground fill is drawn");
assert!(
dim > ground,
"the dim draws over the tile (ground #{ground}, dim #{dim})"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Search (0984: the era SettingsPanel mechanism transcribed): typing reflows the LIVE
/// matching rows under a clickable category head — a matched CHILD pulls its parent volume
/// row in above it (the era parentInitializer rule) — the title reads "Search Results" and
@@ -72,8 +72,9 @@ fn the_duel_flag_idle_resolves_to_stand_and_sits_planted() {
// 4. Stand's AUTHORED bounds describe the planted flag — ground to tip — and emphatically NOT
// the bind pose (z +8.9..+14.7). This is the mouseover picker's volume for the armed idle
// (entity M2s are never frustum-culled — the renderer takes `NoFrustumCulling`, faithful
// to the reference's ≈1e7 entity render bounds; wow-re `unit-anim-visibility-gate.md`).
// (entity M2 parts carry `NoFrustumCulling` — the view cull is the body ROOT's per-object
// election, never a per-part box; the "≈1e7 entity render bounds" reading behind 0648 was
// refuted by wow-re's `outdoor-object-pass-election.md` — decision 1473).
// The bind-pose `Aabb` Bevy would derive sits a whole model-height ABOVE the geometry that
// draws — as a cull box it hid the planted flag from every ground-level camera, and as a
// pick box it would put the hover target in the sky.
+14 -3
View File
@@ -222,6 +222,9 @@ pub(crate) fn update_ground_shade(
&GlobalTransform,
&mut GroundShade,
Option<&crate::world_unit::ViewerUnit>,
// The election's verdict on this root (1270/1475) — `Option` because a fixed doodad's
// shade root carries no `Visibility` of its own and must keep walking.
Option<&Visibility>,
)>,
children: Query<&Children>,
// Parts are matched by carrying a `MeshTag`; interior-classified ones are skipped (their payload
@@ -248,7 +251,7 @@ pub(crate) fn update_ground_shade(
};
let step = SHADE_RAMP_PER_SEC * time.delta_secs();
let ambient_step = AMBIENT_RAMP_PER_SEC * time.delta_secs();
for (root, gt, shade, is_self) in &mut roots {
for (root, gt, shade, is_self, root_vis) in &mut roots {
// `WOW_INTERIOR_LOG=1`: a periodic SELF-node dump (every 3 s) — the probe run's definitive
// "what state is the parked character actually in" line, attributable unlike the
// change-triggered `[node]` lines below (wandering NPCs fire those constantly).
@@ -325,11 +328,19 @@ pub(crate) fn update_ground_shade(
ramp_toward(a.z, at.z, ambient_step),
);
let byte = (shade.t * 255.0).round().clamp(0.0, 255.0) as u8;
// A root the election hid (1270/1475) draws nothing, so the re-assert walk below is
// skipped whole — 1473's audit found it running over every part of every off-view body.
// The ramps above kept stepping, so the byte is current the frame the body wakes; this
// system runs one stage before the election (Update vs PostUpdate), so the wake frame's
// walk re-asserts at most one frame late, into parts whose tags retained their last byte.
if root_vis.is_some_and(|v| *v == Visibility::Hidden) {
continue;
}
// Push to every part below the root (body submeshes are direct children; held items/helm ride
// joint entities deeper down — same full-tree walk as the self-fade). Change-gated per part on
// the byte, so a settled entity writes nothing and never re-triggers render extraction. The
// walk itself is unconditional: it is the MeshTag re-assert over the classifier's exterior
// reclaim (1358), not a change-only push.
// walk itself is unconditional for a DRAWN root: it is the MeshTag re-assert over the
// classifier's exterior reclaim (1358), not a change-only push.
root_shade.insert(root, byte);
for part in children.iter_descendants(root) {
let Ok((mut tag, lit)) = parts.get_mut(part) else {
+187 -11
View File
@@ -49,6 +49,16 @@
//! So a body is exterior scene exactly when it is not in a WMO room, and the windows gate it like
//! the ground it stands on — [`WorldUnit::bound`].
//!
//! **The outdoor half of the same election (decision 1475).** The reference's OUTSIDE leg is not a
//! stand-down — it is one full-screen window through the same walk (`0x6811ff`), whose producer
//! `0x683340` frustum-tests every node; and the sibling in-room producer `0x6834e0` submits a
//! group's members only when the group is rendered, frustum-testing each. So outdoors a body's
//! bound is tested against the camera's own view volume, and an in-room body draws only when its
//! room (one portal hop padded — the doorway-straddle guard) is in the PVS *and* its box is in
//! view. 1270 §6 deferred this half; 1473's audit is why it landed. `WOW_NO_DRAW_ELECTION=1` is
//! the lever back to the window leg alone. Knowingly absent: the horizon-occlusion term
//! (`0x686000`) — its own machinery, its own record.
//!
//! ## The clip geometry
//!
//! Per window, the reference bilerps the camera's four global corner rays (`0xc7bcd8`) by the window's
@@ -160,9 +170,14 @@ impl Plugin for ExteriorCullPlugin {
apply_exterior_cull
.in_set(ExteriorCullSet)
// The windows are written by the flood in `Update`; the cull reads them and must land
// before Bevy's own visibility pass consumes the result this same frame.
// before Bevy's own visibility pass consumes the result this same frame — and before
// the hierarchy propagation, so a root verdict reaches `InheritedVisibility` (which
// 1283's consumers and the billboard mirror read) the SAME frame. 1270 only ordered
// against `CheckVisibility`, leaving a benign race the window leg never noticed; the
// frustum leg follows the camera every frame, so the frame of lag is pinned away.
.after(WmoPvsSet)
.after(bevy::transform::TransformSystems::Propagate)
.before(VisibilitySystems::VisibilityPropagate)
.before(VisibilitySystems::CheckVisibility),
);
}
@@ -359,20 +374,47 @@ type ElectedBody = (
/// it is the sole authority for: terrain cells, the WDL far band, and — in the second query below,
/// a different audience under the same law rather than a second system — the elected net bodies
/// ([`ElectedBody`], decision 1270), whose roots nothing else writes.
#[allow(clippy::too_many_arguments)]
// A Bevy system's params are not an argument list to shorten — each is a distinct world access
// the scheduler needs by name (the `update_ground_shade` precedent).
fn apply_exterior_cull(
windows: Res<ExteriorWindows>,
cam: Query<(&GlobalTransform, &Projection), With<WorldCamera>>,
mut scene: Query<UnownedScene, UnownedSceneFilter>,
mut bodies: Query<ElectedBody>,
instances: Query<&crate::wmo_portal::WmoPortalInstance>,
wmos: Res<Assets<benilla_assets::WmoModel>>,
mut verdict: ResMut<ExteriorCullVerdict>,
trace: Option<Res<CullTrace>>,
mut no_election: Local<Option<bool>>,
) {
// `WOW_NO_DRAW_ELECTION=1` — disable 1475's frustum + room legs only (the 1270 window leg
// stays: it fixes a *correctness* report, not a cost). The A/B lever that brackets the
// election's worth at any pin, and the escape hatch if an artifact is ever reported.
let no_election =
*no_election.get_or_insert_with(|| std::env::var_os("WOW_NO_DRAW_ELECTION").is_some());
let set = |vis: &mut Visibility, target: Visibility| {
if *vis != target {
*vis = target;
}
};
let gate = ExteriorGate::build(&windows, cam.iter().next());
// The camera's own view volume, as the full-screen window: the reference's OUTSIDE leg is
// literally one `{0,0,1,1}` window through the same walk (`0x6811ff`), so outdoors is not a
// stand-down for bodies — an out-of-frustum body is elected pass 2, not drawn and not ticked
// (1473's correction of 0648; decision 1475). Built from this frame's own camera transform
// (we run after `Propagate`), never the `Frustum` component, which another PostUpdate set
// may not have refreshed yet. `None` (no camera) admits: a verdict without a view matrix
// 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)
});
let in_view = |gt: &GlobalTransform, bound: &Aabb| {
full_view
.as_ref()
.is_none_or(|f| f.intersects_obb(bound, &gt.affine(), true, true))
};
let (mut tested, mut hidden, mut unbounded) = (0usize, 0usize, 0usize);
for (gt, aabb, mut vis) in &mut scene {
tested += 1;
@@ -410,12 +452,29 @@ fn apply_exterior_cull(
// *outdoors* makes a mob in your own room appear a frame late, guessing *in a room* makes a
// mob outside appear through solid rock. And `UnitWmoRoom::default()` is itself a
// no-room claim, so this simply decides the missing component the way the present one would.
let exempt = room.is_some_and(|r| r.room().is_some());
let admitted = exempt || gate.admits(gt, Some(bound));
let in_room = room.is_some_and(|r| r.room().is_some());
let admitted = if no_election {
// The lever: 1270's window leg alone — in-room exempt, outdoors a stand-down.
in_room || gate.admits(gt, Some(bound))
} else if in_room {
// The sibling producer (`0x6834e0`): submitted with its building iff its room — or a
// one-hop neighbour, the doorway-straddle guard — is in this frame's portal PVS,
// AND its box passes the camera frustum like any other group member (the producer
// frustum-tests members; a body behind the camera in a visible room is pass 2).
// Fail-open at every resolve seam ([`crate::wmo_portal::room_pvs_visible`]).
crate::wmo_portal::room_pvs_visible(room, &instances, &wmos) && in_view(gt, bound)
} else {
// The exterior walk: outdoors, the one full-screen window; indoors, the portal
// windows — which are already sub-frusta of the view, so no second frustum term.
match &gate {
ExteriorGate::Open => in_view(gt, bound),
ExteriorGate::Windows(_) => gate.admits(gt, Some(bound)),
}
};
trace_body(
&trace,
e,
if exempt { "in-room" } else { "outdoor" },
if in_room { "in-room" } else { "outdoor" },
gt,
room,
Some(bound),
@@ -549,6 +608,7 @@ mod tests {
) -> (Vec<Visibility>, ExteriorCullVerdict) {
let mut app = App::new();
app.init_resource::<ExteriorCullVerdict>()
.insert_resource(Assets::<benilla_assets::WmoModel>::default())
.insert_resource(windows)
.add_systems(Update, apply_exterior_cull);
// The same camera the pure-frustum tests use: origin, down −Z, 90° fov.
@@ -690,21 +750,137 @@ mod tests {
assert_eq!((verdict.bodies, verdict.bodies_hidden), (1, 1));
}
/// Outdoors the driver takes its `{0,0,1,1}` leg and every body is submitted as before — the
/// arm that must never narrow, since it is the state the client spends most of its life in.
/// Outdoors the driver's `{0,0,1,1}` leg IS the ordinary frustum — the reference's outside
/// leg is one full-screen window through the same frustum-testing walk, so a body behind the
/// camera is elected pass 2: not drawn (decision 1475). This test asserted the opposite under
/// 1270 ("`Unrestricted` is a stand-down… the arm that must never narrow") — that law was
/// built before wow-re's election correction and is deliberately superseded here (1473).
#[test]
fn outdoors_every_body_is_submitted() {
fn outdoors_the_full_screen_window_elects_the_bodies() {
let (vis, verdict) = run_bodies(
ExteriorWindows::Unrestricted,
&[
(Vec3::new(0.0, 0.0, -50.0), outdoors(), creature_box()),
// Behind the camera, and still submitted: `Unrestricted` is a stand-down, not a
// frustum test. Bevy's own cull is what drops this one, one system later.
// Behind the camera: pass 2. Its parts carry `NoFrustumCulling` (0648's picker
// law), so this root verdict is the ONLY thing standing between an off-view
// crowd and the draw queue — the Goldshire premium of 1473.
(Vec3::new(0.0, 0.0, 50.0), outdoors(), creature_box()),
],
);
assert_eq!(vis, vec![Visibility::Inherited; 2]);
assert_eq!((verdict.bodies, verdict.bodies_hidden), (2, 0));
assert_eq!(
vis,
vec![Visibility::Inherited, Visibility::Hidden],
"ahead is drawn; behind the camera is elected out"
);
assert_eq!((verdict.bodies, verdict.bodies_hidden), (2, 1));
}
/// The in-room fork of the same election: a body in a PVS-dark room is not drawn — the
/// sibling producer only submits members of rendered groups — and the moment the flood
/// reaches its room (or a one-hop neighbour), it draws again. The imp-upstairs-in-the-inn
/// case from 1473's audit: visible-room wiring, resolve chain and all, not just the truth
/// table (which lives with [`crate::wmo_portal::room_pvs_visible`]).
#[test]
fn a_body_in_a_pvs_dark_room_is_not_drawn_until_the_flood_reaches_it() {
let mut app = App::new();
app.init_resource::<ExteriorCullVerdict>()
.insert_resource(Assets::<benilla_assets::WmoModel>::default())
.insert_resource(ExteriorWindows::Unrestricted)
.add_systems(Update, apply_exterior_cull);
app.world_mut().spawn((
WorldCamera,
GlobalTransform::IDENTITY,
Projection::Perspective(PerspectiveProjection {
fov: std::f32::consts::FRAC_PI_2,
aspect_ratio: 1.0,
near: 0.1,
far: 1000.0,
..default()
}),
));
// A two-group building whose groups share no portal: group 0's verdict is its own bit.
// The nav table must be REAL — an absent nav entry is a fail-open seam that reads
// visible ([`crate::wmo_portal::room_pvs_visible`]'s law), which is right for a
// still-loading model and wrong as a test of the dark-room verdict.
let sealed = |_g: u16| benilla_assets::WmoGroupNav {
flags: 0,
bbox_min: [0.0; 3],
bbox_max: [0.0; 3],
ref_start: 0,
ref_count: 0,
area_table_id: 0,
fog_indices: [0; 4],
group_liquid: benilla_formats::NO_GROUP_LIQUID,
};
let model = benilla_assets::WmoModel {
wmo_id: 1,
group_nav: vec![sealed(0), sealed(1)],
portal_refs: Vec::new(),
..Default::default()
};
let handle = app
.world_mut()
.resource_mut::<Assets<benilla_assets::WmoModel>>()
.add(model);
let inst = app
.world_mut()
.spawn(crate::wmo_portal::WmoPortalInstance {
handle,
world_from_local: bevy::math::Affine3A::IDENTITY,
name_set: 0,
visible: vec![false, false],
liquid_visited: vec![false, false],
flooded: vec![None, None],
})
.id();
// Dead ahead of the camera — only the room term can hide it.
let body = app
.world_mut()
.spawn((
GlobalTransform::from_translation(Vec3::new(0.0, 0.0, -50.0)),
crate::world_unit::WorldUnit {
wades: true,
scale: 1.0,
height: 2.0,
bound: Some(creature_box()),
},
crate::wmo_portal::UnitWmoRoom::claimed(crate::wmo_portal::WmoRoom {
instance: inst,
group: 0,
}),
Visibility::Inherited,
))
.id();
app.update();
assert_eq!(
*app.world().entity(body).get::<Visibility>().unwrap(),
Visibility::Hidden,
"a PVS-dark room's body is not submitted"
);
// The flood reaches its room: drawn again the same frame.
app.world_mut()
.entity_mut(inst)
.get_mut::<crate::wmo_portal::WmoPortalInstance>()
.unwrap()
.visible[0] = true;
app.update();
assert_eq!(
*app.world().entity(body).get::<Visibility>().unwrap(),
Visibility::Inherited,
"PVS reach ⇒ drawn"
);
// And a lit room does not exempt a member from the view: the sibling producer
// frustum-tests members too. Move the body behind the camera — hidden again.
*app.world_mut()
.entity_mut(body)
.get_mut::<GlobalTransform>()
.unwrap() = GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 50.0));
app.update();
assert_eq!(
*app.world().entity(body).get::<Visibility>().unwrap(),
Visibility::Hidden,
"a visible room's behind-camera member is still pass 2"
);
}
/// A doorway on the right admits the body on the right and rejects the one on the left — the
@@ -32,6 +32,7 @@ use std::sync::Arc;
mod fog;
mod interior;
mod probe;
mod room_vis;
mod seed;
use fog::select_wmo_fog;
@@ -47,6 +48,7 @@ pub use interior::{
};
pub use probe::WmoCullProbe;
use probe::{TraceLog, PROBE_DUMP_PATH};
pub use room_vis::room_pvs_visible;
use seed::dominant_axes;
pub use seed::{down_ray_seeds, floor_z_at, DownRaySeeds};
@@ -0,0 +1,118 @@
//! **Is a claimed WMO room visible this frame?** — the one answer two elections share.
//!
//! The reference elects an object standing in a WMO group with its building (`0x6834e0`, inside
//! the WMO-group render path): if the group isn't rendered this frame, the object is pass 2 —
//! neither drawn nor ticked. Two of our systems ask exactly that question of a body's
//! [`UnitWmoRoom`] claim: the anim-LOD gate (`creature_anim::lod`, decision 0739 — parks the
//! pose) and the body draw election (`crate::exterior_cull`, decision 1475 — hides the root).
//! One function answers both, so the two can never drift apart (the drift is how 0448's park and
//! 0648's draw ended up disagreeing for a month — decision 1473).
use bevy::prelude::*;
use benilla_assets::{WmoGroupNav, WmoModel, WmoPortalRef};
use super::{UnitWmoRoom, WmoPortalInstance, WmoRoom};
/// The room leg's resolve chain: unit claim → placement instance → model → PVS bits. Fail-open
/// at every seam (no claim, despawned placement, still-loading model) — a lookup miss must never
/// hide or park a drawable body.
pub fn room_pvs_visible(
room: Option<&UnitWmoRoom>,
instances: &Query<&WmoPortalInstance>,
wmos: &Assets<WmoModel>,
) -> bool {
let Some(WmoRoom { instance, group }) = room.and_then(|r| r.room()) else {
return true;
};
let Ok(inst) = instances.get(instance) else {
return true;
};
let Some(model) = wmos.get(&inst.handle) else {
return true;
};
room_visible(&inst.visible, &model.group_nav, &model.portal_refs, group)
}
/// Is the claimed group — or any group one portal hop from it — in the PVS? The one-hop union is
/// the doorway-straddle guard: a body can extend past its feet's room only through a portal
/// opening, so the neighbour set bounds everything of the unit that could be on screen. Indices
/// out of range read visible (fail-open, [`super::WmoGroupVis::drawn_by`]'s convention); a group
/// with no portal refs at all can only be seen with the camera inside it (the flood cannot reach
/// it), which the direct bit already answered.
fn room_visible(visible: &[bool], nav: &[WmoGroupNav], refs: &[WmoPortalRef], group: u16) -> bool {
let vis = |g: usize| visible.get(g).copied().unwrap_or(true);
if vis(group as usize) {
return true;
}
let Some(n) = nav.get(group as usize) else {
return true;
};
let Some(hops) = refs.get(n.ref_start as usize..(n.ref_start as usize + n.ref_count as usize))
else {
return true;
};
hops.iter().any(|r| vis(r.group as usize))
}
#[cfg(test)]
mod tests {
use super::*;
/// A [`WmoGroupNav`] whose only meaningful fields are its portal-ref slice — these tests
/// never touch flags/bounds.
fn nav(ref_start: u16, ref_count: u16) -> WmoGroupNav {
WmoGroupNav {
flags: 0,
bbox_min: [0.0; 3],
bbox_max: [0.0; 3],
ref_start,
ref_count,
area_table_id: 0,
fog_indices: [0; 4],
group_liquid: benilla_formats::NO_GROUP_LIQUID,
}
}
fn pref(group: u16) -> WmoPortalRef {
WmoPortalRef {
portal: 0,
group,
side: 1,
}
}
/// The room predicate's whole truth table: direct bit, the one-hop straddle guard, the
/// all-dark verdict, the sealed room, and both fail-open seams (group past every table, a
/// ref slice past the refs vec).
#[test]
fn room_visible_covers_the_hop_guard_and_fails_open() {
// Groups 0 ↔ 1 share one portal; group 2 is sealed (no refs).
let navs = vec![nav(0, 1), nav(1, 1), nav(2, 0)];
let refs = vec![pref(1), pref(0)];
assert!(
room_visible(&[true, false, false], &navs, &refs, 0),
"direct PVS bit"
);
assert!(
room_visible(&[true, false, false], &navs, &refs, 1),
"own bit dark but the neighbour lit — the doorway-straddle guard keeps it live"
);
assert!(
!room_visible(&[false, false, true], &navs, &refs, 0),
"own room and every hop dark ⇒ not visible"
);
assert!(
!room_visible(&[true, true, false], &navs, &refs, 2),
"a sealed room's own bit decides — no hops exist to save it"
);
assert!(
room_visible(&[false], &navs, &refs, 9),
"a group past the PVS table reads visible (fail-open)"
);
assert!(
room_visible(&[false], &[nav(7, 2)], &refs, 0),
"a ref slice past the refs vec reads visible (fail-open)"
);
}
}