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