shadows go under the water, and the ping meter measures the server
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Res<ShadowAssets>>,
|
||||
cam: Query<Entity, With<WorldCamera>>,
|
||||
|
||||
@@ -361,6 +361,7 @@ fn debug_panel_ui(
|
||||
mut sound_cfg: ResMut<crate::sound::SoundConfig>,
|
||||
mut cull_probe: ResMut<benilla_world::wmo_portal::WmoCullProbe>,
|
||||
net_status: Res<crate::net::NetStatus>,
|
||||
ping: Res<crate::net::PingShared>,
|
||||
dropped: Res<crate::net::DroppedOpcodes>,
|
||||
mut weather_state: Option<ResMut<benilla_world::weather::WeatherState>>,
|
||||
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(),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<u64>);
|
||||
pub(crate) struct NetStatus {
|
||||
pub(crate) connected: bool,
|
||||
pub(crate) last_reason: Option<String>,
|
||||
/// 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<u32>,
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
/// 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<u32> {
|
||||
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<std::sync::Mutex<io::PingClock>>);
|
||||
|
||||
|
||||
@@ -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<Real>`, 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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Instant>,
|
||||
/// 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<u32>,
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
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<u32> {
|
||||
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<u32> {
|
||||
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::<WorldWriter>();
|
||||
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<CharRequest>,
|
||||
login_rx: &Receiver<LoginRequest>,
|
||||
abandon: &AtomicU64,
|
||||
ping_clock: &Mutex<PingClock>,
|
||||
) -> Result<Cycle> {
|
||||
// ── 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<WorldWriter> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<NonSendMut<UiScript>>, status: Res<NetStatus>) {
|
||||
fn feed_net_stats(script: Option<NonSendMut<UiScript>>, ping: Res<PingShared>) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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::<Option<String>>(&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());
|
||||
}
|
||||
|
||||
@@ -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<i32> = 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<i32> = 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
|
||||
}
|
||||
|
||||
@@ -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_",
|
||||
|
||||
@@ -494,7 +494,8 @@ fn prepare_effects(
|
||||
let effect_fn = draw_functions.read().id::<DrawEffects>();
|
||||
// `$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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user