From 9f6a550b6656751e43f6993d781f2b655e59ce8d Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 2 Sep 2026 16:59:20 +0700 Subject: [PATCH] shadows go under the water, and the ping meter measures the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - your shadow no longer reads through the Stranglethorn river or the Stormwind mage-tower portal (in the reporter's own shot the crocolisk casting it was invisible under the surface and its shadow was not): the reference draws the blob shadow in PHASE 1 among the opaque drains (`0x6812c5`), before the footprints, the M2 opaque pass, the water surfaces and both M2 transparent passes — ours rode a +4096 sort rung above the whole world band (item 81 of the sorted transparent phase, now item 4); the footprints and the ground-target reticle move with it into a pre-water decal band at their own byte-known slots, and the print/shadow order was inverted too — the reference draws a print one slot AFTER the shadow - the ping meter was measuring a client frame, not the server: the RTT was computed in the ECS drain, one frame or more after the pong had landed, so it read high exactly when the client was slow — the world-enter hitches inflated the first sample by hundreds of ms and the 30 s averaging ring carried it for minutes, a meter that opens at ~600 ms and sinks toward the truth over two or three minutes, as reported; the read thread measures where the packet lands now, the way `OnData 0x537b10` hands `SMSG_PONG` to `HandlePong 0x537d60` inline, the ring is 15 usable slots not 16, the average is the reference's unsigned truncating divide, and the keepalive is per-connection from the last SEND, so the first ping is a full 30 s after world enter instead of anywhere inside the load storm; localhost reads 0–3 ms where it read 58 - an extra bar's empty slot no longer loses its hotkey label (E/R/C showing in one screenshot and not the next): one line left behind by the slice that shipped the multibars with no bindings cleared the corner outright, which became a wipe of a live label the day those wells got real `MULTIACTIONBAR` bindings — painted by `UPDATE_BINDINGS`, wiped by the next repaint; the empty arm repaints the corner now, as the reference's `ActionButton_Update` never touches the text there, pinned by a test that drives the reporter's shape through the real shipped `ActionBar.xml` - an unattended probe run opened focused and borderless over the director's work — `WOW_UNATTENDED` joins the background-window prefixes — and the world-API wall was measuring a prefix of every file: a `#[cfg(test)]` guard switched the scan off from the first test module to the end of the file, so the ceiling moves 166 → 172 as a measurement correction, not six crossings Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/assets/ui/ActionBar.xml | 17 +- crates/benilla-app/src/blob_shadow.rs | 12 +- crates/benilla-app/src/debug_panel/mod.rs | 7 +- crates/benilla-app/src/footprints.rs | 5 +- crates/benilla-app/src/net.rs | 88 +------ crates/benilla-app/src/net/apply.rs | 13 +- crates/benilla-app/src/net/apply/session.rs | 27 +- crates/benilla-app/src/net/io.rs | 242 +++++++++++++++++- crates/benilla-app/src/target/reticle.rs | 10 +- crates/benilla-app/src/ui_net.rs | 17 +- .../src/ui_script/multibar_stance_tests.rs | 97 +++++++ crates/benilla-app/tests/world_api_wall.rs | 50 +++- crates/benilla-world/src/bgwin.rs | 16 ++ crates/benilla-world/src/particles/render.rs | 5 +- crates/benilla-world/src/sky_order.rs | 114 +++++++-- 15 files changed, 560 insertions(+), 160 deletions(-) diff --git a/crates/benilla-app/assets/ui/ActionBar.xml b/crates/benilla-app/assets/ui/ActionBar.xml index fc65e56c..55a89b04 100644 --- a/crates/benilla-app/assets/ui/ActionBar.xml +++ b/crates/benilla-app/assets/ui/ActionBar.xml @@ -459,6 +459,12 @@ -- The grid count, same posture: a foreign button reaching ActionButton_HideGrid with -- no count would raise on the arithmetic, which is 1199's class of silent-wrong-shape. button.showgrid = button.showgrid or 0 + -- The binding COMMAND, same posture again, and derived the way the REFERENCE derives + -- it on every read — `this.buttonType..this:GetID()` (ActionButton_UpdateHotkeys + -- l.126) — which is all our own OnLoad caches. Without it PaintHotkey would ask + -- `GetBindingKey(nil)` on somebody else's button the first time its well paints empty. + button.bindingCommand = button.bindingCommand + or ((button.buttonType or "ACTIONBUTTON") .. (button.index or button:GetID() or 1)) end function BenillaActionButton_Action(button) @@ -974,6 +980,16 @@ button.cooldown:Hide() button.rangeTimer = nil button.hotkey:SetVertexColor(0.6, 0.6, 0.6) + -- REPAINTED, never blanked (B348). The label belongs to PaintHotkey, and it is + -- the ref's own arrangement: `ActionButton_Update`'s empty arm touches the corner's + -- COLOUR only (ref l.167-172), while `ActionButton_UpdateHotkeys`' bound leg writes + -- the binding text whether or not the slot holds an action — so a bound-but-empty + -- well wears its key. Repainting here rather than leaving the text alone also + -- clears what the OnUpdate range timer may have left on an UNBOUND slot: the ref + -- keeps that "●" on a well that has just emptied (its OnUpdate then reads + -- IsActionInRange as nil and SHOWS the dot on an empty slot), which is a quirk of + -- reading the label back as state, not behaviour worth having. + BenillaActionButton_PaintHotkey(button) button:SetChecked(0) BenillaActionButton_StopFlash(button) local grid = BenillaActionButton_GridShown(button) @@ -983,7 +999,6 @@ -- ALWAYS_SHOW_MULTIBARS). Only the MAIN bar keeps our always-visible wells -- divergence (the SCOPE note) — its art has sockets; floating rings over bare -- world would not read as a bar. - button.hotkey:SetText("") if not grid then button:Hide() else diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs index 9b817edc..821c6747 100644 --- a/crates/benilla-app/src/blob_shadow.rs +++ b/crates/benilla-app/src/blob_shadow.rs @@ -9,6 +9,15 @@ //! selection ring uses** (`0x6d7330 → 0x6d6fa0 → 0x6d7480`), collector flags `0x2f0122` = the //! ring's `0x200122` **+ the liquid receivers** (a gap here: liquid isn't in the //! [`GroundDecalSurface`] set yet — the shadow lands on terrain + WMO faces only). +//! - **Frame slot**: PHASE 1, among the opaque drains — `0x6812c5 call 0x683dd0`, fifth of the +//! row `0x6812b1`–`0x6812ca` inside `0x681070`, which the driver `0x483460` calls at +//! `0x48361d` (wow-re `water-frame-straddle.md` §1 + `unit-blob-shadow.md` Q1). So the shadow +//! lands after terrain and WMO and **before** everything else: the footprint decals +//! (`0x483654`), the M2 opaque pass (`0x4836a6`), the water surfaces (phase 3, drawn *between* +//! the two M2 transparent passes) and both of those passes. Every transparent in the world +//! paints over it — which is what [`Rung::SHADOW_SORT`](benilla_world::sky_order::Rung) now +//! expresses, and what it did not until B347: at the old positive rung the shadow was the one +//! thing in the scene a river or a portal effect could not attenuate. //! - **Texture**: `Textures\ShadowBlob.blp` — a 32×32 grayscale radial blob (flat gray-160 core, //! linear rim to white) under a binary alpha disc. The reference multitextures a procedural 64×8 //! trapezoid ramp on a second stage (`0x6d81a0`/`0x6d82d0`, blend-mode-selected); its combine @@ -463,7 +472,8 @@ fn update_shadows( } /// Push every shown shadow's cached triangles onto the stream — one Multiply draw per unit at -/// the shadow rung, fog off (the reference's shadow pass state). +/// the shadow rung (the pre-water decal band's floor, the module header's frame slot), fog off: +/// the reference's shadow pass state. fn push_shadows( assets: Option>, cam: Query>, diff --git a/crates/benilla-app/src/debug_panel/mod.rs b/crates/benilla-app/src/debug_panel/mod.rs index 45f1ce33..b56a927f 100644 --- a/crates/benilla-app/src/debug_panel/mod.rs +++ b/crates/benilla-app/src/debug_panel/mod.rs @@ -361,6 +361,7 @@ fn debug_panel_ui( mut sound_cfg: ResMut, mut cull_probe: ResMut, net_status: Res, + ping: Res, dropped: Res, mut weather_state: Option>, mut world: WorldReadout, @@ -634,8 +635,12 @@ fn debug_panel_ui( egui::CollapsingHeader::new("Net") .default_open(false) .show(ui, |ui| { + // The LAST sample, not the ring average the meter shows: the panel + // is the instrument, and "what did the most recent pong measure" is + // the question a stuck or spiking meter needs answered. + let last_rtt = ping.0.lock().expect("ping clock").last_rtt_ms; ui.label(if net_status.connected { - match net_status.latency_ms { + match last_rtt { Some(ms) => format!("connected · {ms} ms ping"), None => "connected".to_string(), } diff --git a/crates/benilla-app/src/footprints.rs b/crates/benilla-app/src/footprints.rs index dc1b1f96..bfecd876 100644 --- a/crates/benilla-app/src/footprints.rs +++ b/crates/benilla-app/src/footprints.rs @@ -67,9 +67,6 @@ const OWN_CAP: usize = 64; const SHARED_CAP: usize = 512; /// Prints farther than this from the local player never spawn (the reference's 2500 yd² gate). const MAX_DISTANCE_SQ: f32 = 2500.0; -/// The print's sort-ladder rung: below the blob shadow's 4096 — where both decals stack the -/// shadow draws later, deterministically (the reference's frame order is the RE's open §13 item). -const PRINT_SORT_BIAS: f32 = 2048.0; /// Vertical reach of the projection slab about the planted foot: enough to catch the ground /// through a slightly-lifted foot bone and drape a step edge, small enough not to paint a /// terrace below (the blob shadow's slab is the model box; a print has no box to read). @@ -367,7 +364,7 @@ fn push_footprints( .batch(cam, print.texture) .anchored(print.anchor) .rung( - PRINT_SORT_BIAS, + benilla_world::sky_order::Rung::FOOTPRINT, benilla_world::sky_order::Rung::SHADOW_RASTER, ) .owner(lane.0); diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 7404e5da..a8e7d01a 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -14,7 +14,7 @@ //! The bridge runs in [`WorldStage::Net`], before `Input`, so a server teleport snaps → streams → //! covers in one frame. Coordinates cross from raw WoW into Bevy space here (`wow_to_bevy`). -use std::collections::{HashMap, VecDeque}; +use std::collections::HashMap; use std::time::{Duration, Instant}; use benilla_protocol::{ @@ -389,89 +389,13 @@ pub(crate) struct SelfGuid(pub(crate) Option); pub(crate) struct NetStatus { pub(crate) connected: bool, pub(crate) last_reason: Option, - /// The last measured ping round trip (ms), from the `SMSG_PONG` echo against [`PingShared`]'s - /// clock. `None` until the first pong of a connection (and again after a disconnect). - pub(crate) latency_ms: Option, - /// The most recent [`RTT_RING`] round trips (ms), oldest first — the real client's own RTT - /// history (wow-re net W1: `HandlePong 0x537d60` puts each sample into a ring with "head/tail - /// wrap 16", which `0x537f20`'s avg-RTT math averages). [`Self::avg_latency_ms`] is what the UI - /// reports through `GetNetStats`; the *last* sample stays separate because that is the one the - /// wire echoes back in the next `CMSG_PING`'s lastRtt field. Cleared with the connection. - pub(crate) rtt_ring: VecDeque, } -/// How many round trips [`NetStatus::rtt_ring`] keeps — the reference ring's depth (wow-re net W1, -/// `HandlePong 0x537d60`: "head/tail wrap 16"). -pub(crate) const RTT_RING: usize = 16; - -impl NetStatus { - /// Record one measured round trip: the newest sample in, the oldest out at depth. - pub(crate) fn record_rtt(&mut self, ms: u32) { - self.latency_ms = Some(ms); - if self.rtt_ring.len() == RTT_RING { - self.rtt_ring.pop_front(); - } - self.rtt_ring.push_back(ms); - } - - /// Forget this connection's measurements — the next one's latency is its own. - pub(crate) fn clear_rtt(&mut self) { - self.latency_ms = None; - self.rtt_ring.clear(); - } - - /// The reported latency: the mean of the ring, truncated — `None` while it is empty (no pong - /// since the connection came up). The reference averages its ring rather than reporting the - /// last sample; the exact rounding is ours (`0x537f20`'s math is recorded as "avg-RTT" without - /// a byte-level form), and at a 30 s ping cadence one sample's rounding is invisible anyway. - pub(crate) fn avg_latency_ms(&self) -> Option { - if self.rtt_ring.is_empty() { - return None; - } - let sum: u64 = self.rtt_ring.iter().map(|&ms| u64::from(ms)).sum(); - Some((sum / self.rtt_ring.len() as u64) as u32) - } -} - -#[cfg(test)] -mod rtt_tests { - use super::{NetStatus, RTT_RING}; - - /// The reported latency is the ring's mean, and the ring is bounded at the reference depth — - /// so a single spike moves the meter by a sixteenth, not the whole way (which is the point of - /// averaging at all: the ping cadence is 30 s, and one bad sample must not sit on a red bar - /// for eight minutes). - #[test] - fn the_reported_latency_is_the_mean_of_a_bounded_ring() { - let mut status = NetStatus::default(); - assert_eq!(status.avg_latency_ms(), None, "no pong yet"); - - status.record_rtt(40); - assert_eq!(status.avg_latency_ms(), Some(40)); - status.record_rtt(60); - assert_eq!(status.avg_latency_ms(), Some(50), "the mean, not the last"); - - // Fill past the ring: only the newest RTT_RING samples count, so the two above age out. - for _ in 0..RTT_RING { - status.record_rtt(100); - } - assert_eq!(status.rtt_ring.len(), RTT_RING); - assert_eq!(status.avg_latency_ms(), Some(100)); - assert_eq!( - status.latency_ms, - Some(100), - "the last sample stays separate" - ); - - status.clear_rtt(); - assert_eq!(status.avg_latency_ms(), None, "a disconnect forgets it all"); - assert_eq!(status.latency_ms, None); - } -} - -/// The ping clock shared with the write thread's 30 s keepalive sender ([`io::PingClock`]): the -/// apply drain matches each `SMSG_PONG` echo against it to measure the round trip (stored back for -/// the next ping's lastRtt field, and surfaced in [`NetStatus::latency_ms`]). +/// The connection's ping clock and RTT history ([`io::PingClock`]), shared with both net threads: +/// the write thread stamps each `CMSG_PING` on it, and the **read thread** measures the +/// `SMSG_PONG` echo against it the moment the packet lands. The app only ever *reads* it — for +/// `GetNetStats` ([`crate::ui_net`]) and the debug panel — and clears it when a connection dies. +/// It does not measure: doing that from the drain is what B346 was. #[derive(Resource)] pub(crate) struct PingShared(pub(crate) std::sync::Arc>); diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index a5faf565..c8fcfd41 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -378,7 +378,8 @@ pub(super) fn apply_net_updates( ), // The aura feed's duration side-table + the clock to stamp arrivals (decisions 0255/0257): the // self-only `SMSG_UPDATE_AURA_DURATION` lands here keyed by raw slot, timestamped for the - // `ui_aura` slot-join — plus the ping clock the Pong arm measures round trips against. + // `ui_aura` slot-join — plus the ping clock, which this drain now only CLEARS on a + // disconnect: the measuring moved to the read thread with B346's fix. // Grouped as a tuple to stay under Bevy's 16-SystemParam ceiling. // // The stamp clock is `Time`, NOT the default virtual one, and both its readers @@ -1895,7 +1896,15 @@ pub(super) fn apply_net_updates( SessionEvent::ClientControl { mover, allow_move } => { client_control.write(super::ClientControlMessage { mover, allow_move }); } - SessionEvent::Pong { sequence } => session::pong(sequence, &aura.2, &mut status), + // **The pong never gets here** — the read thread measures it against the ping clock + // the instant it lands and stops it, the way the reference's `OnData 0x537b10` hands + // `SMSG_PONG` to `HandlePong 0x537d60` inline instead of queueing it (`net::io`). + // Reaching this arm means that bypass was undone and every latency reading is a + // client frame too slow again, which is B346 exactly — so it says so out loud rather + // than measuring here and hiding it. + SessionEvent::Pong { sequence } => { + warn!("net: pong seq={sequence} reached the drain — the read thread's RTT bypass is gone (B346)"); + } SessionEvent::PacketDropped { opcode, unparseable, diff --git a/crates/benilla-app/src/net/apply/session.rs b/crates/benilla-app/src/net/apply/session.rs index bab925f8..375980ae 100644 --- a/crates/benilla-app/src/net/apply/session.rs +++ b/crates/benilla-app/src/net/apply/session.rs @@ -23,8 +23,8 @@ use crate::ui_trainer::TrainerOpen; use super::super::{ CharActionResultMessage, CharListMessage, CinematicTriggeredMessage, DisconnectedMessage, DroppedOpcodes, EnteredWorldMessage, GameTime, GuidIndex, KnockBackMessage, LoggedOutMessage, - LoginFailedMessage, LoginStageMessage, NetStatus, PendingTransfer, PingShared, Reputations, - SelfGuid, ServerTime, ServerWallClock, TeleportMessage, WorldportMessage, + LoginFailedMessage, LoginStageMessage, NetStatus, PendingTransfer, Reputations, SelfGuid, + ServerTime, ServerWallClock, TeleportMessage, WorldportMessage, }; /// The pre-logon handshake reached a new stage (decision 0539) — the login screen's dialog reads it. @@ -187,10 +187,9 @@ pub(super) fn disconnected( // An announced-but-unfinished far teleport died with the socket. pending_transfer.0 = None; status.connected = false; - // A dead socket's last RTT is stale; the write thread resets the shared ping clock itself - // when the reconnect hands it a fresh writer. The averaged ring goes with it — the next - // connection's latency is its own, so `GetNetStats` reads unmeasured until its first pong. - status.clear_rtt(); + // The RTT ring is NOT cleared here. It belongs to the connection, and the read thread wipes + // it as it re-enters its cycle loop (`net::io`) — same instant, one thread, no race with a + // reconnect that has already begun measuring. // Teardown (decision 0065): despawn every streamed entity except the self avatar — // it stays the local puppet (controller + camera keep working); the reconnect's // re-create refreshes it in place. Immediate despawn, not `DespawnFade`: a @@ -477,22 +476,6 @@ pub(super) fn reputation_visible(list_id: u32, reputations: &mut Reputations) { reputations.0[i].0 |= benilla_formats::faction_flags::VISIBLE; } -/// The keepalive echo (`SMSG_PONG`): match it against the shared ping clock to measure the -/// round trip. Stored back on the clock (the next ping's lastRtt field — what the real client -/// reports), pushed into the RTT ring the UI's `GetNetStats` averages, and surfaced as the panel's -/// latency readout. A stale or mismatched sequence (a pong straddling a reconnect) is dropped. -pub(super) fn pong(sequence: u32, ping: &PingShared, status: &mut NetStatus) { - let mut clock = ping.0.lock().expect("ping clock"); - if let Some(sent) = clock.sent_at.filter(|_| clock.sequence == sequence) { - let rtt = sent.elapsed().as_millis().min(u128::from(u32::MAX)) as u32; - if status.latency_ms.is_none() { - info!("net: pong seq={sequence} rtt={rtt}ms (keepalive live)"); - } - clock.last_rtt_ms = Some(rtt); - status.record_rtt(rtt); - } -} - /// The dropped-packet tally (the wire-coverage instrument): count it, and announce each opcode's /// FIRST drop at info — visible in any log without the panel open, and one line per opcode per /// run, so it can never flood. diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 90f8b676..ad9be517 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -94,18 +94,106 @@ pub(crate) struct LoginRequest { /// must never shrink below that. const PING_INTERVAL: Duration = Duration::from_secs(30); -/// The ping clock — shared between the write thread (which stamps each `CMSG_PING`) and the app's -/// event drain (which matches the `SMSG_PONG` echo against it to measure the round trip). One -/// mutex, touched every 30 s by the writer and once per pong by the drain. +/// How many round trips [`PingClock`] keeps — **fifteen**, which is the reference's *usable* +/// depth even though its array holds sixteen (VERIFIED, wow-re `system/net`, 4-agent §5). +/// +/// `conn+0x1a6c` is 16 u32 slots, and W1's shorthand "head/tail wrap 16" is about that array. But +/// the averager treats `read == write` as its **empty** sentinel (`0x537fa8`), so `HandlePong`'s +/// conditional read-index advance (`0x537de8`) makes the full state unreachable: the 16th sample +/// pushes the oldest out of view as it lands, and every reading from then on is over 15. Our +/// `VecDeque` holds what can actually be seen, so the bound is the reachable number rather than +/// the allocation — and the mean is over the same samples the reference's is. +const RTT_RING: usize = 15; + +/// **The connection's ping/RTT stats** — the reference's own per-connection stats block +/// (`conn+0x1a6c` ring, `+0x1aac/+0x1ab0` head/tail, `+0x1a64` send stamp, `+0x1a68` expected +/// sequence; wow-re net W1), behind the same one lock it guards them with (`conn+0x1ac0`'s +/// critical section, taken by both `HandlePong 0x537d60` and the `GetNetStats` math at +/// `0x537f20`). +/// +/// **Shared by all three threads, and the sharing is the point.** The write thread stamps each +/// `CMSG_PING` here; the **read thread** measures the `SMSG_PONG` echo against it the instant the +/// packet comes off the socket; the app reads [`Self::avg_latency_ms`] for `GetNetStats`. +/// +/// That middle one is the whole reason this type owns the ring (bug B346). The measurement used +/// to happen in the ECS drain — `Instant::elapsed` called one frame or more after the pong had +/// already arrived — so the "round trip" it reported was the network round trip **plus a client +/// frame**. The meter therefore read high exactly when the client was slow, and the worst frames +/// of any session are the ones right after entering the world (`pipe_warm` alone grinds ~1051 ms +/// main-thread hitches warming the game's pipeline set; the collider builder costs hundreds of ms +/// more). A pong drained on one of those frames is recorded as a ~600 ms round trip, and at a +/// 30 s cadence the ring then carries that sample for minutes — which is exactly what B346 +/// reported: a meter that opens at ~600 ms and sinks towards the truth over two or three minutes. +/// +/// Measured on a localhost run (where the true RTT is 0-1 ms), the drain added **58 ms** to the +/// pong that landed 2.5 s after world enter against 8-11 ms once the session settled — and the +/// pong was the *first* event of that drain with 0 ms spent inside it, so every bit of the +/// difference was waiting for the frame to come round, not queue backlog. +/// +/// The reference never had this problem, because it never measures on the game thread: `OnData` +/// (`0x537b10`) peeks the opcode and sends `SMSG_PONG` **straight** to `HandlePong` inline on the +/// network thread, bypassing the message queue every other opcode is copied onto. [`record_pong`] +/// is that function, and the read loop calls it from the same position. +/// +/// [`record_pong`]: Self::record_pong #[derive(Default)] pub(crate) struct PingClock { /// Sequence of the most recent ping sent (the real client's ++counter; reset per connection). pub(crate) sequence: u32, /// When that ping went out — `None` until the first ping of a connection. pub(crate) sent_at: Option, - /// The last measured round trip (ms): echoed in the next ping's lastRtt field (what the real - /// client reports) and surfaced as the debug panel's latency readout. + /// The last measured round trip (ms) — echoed in the next ping's lastRtt field, which is what + /// the real client puts on the wire, and shown as the debug panel's readout. Kept apart from + /// the ring because it is the *last* sample, not the average. pub(crate) last_rtt_ms: Option, + /// The most recent [`RTT_RING`] round trips (ms), oldest first — the reference's own RTT + /// history, which [`Self::avg_latency_ms`] averages for `GetNetStats`. + rtt_ring: std::collections::VecDeque, +} + +impl PingClock { + /// **`HandlePong`** — one `SMSG_PONG` off the socket, measured and filed, on the read thread. + /// A stale or mismatched sequence (a pong straddling a reconnect) is dropped, as the + /// reference drops it. Returns the measured round trip when one was recorded. + pub(crate) fn record_pong(&mut self, sequence: u32) -> Option { + let sent = self.sent_at.filter(|_| self.sequence == sequence)?; + let rtt = sent.elapsed().as_millis().min(u128::from(u32::MAX)) as u32; + self.file_rtt(rtt); + Some(rtt) + } + + /// The filing half of [`Self::record_pong`], split out so a test can hand it exact + /// milliseconds — the measuring half reads a real clock, and a ring test wants known numbers. + /// The newest sample in, the oldest out at depth. + fn file_rtt(&mut self, rtt: u32) { + self.last_rtt_ms = Some(rtt); + if self.rtt_ring.len() == RTT_RING { + self.rtt_ring.pop_front(); + } + self.rtt_ring.push_back(rtt); + } + + /// The latency `GetNetStats` reports: the mean of the ring, truncated — `None` while it is + /// empty (no pong since the connection came up), which the UI feed renders as the reference's + /// own literal 0 (`0x537fd2`). + /// + /// Byte-for-byte the reference's own arithmetic, not an approximation of it: `0x537f20` walks + /// read→write summing into `eax` and counting into `edi`, then does one `xor edx,edx; div edi` + /// (`0x537fce`) — **unsigned, truncating, remainder discarded**, with no rounding term + /// anywhere in the function. So the mean is over the samples actually present rather than a + /// fixed sixteen, and integer `sum / len` in milliseconds *is* the reference's answer. + pub(crate) fn avg_latency_ms(&self) -> Option { + if self.rtt_ring.is_empty() { + return None; + } + let sum: u64 = self.rtt_ring.iter().map(|&ms| u64::from(ms)).sum(); + Some((sum / self.rtt_ring.len() as u64) as u32) + } + + /// Forget this connection's measurements — the next connection's latency is its own. + pub(crate) fn clear(&mut self) { + *self = Self::default(); + } } /// Cap on consecutive "command dropped/failed" warns per connection epoch, so a movement stream @@ -173,6 +261,9 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { // The writer thread outlives connections; the read thread hands it each new WorldWriter. let (writer_tx, writer_rx) = crossbeam_channel::unbounded::(); let clock = Arc::clone(&ping_clock); + // The read thread's own handle: `SMSG_PONG` is measured where it lands, not where it is + // drained (B346 — see [`PingClock`]). + let read_clock = Arc::clone(&ping_clock); let abandon = Arc::clone(&login_abandon); thread::Builder::new() .name("wow-net-write".into()) @@ -191,7 +282,22 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { benilla_world::thread_qos::QosClass::UserInitiated, ); loop { - match run(&cfg, &events_tx, &writer_tx, &pick_rx, &login_rx, &abandon) { + // **A cycle starts with no measurements.** Every way the last one ended — a + // stream failure, a logout, a re-park — lands here, so one clear covers them + // all, and it runs on the thread that owns the connection instead of racing + // in from the app's drain a frame later. (`writer_loop` clears again when the + // fresh writer arrives, and still has to: between the old socket dying and + // that handover the keepalive tick can still fire on the stale writer.) + read_clock.lock().expect("ping clock").clear(); + match run( + &cfg, + &events_tx, + &writer_tx, + &pick_rx, + &login_rx, + &abandon, + &read_clock, + ) { Ok(Cycle::Exit) => return, Ok(Cycle::Repark) => {} Ok(Cycle::LoggedOut) => { @@ -252,6 +358,7 @@ fn run( pick_rx: &Receiver, login_rx: &Receiver, abandon: &AtomicU64, + ping_clock: &Mutex, ) -> Result { // ── The pre-logon park (decision 0539): block for credentials. ────────────────────────────── let Ok(req) = login_rx.recv() else { @@ -523,6 +630,21 @@ fn run( ); } for ev in events { + // **The pong bypass** (B346), the reference's own shape: `OnData 0x537b10` + // peeks the opcode and hands `SMSG_PONG` straight to `HandlePong 0x537d60` + // inline, instead of copying it onto the queue the game thread drains. So do + // we — the round trip is measured here, against the clock the write thread + // stamped, and the event stops here. Measuring it after a drain instead added + // a whole client frame to every reading, which is a frame's worth of the + // client's own slowness reported as the server's distance. + if let SessionEvent::Pong { sequence } = ev { + if let Some(rtt) = + ping_clock.lock().expect("ping clock").record_pong(sequence) + { + bevy::log::debug!("net: pong seq={sequence} rtt={rtt}ms"); + } + continue; + } // A confirmed logout ends the cycle *after* the app hears about it. let logged_out = matches!(ev, SessionEvent::LoggedOut); // Receiver dropped → the app exited; end the thread cleanly. @@ -581,7 +703,14 @@ fn writer_loop( ) { let mut writer: Option = None; let mut warned = 0u32; - let ping_tick = crossbeam_channel::tick(PING_INTERVAL); + // **Armed by the connection, re-armed by each send — never free-running** (wow-re net, + // `0x537ff0`: `now - lastSent - 30000 >= 0`, evaluated at the connection's own drain tail, + // and `0x537bcf` stamps `lastSent` with the current tick at connect). It was a process- + // lifetime `tick`, which is a different clock in two ways that both showed: the first ping of + // a session landed anywhere in the 30 s after entering the world rather than at the end of it + // — sometimes squarely inside the world-load storm, which is where B346's inflated sample + // came from — and the cadence never re-phased on a reconnect. + let mut ping_tick = crossbeam_channel::never(); loop { crossbeam_channel::select! { recv(writer_rx) -> w => match w { @@ -591,7 +720,11 @@ fn writer_loop( // A fresh connection restarts the keepalive from scratch, like the real // client: sequence 1 is the new socket's first ping, and a stale in-flight // pong from the old socket can no longer match. - *ping_clock.lock().expect("ping clock") = PingClock::default(); + ping_clock.lock().expect("ping clock").clear(); + // The connect stamp: the first keepalive of a connection is a full interval + // out, not on the next drain (`0x537bcf` — verified; the alternative reading, + // that a zeroed stamp fires one immediately, is what the bytes ruled out). + ping_tick = crossbeam_channel::after(PING_INTERVAL); } // The read thread ended (app exit). Stop selecting the dead channel (a // disconnected receiver is always-ready — it would busy-spin the select); @@ -602,11 +735,28 @@ fn writer_loop( // The keepalive (30 s, the verified real-client cadence). Sent only while a live // writer exists — the parked char-select socket goes without (decision 0193's // self-healing covers it), and vmangos would kick a faster cadence as overspeed. + // Disarmed unless a send is attempted, so a dead connection stops pinging and the + // next writer re-arms from its own connect. + ping_tick = crossbeam_channel::never(); if let Some(w) = writer.as_mut() { + // Re-armed from the SEND, so the interval is measured the way the reference + // measures it — and a failed write still counts, since what the cadence is + // spacing is our attempts on the socket, not the server's answers. + ping_tick = crossbeam_channel::after(PING_INTERVAL); let (sequence, last_rtt) = { let mut c = ping_clock.lock().expect("ping clock"); c.sequence += 1; c.sent_at = Some(Instant::now()); + // `lastRtt`: the most recent single sample, never the mean (VERIFIED — + // the reference reads `ring[write-1]`, folded into + // `[esi + 4*w + 0x1a68]` at `0x537e87`). **One deliberate divergence.** + // Its `jbe` guard at `0x537e85` substitutes a literal 0 whenever the write + // index is 0 — true before the first pong, and again on every sixteenth + // ping thereafter, so a healthy real client reports a 0 ms latency to the + // server once in sixteen. That is an artefact of its index arithmetic, not + // a behaviour: nothing renders it, only the server stores it. We send 0 + // for the first case (no sample yet, which is the honest value) and the + // real sample for the rest. (c.sequence, c.last_rtt_ms.unwrap_or(0)) }; if let Err(e) = w.ping(sequence, last_rtt) { @@ -1188,3 +1338,79 @@ fn writer_loop( } } } + +#[cfg(test)] +mod rtt_tests { + use super::{PingClock, RTT_RING}; + use std::time::Instant; + + /// Arm the clock as the write thread does — a ping sent "now" under `sequence`. + fn sent(clock: &mut PingClock, sequence: u32) { + clock.sequence = sequence; + clock.sent_at = Some(Instant::now()); + } + + /// The reported latency is the ring's mean, and the ring is bounded at the reference depth — + /// so a single spike moves the meter by a fifteenth, not the whole way (which is the point of + /// averaging at all: the ping cadence is 30 s, and one bad sample must not sit on a red bar + /// for seven minutes). + #[test] + fn the_reported_latency_is_the_mean_of_a_bounded_ring() { + let mut clock = PingClock::default(); + assert_eq!(clock.avg_latency_ms(), None, "no pong yet"); + + clock.file_rtt(40); + assert_eq!(clock.avg_latency_ms(), Some(40)); + clock.file_rtt(60); + assert_eq!(clock.avg_latency_ms(), Some(50), "the mean, not the last"); + assert_eq!( + clock.last_rtt_ms, + Some(60), + "the last sample stays separate" + ); + + // Fill past the ring: only the newest RTT_RING samples count, so the two above age out. + for _ in 0..RTT_RING { + clock.file_rtt(100); + } + assert_eq!(clock.rtt_ring.len(), RTT_RING); + assert_eq!(clock.avg_latency_ms(), Some(100)); + + clock.clear(); + assert_eq!(clock.avg_latency_ms(), None, "a disconnect forgets it all"); + assert_eq!(clock.last_rtt_ms, None); + } + + /// **B346's regression.** A pong is only a measurement if it answers the ping we are timing: + /// the sequence has to match, and there has to be a live send to measure against. The echo of + /// a ping from a dead socket (a pong straddling a reconnect, where `clear` has already run) + /// must not enter the ring — it would be timed against nothing, or worse against the *new* + /// connection's send, and one bogus sample sits on the meter for minutes at a 30 s cadence. + #[test] + fn only_the_pong_we_are_waiting_for_is_recorded() { + let mut clock = PingClock::default(); + assert_eq!(clock.record_pong(1), None, "no ping is in flight"); + + sent(&mut clock, 7); + assert_eq!( + clock.record_pong(6), + None, + "a stale sequence is not our ping" + ); + assert_eq!(clock.record_pong(8), None, "nor is one we never sent"); + assert!(clock.rtt_ring.is_empty(), "neither entered the ring"); + + assert!(clock.record_pong(7).is_some(), "the one we are timing"); + assert_eq!(clock.rtt_ring.len(), 1); + assert!(clock.avg_latency_ms().is_some()); + + // The reconnect edge: cleared, so the old socket's echo has nothing to match. + clock.clear(); + assert_eq!( + clock.record_pong(7), + None, + "the dead socket's echo is dropped" + ); + assert_eq!(clock.avg_latency_ms(), None); + } +} diff --git a/crates/benilla-app/src/target/reticle.rs b/crates/benilla-app/src/target/reticle.rs index 106ae9b3..ac6019ba 100644 --- a/crates/benilla-app/src/target/reticle.rs +++ b/crates/benilla-app/src/target/reticle.rs @@ -46,8 +46,12 @@ const RETICLE_DEFAULT_RADIUS: f32 = 1.388_889; /// The projection box's vertical slab: ± 2.0 around the picked point (`0x4837b0` — fixed, not /// radius-scaled like the ring's). const RETICLE_VERT: f32 = 2.0; -/// Same coplanarity treatment as the ring (`sky_order::Rung::RING` rationale). -const RETICLE_DEPTH_BIAS: f32 = 8192.0; +/// Same coplanarity treatment as the ring (`sky_order::Rung::GROUND_FX` rationale) — the +/// *rasterizer* half only. The reticle's sort rung is `Rung::RETICLE`, in the pre-water decal +/// band: the reference draws this pass at `0x4836c5` with flags `0x200122`, before the water +/// (B347). Its liquid-receiver twin at `0x483727` is the half that draws after the water, and it +/// has no counterpart here until liquid becomes a receiving surface. +const RETICLE_RASTER_BIAS: i32 = 8192; /// The two state textures (the render-side residency gate withholds the draw until loaded). #[derive(Resource)] @@ -193,7 +197,7 @@ pub(super) fn push_reticle( let mut batch = draw .batch(cam, texture) .anchored(state.key.center) - .rung(RETICLE_DEPTH_BIAS, RETICLE_DEPTH_BIAS as i32); + .rung(benilla_world::sky_order::Rung::RETICLE, RETICLE_RASTER_BIAS); batch.extend(state.verts.iter().map(|v| EffectVertex { pos: v.pos, uv: v.uv, diff --git a/crates/benilla-app/src/ui_net.rs b/crates/benilla-app/src/ui_net.rs index beb42a5f..54cec082 100644 --- a/crates/benilla-app/src/ui_net.rs +++ b/crates/benilla-app/src/ui_net.rs @@ -2,10 +2,12 @@ //! //! One number: the latency `GetNetStats()` reports, which is the main bar's performance ("ping") //! meter's whole input (`ActionBar.xml`'s `MainMenuBarPerformanceBarFrame` polls it every 10 s and -//! colors the bar green/yellow/red). The measurement itself belongs to the net drain — every -//! `SMSG_PONG` lands a round trip in [`NetStatus::rtt_ring`], the reference's 16-deep RTT history — -//! and this feed only carries its average across the engine boundary (decision 0068 §3: the engine -//! never reaches into ECS or the socket). +//! colors the bar green/yellow/red). The measurement itself belongs to the **net read thread** — +//! every `SMSG_PONG` is timed where it lands and filed in the connection clock's RTT history at the +//! reference's own depth — and this feed only carries its average across the engine boundary (decision +//! 0068 §3: the engine never reaches into ECS or the socket). Reading it takes the connection's +//! lock, exactly as the reference's `GetNetStats` takes the stats critical section; it is +//! uncontended (one writer every 30 s) and this is three words of copy a frame. //! //! The push is unconditional rather than change-gated, for the reason [`crate::minimap`]'s //! containment feed documents: `UiScript` is created when the UI loads, which is *after* a @@ -16,7 +18,7 @@ use bevy::prelude::*; use benilla_ui::script::UiScript; -use crate::net::NetStatus; +use crate::net::PingShared; use crate::ui_script::UiInput; pub(crate) struct UiNetPlugin; @@ -28,7 +30,8 @@ impl Plugin for UiNetPlugin { } /// Push the averaged round trip behind `GetNetStats()`. -fn feed_net_stats(script: Option>, status: Res) { +fn feed_net_stats(script: Option>, ping: Res) { let Some(mut script) = script else { return }; - script.set_latency_ms(status.avg_latency_ms()); + let latency = ping.0.lock().expect("ping clock").avg_latency_ms(); + script.set_latency_ms(latency); } diff --git a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs index 37f8174b..c64e2762 100644 --- a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs +++ b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs @@ -1266,3 +1266,100 @@ fn the_stance_shelf_is_as_long_as_the_form_count() { assert_eq!(cap(&s), 12.0); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } + +/// **B348 — an extra bar's EMPTY well keeps the label of the key bound to it.** +/// +/// The reported symptom is a flicker between two states of the same bar: the occupied slot always +/// wore its `Q`, while the bound-but-empty slots beside it showed `E`/`R`/`C` in one screenshot +/// and nothing in the next. The cause is a single line left behind by 0270's multibars, which +/// carried NO binding commands at all and so had no label to lose: `BenillaActionButton_Update`'s +/// empty arm cleared the HotKey corner outright. 1008 gave those wells real +/// `MULTIACTIONBAR1/2BUTTONn` bindings and that clear became a wipe of a live label — repainted by +/// the next `UPDATE_BINDINGS` and wiped again by the next thing that repaints a button (a world +/// enter, a payload picked up over the bars, the slot next door changing), which is exactly +/// "sometimes yes, sometimes not". +/// +/// The reference never touches the text in `ActionButton_Update` (`ActionButton.lua:167-172` sets +/// only the vertex colour); `ActionButton_UpdateHotkeys` is the sole writer, and its bound leg — +/// `hotkey:SetText(GetBindingText(GetBindingKey(action), "KEY_", 1))` — runs whether or not the +/// slot has an action. +#[test] +fn an_extra_bars_empty_well_keeps_its_bound_hotkey_label() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + // The real command set, so MULTIACTIONBAR1BUTTONn is bindable at all (it ships unbound — + // the player binds it, as the reporter had). + s.register_bindings(&crate::bindings::registry_commands()); + load_action_bar(&s); + load_xml(&s, "MultiBars.xml"); + show_bars(&s, &[1]); + s.set_action( + 61, + Some(ActionSlot { + texture: Some("Interface\\Icons\\Spell_BL".into()), + kind: 0x00, + action: 200, + count: 0, + consumable: false, + }), + ); + // The reporter's shape: an occupied first well and two empty ones beside it, all three bound, + // and the wells held open so the empty ones are on screen at all. + s.run( + r#"SetBinding("Q", "MULTIACTIONBAR1BUTTON1") + SetBinding("E", "MULTIACTIONBAR1BUTTON2") + SetBinding("R", "MULTIACTIONBAR1BUTTON3") + ALWAYS_SHOW_MULTIBARS = "1" MultiActionBar_UpdateGridVisibility()"#, + ) + .unwrap(); + s.fire_event("UPDATE_BINDINGS", vec![]); + + let label = |s: &UiScript, button: &str| { + s.eval::>(&format!("return {button}HotKey:GetText()")) + .unwrap() + .unwrap_or_default() + }; + let drawn = |s: &mut UiScript, want: &str| { + s.resolve(); + s.extract() + .iter() + .any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == want)) + }; + assert_eq!(label(&s, "MultiBarBottomLeftButton1"), "Q"); + assert_eq!(label(&s, "MultiBarBottomLeftButton2"), "E"); + assert_eq!(label(&s, "MultiBarBottomLeftButton3"), "R"); + assert!(drawn(&mut s, "E"), "the empty well's label is on screen"); + + // Anything that repaints the buttons. Each of these reached the empty arm and wiped the label + // before the fix; the occupied well never did, which is why its Q survived every time. + for (event, what) in [ + ("PLAYER_ENTERING_WORLD", "a world enter"), + ("ACTIONBAR_SHOWGRID", "a payload picked up over the bars"), + ("ACTIONBAR_HIDEGRID", "and put down again"), + ] { + s.fire_event(event, vec![]); + assert_eq!(label(&s, "MultiBarBottomLeftButton1"), "Q", "{what}"); + assert_eq!( + label(&s, "MultiBarBottomLeftButton2"), + "E", + "the empty well keeps its bound label through {what}" + ); + assert_eq!(label(&s, "MultiBarBottomLeftButton3"), "R", "{what}"); + } + // The world enter above also re-seeds the four bar toggles from the server byte, which the + // harness does not send — so bar 1 went back down with it. Raise it again: the label is still + // on the well, not merely still in the FontString. + show_bars(&s, &[1]); + assert!(drawn(&mut s, "E"), "…and it is still on screen"); + + // The control: an UNBOUND empty well stays blank throughout — the clear this line was written + // for in 0270 is still done, by the repaint that knows there is no binding. + assert_eq!(label(&s, "MultiBarBottomLeftButton4"), ""); + // And unbinding one takes its label away, rather than freezing the last text painted. + s.run(r#"SetBinding("E", nil)"#).unwrap(); + s.fire_event("UPDATE_BINDINGS", vec![]); + assert_eq!(label(&s, "MultiBarBottomLeftButton2"), ""); + s.fire_event("PLAYER_ENTERING_WORLD", vec![]); + assert_eq!(label(&s, "MultiBarBottomLeftButton2"), ""); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index fff4c239..423bb581 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -332,7 +332,20 @@ fn is_instrument_consumer(rel: &str) -> bool { /// through `step`/`grounded_step`/`airborne_step` puts a gameplay argument into the movement /// core's signature. An `EntityHashSet` that is empty on every living frame is the smallest honest /// expression, and it makes the living player's query provably the one it always was. -const CEILING: usize = 166; +/// And 166 -> 172: **not six crossings — one measurement correction.** Nothing moved through the +/// doorway; the scan was blind to part of every file. A `#[cfg(test)]` was cleared only on +/// `depth < d`, and a top-level test module is seen at depth 0, rises to 1 and returns to 0 — +/// never below it, so the first test module in a file switched the scan off for the rest of that +/// file. [`paths_in`] carries the fix and how it surfaced (2026-09-01, a change that only *moved* +/// a test module out of `net.rs` and pushed the count up by one with no new reference in its +/// diff). These six were named by gameplay code all along, below a test module, uncounted: +/// `lighting::WorldTime`, `art_scope::ArtScope`, `art_scope::ArtSlot`, +/// `billboard::billboard_joint_palette`, `rig_anim::finalize_rig_worlds`, `world_unit::ViewerUnit`. +/// Raising the number is what keeps the ratchet honest — a ceiling measured through a hole is not +/// a ceiling — and it is the ratchet's own instruction: a count that cannot see the whole file +/// cannot report a widening either, which is the one thing this test exists to do. The six are +/// **not** hereby blessed as PUBLISH: they are un-sorted 1164 work, now visible enough to sort. +const CEILING: usize = 172; /// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be /// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it @@ -576,16 +589,31 @@ fn paths_in(text: &str, split: bool, want_engine: bool) -> Vec<(String, usize)> let prefix = if split { "benilla_world::" } else { "crate::" }; let mut found = Vec::new(); let mut depth: i32 = 0; - let mut test_depth: Option = None; + // **A `#[cfg(test)]` guards exactly the item that follows it, and the scan has to find that + // item's END.** It did not, and the miss was total: the guard was cleared only on `depth < d`, + // but a top-level `#[cfg(test)] mod tests { … }` is seen at depth 0, rises to 1 inside and + // returns to 0 — never *below* it. So the first test module in a file switched this scan off + // for the whole rest of that file, and every engine item named below it went uncounted. The + // wall's whole job is to be a number nobody has to re-measure, and it was measuring a prefix. + // + // Found on 2026-09-01 by a change that only *moved* a test module out of `net.rs`: the count + // rose by one with no new reference anywhere in the diff, because deleting the module handed + // the scan back the rest of the file. An instrument that moves when the code does not is + // reporting on itself. + // + // The extent, for both shapes that exist here: `test_at` arms on the attribute line at depth + // `d`; if the guarded item opens a brace (`mod tests {`, `impl X {`) the depth rises and the + // item ends when it comes back to `d`; if it opens none (`#[cfg(test)] mod tests;`, + // `#[cfg(test)] pub(crate) use …;`) it ends at that statement's semicolon. + let mut test_at: Option = None; + let mut test_open = false; for (n, line) in text.lines().enumerate() { let t = line.trim_start(); if t.starts_with("#[cfg(test)]") { - test_depth = Some(depth); + test_at = Some(depth); + test_open = false; } - if test_depth.is_some_and(|d| depth < d) { - test_depth = None; - } - let skip = test_depth.is_some() || t.starts_with("//") || t.starts_with('*'); + let skip = test_at.is_some() || t.starts_with("//") || t.starts_with('*'); if !skip { let mut rest = line; while let Some(i) = rest.find(prefix) { @@ -660,6 +688,14 @@ fn paths_in(text: &str, split: bool, want_engine: bool) -> Vec<(String, usize)> } } depth += line.matches('{').count() as i32 - line.matches('}').count() as i32; + if let Some(d) = test_at { + if depth > d { + test_open = true; + } else if test_open || t.trim_end().ends_with(';') { + test_at = None; + test_open = false; + } + } } found } diff --git a/crates/benilla-world/src/bgwin.rs b/crates/benilla-world/src/bgwin.rs index 965b5800..572c1e2b 100644 --- a/crates/benilla-world/src/bgwin.rs +++ b/crates/benilla-world/src/bgwin.rs @@ -69,6 +69,14 @@ const BG_ENV_PREFIXES: &[&str] = &[ "WOW_PROBE", "WOW_RIG", "WOW_SCHED_CENSUS", + // The one that names the condition directly: `WOW_UNATTENDED` *is* "the chair is empty" + // (1769), and method.md tells every ad-hoc probe to set it. It was missing here for as long + // as this list has existed, so a probe launched exactly the way the method prescribes — but + // under none of the named prefixes — opened focused and borderless-fullscreen over the + // director's work. Reported 2026-09-01 while probing B346. This is the case the list's own + // doc predicts ("one that doesn't shows up as the new probe window opened focused"); it is + // also the strongest signal available, since nothing else asserts nobody is there. + "WOW_UNATTENDED", "WOW_WORLDVIEW_", ]; @@ -97,6 +105,14 @@ pub fn background_run() -> bool { /// `WOW_PROBE…`-named env that captures pixels has to be excluded by name here, since the bare /// `WOW_PROBE` prefix would otherwise sweep it in. const NO_PIXEL_ENV_PREFIXES: &[&str] = &[ + // Paired with its entry above, and it has to be BOTH or neither: `leg.sh`, `cine.sh` and + // `summon-live.sh` all set `WOW_UNATTENDED` beside a `WOW_PROBE*`/`WOW_RIG` that IS no-pixel, + // so listing it only as a background env would make it the one non-no-pixel member of every + // such run and the all-of rule would grow all three back to the full 1600x900 that decision + // 1148 shrank. A run whose ONLY background env is this one reads no pixels by construction — + // anything that photographs the frame names itself (`WOW_CAPTURE`, `WOW_LIVE_SHOT`, + // `WOW_*_SHOT`), and none of those are here, so every such pairing keeps its full size. + "WOW_UNATTENDED", "WOW_FEED_GATE_CHECK", "WOW_FEED_GATE_TRACE", "WOW_FPS_", diff --git a/crates/benilla-world/src/particles/render.rs b/crates/benilla-world/src/particles/render.rs index 1b034683..a7f8ea6a 100644 --- a/crates/benilla-world/src/particles/render.rs +++ b/crates/benilla-world/src/particles/render.rs @@ -494,7 +494,8 @@ fn prepare_effects( let effect_fn = draw_functions.read().id::(); // `$WOW_EFFECT_TRACE` — the lane's own phase probe (the WOW_PHASE shape, decision 0665, // asked of effect items): during the merge walk, record each BLOB-SHADOW item (Tris - // topology + the shadow's own raster bias) with its sort distance and appended index + // topology + the shadow's own sort rung — its RASTER bias is shared with the footprint + // lane, which used to label prints as shadows here) with its sort distance and appended index // range, then log the phase totals. Splits "never pushed / never queued / merged away / // submitted but the GPU state ate it" — the gap no pixel reading can see into. let trace = std::env::var_os("WOW_EFFECT_TRACE").is_some(); @@ -558,7 +559,7 @@ fn prepare_effects( let index_end = meta.indices.len() as u32; if trace && draw.topology == EffectTopology::Tris - && draw.raster_bias == crate::sky_order::Rung::SHADOW_RASTER + && draw.bias == crate::sky_order::Rung::SHADOW_SORT { trace_lines.push(format!( " item {i}: shadow anchor {:.1?}, dist {:.1}, verts {}, indices \ diff --git a/crates/benilla-world/src/sky_order.rs b/crates/benilla-world/src/sky_order.rs index 1612360a..32526846 100644 --- a/crates/benilla-world/src/sky_order.rs +++ b/crates/benilla-world/src/sky_order.rs @@ -129,33 +129,88 @@ pub(crate) const WATER_BIAS: f32 = -2.0e4; /// (the sign law above), and nothing is gained by making it bigger than the ordering needs. pub(crate) const GLARE_BIAS: f32 = 2.0e4; +/// The floor of any world draw's view-z: the projection's ~3 km far plane, negated (the sign law +/// above — view-z is negative in front of the eye, more negative the farther away). Two rungs +/// order two draws *unconditionally* only when their gap exceeds this, so it is the unit the +/// assert block measures the ladder's margins in. +pub(crate) const WORLD_VIEW_Z_FLOOR: f32 = -3.0e3; + /// **The world-side rungs** — the biases lanes *outside* this module apply to their own draw, and /// the reason they are here rather than each next to its lane: they are one ladder. 1163 found the /// four of them scattered across `blob_shadow`, `ground_fx`, `nameplates` and `target::ring`, each /// documenting itself by pointing at one of the others ("the same constant, for the same reason, /// as the ring's"), which is a ladder held together by prose. The compile-time assert below can -/// only check an order it can see. +/// only check an order it can see — and 1163 left two out of view: the footprint print and the +/// ground-target reticle each kept a private constant in its own lane, copied from a neighbour's +/// number by the same prose 1163 came to end. Both are rungs now (B347). /// /// Named as one type so a lane reaches the ladder, not a constant: `Rung::GROUND_FX`. pub struct Rung; impl Rung { - /// The **surface-decal** rung — sort bias AND rasterizer depth-bias constant, one number for - /// both roles. Projected decal vertices are geometrically coplanar with the drawn ground, so - /// without a bias the depth test dissolves into per-pixel f32 noise (stipple, view-dependent - /// bites); 8192 · 2⁻²³ pushes their depth ~1e-3·distance toward the camera — far above the - /// coplanar noise floor (an ulp of Elwynn's 10⁴-yard coords), far below anything visible. - /// Geometric lifts (0.1, 0.02) read as hovering at grazing angles — director-confirmed. The - /// reference fights the same coplanarity with polygon offset (trace-verified), the - /// fixed-function twin of this bias. + /// The **coplanarity** rung — sort bias AND rasterizer depth-bias constant, one number for + /// both roles, and the home of the rationale the whole decal family shares. Projected decal + /// vertices are geometrically coplanar with the drawn ground, so without a bias the depth + /// test dissolves into per-pixel f32 noise (stipple, view-dependent bites); 8192 · 2⁻²³ pushes + /// their depth ~1e-3·distance toward the camera — far above the coplanar noise floor (an ulp + /// of Elwynn's 10⁴-yard coords), far below anything visible. Geometric lifts (0.1, 0.02) read + /// as hovering at grazing angles — director-confirmed. The reference fights the same + /// coplanarity with polygon offset (trace-verified), the fixed-function twin of this bias. + /// + /// It stays a *sort* rung only for **ground-fx spell decals**, which have no reference frame + /// slot to take: the real client draws those quads as ordinary M2 batches with no ground + /// conform anywhere in the spell-visual chain (`ground_fx`'s header) — conforming them is our + /// own improvement, so "wherever that M2 transparent sorts" is the honest answer and the + /// near-side default is after the water. Everything with a *known* slot moved to the + /// pre-water band below (B347). pub const GROUND_FX: f32 = 8192.0; - /// The **selection ring**, the same rung and the same rationale: ties against the ground-fx - /// decal do not matter — all of these write no depth, so the bias only ever competes with the - /// opaque ground. + /// The **selection ring**, the same number in both roles and the same coplanarity rationale. + /// **Not in the pre-water band — not yet, and not by argument:** the ring's draw hangs off the + /// CGObject per-frame slot (`0x614ada` inside `0x614a90` = vtable +0x38), not off a call the + /// frame driver's own row names, so which side of the water pass it lands on is unread. An RE + /// is out for it (B347); until it answers the ring keeps the rung it has shipped on rather + /// than moving on a guess. pub const RING: f32 = 8192.0; - /// The **blob shadow**'s sort rung: half the ring's, so where both decals stack the ring draws - /// later deterministically. - pub const SHADOW_SORT: f32 = 4096.0; + /// **The pre-water decal band, rung 1: the unit blob shadow.** + /// + /// The reference draws its ground decals *before* the water, and B347 is what it costs not + /// to. The frame driver `0x483460` emits this one inside PHASE 1's opaque drain row + /// (`0x6812c5 call 0x683dd0` → `0x6d78f0` → `0x6d7920`; wow-re `unit-blob-shadow.md` Q1) — + /// before the footprints, before the M2 opaque pass (`0x4836a6`), and long before the water + /// surfaces, which draw in phase 3 *between* the two M2 transparent passes + /// (`water-frame-straddle.md` §1). Every transparent in the world paints over a shadow there. + /// + /// Here it did the exact opposite. At the old **+4096** the shadow's key (`view-z + bias`) + /// beat every world transparent — [`WATER_BIAS`], [`FAR_SIDE_BIAS`], and the unbiased + /// near-side default — making it the one thing in the scene the water did not attenuate: a + /// crocolisk's blob read through a Stranglethorn river that hid the crocolisk itself, and the + /// player's read through the Stormwind mage-tower portal (B347, both shots). + /// + /// **Where the band fits.** Its keys must clear the world-transparent band below it and the + /// WMO-skybox band above it, each by more than a world view-z can travel (`WORLD_VIEW_Z_FLOOR` + /// — two rungs are only separated if their gap exceeds it). The deepest world transparent + /// sorts at `FAR_SIDE_BIAS − 8 − far ≈ −4.3e4`; the shallowest skybox batch at + /// `WMO_SKYBOX_BIAS − 0.09 ≈ −6.0e4` — a window of `(−5.7e4, −4.3e4)`, which the three rungs + /// take centred, 2e3 apart, with ~5e3 of margin at each end. The assert block below is the + /// check, and it is arithmetic on the neighbours rather than a blanket 1e4: the window is only + /// 1.4e4 wide, so the ladder's usual margin does not fit and pretending it does would be the + /// lie. **The depth of the rung costs nothing at the rasterizer** — unlike [`WATER_BIAS`] and + /// the two rungs above, the effect stream carries sort and raster as separate fields + /// ([`SHADOW_RASTER`](Rung::SHADOW_RASTER) is this lane's other half). + pub const SHADOW_SORT: f32 = -5.2e4; + /// **The pre-water band, rung 2: footprints** — the reference draws them at + /// `0x483654` (`0x670240` → `0x69a3e0`), one slot AFTER the shadow pass and still before the + /// M2 opaque pass, so a print paints over a shadow rather than under it. That was inverted + /// here: the print rode +2048 *below* the shadow's +4096, on a comment calling the reference's + /// frame order an open RE item — `water-frame-straddle.md` §1 had already closed it. + pub const FOOTPRINT: f32 = -5.0e4; + /// **The pre-water band, rung 3: the ground-target reticle** — the reference's solid-receiver + /// pass (`0x4836c5`, flags `0x200122`) is the last decal before the water. Its *liquid* pass + /// (`0x483727`, flags `0xf0000`) is the family's one genuinely post-water draw, and it has no + /// counterpart here: liquid is not a `GroundDecalSurface` yet, so nothing we project can land + /// on a water surface at all. When it can, that half wants a rung above [`WATER_BIAS`] — not + /// this one. + pub const RETICLE: f32 = -4.8e4; /// The blob shadow's **rasterizer** margin, sized independently of its sort rung (B131 split /// one constant into its two roles). It funds the `GreaterEqual` tie against the drawn ground /// (`DECAL_WORLD_CLIP`, 0781). The reference's mechanism is the same class: `shadowBias` cvar @@ -178,11 +233,13 @@ impl Rung { } /// The ladder IS the reference order — checked at compile time: monotonic through the sky pass, -/// every sky rung below the world (so world transparents draw over it) and below the world-side -/// decal rung, then rain (unbiased view-z, ≈ 0), the glare above it, and the nameplates above the +/// then the **pre-water decal band** (the reference's own decal slots, B347), then the water-plane +/// interleave, then rain (unbiased view-z, ≈ 0), the glare above it, and the nameplates above the /// glare so text stays readable through a flare. Rung gaps stay wider than any view-z that could -/// reorder them: > 10⁴ inside the sky (camera-anchored shells spread ±far·0.85 ≈ 2.6e3), and -/// > 10⁴ on the world side (> the far plane, a few ×10³). +/// reorder them — over 10⁴ inside the sky (camera-anchored shells spread ±far·0.85 ≈ 2.6e3) and +/// over 10⁴ on the world side (more than the far plane, a few ×10³) — except inside the decal +/// band, which lives in a 1.4e4 window between two bands it must not touch and is therefore +/// checked against its actual neighbours rather than the blanket. const _: () = { assert!(SUN_DISC_BIAS - STARS_BIAS > 1.0e4); assert!(WHITE_MOON_BIAS > SUN_DISC_BIAS && MOON02_BIAS > WHITE_MOON_BIAS); @@ -197,9 +254,26 @@ const _: () = { assert!(Rung::GROUND_FX - CLOUDS_BIAS > 1.0e4); assert!(GLARE_BIAS - Rung::GROUND_FX > 1.0e4); assert!(Rung::NAMEPLATE - GLARE_BIAS > 1.0e4); - // The two decal rungs stack deterministically where both draw, and the shadow's raster margin + // The ring still draws after the shadow where the two stack, and the shadow's raster margin // is its own axis (B131) — not comparable to the sort rungs, only to zero. assert!(Rung::RING > Rung::SHADOW_SORT && Rung::SHADOW_RASTER > 0); + + // ─── The pre-water decal band (B347) ──────────────────────────────────────────────────── + // Internally ordered as the reference's frame emits them — shadow (PHASE 1, `0x6812c5`) → + // footprints (`0x483654`) → the reticle's solid pass (`0x4836c5`) — with a step far wider + // than the view-z two *stacked* decals can differ by (they share a patch of ground, so their + // anchors are yards apart, not kilometres). + assert!(Rung::FOOTPRINT - Rung::SHADOW_SORT > 1.0e3); + assert!(Rung::RETICLE - Rung::FOOTPRINT > 1.0e3); + // And below EVERY world transparent whatever its distance — the deepest being a far-side + // zfill twin (−8) out at the far plane. This is the assert B347 was missing: at the old + // positive rungs the band sat above the water and above every M2 transparent, and the shadow + // was the one thing in the scene the water did not attenuate. + assert!(FAR_SIDE_BIAS - 8.0 + WORLD_VIEW_Z_FLOOR - Rung::RETICLE > 1.0e3); + // ...and above the WMO-skybox band whatever the decal's distance, so a building's painted sky + // can never sort over a shadow (it is depth-forced to the far plane as well, but the ladder + // does not get to lean on a second mechanism to state its own order). + assert!(Rung::SHADOW_SORT + WORLD_VIEW_Z_FLOOR - WMO_SKYBOX_BIAS > 1.0e3); }; /// The depth law (module doc) is a property of the **shaders**, so it is checked there: every sky