diff --git a/crates/benilla-app/assets/ui/PetActionBarAdapters.xml b/crates/benilla-app/assets/ui/PetActionBarAdapters.xml
deleted file mode 100644
index 7edd4b10..00000000
--- a/crates/benilla-app/assets/ui/PetActionBarAdapters.xml
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
-
diff --git a/crates/benilla-app/assets/ui/SpellBookAdapters.xml b/crates/benilla-app/assets/ui/SpellBookAdapters.xml
index d37fa3be..80dfc700 100644
--- a/crates/benilla-app/assets/ui/SpellBookAdapters.xml
+++ b/crates/benilla-app/assets/ui/SpellBookAdapters.xml
@@ -1,24 +1,27 @@
diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc
index 76095f09..13092b22 100644
--- a/crates/benilla-app/assets/ui/benilla.toc
+++ b/crates/benilla-app/assets/ui/benilla.toc
@@ -446,10 +446,6 @@ Interface\FrameXML\MultiActionBars.xml
# right-click dialogs — ABANDON_PET / RENAME_PET / PETRENAMECONFIRM — now live). 0982's
# transcription is gone.
Interface\FrameXML\PetActionBarFrame.xml
-# The one adapter the reference's file needs from this engine: its autocast shine is a
-# (UI-AutoCastButton.mdx) and this engine's draws no pixels; the shine benilla draws is
-# its own token texture (1383). BENILLA_ALLOW_OWN_UI=1.
-PetActionBarAdapters.xml
# The micro-button row in the bar's right-hand recess — the reference's own file (1751;
# decision 1987), which its toc seats right after MainMenuBar (l.34-35). After MainMenuBar.xml:
# the eight buttons carry parent="MainMenuBarArtFrame", resolved by name at load (1734), and a
@@ -823,9 +819,9 @@ Interface\FrameXML\DressUpFrame.xml
# action-bar files it calls into and after MainMenuBarMicroButtons (UpdateMicroButtons on show). The 'P' binding
# calls its own ToggleSpellBook(BOOKTYPE_SPELL). 0216's transcription is gone.
Interface\FrameXML\SpellBookFrame.xml
-# The one adapter the reference's file needs from this engine: its pet-page autocast shine is a
-# (UI-AutoCastButton.mdx) and this engine's draws no pixels; the shine benilla
-# draws is its own token texture (1383/1392/1393). BENILLA_ALLOW_OWN_UI=1.
+# The director's two look deviations on the pet page's autocast button (1392/1393), re-seated onto
+# the reference's own Model now that this engine renders it (2013/2014): the corner brackets at
+# 71.53 and the shine pane squared on the button at scale 1.48. BENILLA_ALLOW_OWN_UI=1.
SpellBookAdapters.xml
# ══ THE SOCIAL WINDOW — the reference's own, off the player's chain (1751; decision 1959) ══
# FriendsFrame.xml + FriendsFrame.lua: the O-key window's four tabs — friends/ignore, who, the
diff --git a/crates/benilla-app/src/autocast_shine.rs b/crates/benilla-app/src/autocast_shine.rs
deleted file mode 100644
index 07dc5dba..00000000
--- a/crates/benilla-app/src/autocast_shine.rs
+++ /dev/null
@@ -1,803 +0,0 @@
-//! The **autocast shine** — the flaming rim that runs round an autocasting pet button — drawn
-//! natively on the UI quad APPEND lane (decisions 1383, 1386; ledger B282, B228).
-//!
-//! ## Why native (B282)
-//!
-//! Until 1383 the shine was ~92 script-layer `Texture` regions per button, `SetPoint`ed every
-//! frame from a Lua `OnUpdate`. Moving a region dirties the layout epoch, so the first layout
-//! getter later in the same tick forced a real anchor solve — whose cost is the whole-roster
-//! preamble + fingerprint walk, NOT the ~92 moved leaves (decision 1350 scopes the *rounds*, not
-//! the gate walk) — and the seeds-grown law forced a second full walk before the gate could
-//! close. Measured at the SW gates pin on a default roster: **+4.25 ms cpu/frame for ONE
-//! autocasting button**, spark-count-independent (23 sparks cost the same as 92), scaling with
-//! the whole UI (an addon-laden roster pays multiples — nazriel's ~11 ms, B282). The reference
-//! never pays any of this: its shine is a `` widget playing an M2 *outside* the FrameXML
-//! layout tree. This module is that architecture on our substrate: the script layer carries one
-//! **token texture** per button ([`SHINE_TOKEN`], shown/hidden on the autocast *edge*), the
-//! extract's conversion records where it sits ([`ShineSite`] — the minimap-slot pattern), and
-//! [`emit_shine`] appends the spark quads per frame with zero layout traffic: a shine-only frame
-//! leaves the script pipeline fully settled.
-//!
-//! ## What is drawn — the M2's own law
-//!
-//! The reference's "autocast is on" mark is a `` playing
-//! `Interface\Buttons\UI-AutoCastButton.mdx`; what that model actually is came out of the shipped
-//! .m2 with `benilla-extract m2bones`/`m2part` (this block moved whole from `PetActionBar.xml`,
-//! which owned the drawing until 1383):
-//!
-//! - 4 bones, pivoting at the corners of a 0.02 × 0.02 model-unit square, each keyed with the
-//! SAME 5-key 2.000 s translation loop walking the square edge by edge (0.5 s per edge), the
-//! four phase-shifted by one corner each so they chase one another round it. The tracks are
-//! `interp == 1`, LINEAR: constant speed along each edge, no easing into the corners.
-//! - 4 particle emitters, one per bone: 300/s, life 1.000 s, Add blend, unlit, texture
-//! `Interface\Buttons\GlowStar.blp`, size half-extent 0.005 → 0.0015 → 0.001 over life, colour
-//! (0.976,0.875,0.192,1) → (0.996,0.945,0.745,1) → (1,1,1,0). The spline's 4 control points are
-//! ALL at the origin — a particle never travels: it is born where the bone is and shrinks away
-//! there. The effect is four comet trails running round the button's rim, not a rotating ring.
-//!
-//! MODEL UNITS → FrameXML units is the widget's own projection, not a calibration (wow-re
-//! `system/ui/scratch/modelframe-animation-clock.md`, decision 1321's fold-back): the ortho leg
-//! collapses to **1280 × modelScale × layoutScale** FrameXML units per model unit, independent of
-//! aspect and resolution; `scale=` on the ref's `` is SetModelScale, and the model ORIGIN
-//! is the frame rect's BOTTOM-LEFT, which is why a square authored 0..0.02 lands across the whole
-//! rect instead of in one quadrant.
-//!
-//! **The ``'s rect and scale are per-TEMPLATE, and the reference does not repeat itself**
-//! (decision 1391) — which is why they ride the [`ShineSite`] and not a constant:
-//!
-//! - **pet button** — `setAllPoints` on 30×30 at `scale="1.2"`: the path is **30.72 units**,
-//! 1.024× the button, overhanging its top and right by 2.4% and clipped there.
-//! - **spell book button** — its own `36×36 ` at `CENTER (1,1)` on a 37×37 button, at
-//! `scale="1.22"`: the path is **31.232 units** inside a 36-unit rect that itself sits 1.5 units
-//! in from the button's bottom-left.
-//!
-//! **The stars on that path do NOT take the same projection** ([`STAR`], decisions 1386/1390):
-//! their half-extent is an EYE-SPACE length added after the transform, so it misses `modelScale`
-//! — the square is scaled by 1.2 and the stars standing on it are not — and, unlike the square,
-//! it keeps the ortho leg's aspect term: `2 · half · √(W² + H²)` **window px**, free of UI scale
-//! and frame scale. 12.80 px at 1024×768; 14.69 at 1280×720. 1386 froze the 4:3 case into a
-//! constant, which is why our 16:9 stars were 22 % small until 1390.
-//!
-//! **"Free of UI scale" is byte-law, not a loose phrase** (wow-re
-//! `system/ui/scratch/modelframe-uiscale-law.md`, a §5 cross-check dispatched *because* the
-//! phrasing looked too loose to rest on). `uiScale` is an ordinary `SetScale` on the frame named
-//! `UIParent` (`0x494550` → `0x76ac10(ecx = [0xb4b44c])`, and `0x494590` "GetUiScale" is literally
-//! `fld [[0xb4b44c]+0x7c]`), and it *does* reach both the widget's rect and the model root
-//! (`76d1dd fmul [esi+0x7c]`). It cancels anyway: `0x76d240` builds the ortho `m00 = 2/w` **and**
-//! the viewport from the SAME rect, so px per eye unit is `√(W² + H²)` whatever `w` is. So:
-//!
-//! > **the button scales with the dial and the star does not.** At 2560×1440 a newborn star is
-//! > **29.37 px at every setting**, against a 37-unit button of 69.4 / 62.4 / 44.4 px at `uiScale`
-//! > 1.0 / 0.9 / 0.64 — 42 % / 47 % / **66 %** of the button.
-//!
-//! And the consequence that actually reads on screen is not that ratio but this: the four emitters
-//! sit on a path that DOES shrink with the dial (`31.2 · uiScale` units), so at 0.64 four
-//! fixed-size 29 px stars pack into a 37 px square instead of a 59 px one and **overlap into one
-//! blob**. Every bit of that is the reference's own behaviour; there is nothing to fix here, and a
-//! low-`uiScale` complaint about this widget is a taste call, not a defect.
-//!
-//! **And the widget is its own scissor** ([`site_quads`], decision 1387): a `` installs its
-//! frame rect as the VIEWPORT for the duration of its draw and restores it after, so the model
-//! cannot paint one pixel outside the button. With the path running on the rim and the stars
-//! straddling it, that clip is half the silhouette — the reference's flat outer edge with the
-//! flames licking inward, against the soft halo an unclipped draw hangs round the whole button.
-//!
-//! **We draw the authored population, not a sample of it** (decision 1386). `rate × life` is
-//! 300 live particles per emitter, 1200 for the model, and that number IS the look: the bone
-//! lays one down every 0.205 units of rim while a newborn star is 12.8 units across at 4:3 (more
-//! on a widescreen — [`STAR`]), so ~62 of them pile onto any point of the band and ADD saturates
-//! it into one continuous flaming rim —
-//! unevenly by channel (the ramp is gold, so R and G clip where B does not, which is why it
-//! reads gold and not white), and what travels is the brightness envelope, not any one star.
-//! 1383 shipped a 23-sample stand-in for that population, chosen to keep the *coverage*
-//! unbroken, and coverage was the wrong invariant: the samples tile the same path at 1/13th the
-//! intensity, so the band came apart into legible marching sparkles (B228, the director's A/B
-//! against the reference). Sampling saved cost the script layer was charging us; native, the
-//! authored count is affordable, so there is nothing left to approximate.
-//!
-//! ## The clock — truncation and all
-//!
-//! The reference does NOT play this loop at wall-clock rate (decision 1321, B228's second round):
-//! `CSimpleModel` advances its private scene clock by `__ftol(elapsed * 1000.0)` — truncated,
-//! no fractional carry (`0x76d846`; the WORLD driver adds 0.5 first, `0x48366b`) — so a 2000 ms
-//! band takes `2000·T/floor(T)` ms at frame time T ms: 2083 ms at 60 fps, 2315 at 144, never
-//! less than 2000. [`ShineClock`] is that integer-millisecond accumulator, advanced the
-//! reference's way. All shine sites share one clock: the ref's per-button Models are all created
-//! at UI load with `SetSequence(0)` and never drift, the loop being exact — one shared clock IS
-//! that behaviour.
-
-use bevy::prelude::*;
-
-use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads};
-
-/// The script layer's registration token: a `Texture` region whose path starts with this converts
-/// to **no quads** — the conversion records a [`ShineSite`] at its rect/z instead, and
-/// [`emit_shine`] draws there. Ours, not the install's — the string never reaches the resolver.
-///
-/// An optional `:` suffix carries the ``'s **`scale=`** ([`token_model_scale`]), which
-/// is `SetModelScale` and is not the same on every button that wears this shine (1391).
-pub(crate) const SHINE_TOKEN: &str = "benilla:autocast-shine";
-
-/// Read a token's model scale, or `None` if `path` is not a shine token at all — the single
-/// definition of what the token's grammar is, shared by the conversion (which records the site)
-/// and the shipped-XML sweep (which must exempt exactly this and nothing that merely resembles
-/// it, so a typo still fails the archive check).
-pub(crate) fn token_model_scale(path: &str) -> Option {
- match path.strip_prefix(SHINE_TOKEN)? {
- "" => Some(1.2),
- rest => rest.strip_prefix(':')?.parse().ok(),
- }
-}
-
-/// The spark art — the M2's own emitter texture, resolved once per site at conversion time
-/// through the same resolver every UI texture uses.
-pub(crate) const STAR_TEXTURE: &str = "Interface\\Buttons\\GlowStar";
-
-/// FrameXML units per model unit — `768 · 5/3`, **aspect- and resolution-independent** (wow-re
-/// `modelframe-clip-and-scale.md` C2, confirmed exactly as 1321 recorded it): 1 model unit is
-/// *not* 1 layout unit, because `0x76d1a0` always folds in `G48 · 5/3` (`76d1c6 0x41ae70(1.0)` =
-/// G48 · `76d1cb fmul [0x80655c]` = f32(5/3)), and that `G48` cancels the `768·√(a²+1)` in the
-/// FrameXML-unit definition. The star's half-extent ([`STAR`]) escapes that fold, which is the
-/// whole reason the two constants no longer live in the same space.
-const PER_MODEL_UNIT: f32 = 1280.0;
-
-/// The rim square's side in FrameXML units at a given `SetModelScale`: the M2's authored
-/// 0.02-unit bone square through [`PER_MODEL_UNIT`]. **30.72 at the pet bar's `scale="1.2"`,
-/// 31.23 at the spell book's `scale="1.22"`** — the ref writes a different number on each
-/// template and 1391 stopped averaging them into one constant.
-fn side(model_scale: f32) -> f32 {
- 0.02 * PER_MODEL_UNIT * model_scale
-}
-/// The size ramp's authored **half-extents, in MODEL units** (`file+0x104`) — deliberately not
-/// pre-projected like [`side`], because they do not take [`side`]'s projection (decision 1390;
-/// wow-re `modelframe-clip-and-scale.md` C3, which corrects the FrameXML-unit constant 1386 wrote
-/// here and confirms everything else in that record).
-///
-/// A particle quad is not built in model space and transformed with the model: `0x7b3d20`
-/// installs an **identity view** for the whole of the emitter's vertex generation (`7b3e41` save
-/// / `7b3e46` install / restored at the tail), so the `±half` that `0x7b2a50` adds from the
-/// `±1.0` corner table to the already-transformed position is an **eye-space length**; and file
-/// flag `+0x04` bit `0x200` gates off the one multiply by the emitter scale (`0x7b2ba6 je`, taken
-/// because `0x7b2ba3 test ah,0x2` finds the bit clear — the two *ungated* readers of
-/// `[emitter+0x264]` are the bounding sphere). So `SetModelScale(1.2)` reaches the bone square
-/// but **not** the stars standing on it.
-///
-/// The consequence, and the reason this is a model-unit constant now: an eye-space length is
-/// projected by the ortho leg, which maps the frame's *diagonal-normalized* layout rect to NDC
-/// ±1 — so a star's size is **aspect-dependent where the path's is not**.
-///
-/// > full width = `2 · half · 768·√(a²+1)` FrameXML units = **`2 · half · √(W² + H²)` window px**
-///
-/// The pixel form is the one [`site_quads`] uses: no UI scale, no frame scale, just the screen
-/// diagonal. That is 12.80 px at 1024×768 — the number 1386 froze into a constant — but **14.69
-/// px at 1280×720**, 22 % larger against a button 4 % *smaller*. The reference's shine really is
-/// fatter on a widescreen; the 1280 that 1321 folded back is the 4:3 case of a law, not the law.
-const STAR: [f32; 3] = [0.005, 0.0015, 0.001];
-/// The in-plane billboard spin, radians per second of age (`file+0x198`, `angle = spin · age`,
-/// closed form at `0x7b2ddc`) — 0.1 rad = 5.73° over a particle's whole life. The per-particle
-/// sign flip that `0x7b2dea` would apply is gated by runtime bit `0x8000` ← file `+0x04` bit
-/// `0x200`, which is CLEAR here, so **every** star turns the same way (wow-re C4: the `test
-/// bl,0x20` behind it reads slot parity, not a stored field — there is no RNG in it).
-const SPIN: f32 = 0.1;
-/// The over-life colour ramp's three keys, `{r, g, b, a}` — the emitter's own.
-const COLOR: [[f32; 4]; 3] = [
- [0.976, 0.875, 0.192, 1.0],
- [0.996, 0.945, 0.745, 1.0],
- [1.000, 1.000, 1.000, 0.0],
-];
-/// The bone loop, milliseconds (seq 0's authored 2.000 s band).
-const PERIOD_MS: u32 = 2000;
-/// The bone loop in seconds, for the phase/age arithmetic.
-const PERIOD: f32 = 2.0;
-/// A particle's lifespan, seconds (`file+0xc0`).
-const LIFE: f32 = 1.0;
-/// The authored emission rate, particles per second (`file+0xdc`).
-const RATE: f32 = 300.0;
-/// One emitter per bone corner.
-const EMITTERS: usize = 4;
-/// Where the over-life ramps break from key 0→1 to key 1→2 (`file+0x14c`).
-const MID: f32 = 0.5;
-
-/// Sample a 3-key over-life ramp at normalized age `u` — the reference's own two-segment law
-/// (`age > midPoint·lifespan` picks the segment, `0x7b5aaa`) with the inset it applies to the
-/// segment-local time (`t = u·0.99 + 0.005`, `0x7b9b10`), so the authored endpoint values are
-/// **never exactly reached** — a newborn star is already 0.5 % of the way toward the mid key.
-/// Tiny, and carried because it is free and because a golden that pins the authored endpoints
-/// pins something the reference never draws.
-fn ramp(keys: [f32; 3], u: f32) -> f32 {
- let (a, b, seg) = if u <= MID {
- (keys[0], keys[1], u / MID)
- } else {
- (keys[1], keys[2], (u - MID) / (1.0 - MID))
- };
- let t = seg * 0.99 + 0.005;
- a + (b - a) * t
-}
-
-/// [`ramp`] over the colour keys, all four lanes.
-fn ramp_color(age: f32) -> [f32; 4] {
- std::array::from_fn(|i| ramp([COLOR[0][i], COLOR[1][i], COLOR[2][i]], age))
-}
-
-/// Where an emitter sits at lap fraction `f` (wrapped into 0..1), as a FrameXML-unit offset from
-/// the button's BOTTOM-LEFT (the model's own origin — module doc). The bones walk the square
-/// clockwise on screen from that corner, one edge per quarter-lap.
-fn point(f: f32, side: f32) -> (f32, f32) {
- let f = f - f.floor();
- let edge = f * 4.0;
- let leg = edge.floor();
- let t = edge - leg;
- match leg as u32 {
- 0 => (0.0, t * side), // bottom-left → top-left
- 1 => (t * side, side), // top-left → top-right
- 2 => (side, side - t * side), // top-right → bottom-right
- _ => (side - t * side, 0.0), // bottom-right → bottom-left
- }
-}
-
-/// How many particles one emitter keeps alive — the emitter's own steady state, `rate × life`
-/// (decision 1386: the population IS the look, and 1383 shipped a 23-sample stand-in for it).
-///
-/// **Both of the spawn driver's multipliers are byte-confirmed 1.0 for this widget** (wow-re
-/// `modelframe-particle-density.md`): the camera-distance LOD
-/// (`clamp(1 − (dist − 50)·0.02, 0.25, 1.0)`) sits at 1.0 *structurally*, because
-/// `CSimpleModel::Draw` ZEROES the eye it is measured against (`0x76d3da`), leaving
-/// `dist ≈ 0.02` layout units against a 50-unit inner radius — a UI model would have to be 50
-/// screen diagonals away to fall off; and `particleDensity` defaults to 1.0 (`0x82e92c`) with no
-/// UI exemption. The emitter's pool is `trunc(life·rate·1.15)` = 345 slots, so the 300 never hit
-/// a cap either. `WOW_SHINE_N=` stays as the A/B instrument, not as a pending question.
-fn live_per_emitter() -> usize {
- static N: std::sync::LazyLock = std::sync::LazyLock::new(|| {
- std::env::var("WOW_SHINE_N")
- .ok()
- .and_then(|v| v.parse().ok())
- .unwrap_or((RATE * LIFE).round() as usize)
- });
- *N
-}
-
-/// A multiplier on the authored star width (`WOW_SHINE_SIZE=`, default 1.0) — an A/B
-/// instrument, not a pending question: the projection it used to bracket is settled (1390).
-///
-/// The band's saturated THICKNESS is set by the quad width and its brightness by width × count,
-/// so the two trade off and neither can be read off a capture alone — which is exactly why this
-/// stays. `WOW_SHINE_SIZE=0.817` reproduces the pre-1390 4:3 sizing on a 16:9 window, so a
-/// reference shot captured at 4:3 can be compared against ours without rebuilding.
-/// **Never a tuning knob to leave dialled**: the shipped value is the one the bytes say.
-fn size_scale() -> f32 {
- static S: std::sync::LazyLock = std::sync::LazyLock::new(|| {
- std::env::var("WOW_SHINE_SIZE")
- .ok()
- .and_then(|v| v.parse().ok())
- .unwrap_or(1.0)
- });
- *S
-}
-
-/// The live population, computed once: one entry per particle, oldest last, carrying everything
-/// its FIXED age fixes — its age, its **model-unit half-extent** ([`STAR`]; the site's screen
-/// diagonal turns it into pixels at emit), and its colour+alpha. Only the position moves per
-/// frame, which is what keeps a 1200-quad emitter cheap.
-///
-/// The ages are the steady state of a `rate`/s emitter: one particle born every `1/rate` seconds,
-/// none older than `life`. **Our one departure from the reference's own timeline**: it spawns in
-/// per-frame batches (≈5 coincident particles per frame at 60 fps, at whatever position the bone
-/// held that frame), where this spreads the same population evenly along the path. At the
-/// authored star width against a 0.2-unit spawn pitch the two are the same picture; unlike a
-/// sample count, it costs nothing to revisit, because the population is the file's.
-static PARTICLES: std::sync::LazyLock> = std::sync::LazyLock::new(|| {
- let size_scale = size_scale();
- (0..live_per_emitter())
- .map(|k| {
- let age = k as f32 / RATE;
- let u = (age / LIFE).min(1.0);
- (age, ramp(STAR, u) * size_scale, ramp_color(u))
- })
- .collect()
-});
-
-/// One autocasting button's shine site, recorded by the extract's conversion when it meets a
-/// shown [`SHINE_TOKEN`] texture (the minimap-slot pattern): everything [`emit_shine`] needs to
-/// draw there without asking the script layer anything.
-#[derive(Clone, PartialEq)]
-pub(crate) struct ShineSite {
- /// The token region's resolved rect — the BUTTON's rect (the token is SetAllPoints on it) —
- /// in window pixels, y-down.
- pub(crate) rect: Rect,
- /// The token's paint-order key, verbatim — sparks draw exactly where the old spark textures
- /// drew (the button's OVERLAY layer).
- pub(crate) z: u64,
- /// The token's clip rect, if any (a scroll-framed ancestor), window px y-down.
- pub(crate) clip: Option,
- /// The token's effective UI alpha (parent chain folded), multiplied into every spark.
- pub(crate) alpha: f32,
- /// FrameXML-units → window-pixels factor at the conversion that recorded this site (the
- /// extract's `s`); a resize re-extracts and re-records. Scales the bone PATH ([`side`]).
- pub(crate) scale: f32,
- /// The ``'s `SetModelScale`, read off the token ([`token_model_scale`]) — the ref
- /// writes `1.2` on the pet button and `1.22` on the spell book's, so it belongs to the SITE
- /// and not to a module constant (1391). Scales the bone path only; never the stars.
- pub(crate) model_scale: f32,
- /// The window's diagonal in the same pixels, `√(W² + H²)` — what scales the STARS, and
- /// nothing else does (decision 1390; [`STAR`]'s law is in screen px, free of UI scale and
- /// frame scale). Recorded beside `scale` so a resize re-answers both at once.
- pub(crate) diag: f32,
- /// `Interface\Buttons\GlowStar`, resolved at record time.
- pub(crate) texture: Handle,
-}
-
-/// This frame's shine sites. Refilled by every FULL conversion (and left alone by the settled /
-/// spliced paths — the token entries didn't change, so last conversion's sites are still true;
-/// the token arm is deliberately not splice-simple, so any token edge reaches the full path).
-#[derive(Resource, Default)]
-pub(crate) struct ShineSites(pub(crate) Vec);
-
-/// The shared integer-millisecond loop clock (module doc: the reference's truncating
-/// accumulator, decision 1321 — `floor(elapsed_ms)` added per frame, no fractional carry, so the
-/// lap tracks the player's frame rate exactly as the reference widget's does).
-#[derive(Resource, Default)]
-pub(crate) struct ShineClock {
- ms: u32,
-}
-
-impl ShineClock {
- /// Advance by one frame's wall delta (seconds) and return the clock, in `0..PERIOD_MS`.
- fn advance(&mut self, elapsed: f64) -> u32 {
- self.ms = (self.ms + (elapsed * 1000.0).floor() as u32) % PERIOD_MS;
- self.ms
- }
-}
-
-/// Append every site's sparks to the UI quad overlay lane — [`UiQuadAppend`], beside the minimap
-/// and the V-plates: re-emitted each frame, diffed by the mesh rebuild, never touching the
-/// script layout. Runs (and advances the clock) unconditionally; with no sites it is two reads
-/// and an add.
-fn emit_shine(
- mut clock: ResMut,
- sites: Res,
- time: Res>,
- mut quads: ResMut,
-) {
- let ms = clock.advance(time.delta_secs_f64());
- for site in &sites.0 {
- site_quads(site, ms, &mut quads.overlays);
- }
-}
-
-/// One site's sparks at clock `ms`, appended to `out` — [`emit_shine`]'s whole body, pure so the
-/// tests drive it at exact millisecond clocks the way the old Lua goldens drove the `OnUpdate`.
-fn site_quads(site: &ShineSite, ms: u32, out: &mut Vec) {
- // THE WIDGET IS ITS OWN SCISSOR (decision 1387). A `` does not draw into the UI's
- // shared space: `0x76d240` saves the viewport, installs the frame's OWN device rect as the
- // viewport for the duration of the model's draw, and restores it after
- // (`76d3cd` save · `76d5d7 0x58af60(left·x1, right·x1, bottom·y1, top·y1, 0, 1)` ·
- // `76d66c` restore — wow-re `system/ui/scratch/modelframe-camera-law.md` §4 steps 3/6/9),
- // while the ortho leg maps that same rect to NDC ±1 (§4b). Everything the model draws outside
- // the rect is therefore cut by the clip volume, with a hard flat boundary at the rect edge.
- //
- // That is not cosmetic here, it IS the shine's silhouette: the authored 0.02 square projects
- // to 30.72 units on a 30-unit button, so the bone path runs ON the rim and each 12.8-unit
- // newborn star straddles it — half in, half out. Clipped, only the inward half survives, and
- // the rim reads as the reference's does: a flat outer edge at the button border with ragged
- // flame tongues bleeding inward over the icon. Unclipped, the same stars spill a soft halo a
- // fifth of a button wide on all four sides — the director's A/B against the real client,
- // which shows the stone behind the button completely unlit (B228).
- let viewport = match site.clip {
- Some(inherited) => site.rect.intersect(inherited),
- None => site.rect,
- };
- if viewport.is_empty() {
- return;
- }
- let lap = ms as f32 / PERIOD_MS as f32;
- let s = site.scale;
- let side = side(site.model_scale);
- // The model origin: the button rect's bottom-left, in y-down px.
- let (ox, oy) = (site.rect.min.x, site.rect.max.y);
- for e in 0..EMITTERS {
- // Bone `e` LAGS bone `e-1` by 500 ms — a quarter lap *behind*, not ahead (wow-re's bone
- // table: bone 0 opens at BL, bone 1 at BR, bone 2 at TR, bone 3 at TL, all walking the
- // one clockwise cycle BL→TL→TR→BR). Four identical emitters at four phases draw the same
- // picture whichever way the index runs, which is exactly why the old `+e/4` could be
- // wrong for two of them and look right; the law is worth being right about anyway.
- let corner = -(e as f32) / EMITTERS as f32;
- for &(age, w, c) in PARTICLES.iter() {
- // The particle's fixed place in the loop: its emitter's quarter-lap offset, less how
- // far the bone has moved on since this star was born (it is baked into the model
- // frame at birth and never rides the bone — wow-re §7, flags bit 0x10 clear).
- let (x, y) = point(lap + corner - age / PERIOD, side);
- let (cx, cy) = (ox + x * s, oy - y * s);
- // The path took `s`; the star takes the screen diagonal instead ([`STAR`]) — the two
- // are different projections of the same model, not a shared one.
- let half = w * site.diag;
- out.push(UiQuad {
- rect: Rect::new(cx - half, cy - half, cx + half, cy + half),
- z_key: site.z,
- texture: Some(site.texture.clone()),
- color: [c[0], c[1], c[2], c[3] * site.alpha],
- additive: true,
- // The in-plane billboard spin ([`SPIN`]) — the one field 1321 recorded as live
- // and left unmodelled for want of a rotation to hang it on. `UiQuad::rotation`
- // is that rotation, and it turns the corners with their UVs, which is what the
- // reference's screen-plane rotate does.
- rotation: SPIN * age,
- clip: Some(viewport),
- ..Default::default()
- });
- }
- }
-}
-
-/// Resource + producer registration (the [`UiQuadAppend`] window — `ui_pass` sorts the lanes
-/// together by `z_key`, so the sparks interleave exactly at the button's overlay layer).
-pub(crate) struct AutocastShinePlugin;
-
-impl Plugin for AutocastShinePlugin {
- fn build(&self, app: &mut App) {
- app.init_resource::()
- .init_resource::()
- .add_systems(Update, emit_shine.in_set(UiQuadAppend));
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- /// The pet button's rim square — the geometry every golden below was taken at
- /// (`scale="1.2"`, the ref's own value on that template).
- fn s12() -> f32 {
- side(1.2)
- }
-
- /// The token's grammar (1391): it carries the ``'s `scale=`, because the reference
- /// writes a different one on each template that wears this shine — and it still refuses
- /// anything that merely looks like the token, which is what keeps the shipped-XML archive
- /// sweep honest about a typo.
- #[test]
- fn the_token_carries_the_models_scale_and_nothing_else_parses_as_one() {
- assert_eq!(token_model_scale("benilla:autocast-shine:1.2"), Some(1.2));
- assert_eq!(token_model_scale("benilla:autocast-shine:1.22"), Some(1.22));
- assert_eq!(
- token_model_scale(SHINE_TOKEN),
- Some(1.2),
- "bare = the pet bar's"
- );
- for bad in [
- "benilla:autocast-shin",
- "benilla:autocast-shine2",
- "benilla:autocast-shine:",
- "benilla:autocast-shine:x",
- "benilla:autocast-shine1.22",
- "Interface\\Buttons\\GlowStar",
- ] {
- assert_eq!(token_model_scale(bad), None, "{bad} must not parse");
- }
- // The two rim squares the shipped templates actually ask for.
- assert!((side(1.2) - 30.72).abs() < 1e-4, "pet button");
- assert!((side(1.22) - 31.232).abs() < 1e-4, "spell book");
- }
-
- /// The population golden (1386): the emitter keeps `rate × life` particles alive, aged one
- /// spawn interval apart, none past its life — the file's own numbers, so a constant edit
- /// cannot quietly re-open the sampling era.
- #[test]
- fn the_population_is_the_files_own_steady_state() {
- assert_eq!(PARTICLES.len(), 300, "300/s × 1.0 s life");
- assert_eq!(PARTICLES[0].0, 0.0, "the newest particle is newborn");
- for w in PARTICLES.windows(2) {
- let step = w[1].0 - w[0].0;
- assert!(
- (step - 1.0 / RATE).abs() < 1e-6,
- "spawn interval {step} ≠ 1/{RATE}"
- );
- }
- assert!(PARTICLES.iter().all(|p| p.0 < LIFE));
- // Density is what the look rests on: the bone lays a particle every 0.20 units of rim
- // while a newborn star is 12.8 units wide at 4:3, so ~60 stars pile onto any point of
- // the band. Additive, that saturates — the reference's thick continuous rim, and exactly
- // what 1383's 23 samples (7.7 units apart, one star deep) could not produce. Both terms
- // are in FrameXML units here, so the star takes the 4:3 case of its own law (1390).
- let pitch = 4.0 * s12() / PERIOD / RATE;
- let depth = 2.0 * ramp(STAR, 0.0) * 1280.0 / pitch;
- assert!(
- depth > 50.0,
- "only {depth:.0} stars deep — that will not saturate"
- );
- }
-
- /// The newborn particle's quad, generated through the REAL producer body at exact
- /// millisecond clocks: origin at the site rect's bottom-left, the quarter-edge midpoint
- /// exactly half way (LINEAR, `interp == 1`), the ramps' own size and colour, the site's
- /// alpha folded, and `4 × population` quads per site.
- #[test]
- fn a_sites_particles_come_out_where_the_bone_walked() {
- let site = ShineSite {
- rect: Rect::new(72.0, 56.0, 102.0, 86.0), // the pet bar's button 1, y-down px
- z: 42,
- clip: None,
- alpha: 1.0,
- scale: 1.0,
- model_scale: 1.2,
- diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case
- texture: Handle::default(),
- };
- let at = |ms: u32| {
- let mut out = Vec::new();
- site_quads(&site, ms, &mut out);
- assert_eq!(out.len(), EMITTERS * PARTICLES.len());
- // Emitter 0's newest particle is the first quad; its centre is the bone itself.
- let r = out[0].rect;
- (
- (r.min.x + r.max.x) * 0.5 - site.rect.min.x,
- site.rect.max.y - (r.min.y + r.max.y) * 0.5,
- )
- };
- // The old Lua golden's own 0.01-px tolerance: the centre is reconstructed from the
- // quad's f32 edges, so exact-bit equality is not the claim — the corner is.
- let near = |got: (f32, f32), want: (f32, f32), what: &str| {
- assert!(
- (got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01,
- "{what}: at {got:?}, expected {want:?}"
- );
- };
- near(at(0), (0.0, 0.0), "clock 0: the bottom-left origin");
- near(
- at(250),
- (0.0, s12() / 2.0),
- "quarter edge in: the LEFT edge's midpoint (LINEAR)",
- );
- near(at(500), (0.0, s12()), "clock 500 ms: top-left");
- near(at(1000), (s12(), s12()), "clock 1000 ms: top-right");
- near(at(1500), (s12(), 0.0), "clock 1500 ms: bottom-right");
- near(at(2000), (0.0, 0.0), "clock 2000 ms wraps onto clock 0");
- // The newborn quad wears the ramp's birth values (the inset included — it is 0.5 % of
- // the way to the mid key already), the additive blend the emitter authors, and the
- // site's own paint order.
- let mut out = Vec::new();
- site_quads(&site, 0, &mut out);
- let head = &out[0];
- let width = head.rect.max.x - head.rect.min.x;
- assert!((width - 2.0 * ramp(STAR, 0.0) * site.diag).abs() < 0.01);
- assert!(width < 2.0 * STAR[0] * site.diag, "the inset bites");
- assert_eq!(head.color, ramp_color(0.0));
- assert!(head.additive);
- assert_eq!(head.z_key, 42);
- // The site's alpha folds into every particle on top of the ramp's own.
- let dim = ShineSite { alpha: 0.5, ..site };
- let mut dimmed = Vec::new();
- site_quads(&dim, 0, &mut dimmed);
- assert_eq!(dimmed[0].color[3], ramp_color(0.0)[3] * 0.5);
- }
-
- /// The widget's viewport IS the silhouette (1387): every spark carries the site rect as its
- /// clip, an inherited scroll clip intersects with it rather than replacing it, and — the
- /// point of the whole thing — the sparks really do straddle the rim, so the clip is load
- /// bearing and not a decorative no-op.
- #[test]
- fn nothing_the_model_draws_escapes_the_widgets_rect() {
- let site = ShineSite {
- rect: Rect::new(72.0, 56.0, 102.0, 86.0),
- z: 42,
- clip: None,
- alpha: 1.0,
- scale: 1.0,
- model_scale: 1.2,
- diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case
- texture: Handle::default(),
- };
- let mut out = Vec::new();
- site_quads(&site, 0, &mut out);
- assert!(out.iter().all(|q| q.clip == Some(site.rect)));
-
- // Unclipped, a newborn star hangs a long way past the border — half its 12.8 units plus
- // the 0.36 the 30.72 path already overhangs. That overhang is the halo the reference
- // does not have; the clip above is what removes it.
- let escaping = out
- .iter()
- .filter(|q| !site.rect.contains(q.rect.min) || !site.rect.contains(q.rect.max))
- .count();
- assert!(
- escaping > out.len() / 2,
- "only {escaping}/{} sparks cross the rim — the path is not on the rim any more",
- out.len()
- );
- let worst = out
- .iter()
- .map(|q| site.rect.min.x - q.rect.min.x)
- .fold(f32::MIN, f32::max);
- assert!(
- (worst - ramp(STAR, 0.0) * site.diag).abs() < 0.01,
- "the newborn star should hang exactly half its width past the left rim, not {worst}"
- );
-
- // A scroll-framed ancestor narrows the viewport; it never widens it.
- let scrolled = ShineSite {
- clip: Some(Rect::new(0.0, 0.0, 90.0, 200.0)),
- ..site.clone()
- };
- let mut out = Vec::new();
- site_quads(&scrolled, 0, &mut out);
- assert!(out
- .iter()
- .all(|q| q.clip == Some(Rect::new(72.0, 56.0, 90.0, 86.0))));
-
- // Scrolled fully out of view, the site draws nothing at all.
- let hidden = ShineSite {
- clip: Some(Rect::new(0.0, 0.0, 10.0, 10.0)),
- ..site
- };
- let mut out = Vec::new();
- site_quads(&hidden, 0, &mut out);
- assert!(out.is_empty());
- }
-
- /// The two projections and the spin, pinned where they can't drift back into one another
- /// (1386, corrected by 1390): the bone square is 1280/model-unit × the Model's 1.2 at every
- /// aspect, the star is an eye-space length that takes the screen diagonal and neither scale,
- /// and every particle spins the same way at `spin · age`.
- #[test]
- fn the_star_skips_the_models_scale_and_every_spin_turns_one_way() {
- // The bone square: 1280 per model unit and the Model's 1.2, at EVERY aspect (C2).
- assert!(
- (s12() - 30.72).abs() < 1e-4,
- "the bone square DOES take the 1.2"
- );
- // The star: `2 · half · √(W² + H²)` window px, no 1.2 and no UI scale — so unlike the
- // square it MOVES with the window's shape (C3). 1024×768 is the case 1386 mistook for
- // the whole law; 1280×720 is 22 % wider on a button 4 % narrower.
- let width = |w: f32, h: f32| 2.0 * STAR[0] * (w * w + h * h).sqrt();
- assert!((width(1024.0, 768.0) - 12.80).abs() < 0.01, "4:3");
- assert!((width(1280.0, 720.0) - 14.69).abs() < 0.01, "16:9");
- assert!(
- width(1280.0, 720.0) > 1.14 * width(1024.0, 768.0),
- "the aspect term is what 1386 froze out; it must be live"
- );
- // The square is 2.4 star-widths across at 4:3 — 1321's 2.0 was the 1.2 leaking into the
- // star. It is only 2.1 at 16:9, and that drift IS the law, not a rounding.
- assert!((s12() / width(1024.0, 768.0) - 2.4).abs() < 1e-3);
-
- let site = ShineSite {
- rect: Rect::new(0.0, 0.0, 30.0, 30.0),
- z: 0,
- clip: None,
- alpha: 1.0,
- scale: 1.0,
- model_scale: 1.2,
- diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case
- texture: Handle::default(),
- };
- let mut out = Vec::new();
- site_quads(&site, 0, &mut out);
- // Rotation is a pure function of age, one direction, reaching 0.1 rad at the end of life.
- assert_eq!(out[0].rotation, 0.0, "a newborn star has not turned yet");
- let oldest = &out[PARTICLES.len() - 1];
- assert!(
- (oldest.rotation - SPIN * (1.0 - 1.0 / RATE)).abs() < 1e-6,
- "the oldest star has turned spin × its age"
- );
- assert!(
- out.iter().all(|q| q.rotation >= 0.0),
- "the sign-flip bit is clear on this asset — nothing counter-rotates"
- );
- }
-
- /// The corner walk and the four bones' phasing — wow-re's own bone table
- /// (`modelframe-animation-clock.md` §6): one clockwise cycle BL→TL→TR→BR walked by all four,
- /// each successive bone index LAGGING the last by 500 ms, so at clock 0 bone 0 sits at BL,
- /// bone 1 at BR, bone 2 at TR and bone 3 at TL.
- #[test]
- fn the_bones_walk_one_clockwise_cycle_each_lagging_the_last() {
- assert_eq!(point(0.0, s12()), (0.0, 0.0), "bottom-left at 0 s");
- assert_eq!(point(0.25, s12()), (0.0, s12()), "top-left at 0.5 s");
- assert_eq!(point(0.5, s12()), (s12(), s12()), "top-right at 1.0 s");
- assert_eq!(point(0.75, s12()), (s12(), 0.0), "bottom-right at 1.5 s");
- assert_eq!(point(1.0, s12()), (0.0, 0.0), "home at 2.0 s");
-
- // Where each emitter's newborn particle sits at clock 0, straight off the producer.
- let site = ShineSite {
- rect: Rect::new(0.0, 0.0, 30.0, 30.0),
- z: 0,
- clip: None,
- alpha: 1.0,
- scale: 1.0,
- model_scale: 1.2,
- diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case
- texture: Handle::default(),
- };
- let mut out = Vec::new();
- site_quads(&site, 0, &mut out);
- let born = |e: usize| {
- let r = out[e * PARTICLES.len()].rect;
- (
- (r.min.x + r.max.x) * 0.5,
- site.rect.max.y - (r.min.y + r.max.y) * 0.5,
- )
- };
- let near = |got: (f32, f32), want: (f32, f32), what: &str| {
- assert!(
- (got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01,
- "{what}: at {got:?}, expected {want:?}"
- );
- };
- near(born(0), (0.0, 0.0), "bone 0 opens at BL");
- near(
- born(1),
- (s12(), 0.0),
- "bone 1 opens at BR — a quarter lap BEHIND bone 0",
- );
- near(born(2), (s12(), s12()), "bone 2 opens at TR");
- near(born(3), (0.0, s12()), "bone 3 opens at TL");
- }
-
- /// Decision 1321's clock law, on the native accumulator: at a exact 60 fps the floor of
- /// every 16.666… ms delta is 16 ms, so the 2000 ms band takes ⌈2000/16⌉ = 125 frames —
- /// 2083 ms of wall time, never less than authored.
- #[test]
- fn the_clock_truncates_like_the_reference_widget() {
- let mut clock = ShineClock::default();
- let dt = 1.0 / 60.0;
- let mut frames = 0;
- loop {
- frames += 1;
- let before = clock.ms;
- let now = clock.advance(dt);
- if now < before {
- break; // wrapped: one full lap
- }
- assert!(frames < 1000, "clock never wrapped");
- }
- assert_eq!(
- frames, 125,
- "a 2000 ms band takes 125 frames at 60 fps (2083 ms)"
- );
- // 144 fps truncates harder — 6.944 ms banked as 6 — so the same lap takes 334 frames
- // (2315 ms of wall clock). A frame-rate-independent clock would land both at 2000 ms.
- let mut clock = ShineClock::default();
- let dt = 1.0 / 144.0;
- let mut frames = 0;
- loop {
- frames += 1;
- let before = clock.ms;
- if clock.advance(dt) < before {
- break;
- }
- assert!(frames < 1000, "clock never wrapped");
- }
- assert_eq!(
- frames, 334,
- "the same band takes 334 frames at 144 fps (2315 ms)"
- );
- }
-
- /// The ramps read the M2's keys through the reference's own two-segment sampler — including
- /// its `t = u·0.99 + 0.005` inset, which means the authored endpoints are approached and
- /// never reached. Pinned as inequalities plus the inset's exact arithmetic, so the law is
- /// what is asserted rather than three literals.
- #[test]
- fn the_ramps_read_the_m2s_keys_through_the_references_inset() {
- let near = |got: f32, want: f32| assert!((got - want).abs() < 1e-4, "{got} vs {want}");
- // Segment-local time 0 lands 0.5 % in; segment-local time 1 lands 0.5 % short. The
- // reference's own branch is `age > midPoint·lifespan`, so age == mid is still the FIRST
- // segment — its 0.995 end, not the second segment's 0.005 start.
- near(ramp(STAR, 0.0), STAR[0] + (STAR[1] - STAR[0]) * 0.005);
- near(ramp(STAR, MID), STAR[0] + (STAR[1] - STAR[0]) * 0.995);
- near(ramp(STAR, 1.0), STAR[1] + (STAR[2] - STAR[1]) * 0.995);
- assert!(ramp(STAR, 0.0) < STAR[0], "the newborn star never hits k0");
- assert!(ramp(STAR, 1.0) > STAR[2], "the dying star never reaches k2");
- // The size ramp only ever shrinks, and the alpha only ever fades after the mid key —
- // the two facts the population's brightness envelope rests on.
- let mut prev = f32::INFINITY;
- for k in 0..=100 {
- let w = ramp(STAR, k as f32 / 100.0);
- assert!(w < prev, "the size ramp must shrink monotonically");
- prev = w;
- }
- // Alpha holds at the authored 1.0 across the whole first segment (k0 and k1 are both
- // opaque) and only fades over the second — the reason the band's bright half is its
- // freshest half and the wave reads as travelling.
- near(ramp_color(0.0)[3], 1.0);
- near(ramp_color(MID)[3], 1.0);
- assert!(ramp_color(1.0)[3] < 0.01, "a dying particle is ~invisible");
- }
-}
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index 88774630..0bd008b5 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -46,7 +46,6 @@ mod area_trigger;
#[cfg(feature = "dev")]
mod asset_churn;
mod aura_visual;
-mod autocast_shine;
mod bindings;
mod blob_shadow;
mod bowstring;
@@ -606,7 +605,6 @@ pub fn run(build: BuildId) -> AppExit {
.add_plugins(minimap::MinimapPlugin)
// The pet-bar / spellbook autocast shine, drawn on the append lane from the conversion's
// parked sites — zero per-frame script-layout traffic (decision 1383, B282).
- .add_plugins(autocast_shine::AutocastShinePlugin)
// The `` widgets' M2s, rendered as tiles of one atlas and composited at the
// callback rank (decision 2008).
.add_plugins(ui_models::UiModelsPlugin)
diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs
index fb31cf7a..094d145d 100644
--- a/crates/benilla-app/src/player.rs
+++ b/crates/benilla-app/src/player.rs
@@ -35,7 +35,6 @@ use bevy::window::{CursorOptions, PrimaryWindow};
use crate::creature_anim::{move_flags, wrap_pi, BodyTwist, MovementState};
use crate::net::{ClientCommand, Embodied, NetCommands, TeleportMessage, WorldportMessage};
use crate::ui_script::InspectMode;
-use crate::ui_script::PointerOverUi;
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::AssetSet;
use benilla_world::interact::{WorldClick, WorldRightClick, WorldRightPress};
@@ -433,6 +432,18 @@ impl Plugin for PlayerPlugin {
// could nudge is not a regression baseline any more.
.run_if(not(resource_exists::)),
)
+ // **Which mouse buttons the world owns** (ledger B364) — decoded once, ahead of all
+ // three readers: `/follow`'s both-button cancel below, the look session, and the camera's
+ // input command word. `control` cannot own it, because `steer_follow` runs before
+ // `control` and would then read it a frame late.
+ .add_systems(
+ Update,
+ camera::latch_world_mouse
+ .in_set(WorldStage::Input)
+ .before(control)
+ .before(follow::steer_follow)
+ .run_if(in_state(crate::char_select::ClientState::InWorld)),
+ )
// `/follow` (decision 0890): steer the facing and decide this tick's synthesized forward
// input immediately BEFORE the controller, which folds the flag into its forward axis.
// The player's own turn input therefore runs after us and wins, which is exactly what
diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs
index 1b59056a..bba4fa70 100644
--- a/crates/benilla-app/src/player/camera.rs
+++ b/crates/benilla-app/src/player/camera.rs
@@ -8,12 +8,13 @@ use bevy::ecs::entity::EntityHashSet;
use bevy::input::mouse::AccumulatedMouseMotion;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
-use bevy::window::{CursorGrabMode, CursorOptions};
+use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
use avian3d::prelude::*;
use crate::creature_anim::wrap_pi;
use crate::net::Embodied;
+use crate::ui_script::PointerOverUi;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::interact::{WorldClick, WorldRightClick, WorldRightPress};
use benilla_world::model_fade::{
@@ -761,6 +762,11 @@ pub(crate) struct CameraControl {
pub(super) collision_distance: f32,
/// The button currently held for look, or `None`.
pub(super) look: Option,
+ /// **Which mouse buttons the world owns** this frame ([`WorldMouse`]) — the player side's one
+ /// answer to "did the UI eat that press?", written by [`latch_world_mouse`] before anything
+ /// reads a button. The look session, the camera's input command word and the both-button run
+ /// all read it instead of `ButtonInput`.
+ pub(super) world_mouse: WorldMouse,
/// Logical cursor position captured when look began, to restore on release.
pub(super) cursor_stash: Option,
/// The self-avatar's render alpha for this frame, from the camera-to-pivot distance
@@ -824,6 +830,88 @@ impl LookButton {
}
}
+/// **The mouse buttons the world owns**, latched at the press — where the player side asks
+/// about a primary *press*, instead of `ButtonInput` (ledger B364). The raw buttons
+/// stay readable for the hold and release of a gesture already claimed, which is
+/// [`run_look_session`]'s business and no one else's.
+///
+/// 1.12 never reads the mouse for any of this. It reads two *bindings* — `TurnOrAction` (right)
+/// and `CameraOrSelectOrMove` (left) — and a press a UI frame captured never dispatches them: it
+/// sets neither of `[InputControl+0x4]`'s mouse bits, engages no look session, and classifies as
+/// no [`FollowState`]. That is the observable rule, and it is why a right-click in a bag has never
+/// turned anyone. Only the look session applied it here; the command word and the both-button run
+/// read the raw buttons, so a right-click on a Who-list row was a `Turn`, whose Smart row is
+/// `(0.0, 1.0)` — an immediate return that ran to completion and swung the camera round to behind
+/// the character (B364). The same raw read had both primaries over a bag running the avatar
+/// forward, through [`super::state::forward_axis`]'s both-button term.
+///
+/// **Latched, not re-tested each frame**, because the reference latches: the down that reached the
+/// world sets the bit, and that button's *up* clears it. A level test would hand the button back
+/// to the UI mid-gesture the moment a frame appeared under the (stationary, locked) cursor — an
+/// edge on the command word from nothing the player did.
+#[derive(Default)]
+pub(super) struct WorldMouse {
+ /// Held by the world right now, indexed by [`LookButton`].
+ held: [bool; 2],
+ /// Took its DOWN edge from the world this frame, same index.
+ down: [bool; 2],
+}
+
+impl WorldMouse {
+ /// Is the world holding this button?
+ pub(super) fn held(&self, b: LookButton) -> bool {
+ self.held[b as usize]
+ }
+
+ /// Did the world take this button's DOWN edge this frame?
+ pub(super) fn down(&self, b: LookButton) -> bool {
+ self.down[b as usize]
+ }
+
+ /// Both primaries in the world's hand — vanilla's both-button run, minus the presses the UI ate.
+ pub(super) fn both(&self) -> bool {
+ self.held(LookButton::Right) && self.held(LookButton::Left)
+ }
+
+ /// Latch this frame. `world_press` says whether a DOWN edge *now* belongs to the world; the
+ /// held bits then ride to their own release, whatever the cursor is over by then — including
+ /// the release a cover synthesises by emptying the button planes, which is what keeps a
+ /// loading screen from stranding a latched bit.
+ fn update(&mut self, buttons: &ButtonInput, world_press: bool) {
+ for b in [LookButton::Right, LookButton::Left] {
+ let i = b as usize;
+ self.down[i] = world_press && buttons.just_pressed(b.button());
+ self.held[i] = (self.held[i] || self.down[i]) && buttons.pressed(b.button());
+ }
+ }
+}
+
+/// Decide, once per frame, which mouse buttons the world owns — [`CameraControl::world_mouse`].
+///
+/// **Its own system**, ahead of every reader, rather than a call inside [`super::control`]: the
+/// readers are not all in the controller. The look session and the camera's command word are, but
+/// `/follow`'s both-button cancel ([`super::follow::steer_follow`]) is a system that runs *before*
+/// it — reading a latch the controller wrote would put that cancel a frame behind, and a frame
+/// late on an edge-driven cancel is the wrong frame entirely.
+///
+/// A press belongs to the world when it lands in the viewport off the UI — or whenever a look
+/// session already owns the (hidden, locked) cursor, because the second button of a chord joins a
+/// gesture the world already has.
+pub(super) fn latch_world_mouse(
+ buttons: Res>,
+ pointer_over_ui: Res,
+ mut rig: ResMut,
+ cameras: Query<&Camera, With>,
+ window: Single<&Window, With>,
+) {
+ let Ok(camera) = cameras.single() else {
+ return;
+ };
+ let world_press =
+ rig.look.is_some() || (cursor_in_viewport(&window, camera) && !pointer_over_ui.0);
+ rig.world_mouse.update(&buttons, world_press);
+}
+
#[derive(Component)]
// `pub(crate)` on the TYPE only — the scripted camera park has to name it in a query. Its fields
// stay `pub(super)`; [`FlyCam::park`] is the whole surface an instrument gets (decision 1174).
@@ -875,8 +963,6 @@ pub(super) fn run_look_session(
face_yaw: &mut f32,
window: &mut Window,
cursor_opts: &mut CursorOptions,
- camera: &Camera,
- pointer_over_ui: bool,
inspect_enabled: bool,
// A left press this frame the UI already consumed as a cursor-payload world drop (0216 §3) —
// the left click test must yield to it exactly as it yields to a UI hover, so dropping a held
@@ -892,19 +978,19 @@ pub(super) fn run_look_session(
now: f32,
) {
// The right button's DOWN edge, before any click-vs-drag classification — the reference's
- // WorldFrame OnMouseDown fires at the press whether it becomes a click or a turn. It belongs
- // to the world when the press lands in the viewport off the UI, or whenever a look session
- // already owns the (hidden, locked) cursor — a right join into a left-orbit is still a world
- // press. Ground-targeting's cancel reads this edge (decision 0792).
- if buttons.just_pressed(MouseButton::Right)
- && (rig.look.is_some() || (cursor_in_viewport(window, camera) && !pointer_over_ui))
- {
+ // WorldFrame OnMouseDown fires at the press whether it becomes a click or a turn. Whether the
+ // press was the world's at all is [`latch_world_mouse`]'s single answer, shared with the
+ // camera's command word: the viewport off the UI, or a right join into a left-orbit, whose
+ // session already owns the cursor. Ground-targeting's cancel reads this edge (decision 0792).
+ if rig.world_mouse.down(LookButton::Right) {
world_right_press.write(WorldRightPress);
}
// A chord — both primaries down — is a both-button run, never a select. The reference kills the
// pending click on the *second* press and refuses to arm a new one while another primary is held
// (`0x514ac1`, `0x51481a`), so neither release of a chord can dispatch. Cancel both tests.
- if buttons.pressed(MouseButton::Left) && buttons.pressed(MouseButton::Right) {
+ // The world's pair, not the device's: what those two sites test is the *binding* state, so a
+ // primary a UI frame is holding has never been half of a chord (ledger B364).
+ if rig.world_mouse.both() {
*left_click = None;
*right_click = None;
}
@@ -947,14 +1033,16 @@ pub(super) fn run_look_session(
}
}
}
- // The latched button went up. If the *other* look button is still held (both-button run
- // → single-button), hand the look session off to it rather than ending it — vanilla keeps
- // turning/orbiting seamlessly on the remaining button, cursor staying hidden throughout.
+ // The latched button went up. If the *other* look button is still held **by the
+ // world** (both-button run → single-button), hand the look session off to it rather
+ // than ending it — vanilla keeps turning/orbiting seamlessly on the remaining button,
+ // cursor staying hidden throughout. A primary the UI is holding is not a candidate:
+ // its binding never fired, so the reference has nothing to hand off to (B364).
let other = match active {
LookButton::Right => LookButton::Left,
LookButton::Left => LookButton::Right,
};
- if buttons.pressed(other.button()) {
+ if rig.world_mouse.held(other) {
rig.look = Some(other);
} else {
rig.look = None;
@@ -967,18 +1055,19 @@ pub(super) fn run_look_session(
}
}
} else {
- // A press over the egui dev UI (the overlaid debug panel, the perf pill) or outside the world
- // viewport is not ours — this keeps a slider-drag from grabbing the cursor into mouse-look.
- let world_press = cursor_in_viewport(window, camera) && !pointer_over_ui;
+ // A press over the egui dev UI (the overlaid debug panel, the perf pill), over a
+ // mouse-enabled player frame, or outside the world viewport is not ours — the whole content
+ // of [`WorldMouse`], and what keeps a slider-drag from grabbing the cursor into mouse-look.
// Right-drag turn. Arms its context-click test too; not armed when left is already down (a
// chord is never a click).
- if buttons.just_pressed(MouseButton::Right) && world_press {
+ if rig.world_mouse.down(LookButton::Right) {
rig.look = Some(LookButton::Right);
rig.cursor_stash = window.cursor_position();
cursor_opts.grab_mode = CursorGrabMode::Locked;
cursor_opts.visible = false;
- *right_click = (!buttons.pressed(MouseButton::Left)).then(|| PressGesture::new(now));
- } else if buttons.just_pressed(MouseButton::Left) && world_press && !inspect_enabled {
+ *right_click =
+ (!rig.world_mouse.held(LookButton::Left)).then(|| PressGesture::new(now));
+ } else if rig.world_mouse.down(LookButton::Left) && !inspect_enabled {
// Left-drag orbit — engaged on the press, exactly like right, because the reference
// engages on the press (`0x51491f`). The select is not deferred behind it; it rides
// along and settles at the release. While the inspector is armed left belongs to it
@@ -990,7 +1079,7 @@ pub(super) fn run_look_session(
// A press the UI already consumed as a cursor-payload world drop (0216 §3) still orbits
// — the reference's orbit is unconditional on the down edge — but must not also select.
*right_click = None;
- *left_click = (!click_consumed && !buttons.pressed(MouseButton::Right))
+ *left_click = (!click_consumed && !rig.world_mouse.held(LookButton::Right))
.then(|| PressGesture::new(now));
}
}
@@ -1867,6 +1956,109 @@ mod tests {
);
}
+ /// **B364** (MarcusAga): right-clicking a row of the Who list swung the camera round to
+ /// behind the character — over ~180°, on a body that never turned. The click itself was the
+ /// UI's (1816's no-fall-through hit test opened the dropdown); only the camera's command word
+ /// saw it, because the word's two mouse bits were built from `ButtonInput` with no UI term.
+ /// `RIGHT_MOUSE` alone is a [`FollowState::Turn`], and Smart's Turn row is `(0.0, 1.0)` — no
+ /// delay, full factor — so the return armed on the press edge and ran to completion.
+ ///
+ /// The decode is pinned end to end, from the latch to the row the classifier picks, because
+ /// each half was individually right: the classifier is the reference's (the test below), and
+ /// the look session's own gate was already there. What was missing was the word reading the
+ /// same gate.
+ #[test]
+ fn a_press_the_ui_ate_never_reaches_the_camera_command_word() {
+ let word = |world_press: bool| {
+ let mut rig = CameraControl::default();
+ let mut buttons = ButtonInput::::default();
+ buttons.press(MouseButton::Right);
+ rig.world_mouse.update(&buttons, world_press);
+ super::super::input::look_input(
+ &crate::bindings::BindingsState::default(),
+ &super::super::Player::default(),
+ &rig,
+ )
+ .follow_command
+ };
+ let classify = |command| {
+ FollowInput {
+ cfg: FollowConfig::default(),
+ face_yaw: 0.0,
+ command,
+ }
+ .state(false)
+ };
+
+ // The report: the press landed on a UI row.
+ assert_eq!(word(false), 0, "a captured press sets no mouse bit");
+ assert_eq!(classify(word(false)), FollowState::Idle);
+ assert_eq!(
+ FollowStyle::Smart.row(FollowState::Idle),
+ (0.0, 0.0),
+ "and Idle is the row that arms nothing — the swing has no source"
+ );
+
+ // The control that must not change: the same press in the world still turns.
+ assert_eq!(word(true), follow_cmd::RIGHT_MOUSE);
+ assert_eq!(classify(word(true)), FollowState::Turn);
+ assert_eq!(FollowStyle::Smart.row(FollowState::Turn), (0.0, 1.0));
+ }
+
+ /// The latch's own law: a button is claimed at the DOWN edge and held to its own release.
+ ///
+ /// Level-testing "is the cursor over UI *right now*" instead would drop the bit mid-drag the
+ /// moment a frame appeared under the (stationary, locked) cursor — a phantom edge on the
+ /// command word, which is exactly what arms a return. And the second button of a chord has to
+ /// join: the reference's both-button run is both bindings held, and the second press lands on
+ /// a cursor the first one already hid.
+ #[test]
+ fn the_world_holds_a_button_from_its_press_to_its_release() {
+ let mut rig = CameraControl::default();
+ let mut buttons = ButtonInput::::default();
+
+ // Pressed over a UI row: never claimed, and no amount of later frames claims it.
+ buttons.press(MouseButton::Right);
+ rig.world_mouse.update(&buttons, false);
+ assert!(!rig.world_mouse.held(LookButton::Right));
+ buttons.clear();
+ rig.world_mouse.update(&buttons, true);
+ assert!(
+ !rig.world_mouse.held(LookButton::Right),
+ "a press the UI ate is never handed back mid-hold"
+ );
+ buttons.release(MouseButton::Right);
+ buttons.clear();
+
+ // Pressed in the world: claimed, and it survives the UI arriving under the locked cursor.
+ buttons.press(MouseButton::Right);
+ rig.world_mouse.update(&buttons, true);
+ assert!(rig.world_mouse.down(LookButton::Right));
+ buttons.clear();
+ rig.world_mouse.update(&buttons, false);
+ assert!(rig.world_mouse.held(LookButton::Right));
+ assert!(
+ !rig.world_mouse.down(LookButton::Right),
+ "the edge is one frame"
+ );
+
+ // The chord's second button joins the gesture the world already holds…
+ buttons.press(MouseButton::Left);
+ rig.world_mouse.update(&buttons, true);
+ assert!(
+ rig.world_mouse.both(),
+ "both primaries = the both-button run"
+ );
+
+ // …and the release is what ends it — including the synthetic one a cover produces by
+ // emptying the button planes, which is a release of both without a `just_released`.
+ buttons = ButtonInput::::default();
+ rig.world_mouse.update(&buttons, false);
+ assert!(!rig.world_mouse.both());
+ assert!(!rig.world_mouse.held(LookButton::Right));
+ assert!(!rig.world_mouse.held(LookButton::Left));
+ }
+
/// The state classifier's three vanilla input rules (wow-re `camera-smooth-style.md` §6.2) —
/// the ones a client that keys off character *velocity* cannot reproduce, because they are
/// read off the camera's own command word: right-mouse alone is a `Turn`, a turn key held
diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs
index d56f4cf6..a37bc08a 100644
--- a/crates/benilla-app/src/player/controller.rs
+++ b/crates/benilla-app/src/player/controller.rs
@@ -80,7 +80,6 @@ pub(super) fn control(
Res,
Res,
Res,
- Res,
Res,
Res,
Res,
@@ -108,7 +107,7 @@ pub(super) fn control(
mut rig: ResMut,
// Avian's kinematic move-and-slide: sweeps the capsule against the streamed colliders (decision 0009).
collide: benilla_world::collision::WorldCollision,
- mut cameras: Query<(&mut Transform, &mut FlyCam, &Camera)>,
+ mut cameras: Query<(&mut Transform, &mut FlyCam), With>,
// **The body in our hands** — see [`BodyQuery`] for what rides on it and why a possessed
// creature needs nothing special here.
mut body: BodyQuery,
@@ -140,27 +139,26 @@ pub(super) fn control(
) {
let (world, transports, child_of) = (&world_q.0, &world_q.1, &world_q.2);
let (left_click, right_click) = (&mut *click_test.0, &mut *click_test.1);
- let Ok((mut cam_t, mut cam, camera)) = cameras.single_mut() else {
+ let Ok((mut cam_t, mut cam)) = cameras.single_mut() else {
return;
};
let (mut window, mut cursor_opts) = window.into_inner();
let mouse_motion = &pointer.0;
let look_cfg = *pointer.1;
let zoom_max = pointer.2.max;
- let (move_speed, capsule, cam_probe, pointer_over_ui, inspect, ui_capture, click_consumed) = (
+ let (move_speed, capsule, cam_probe, inspect, ui_capture, click_consumed) = (
&speed_capsule.0,
&speed_capsule.1 .0,
&speed_capsule.2 .0,
&speed_capsule.3,
&speed_capsule.4,
&speed_capsule.5,
- &speed_capsule.6,
);
- let binds = &speed_capsule.7;
- let view_subject = &speed_capsule.8;
- let self_guid = speed_capsule.9 .0;
- let scoped = &speed_capsule.10;
- let covered = speed_capsule.11.covering();
+ let binds = &speed_capsule.6;
+ let view_subject = &speed_capsule.7;
+ let self_guid = speed_capsule.8 .0;
+ let scoped = &speed_capsule.9;
+ let covered = speed_capsule.10.covering();
// The auto-follow knobs (decisions 1493/1502), with far sight's one exception folded in here so
// both camera seats below agree: while the rig orbits somebody ELSE's body (Mind Vision, Sentry
// Totem), our own facing is not what "behind" means, so the return is forced off rather than
@@ -190,13 +188,18 @@ pub(super) fn control(
// anymore. Nothing here reads a bare key any more (decision 1043) — the free-fly toggle is on
// the dev chord, and the Ctrl run boost is gone.
+ // Which mouse buttons the world owns this frame is [`camera::latch_world_mouse`]'s, decided
+ // in a system ahead of this one (ledger B364). Every *press* below is taken from that latch —
+ // the look session's engage, the camera's command word, the both-button run — so a press the
+ // UI ate reaches none of them. The raw buttons are still read below, but only for the hold and
+ // release of a gesture the world already owns.
// The both-button state and the camera's input command word ([`input::look_input`]) — read
// here, before the look session, because the not-driving path below seats its camera from the
// same word and returns without ever reaching the movement axes.
let input::LookInput {
both_buttons,
follow_command,
- } = input::look_input(&buttons, binds, &player);
+ } = input::look_input(binds, &player, &rig);
// The look session gets a SHADOW copy of `CursorOptions`, written back only on a real change:
// handing it the component's `Mut` directly reborrowed mutably every frame, which marks it
@@ -292,8 +295,6 @@ pub(super) fn control(
&mut player.face_yaw,
&mut window,
&mut opts_shadow,
- camera,
- pointer_over_ui.0,
inspect.enabled,
click_consumed.0,
&mut world_clicks.0,
diff --git a/crates/benilla-app/src/player/follow.rs b/crates/benilla-app/src/player/follow.rs
index 88950536..6c77a07b 100644
--- a/crates/benilla-app/src/player/follow.rs
+++ b/crates/benilla-app/src/player/follow.rs
@@ -255,9 +255,12 @@ pub(super) struct FollowInput<'w, 's> {
/// It already carries the typing gate this struct used to hold `UiKeyboardCapture` for
/// (typing "we should go" in chat must not break the follow).
binds: Res<'w, crate::bindings::BindingsState>,
- /// The camera rig, for the mouse-look level. Read one frame behind `control`'s own update,
+ /// The camera rig, for the mouse-look level and the world's own mouse buttons
+ /// ([`super::camera::WorldMouse`]). The look level is read one frame behind `control`'s own update,
/// which is deliberate and harmless: the cancel then lands on the frame *after* the look
- /// begins, versus the reference's same-frame commit.
+ /// begins, versus the reference's same-frame commit. The button latch is **not** behind —
+ /// [`super::camera::latch_world_mouse`] is ordered ahead of this system precisely so the both-button
+ /// cancel below sees this frame's edge.
rig: Res<'w, CameraControl>,
/// The **mover's** descriptor block, for the terms of the cancel set that are not inputs:
/// its health and its stun. See [`FollowInput::input_torn_down`].
@@ -341,10 +344,12 @@ pub(super) fn steer_follow(
return;
}
// ── The cancel set ── before anything else this frame (see the doc above).
- let both_engaged = input.buttons.pressed(MouseButton::Left)
- && input.buttons.pressed(MouseButton::Right)
- && (input.buttons.just_pressed(MouseButton::Left)
- || input.buttons.just_pressed(MouseButton::Right));
+ // Off the WORLD's buttons ([`super::camera::WorldMouse`], ledger B364), not the device's: the
+ // reference's both-button run is its two bindings held, and a press a UI frame captured
+ // dispatches neither — so two primaries pressed over a bag are two clicks, not a cancel.
+ let both_engaged = input.rig.world_mouse.both()
+ && (input.rig.world_mouse.down(LookButton::Left)
+ || input.rig.world_mouse.down(LookButton::Right));
if follow_cancelled(
input.move_start(),
// The ON edge only: `control` toggles `autorun` AFTER this runs, so the flag we read here
diff --git a/crates/benilla-app/src/player/input.rs b/crates/benilla-app/src/player/input.rs
index b4f921b7..970fc324 100644
--- a/crates/benilla-app/src/player/input.rs
+++ b/crates/benilla-app/src/player/input.rs
@@ -4,7 +4,10 @@
//! - [`look_input`] — the both-button state and the reference's own **camera command word**
//! (1.12's `[InputControl+0x4]`, decision 1502), which the auto-follow is armed by. Read
//! *before* the look session, because both camera seats want it — including the one on the
-//! not-driving path, which returns before the axes below are ever computed.
+//! not-driving path, which returns before the axes below are ever computed. Its two mouse
+//! terms come off [`camera::WorldMouse`], never `ButtonInput`: the reference's mouse bits are
+//! two *bindings* the UI can eat, and reading the device instead swung the camera on every
+//! right-click of a UI row (ledger B364).
//! - [`move_axes`] — the **netted** forward/back and strafe axes plus the modes that select
//! between them (mouselook, keyboard turn), and the autorun latch with its cancel set. Netted
//! once here so that the direction we move, the speed we pick, the swim amounts and the flags we
@@ -27,19 +30,24 @@ pub(super) struct LookInput {
/// The mouse/binding state the camera needs, built before the look session so the not-driving
/// path (which returns without ever reaching [`move_axes`]) can seat its camera from the same word.
+///
+/// Takes the rig for one field only — [`camera::WorldMouse`], already latched this frame by
+/// [`camera::latch_world_mouse`]. There is deliberately no `ButtonInput` parameter left: every
+/// mouse fact here is one the UI gets first refusal on.
pub(super) fn look_input(
- buttons: &ButtonInput,
binds: &crate::bindings::BindingsState,
player: &Player,
+ rig: &CameraControl,
) -> LookInput {
// Both mouse buttons held together = vanilla's "both-button run": the avatar runs forward while
// the character steers with the mouse (turns like a right-drag), regardless of which button went
- // down first. Checked directly here rather than through the controller's single-button look mode.
+ // down first. Off the world-owned pair, not the device's: both primaries pressed over a bag are
+ // two clicks the UI took, and the reference's run is its two *bindings* held, neither of which
+ // a captured press dispatches.
// MOVEANDSTEER (default Middle Mouse) is the same state through a binding — 1.12's own body
// runs the identical CameraOrSelectOrMove + TurnOrAction pair a both-button press does.
let steer_held = binds.pressed(crate::bindings::cmd::MOVE_AND_STEER);
- let both_buttons =
- (buttons.pressed(MouseButton::Left) && buttons.pressed(MouseButton::Right)) || steer_held;
+ let both_buttons = rig.world_mouse.both() || steer_held;
// The camera's **input command word** (decision 1502) — 1.12's `[InputControl+0x4]`, bit for
// bit. The auto-follow is armed by *edges on this word* and its state is classified from it, so
@@ -55,11 +63,11 @@ pub(super) fn look_input(
}
};
set(
- buttons.pressed(MouseButton::Right) || steer_held,
+ rig.world_mouse.held(LookButton::Right) || steer_held,
bit::RIGHT_MOUSE,
);
set(
- buttons.pressed(MouseButton::Left) || steer_held,
+ rig.world_mouse.held(LookButton::Left) || steer_held,
bit::LEFT_MOUSE,
);
// `/follow` is the forward bit in the reference too — the same setter the W key drives
@@ -187,7 +195,7 @@ pub(super) fn move_axes(
// Deliberately absent, each VERIFIED as a *survivor*: a jump, a chat EditBox taking focus, and
// a zone change. Mounting is genuinely unsettled in the reference and left alone here.
let both_buttons_engaged = (both_buttons
- && (buttons.just_pressed(MouseButton::Left) || buttons.just_pressed(MouseButton::Right)))
+ && (rig.world_mouse.down(LookButton::Left) || rig.world_mouse.down(LookButton::Right)))
|| binds.just_pressed(crate::bindings::cmd::MOVE_AND_STEER);
if state::autorun_cancelled(
binds.just_pressed(crate::bindings::cmd::MOVE_FORWARD),
diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs
index 96c3dc23..23cc7488 100644
--- a/crates/benilla-app/src/ui_chat/input/mod.rs
+++ b/crates/benilla-app/src/ui_chat/input/mod.rs
@@ -15,7 +15,7 @@ use bevy::prelude::*;
mod parse;
#[cfg(test)]
pub(super) use parse::lua_long_string;
-pub(super) use parse::{parse_line, ParsedChat};
+pub(super) use parse::{console_command, parse_line, ParsedChat};
use crate::creature_anim::{move_flags, MovementState};
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
@@ -136,6 +136,10 @@ pub(super) struct ChatOut<'w> {
/// since a resource reachable twice from one system is a `B0002` panic on the first live frame
/// (1903). Nothing else in this system touches `UiErrorKeys`.
ui_errors: ResMut<'w, crate::ui_action::UiErrorKeys>,
+ /// `/console detailDoodadAlpha` — the ground-clutter cutout reference. Rides this bundle for
+ /// the same arity reason as `ui_errors` above, and like it, nothing else in this system
+ /// touches `ClutterConfig` (a resource reachable twice from one system is a `B0002` panic).
+ clutter: ResMut<'w, benilla_world::clutter::ClutterConfig>,
}
// One parameter per concern — the chat drain fans out to every command's consumer.
@@ -167,14 +171,11 @@ fn engine_verbs(
}
for line in script.take_console_lines() {
// `ConsoleExec` already wrote the CVar lines to the store; what reaches here is a
- // console COMMAND, of which this client implements the one the reference's `/reload`
- // alias spells out.
- let cmd = line.trim().to_string();
- if cmd.eq_ignore_ascii_case("reloadui") {
- out.push((line, ParsedChat::ReloadUi));
- } else {
- out.push((line, ParsedChat::ConsoleUnknown { cmd }));
- }
+ // console COMMAND — a name the engine's own command table owns rather than
+ // `CVar::Register`'s, which is why `detailDoodadAlpha` lives here and not in the CVar
+ // store (wow-re: registrar `0x63f9e0`, so it never persists — 2012).
+ let parsed = console_command(&line);
+ out.push((line, parsed));
}
for cmd in script.take_channel_commands() {
out.push((String::new(), ParsedChat::Channel(cmd)));
@@ -916,15 +917,35 @@ pub(super) fn drain_chat_input(
ParsedChat::ReloadUi => {
script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi)
}
+ // `/console detailDoodadAlpha [0..255]` — the reference's own console command
+ // (`0x6739a0`), and the dial that decides where ground clutter first appears: the
+ // detail-doodad draw alpha-tests `texel.a x distance_ramp` against it, so 128 (the
+ // default) hides everything past ~61 yd of the 70 yd fade horizon and a lower value
+ // walks that onset out toward the horizon. Non-persistent, exactly as the reference's
+ // command table is.
+ ParsedChat::DetailDoodadAlpha { value } => {
+ let text = match value {
+ Some(v) => {
+ chat_out.clutter.alpha_ref = f32::from(v) / 255.0;
+ format!("detailDoodadAlpha set to {v}")
+ }
+ None => format!(
+ "detailDoodadAlpha is {} (usage: /console detailDoodadAlpha 0-255)",
+ (chat_out.clutter.alpha_ref * 255.0).round() as u32
+ ),
+ };
+ chat_log.push_event(super::event::ChatEvent::text_only(
+ super::event::ChatEventKind::System,
+ text,
+ ));
+ }
ParsedChat::ConsoleUnknown { cmd } => {
+ const IMPLEMENTED: &str = "reloadUI, detailDoodadAlpha, and ` `";
let text = if cmd.is_empty() {
- "console: no command given (this client implements: reloadUI, and \
- ` `)"
- .to_string()
+ format!("console: no command given (this client implements: {IMPLEMENTED})")
} else {
format!(
- "console: '{cmd}' is not implemented (this client implements: reloadUI, \
- and ` `)"
+ "console: '{cmd}' is not implemented (this client implements: {IMPLEMENTED})"
)
};
chat_log.push_event(super::event::ChatEvent::text_only(
diff --git a/crates/benilla-app/src/ui_chat/input/parse.rs b/crates/benilla-app/src/ui_chat/input/parse.rs
index dcf7057c..32a884c2 100644
--- a/crates/benilla-app/src/ui_chat/input/parse.rs
+++ b/crates/benilla-app/src/ui_chat/input/parse.rs
@@ -171,8 +171,16 @@ pub(in crate::ui_chat) enum ParsedChat {
/// session seam like [`ParsedChat::Logout`]; [`crate::ui_script::run_pending_reload`] runs it
/// at the top of the next frame.
ReloadUi,
- /// A `/console` command that is not `reloadUI` — the engine console this client does not
- /// have. Answered with a system line naming what was asked, because a claimed alias that
+ /// `/console detailDoodadAlpha [0..255]` — the **ground-clutter cutout reference**, a real
+ /// console command in the reference (`0x6739a0`; registrar `0x63f9e0`, a command table and not
+ /// `CVar::Register`, so it never persists — 1804 does not apply to it). It decides where grass
+ /// first appears: the detail-doodad draw alpha-tests `texel.a x distance_ramp` against this, so
+ /// at the default 128 nothing survives past ~61 yd of the 70 yd horizon, and lowering it walks
+ /// that onset out toward the horizon. Bare = report the current value (the reference reads an
+ /// uninitialised stack slot there; a readout is the useful reading of "no argument").
+ DetailDoodadAlpha { value: Option },
+ /// A `/console` command this client does not implement — the engine console is otherwise not
+ /// here. Answered with a system line naming what was asked, because a claimed alias that
/// silently drops its argument reads as a hang.
ConsoleUnknown { cmd: String },
/// A slash line matching neither a chat command nor an `EmotesText` name — dropped.
@@ -242,6 +250,27 @@ pub(in crate::ui_chat) fn lua_long_string(s: &str) -> String {
/// The per-command argument grammar. Each arm is the reference handler's own body reduced to what
/// it does with `msg` — the aliases that reach it are the table's business, never this function's.
+/// Classify one **console command** line — what is left after `ConsoleExec` has written the CVar
+/// lines (2008), i.e. a name the engine's own command table owns rather than `CVar::Register`'s.
+/// Pure, so the arms are testable without a VM; called from `engine_verbs`'s console drain.
+pub(in crate::ui_chat) fn console_command(line: &str) -> ParsedChat {
+ let mut words = line.split_whitespace();
+ match words.next() {
+ Some(cmd) if cmd.eq_ignore_ascii_case("reloadui") => ParsedChat::ReloadUi,
+ // The reference rejects an out-of-range value rather than saturating
+ // (`0x6739b9: cmp eax,0xff; jbe`), so out-of-range and unparseable are the same `None`
+ // here and the handler says so. A bare name reports the current value.
+ Some(cmd) if cmd.eq_ignore_ascii_case("detaildoodadalpha") => {
+ ParsedChat::DetailDoodadAlpha {
+ value: words.next().and_then(|v| v.parse::().ok()),
+ }
+ }
+ _ => ParsedChat::ConsoleUnknown {
+ cmd: line.trim().to_string(),
+ },
+ }
+}
+
fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
use SlashIndex as S;
match index {
diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs
index 09d2839f..5c438b40 100644
--- a/crates/benilla-app/src/ui_chat/tests.rs
+++ b/crates/benilla-app/src/ui_chat/tests.rs
@@ -2186,3 +2186,44 @@ fn level_up_gains_are_matched_by_level_and_a_miss_is_not_an_absence() {
"the entry was taken, not copied"
);
}
+
+/// `detailDoodadAlpha` — the reference's own console command (`0x6739a0`), and the dial that
+/// decides where ground clutter first appears (the detail-doodad draw alpha-tests
+/// `texel.a × distance_ramp` against it). It is a **console command**, not a CVar (registrar
+/// `0x63f9e0`, not `CVar::Register`), so it arrives through the console drain rather than the
+/// CVar store `ConsoleExec` writes — this pins the classifier that splits the two. The three
+/// cases the reference distinguishes: in range sets it, out of range is REJECTED rather than
+/// saturated (`0x6739b9: cmp eax,0xff; jbe`), and unparseable is that same rejection.
+#[test]
+fn console_detail_doodad_alpha_parses() {
+ use super::input::{console_command, ParsedChat};
+ assert_eq!(
+ console_command("detailDoodadAlpha 8"),
+ ParsedChat::DetailDoodadAlpha { value: Some(8) }
+ );
+ // The console is case-insensitive, like the engine's own table lookup.
+ assert_eq!(
+ console_command("DETAILDOODADALPHA 255"),
+ ParsedChat::DetailDoodadAlpha { value: Some(255) }
+ );
+ assert_eq!(
+ console_command("detailDoodadAlpha 256"),
+ ParsedChat::DetailDoodadAlpha { value: None }
+ );
+ assert_eq!(
+ console_command("detailDoodadAlpha wat"),
+ ParsedChat::DetailDoodadAlpha { value: None }
+ );
+ // Bare = report the current value; and the sibling command still routes.
+ assert_eq!(
+ console_command("detailDoodadAlpha"),
+ ParsedChat::DetailDoodadAlpha { value: None }
+ );
+ assert_eq!(console_command("reloadUI"), ParsedChat::ReloadUi);
+ assert_eq!(
+ console_command("nosuchthing"),
+ ParsedChat::ConsoleUnknown {
+ cmd: "nosuchthing".to_string()
+ }
+ );
+}
diff --git a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs
index 9fec8868..de48510b 100644
--- a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs
+++ b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs
@@ -79,7 +79,7 @@ fn shipped_xml() -> Vec<(String, String)> {
out.sort();
// A sanity floor for the walk, not a census: `assets/ui` retires file by file (1751), so
// the floor sits well under the count rather than one step above it (1956).
- assert!(out.len() >= 10, "only {} xml files swept", out.len());
+ assert!(out.len() >= 6, "only {} xml files swept", out.len());
out
}
diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs
index 720b025b..4d3fb944 100644
--- a/crates/benilla-app/src/ui_script/extract/mod.rs
+++ b/crates/benilla-app/src/ui_script/extract/mod.rs
@@ -280,7 +280,7 @@ struct Alignment {
/// `None` — it must be converted. Kept indices are strictly increasing.
source: Vec>,
/// The OLD entries nothing reuses. Their quads leave the list, so their conversion's side
- /// effects (a parked minimap slot, a shine site, a link span) would have to be *undone* — the
+ /// effects (a parked minimap slot, a link span, a tile request) would have to be *undone* — the
/// splice cannot, so it only proceeds when every one of them was quads-only.
dropped: Vec,
}
@@ -348,10 +348,9 @@ fn span_bounds(spans: &[u32], i: usize) -> (usize, usize) {
}
/// Whether an entry's conversion writes **quads and nothing else** — the splice's admission test.
-/// The full path plumbs four side channels (the engine's link spans, the parked minimap slot, the
-/// shine sites, the booth panes); the splice keeps last frame's values for all of them, which is
-/// only sound for entries that never write one. The autocast-shine token (decision 1383) is a
-/// side-channel kind wearing a Texture's clothes, so it is excluded by path.
+/// The full path plumbs three side channels (the engine's link spans, the parked minimap slot,
+/// the booth panes and tile requests); the splice keeps last frame's values for all of them,
+/// which is only sound for entries that never write one, or write them idempotently.
fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
match &eq.content {
QuadContent::Frame
@@ -374,11 +373,8 @@ fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
QuadContent::Text { .. } => matches!(eq.target, benilla_ui::order::ZTarget::Region(_)),
QuadContent::Texture {
portrait_unit: None,
- path,
..
- } => !path
- .as_deref()
- .is_some_and(|p| crate::autocast_shine::token_model_scale(p).is_some()),
+ } => true,
_ => false,
}
}
@@ -413,7 +409,7 @@ fn verify_splice(
) {
let mut check: Vec = Vec::with_capacity(quads.quads.len());
let mut spans: Vec = Vec::with_capacity(prev.extracted.len());
- let (mut links, mut slot, mut shine) = (Vec::new(), None, Vec::new());
+ let (mut links, mut slot) = (Vec::new(), None);
for eq in prev.extracted.iter().cloned() {
convert_entry(
eq,
@@ -429,7 +425,6 @@ fn verify_splice(
&mut check,
&mut links,
&mut slot,
- &mut shine,
);
spans.push(check.len() as u32);
}
@@ -528,16 +523,10 @@ pub(super) fn drive_script(
Option>,
Res,
),
- // The append-lane holes this pass parks for their UiQuadAppend producers, tupled for the
- // same param-budget squeeze as `run`:
- // · the `` widget slot (`minimap::emit_minimap` fills it with tile/arrow quads —
- // decision 0203 phase 1);
- // · the autocast shine sites (`autocast_shine::emit_shine` draws the spark trails there —
- // decision 1383, B282).
- mut parked: (
- ResMut,
- ResMut,
- ),
+ // The append-lane hole this pass parks for its UiQuadAppend producer: the `` widget
+ // slot (`minimap::emit_minimap` fills it with tile/arrow quads — decision 0203 phase 1). (The
+ // autocast shine's sites used to ride beside it; the shine is a model tile since 2014.)
+ mut parked_minimap: ResMut,
// The seam scale the engine's text-metric caches were answered under. When `s` moves (window
// resize / fullscreen toggle / uiScale change), every cached measure is stale — see the
// invalidation below.
@@ -781,9 +770,6 @@ pub(super) fn drive_script(
let mut out = Vec::new();
let mut assets = world_assets;
let mut minimap_slot = None;
- // This frame's autocast shine sites (decision 1383) — refilled by the loop below like the
- // minimap slot; the settled and spliced paths above keep last conversion's set.
- let mut shine_sites: Vec = Vec::new();
// Hyperlink spans collected while rasterizing message-line Text quads (frame-targeted only —
// chat lines; FontString-region links are a later arc), fed back to the engine's click
// hit-test after the loop. Rects flip back to the engine's y-up space.
@@ -838,7 +824,7 @@ pub(super) fn drive_script(
// Every input the conversion below reads, compared against last frame's. Equal inputs make
// the loop a pure re-derivation of `UiQuads` the diff at the bottom would discard — at the
// LBRS pin that was every single settled frame, ~0.3 ms/frame of glyph re-rasterization for
- // an identical `Vec`. On a skip, `quads`/the parked minimap slot and shine sites/the
+ // an identical `Vec`. On a skip, `quads`/the parked minimap slot/the
// engine's link spans all keep last frame's values, which the equal inputs prove are this
// frame's values too.
let dims = (w.to_bits(), h.to_bits(), s.to_bits(), dpi.to_bits());
@@ -973,7 +959,7 @@ pub(super) fn drive_script(
no_splice!("no entry differs (a comparison razor slipped)");
}
// Checked on both ends: every entry being converted, and every entry LEAVING the list —
- // a departing minimap slot or shine site would have to be un-parked, which the splice has
+ // a departing minimap slot would have to be un-parked, which the splice has
// no way to do.
if let Some(eq) = changed
.iter()
@@ -994,7 +980,6 @@ pub(super) fn drive_script(
let mut scratch_ranges: Vec> = Vec::with_capacity(changed.len());
let mut scratch_links = Vec::new();
let mut scratch_slot = None;
- let mut scratch_shine = Vec::new();
for &j in &changed {
let at = scratch.len();
convert_entry(
@@ -1011,11 +996,10 @@ pub(super) fn drive_script(
&mut scratch,
&mut scratch_links,
&mut scratch_slot,
- &mut scratch_shine,
);
scratch_ranges.push(at..scratch.len());
}
- if !scratch_links.is_empty() || scratch_slot.is_some() || !scratch_shine.is_empty() {
+ if !scratch_links.is_empty() || scratch_slot.is_some() {
no_splice!("a re-converted entry wrote a side channel");
}
// The in-place case: nothing was inserted or removed, so every kept entry is still at
@@ -1164,7 +1148,6 @@ pub(super) fn drive_script(
&mut out,
&mut link_spans,
&mut minimap_slot,
- &mut shine_sites,
);
spans.push(out.len() as u32);
}
@@ -1203,11 +1186,9 @@ pub(super) fn drive_script(
// (`OnHyperlinkClick`) hit-tests against exactly what is on screen.
script.set_link_spans(link_spans);
- // Park this frame's Minimap widget slot and autocast shine sites (or clear them — a hidden
- // cluster extracts nothing); `minimap::emit_minimap` / `autocast_shine::emit_shine` run
- // later in the frame (UiQuadAppend) and fill the holes.
- parked.0 .0 = minimap_slot;
- parked.1 .0 = shine_sites;
+ // Park this frame's Minimap widget slot (or clear it — a hidden cluster extracts nothing);
+ // `minimap::emit_minimap` runs later in the frame (UiQuadAppend) and fills the hole.
+ parked_minimap.0 = minimap_slot;
drop(extract_span);
let us_exa = lap();
@@ -1272,8 +1253,8 @@ pub(super) fn drive_script(
/// One extracted entry converted to its screen quads, pushed onto `out` — with the side channels
/// some arms carry: chat link spans (Text), the minimap widget slot (Minimap), the booth pane
-/// aspects (portrait-bound Texture), the autocast shine sites (the token-path Texture, decision
-/// 1383). The ONE conversion body: the full pass and the per-entry
+/// aspects (portrait-bound Texture), the model tile requests (ModelPane, decision 2013). The ONE
+/// conversion body: the full pass and the per-entry
/// splice both call this, which is what makes the splice's output equal the full path's by
/// construction. An arm is splice-eligible only if it writes nothing but `out` — keep
/// [`splice_simple`] in agreement when an arm's side effects change.
@@ -1282,8 +1263,8 @@ fn convert_entry(
eq: benilla_ui::script::ExtractedQuad,
s: f32,
// The window, logical px. `h` flips y-up WoW space into the y-down quad pass; `w` is here for
- // the one producer whose law is in SCREEN pixels rather than FrameXML units — the autocast
- // shine's particle size, which the reference projects by the screen diagonal (decision 1390).
+ // the one producer whose law needs the SCREEN's shape — a model tile's layout unit and
+ // particle unit run on the screen diagonal (decision 2013).
w: f32,
h: f32,
assets: &mut Option>,
@@ -1300,7 +1281,6 @@ fn convert_entry(
String,
)>,
minimap_slot: &mut Option,
- shine_sites: &mut Vec,
) {
let Some(r) = eq.rect else { return };
// WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is
@@ -1497,35 +1477,6 @@ fn convert_entry(
rotation,
desaturated,
} => {
- // The autocast-shine token (decision 1383, B282): a shown marker registers WHERE the
- // shine plays — rect, paint order, clip, alpha, and the star art resolved through
- // the same resolver as any texture — and draws nothing itself;
- // `autocast_shine::emit_shine` (UiQuadAppend) animates the sparks there with zero
- // per-frame script-layout traffic. A side-channel arm, so deliberately NOT
- // splice-simple (the `simple` predicate above excludes the token by path).
- if let Some(model_scale) = path
- .as_deref()
- .and_then(crate::autocast_shine::token_model_scale)
- {
- // A no-assets context (the extract test harness; a capture booted without the
- // archive) records the site with the default handle — the site's GEOMETRY is
- // the registration, and the live app always has the resolver.
- let star = assets
- .as_mut()
- .and_then(|a| a.sprite_texture(crate::autocast_shine::STAR_TEXTURE, images))
- .unwrap_or_default();
- shine_sites.push(crate::autocast_shine::ShineSite {
- rect,
- z: eq.z,
- clip,
- alpha: eq.alpha,
- scale: s,
- model_scale,
- diag: (w * w + h * h).sqrt(),
- texture: star,
- });
- return;
- }
// A live unit portrait (`SetPortraitTexture(region, unit)`): sample this token's
// source ([`crate::portrait::PortraitImages`]) — the off-screen model bake, or the
// ref's 2D TemporaryPortrait stand-in while the model streams in. Absent entry (no
@@ -2061,7 +2012,6 @@ mod clip_plumb_tests {
app.init_resource::();
app.init_resource::();
app.init_resource::();
- app.init_resource::();
app.init_resource::();
app.init_resource::();
app.init_resource::();
@@ -2149,7 +2099,6 @@ mod clip_plumb_tests {
app.init_resource::();
app.init_resource::();
app.init_resource::();
- app.init_resource::();
app.init_resource::();
app.init_resource::();
app.init_resource::();
@@ -2357,7 +2306,6 @@ mod extract_gate_tests {
app.init_resource::();
app.init_resource::();
app.init_resource::();
- app.init_resource::();
app.init_resource::();
app.init_resource::();
app.init_resource::();
@@ -2580,88 +2528,6 @@ mod extract_gate_tests {
);
}
- /// The autocast-shine token (decision 1383, B282): a shown marker converts to a parked SITE
- /// and zero quads; a spliced frame keeps last conversion's sites; hiding the marker reaches
- /// the full path and clears them. The site's rect/z/scale are the registration — the
- /// producer (`autocast_shine::emit_shine`) draws there with no script traffic at all.
- #[test]
- fn the_shine_token_parks_a_site_and_never_a_quad() {
- let mut app = app_from_script(
- r#"
- local b = CreateFrame("Button", "PetB")
- b:SetPoint("BOTTOMLEFT", 72, 652)
- b:SetSize(30, 30)
- local shine = b:CreateTexture(nil, "OVERLAY")
- shine:SetTexture("benilla:autocast-shine")
- shine:SetAllPoints()
- marker = b:CreateTexture(nil, "ARTWORK")
- marker:SetTexture(1, 0, 0)
- marker:SetAllPoints()
- "#,
- );
- app.update();
- {
- let sites = app.world().resource::();
- assert_eq!(sites.0.len(), 1, "one shown token, one site");
- let site = &sites.0[0];
- // 1024x768 window ⇒ the 768-virtual scale is 1.0 and y flips through 768: the
- // button's y-up [652, 682] lands at y-down [86, 116].
- assert_eq!(site.rect, Rect::new(72.0, 86.0, 102.0, 116.0));
- assert_eq!(site.scale, 1.0);
- assert!(site.clip.is_none());
- assert_eq!(site.alpha, 1.0);
- assert_eq!(
- app.world().resource::().quads.len(),
- 1,
- "the red marker's quad alone — the token itself draws nothing"
- );
- }
-
- // A paint write elsewhere splices — and the parked site survives it untouched.
- app.world_mut()
- .resource_mut::()
- .0 = true;
- app.world_mut().resource_mut::().dirty = false;
- app.world_mut()
- .non_send_resource_mut::()
- .run("marker:SetTexture(0, 0, 1)")
- .unwrap();
- app.update();
- assert_eq!(
- app.world()
- .resource::()
- .spliced,
- 1,
- "the marker write splices; the token entry did not change"
- );
- assert_eq!(
- app.world()
- .resource::()
- .0
- .len(),
- 1,
- "a spliced frame keeps last conversion's sites"
- );
-
- // Hiding the marker changes the extracted list's length — the full path runs and the
- // site set empties: the shine goes out with the region that registered it.
- app.world_mut()
- .non_send_resource_mut::()
- .run("getglobal('PetB'):Hide()")
- .unwrap();
- app.update();
- assert!(
- app.world()
- .resource::()
- .0
- .is_empty(),
- "a hidden token registers nothing"
- );
- }
-
- /// The stitch half: a write that CHANGES an entry's quad count (a cleared texture emits
- /// nothing) re-derives the span table — and a later splice against the re-derived spans
- /// still matches the full conversion, in both directions (1 → 0 quads, then 0 → 1).
#[test]
fn a_count_changing_write_stitches_and_keeps_the_spans_true() {
let mut app = app_with_marker();
diff --git a/crates/benilla-app/src/ui_script/minimap_tests.rs b/crates/benilla-app/src/ui_script/minimap_tests.rs
index 7bdbdb11..9f906777 100644
--- a/crates/benilla-app/src/ui_script/minimap_tests.rs
+++ b/crates/benilla-app/src/ui_script/minimap_tests.rs
@@ -528,7 +528,6 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
app.init_resource::();
app.init_resource::();
app.init_resource::();
- app.init_resource::();
app.init_resource::();
app.init_resource::();
app.init_resource::();
diff --git a/crates/benilla-app/src/ui_script/pet_bar_tests.rs b/crates/benilla-app/src/ui_script/pet_bar_tests.rs
index 1a521696..83e199cc 100644
--- a/crates/benilla-app/src/ui_script/pet_bar_tests.rs
+++ b/crates/benilla-app/src/ui_script/pet_bar_tests.rs
@@ -43,7 +43,6 @@ pub(super) fn load_pet_bar(s: &UiScript) {
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
"Interface\\FrameXML\\PetActionBarFrame.xml",
- "PetActionBarAdapters.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
@@ -193,19 +192,25 @@ fn the_shipped_pet_bar_drives_end_to_end() {
"Attack + Defensive are lit; Claw is not"
);
- // Autocast: the static ring on the one slot that allows it, and the shine MARKER on the one
- // slot where it is running — the native lane's registration (decision 1383): the extract
- // carries the token, never sparks, and `autocast_shine::emit_shine` draws the trails at it
- // on the append lane while the script layer stays settled.
+ // Autocast: the static ring on the one slot that allows it, and the stock shine MODEL on the
+ // one slot where it is running — `$parentAutoCast`, `UI-AutoCastButton.mdx` over the whole
+ // button at `scale="1.2"`, rendered as a tile (decisions 2013/2014). A hidden pane is not
+ // extracted, so one pane is exactly one running shine.
assert_eq!(
textures(&quads, "Interface\\Buttons\\UI-AutoCastableOverlay"),
1,
"only Claw can autocast"
);
+ let shines: Vec<_> = quads
+ .iter()
+ .filter(|q| q.rect.is_none_or(|r| (r.top + r.bottom) / 2.0 >= 50.0))
+ .filter(|q| matches!(&q.content, QuadContent::ModelPane { model: Some(p), model_scale, .. }
+ if p == "Interface\\Buttons\\UI-AutoCastButton.mdx" && (*model_scale - 1.2).abs() < 1e-6))
+ .collect();
assert_eq!(
- textures(&quads, "benilla:autocast-shine:1.2"),
+ shines.len(),
1,
- "the shine marker on Claw alone — enabled, not merely allowed"
+ "the shine pane on Claw alone — enabled, not merely allowed"
);
// Geometry, quoted from the ref: the bar's TOPLEFT is MainMenuBar's BOTTOMLEFT +(36,97),
@@ -229,7 +234,13 @@ fn the_shipped_pet_bar_drives_end_to_end() {
s.resolve();
let gone = s.extract();
assert_eq!(textures(&gone, "Interface\\PetActionBar\\UI-PetBar"), 0);
- assert_eq!(textures(&gone, "benilla:autocast-shine:1.2"), 0);
+ assert!(
+ !gone.iter().any(
+ |q| matches!(&q.content, QuadContent::ModelPane { model: Some(p), .. }
+ if p == "Interface\\Buttons\\UI-AutoCastButton.mdx")
+ ),
+ "the shine pane hides with its bar"
+ );
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -644,9 +655,9 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
-// The autocast trail's MOTION tests (the B228 corner-walk goldens, the 1321 truncating-clock
-// law, the no-seam continuity pin) live with the drawing now: `crate::autocast_shine`'s own
-// test module — the script layer no longer moves a single spark (decision 1383).
+// The autocast trail's MOTION is the model's own since decisions 2013/2014: the stock
+// `$parentAutoCast` Model's four bones and four emitters, rendered as a tile on the pane's clock
+// — the script layer never moves a spark, and no test here needs to.
/// A hunter bar hovered through the REAL gesture, with the real binding registry and the real CVar
/// table behind it — `mouse_move` runs the shipped `` with `this` bound, which is the only
diff --git a/crates/benilla-app/src/ui_script/reputation_frame_tests.rs b/crates/benilla-app/src/ui_script/reputation_frame_tests.rs
index 71f95396..035d2ce9 100644
--- a/crates/benilla-app/src/ui_script/reputation_frame_tests.rs
+++ b/crates/benilla-app/src/ui_script/reputation_frame_tests.rs
@@ -13,12 +13,13 @@
//! Steamwheedle over Booty Bay, the parentless bucket last) so a failure here reads against a shape
//! that is already pinned one layer down, and the two can be compared line for line.
//!
-//! **The window around the page is the reference's own since 1751** — `CharacterFrame.xml` and
-//! `PaperDollFrame.xml` off the player's chain — so every test here opens with
-//! `wow_data_or_skip!()` and loads [`super::test_ui::CHARACTER_UI`]. The page itself
-//! (`ReputationFrame.xml`) and the watch bar (`ActionBar.xml`) are still ours.
+//! **All of it is the reference's own now** — the window since 1751 (`CharacterFrame.xml`,
+//! `PaperDollFrame.xml`), the page itself (`ReputationFrame.xml`) with it, and the watch bar with
+//! `ActionBar.xml`'s retirement into `ActionBarFrame.xml`. So every test here opens with
+//! `wow_data_or_skip!()` and loads [`super::test_ui::CHARACTER_UI`], and what it pins is our
+//! ENGINE under stock XML rather than XML of ours.
-use benilla_ui::script::{FactionEntry, ReputationState, UiScript, UnitState};
+use benilla_ui::script::{FactionEntry, QuadContent, ReputationState, UiScript, UnitState};
/// An ordinary bar row: visible, not a header, `standing_id` 5 ("Friendly") sitting 1000 into a
/// 6000-wide rank window. The same numbers `benilla-ui`'s own fixture uses.
@@ -528,6 +529,95 @@ fn clicking_a_bar_opens_the_detail_popup_on_that_faction() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+/// **B369 — a disabled box is still a box.** The reported symptom, at the quad level.
+///
+/// `ReputationDetailAtWarCheckBox` (stock `ReputationFrame.xml`) carries a `` and
+/// **no ``**, and `ReputationFrame_Update` `Disable()`s it for every faction whose
+/// war flag cannot be toggled (`ReputationFrame.lua` l.115-120). The client's `SetState 0x779790`
+/// gates its hide-old step on the new state having a texture, so the `UI-CheckBox-Up` box stays up
+/// and the row reads "a greyed **At War** beside an empty box". Ours resolved the shown texture as
+/// a pure function of the state, hid it, and left a bare grey label with nothing beside it — which
+/// is what MarcusAga photographed on Ironforge.
+///
+/// Three rows, because the tick is the half that made the report confusing: a peace-forced faction
+/// that is NOT at war shows the empty box (the shot), a peace-forced faction that IS at war shows
+/// the box plus its grey `DisabledCheckedTexture` (*"the tick renders, however"*), and a
+/// toggleable one is the control that must not move.
+#[test]
+fn the_at_war_box_keeps_its_art_while_it_is_disabled() {
+ let _data = benilla_formats::wow_data_or_skip!();
+ let mut s = UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ load_page(&s);
+ s.set_unit("player", Some(player(40)));
+ let mut st = state();
+ for e in &mut st.entries {
+ match e.name.as_str() {
+ // The report: peace-forced and at peace.
+ "Ironforge" => e.can_toggle_at_war = false,
+ // Peace-forced and at war — the same box, wearing the grey tick.
+ "Stormwind" => {
+ e.can_toggle_at_war = false;
+ e.at_war = true;
+ }
+ _ => {}
+ }
+ }
+ s.set_reputation(st);
+ s.run(r#"ToggleCharacter("ReputationFrame")"#).unwrap();
+ s.resolve();
+
+ /// Every texture the At War box itself draws, in painter order.
+ fn box_art(s: &UiScript) -> Vec {
+ s.extract()
+ .iter()
+ .filter(|q| {
+ s.quad_owner_name(q.target).as_deref() == Some("ReputationDetailAtWarCheckBox")
+ })
+ .filter_map(|q| match &q.content {
+ QuadContent::Texture { path: Some(p), .. } => Some(p.clone()),
+ _ => None,
+ })
+ .collect()
+ }
+ let up = "Interface\\Buttons\\UI-CheckBox-Up".to_string();
+
+ // Ironforge (row 2): peace-forced, at peace — the photographed case.
+ click_center(&mut s, "ReputationBar2");
+ assert_eq!(
+ s.eval::("return ReputationDetailAtWarCheckBox:IsEnabled()")
+ .unwrap(),
+ 0,
+ "the box is disabled for a faction whose war flag is locked"
+ );
+ assert_eq!(
+ box_art(&s),
+ vec![up.clone()],
+ "and it still draws its box — the sticky shown texture"
+ );
+
+ // Stormwind (row 3): peace-forced and at war — box plus the grey disabled tick.
+ click_center(&mut s, "ReputationBar3");
+ assert_eq!(
+ box_art(&s),
+ vec![
+ up.clone(),
+ "Interface\\Buttons\\UI-CheckBox-Check-Disabled".to_string()
+ ],
+ "a peace-forced faction at war keeps the box under its grey tick"
+ );
+
+ // Booty Bay (row 5): the control — toggleable, and unchanged by any of this.
+ click_center(&mut s, "ReputationBar5");
+ assert_eq!(
+ s.eval::("return ReputationDetailAtWarCheckBox:IsEnabled()")
+ .unwrap(),
+ 1
+ );
+ assert_eq!(box_art(&s), vec![up], "a live box is the same box");
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
/// **The watch bar shows what is being watched, and swaps posture at max level.**
///
/// Below max level it STACKS above the XP bar, 8px tall, wearing the `UI-ReputationWatchBar` end
diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
index bd7046d9..dfde8586 100644
--- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
+++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
@@ -31,8 +31,9 @@ fn every_shipped_ui_xml_parses() {
// into a test that guards zero files while staying green.
assert!(
// A sanity floor for the walk, not a census: `assets/ui` retires file by file (1751),
- // so the floor sits well under the count rather than one step above it (1956).
- checked >= 10,
+ // so the floor sits well under the count rather than one step above it (1956). Nine
+ // files stand after 2014; the glue screens and the dev frames alone are more than six.
+ checked >= 6,
"only {checked} xml files swept — sweep broke"
);
}
@@ -212,7 +213,7 @@ fn the_autocast_brackets_reach_each_buttons_corners() {
}
}
// The spell book's overlay is the reference's own template resized by SpellBookAdapters.xml's
- // script (1952), so its number is measured off a VM, not read off an XML.
+ // script (1952/2014), so its number is measured off a VM, not read off an XML.
let s = super::spellbook_tests::spellbook_ui(1024.0, 768.0);
let (overlay, button): (f32, f32) = s
.eval("return SpellButton1AutoCastable:GetWidth(), SpellButton1:GetWidth()")
@@ -235,134 +236,42 @@ fn the_autocast_brackets_reach_each_buttons_corners() {
}
}
-/// Every shipped autocast-shine token, and the rim/viewport ratio each one asks for — the one
-/// place the widget's geometry is decided, so the one place to pin it.
+/// The two shipped autocast-shine panes, and the rim/viewport ratio each one asks for — the one
+/// place the widget's geometry is decided, so the one place to pin it. Read off the live panes
+/// (the stock `$parentAutoCast` Models the tile renderer draws, 2013/2014), never an XML.
///
/// The pet button is the REFERENCE's own numbers (`setAllPoints` on 30x30 at `scale="1.2"`), and
/// its ratio is why that button reads as a rim: the rim square is 1.024x its viewport, so it runs
-/// ON the edge and the widget's scissor halves every star (1387/1391).
+/// ON the edge and the tile's cell — the widget's own scissor — halves every star (1387/1391).
///
/// The spell book is **one deliberate deviation** (decision 1392). The reference writes
/// `scale="1.22"` into a 36-unit viewport — a 0.87x rim that floats clear of the edge, is never
/// clipped, and washes the icon; the real 1.12 client looks the same way (director-checked), so
-/// this is taste, not fidelity. We write 1.48 on a 37-unit rim (1393's numbers, set by
-/// SpellBookAdapters.xml over the reference's own template since 1952) to borrow the pet button's
-/// ratio. This test is what stops that drifting, or being "corrected" back to 1.22 by someone
-/// who only read the ref.
+/// this is taste, not fidelity. We re-seat the stock Model to 1.48 on a 37-unit rim (1393's
+/// numbers, SpellBookAdapters.xml) to borrow the pet button's ratio. This test is what stops that
+/// drifting, or being "corrected" back to 1.22 by someone who only read the ref.
#[test]
-fn the_shine_tokens_ask_for_the_rims_we_meant() {
- use benilla_ui::framexml::{Element, TopLevel};
-
- fn walk(el: &Element, out: &mut Vec<(String, f32, f32)>) {
- for (key, value) in el.attrs() {
- let scale = crate::autocast_shine::token_model_scale(value);
- let is_token = key.eq_ignore_ascii_case("file")
- && value.starts_with(crate::autocast_shine::SHINE_TOKEN);
- if let (true, Some(scale)) = (is_token, scale) {
- let view = el
- .children
- .iter()
- .find(|c| c.tag.eq_ignore_ascii_case("Size"))
- .and_then(|sz| sz.children.first())
- .and_then(|d| {
- d.attrs()
- .iter()
- .find(|(k, _)| k.eq_ignore_ascii_case("x"))
- .map(|(_, v)| v.to_string())
- })
- .and_then(|v| v.parse::().ok())
- // No means setAllPoints on the pet button, which is 30x30.
- .unwrap_or(30.0);
- out.push((
- el.attrs()
- .iter()
- .find(|(k, _)| k.eq_ignore_ascii_case("name"))
- .map(|(_, v)| v.to_string())
- .unwrap_or_default(),
- 0.02 * 1280.0 * scale,
- view,
- ));
- }
- }
- for child in &el.children {
- walk(child, out);
- }
- }
-
- let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
- let mut found = Vec::new();
- for entry in std::fs::read_dir(&dir).expect("assets/ui") {
- let path = entry.expect("entry").path();
- if path.extension().is_none_or(|e| e != "xml") {
- continue;
- }
- let src = std::fs::read_to_string(&path).expect("read");
- for item in benilla_ui::framexml::parse(&src).expect("parse").items {
- match item {
- TopLevel::Font(el) | TopLevel::Template(el) | TopLevel::Instance(el) => {
- walk(&el, &mut found)
- }
- TopLevel::Include(_) | TopLevel::Script(_) => {}
- }
- }
- }
- // The spell book's shine is created by SpellBookAdapters.xml's script beside the reference's
- // own Model (1952), so its token is read off a VM's extract — where the renderer reads it —
- // not an XML. (Not `GetTexture`, which answers the path up to its last dot, the reference's
- // own shape, and so would cut the token's scale in half.)
+fn the_shine_panes_ask_for_the_rims_we_meant() {
+ let mut found: Vec<(String, f32, f32)> = Vec::new();
let mut s = super::spellbook_tests::spellbook_ui(1024.0, 768.0);
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
s.tick(0.05);
s.resolve();
- s.run("SpellButton1Shine:Show()").unwrap();
- s.resolve();
- let quads = s.extract();
- let token = quads
- .iter()
- .find_map(|q| match &q.content {
- benilla_ui::script::QuadContent::Texture { path: Some(p), .. }
- if p.starts_with(crate::autocast_shine::SHINE_TOKEN) =>
- {
- Some(p.clone())
- }
- _ => None,
- })
- .unwrap_or_else(|| {
- let owned: Vec<_> = quads
- .iter()
- .filter(|q| {
- s.quad_owner_name(q.target)
- .is_some_and(|n| n.starts_with("SpellButton1"))
- })
- .map(|q| (s.quad_owner_name(q.target), format!("{:?}", q.content)))
- .collect();
- panic!("no shine token among SpellButton1's quads: {owned:?}")
- });
- let scale = crate::autocast_shine::token_model_scale(&token).expect("a shine token");
- let view: f32 = s.eval("return SpellButton1Shine:GetWidth()").unwrap();
- found.push(("SpellButton1Shine".into(), 0.02 * 1280.0 * scale, view));
- // The pet button's shine is PetActionBarAdapters.xml's, over the whole button (1953): the
- // token off the shown marker's quad, the viewport off that quad's own rect.
+ let (scale, view): (f32, f32) = s
+ .eval("return SpellButton1AutoCast:GetModelScale(), SpellButton1AutoCast:GetWidth()")
+ .unwrap();
+ found.push(("SpellButton1AutoCast".into(), 0.02 * 1280.0 * scale, view));
let s = pet_bar_vm();
- let (token, view) = s
- .extract()
- .into_iter()
- .find_map(|q| match (&q.content, q.rect) {
- (benilla_ui::script::QuadContent::Texture { path: Some(p), .. }, Some(r))
- if p.starts_with(crate::autocast_shine::SHINE_TOKEN) =>
- {
- Some((p.clone(), r.right - r.left))
- }
- _ => None,
- })
- .expect("the pet bar's shine marker (Claw autocasts in the hunter fixture)");
- let scale = crate::autocast_shine::token_model_scale(&token).expect("a shine token");
- found.push(("PetActionButton1Shine".into(), 0.02 * 1280.0 * scale, view));
- assert_eq!(
- found.len(),
- 2,
- "expected exactly two shine tokens: {found:?}"
- );
+ let (scale, view): (f32, f32) = s
+ .eval(
+ "return PetActionButton1AutoCast:GetModelScale(), PetActionButton1AutoCast:GetWidth()",
+ )
+ .unwrap();
+ found.push((
+ "PetActionButton1AutoCast".into(),
+ 0.02 * 1280.0 * scale,
+ view,
+ ));
for (name, rim, view) in &found {
assert!(
(rim / view - 1.024).abs() < 1e-3,
@@ -396,11 +305,7 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
let archive_path = ["file", "bgfile", "edgefile"]
.contains(&key.to_ascii_lowercase().as_str())
&& !value.is_empty();
- // The autocast-shine token (decision 1383) is a REGISTRATION, not an archive path:
- // conversion intercepts it before the resolver and it draws nothing, so it is
- // exempt — through the token's OWN parser, so a typo'd token (or an unparseable
- // `scale=` suffix) still fails this sweep as the white quad it would actually be.
- if archive_path && crate::autocast_shine::token_model_scale(value).is_none() {
+ if archive_path {
out.push((file.to_string(), el.tag.clone(), value.clone()));
}
}
@@ -846,7 +751,7 @@ fn no_shipped_script_sets_a_global_string_key_as_display_text() {
assert!(offenders.is_empty(), "{}", offenders.join("\n"));
// The sweep must never pass by finding nothing to sweep.
// The same walk floor as above (1956).
- assert!(swept >= 10, "only {swept} xml files swept — sweep broke");
+ assert!(swept >= 6, "only {swept} xml files swept — sweep broke");
}
/// **The `$parentTextureFrame` idiom's contract, over the whole shipped UI**: a frame whose art is
diff --git a/crates/benilla-app/src/ui_script/spellbook_tests.rs b/crates/benilla-app/src/ui_script/spellbook_tests.rs
index 4f0885c3..afdb8650 100644
--- a/crates/benilla-app/src/ui_script/spellbook_tests.rs
+++ b/crates/benilla-app/src/ui_script/spellbook_tests.rs
@@ -151,12 +151,7 @@ fn shipped_spellbook_loads_clean() {
"window + close + prev/next + 12 spell buttons (each with its Cooldown and AutoCast \
Model children) + 8 skill-line tabs + the 3 Spell/Pet toggle tabs + the tab flash frame"
);
- for name in [
- "SpellBookFrame",
- "SpellButton12",
- "SpellButton1AutoCast",
- "SpellButton1Shine",
- ] {
+ for name in ["SpellBookFrame", "SpellButton12", "SpellButton1AutoCast"] {
assert!(
s.eval::(&format!("return {name} ~= nil")).unwrap(),
"{name} exists"
@@ -322,14 +317,24 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
-/// The empty-slot LOOK, pinned at the quad level (the regression that shipped with slice 5): a
-/// slot past the book's length is a **disabled** SpellButton with no DisabledTexture and an
-/// unchecked ring — so it draws its UI-Spellbook-SpellBackground square and **nothing else**. The
-/// slice-5 build leaked both the UI-Quickslot2 NormalTexture (disabled wrongly fell back to
-/// Normal) and the CheckButtonHilight ring (the reference's `SetChecked(0)` was read Lua-truthy),
-/// putting a gold ring on all 12 slots — the director's "spellbook looks very wrong".
+/// The empty-slot LOOK, pinned at the quad level: a slot past the book's length is a **disabled**
+/// SpellButton, and it draws its `UI-Spellbook-SpellBackground` square **and its `UI-Quickslot2`
+/// socket ring** — but no `CheckButtonHilight` glow.
+///
+/// **The ring was wrong to remove, and this test asserted the wrong half for a year** (decision
+/// 2011, correcting 0227's second finding). 0227 read the empty slot as a *born-disabled* button
+/// whose Normal texture had therefore never been shown — but stock `SpellButton_UpdateButton`
+/// `Disable()`s a button that was created enabled and has been wearing its ring since LoadXML
+/// (l.328, and the `CSimpleButton` ctor `0x7786a0` ends in `SetState(NORMAL)`), so the shown
+/// pointer `+0x4c4` is already on the ring when the disable arrives and `SetState 0x779790` has
+/// no step that takes it off. The reference's own Lua is the tell: the disable branch resets that
+/// very ring's vertex colour to white (l.337), which is only meaningful on a ring that draws.
+///
+/// What the slice-5 build really got wrong was the OTHER ring — the `CheckButtonHilight` glow on
+/// all 12 slots, from reading the reference's `SetChecked(0)` as Lua-truthy (0227's first
+/// finding, which stands). That is the half this still pins.
#[test]
-fn shipped_spellbook_empty_slot_draws_only_the_background() {
+fn shipped_spellbook_empty_slot_draws_its_background_and_socket_ring() {
let mut s = spellbook_ui(640.0, 700.0);
// A one-spell book: slot 5 takes the reference's `id > offset + numSpells` disable path. (A
// book with NO spells is a state no character is ever in — the reference's own
@@ -354,8 +359,11 @@ fn shipped_spellbook_empty_slot_draws_only_the_background() {
}
assert_eq!(
slot5_paths,
- vec!["Interface\\Spellbook\\UI-Spellbook-SpellBackground".to_string()],
- "an empty slot draws its background square and nothing else (no ring, no checked glow)"
+ vec![
+ "Interface\\Spellbook\\UI-Spellbook-SpellBackground".to_string(),
+ "Interface\\Buttons\\UI-Quickslot2".to_string(),
+ ],
+ "an empty slot keeps its socket ring and gains no checked glow"
);
// The reference passes SetChecked(0) — numeric coercion, not Lua truthiness.
assert!(!s
@@ -507,13 +515,13 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
.unwrap(),
"a passive is not autocastable"
);
- // …and the shine marker follows the SECOND (is it on) — the native lane (decision 1383)
- // draws the sparkle wherever a shown marker sits, so shown-ness IS the enable.
+ // …and the shine MODEL follows the SECOND (is it on) — the stock `$parentAutoCast`, whose
+ // file the tile renderer draws (2013/2014), so shown-ness IS the enable.
assert!(s
- .eval::("return SpellButton1Shine:IsVisible()")
+ .eval::("return SpellButton1AutoCast:IsVisible()")
.unwrap());
assert!(!s
- .eval::("return SpellButton3Shine:IsVisible()")
+ .eval::("return SpellButton3AutoCast:IsVisible()")
.unwrap());
// The corner brackets' RECT, through the live widget. Asserted on the resolved rect rather
// than the XML, so an anchor bug between the two is still caught — and CENTERED with no
@@ -543,29 +551,35 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
br[3]
);
- // The marker's RECT: 1391 gave it the ref's own 36x36 at CENTER (1,1); 1393 squares it on the
- // button instead, so the glow and the brackets share a centre. Checked here rather than
- // trusted to the XML, because the whole spell-book thread turns on where this viewport sits.
- let geom: Vec = ["GetWidth", "GetHeight"]
+ // The pane's RECT: the ref's own template gives it 36x36 at CENTER (1,1); 1393 squares it on
+ // the button instead (now by re-seating the stock Model, 2014), so the glow and the brackets
+ // share a centre. Checked here rather than trusted to the XML, because the whole spell-book
+ // thread turns on where this viewport sits.
+ let geom: Vec = ["GetWidth", "GetHeight", "GetModelScale"]
.iter()
.map(|m| {
- s.eval::(&format!("return SpellButton1Shine:{m}()"))
+ s.eval::(&format!("return SpellButton1AutoCast:{m}()"))
.unwrap()
})
.collect();
assert!(
(geom[0] - 37.0).abs() < 0.01 && (geom[1] - 37.0).abs() < 0.01,
- "shine marker is {geom:?}, expected 37x37 (1393 squares it on the button)"
+ "shine pane is {geom:?}, expected 37x37 (1393 squares it on the button)"
+ );
+ assert!(
+ (geom[2] - 1.48).abs() < 0.001,
+ "shine pane's model scale is {}, expected 1393's 1.48 (the pet button's rim ratio)",
+ geom[2]
);
let dx = s
- .eval::("return SpellButton1Shine:GetLeft() - SpellButton1:GetLeft()")
+ .eval::("return SpellButton1AutoCast:GetLeft() - SpellButton1:GetLeft()")
.unwrap();
let dy = s
- .eval::("return SpellButton1Shine:GetBottom() - SpellButton1:GetBottom()")
+ .eval::("return SpellButton1AutoCast:GetBottom() - SpellButton1:GetBottom()")
.unwrap();
assert!(
dx.abs() < 0.01 && dy.abs() < 0.01,
- "shine marker sits at ({dx}, {dy}) inside the button; 1393 squares it on the button so it \
+ "shine pane sits at ({dx}, {dy}) inside the button; 1393 squares it on the button so it \
is concentric with the brackets — the ref's +1,+1 is what read as a top/right bias"
);
diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs
index 4577b02b..13bd81df 100644
--- a/crates/benilla-ui/src/script/button.rs
+++ b/crates/benilla-ui/src/script/button.rs
@@ -5,10 +5,10 @@
//! (`Normal/Pushed/Disabled/Highlight`), the `ButtonText` fontstring + `text` attribute, the
//! `OnClick` script slot (`+0x4cc`); CheckButton runs Button's loader first and adds
//! `CheckedTexture`/`DisabledCheckedTexture` + the `checked` bool (`+0x4dc`). Which texture *shows*
-//! is interaction state ([`ButtonState::region_visible`], applied at extract) — faithful to the
-//! documented widget model (texture array + current pointer `+0x4c4`), not byte-pinned. Two stated
-//! v1 gaps: the highlight draws with normal blending (the client ADD-blends it; the quad pass has
-//! no blend modes yet), and `PushedTextOffset` is not modeled. Per-state label fonts *are*: the
+//! is **latched on the transition**, not resolved at paint: [`ButtonState::set_state`] is the
+//! client's `SetState 0x779790` and [`settle`] is where the derived inputs reach it. Two stated v1
+//! gaps: the highlight draws with normal blending (the client ADD-blends it; the quad pass has no
+//! blend modes yet), and `PushedTextOffset` is not modeled. Per-state label fonts *are*: the
//! `*FontObject` trio picks which object each state inherits and `extract` re-resolves it every
//! frame, while `SetFont` writes the button's own face/size/flags over all of them
//! ([`crate::widget::ButtonFont`]).
@@ -25,7 +25,8 @@ use super::{event, JustifyH, Model, RegionData};
use crate::justify::Justify;
use crate::order::DrawLayer;
use crate::widget::{
- ButtonFont, ButtonState, FrameHandle, FrameKind, KindState, RegionHandle, RegionKind,
+ ButtonFont, ButtonState, ButtonVisualState, FrameHandle, FrameKind, KindState, RegionHandle,
+ RegionKind,
};
pub(super) const REG_BUTTON_METHODS: &str = "__benilla_button_methods";
@@ -56,11 +57,15 @@ impl Slot {
}
}
+ /// The three STATE slots go through [`ButtonState::set_state_slot`] — the client's
+ /// `0x778fd0` family, which stores the slot *and* pushes it to the shown pointer when it is
+ /// the current state's. Every other slot is a plain field: the highlight, the checked pair
+ /// and the label are not in the state array and have show rules of their own.
fn set(self, bs: &mut ButtonState, rh: crate::widget::RegionHandle) {
match self {
- Slot::Normal => bs.normal = Some(rh),
- Slot::Pushed => bs.pushed = Some(rh),
- Slot::Disabled => bs.disabled = Some(rh),
+ Slot::Normal => bs.set_state_slot(ButtonVisualState::Normal, Some(rh)),
+ Slot::Pushed => bs.set_state_slot(ButtonVisualState::Pushed, Some(rh)),
+ Slot::Disabled => bs.set_state_slot(ButtonVisualState::Disabled, Some(rh)),
Slot::Highlight => bs.highlight = Some(rh),
Slot::Checked => bs.checked_tex = Some(rh),
Slot::DisabledChecked => bs.disabled_checked = Some(rh),
@@ -226,6 +231,11 @@ pub(crate) fn set_label_font_justify_h_lua(
}
/// Run `f` over a frame's Button state under one short write borrow.
+/// Run `f` over a frame's Button state under one short write borrow, then **settle the state
+/// machine** — `f` is every Lua write that can move an input (`Enable`/`Disable`,
+/// `SetButtonState`, `RegisterForClicks`), and the client's own writers call `SetState` on the
+/// spot rather than leaving a paint to notice. Settling a read is a no-op (the transition guard is
+/// `new == state`), so this stays on the one path instead of splitting into read/write halves.
fn with_button(
lua: &Lua,
this: &Table,
@@ -233,16 +243,86 @@ fn with_button(
) -> mlua::Result {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::().expect("model app_data");
+ let (hovered, held) = press_inputs(&model, h);
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match &mut frame.kind_state {
- KindState::Button(bs) => Ok(f(bs)),
+ KindState::Button(bs) => {
+ let out = f(bs);
+ bs.settle(hovered, held);
+ Ok(out)
+ }
_ => Err(mlua::Error::runtime("not a Button")),
}
}
+/// The two interaction inputs the model owns rather than the button: the cursor is over it, and a
+/// registered press is holding it.
+fn press_inputs(model: &Model, h: FrameHandle) -> (bool, bool) {
+ (model.mouseover == Some(h), press_held(model, h))
+}
+
+/// `Enable()` / `Disable()` — and the second thing they do, which is not the state texture.
+///
+/// `Disable 0x77ffd0` ends `0x78009a call [vtbl+0x90](0)`, reaching the shared helper
+/// `0x779160`; the constructor reaches the same helper with `1` (`0x778766`), which is what puts
+/// a fresh button in NORMAL. That helper does **two** things:
+///
+/// - `[vtbl+0x9c](state)` = `SetState 0x779790` — [`ButtonState::settle`]'s half; and
+/// - `0x7791bb push 4; call 0x76a730` — the per-layer enable for layer **4 = HIGHLIGHT**, written
+/// into the very `[frame+0x198]` array that `Enable/DisableDrawLayer` writes and that
+/// `0x76b3a0` reads back at draw time.
+///
+/// So a disabled button's highlight is switched off **at the layer**, which takes every region
+/// the frame owns there with it — not only the HighlightTexture. That is why the highlight is no
+/// longer gated on `enabled` inside [`ButtonState::region_visible`]: one mechanism, in the place
+/// the client keeps it, instead of a second rule that agreed with it on the common case and
+/// disagreed on `` art the button did not put there itself.
+///
+/// The reference has one array and one writer, so an addon's `DisableDrawLayer("HIGHLIGHT")` is
+/// undone by the next `Enable()`, exactly as here.
+fn set_enabled(lua: &Lua, this: &Table, on: bool) -> mlua::Result<()> {
+ let h = frame_handle_of(lua, this)?;
+ let mut model = lua.app_data_mut::().expect("model app_data");
+ let (hovered, held) = press_inputs(&model, h);
+ let frame = model
+ .arena
+ .frame_mut(h)
+ .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
+ match &mut frame.kind_state {
+ KindState::Button(bs) => {
+ bs.enabled = on;
+ bs.settle(hovered, held);
+ }
+ _ => return Err(mlua::Error::runtime("not a Button")),
+ }
+ let bit = 1u8 << DrawLayer::Highlight.index();
+ if on {
+ frame.disabled_layers &= !bit;
+ } else {
+ frame.disabled_layers |= bit;
+ }
+ Ok(())
+}
+
+/// Re-latch a button's state texture after something OUTSIDE the widget moved an input — the
+/// mouse crossing its boundary, a press landing or lifting, a capture dropped when the pointer
+/// left the window, or the hover cleared because the frame was hidden under the cursor.
+///
+/// These are the client's own `SetState` call sites (`0x7791ed` enter, `0x7793f0` leave,
+/// `0x7792ad` down, `0x7793c2` up); ours reach the same transition through the derived inputs.
+/// Harmless on a non-Button handle, and on a stale one.
+pub(super) fn settle(model: &mut Model, h: FrameHandle) {
+ let (hovered, held) = press_inputs(model, h);
+ if let Some(frame) = model.arena.frame_mut(h) {
+ if let KindState::Button(bs) = &mut frame.kind_state {
+ bs.settle(hovered, held);
+ }
+ }
+}
+
/// Write [`ButtonState::loot_slot`] — `LootButton:SetSlot`'s whole body, kept here beside the
/// other `ButtonState` writers rather than reaching into the arena from `script::loot`.
pub(super) fn set_loot_slot(lua: &Lua, this: &Table, slot: Option) -> mlua::Result<()> {
@@ -788,11 +868,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"Enable",
- lua.create_function(|lua, this: Table| with_button(lua, &this, |bs| bs.enabled = true))?,
+ lua.create_function(|lua, this: Table| set_enabled(lua, &this, true))?,
)?;
m.set(
"Disable",
- lua.create_function(|lua, this: Table| with_button(lua, &this, |bs| bs.enabled = false))?,
+ lua.create_function(|lua, this: Table| set_enabled(lua, &this, false))?,
)?;
// IsEnabled() → the NUMBER 1 or the NUMBER 0 — never a boolean, and never nil.
//
diff --git a/crates/benilla-ui/src/script/event.rs b/crates/benilla-ui/src/script/event.rs
index ef837acb..5870809e 100644
--- a/crates/benilla-ui/src/script/event.rs
+++ b/crates/benilla-ui/src/script/event.rs
@@ -169,6 +169,10 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec) {
.filter(|&m| items.iter().any(|&(h, _, vis)| h == m && !vis));
if let Some(m) = hidden_hover {
model.mouseover = None;
+ // The hover the hide just dropped is an input to the button state machine, so the
+ // frame under it re-latches here as it would on a mouse-out (`0x7793f0`) — a button
+ // hidden mid-press must not come back up still wearing its pushed art.
+ super::button::settle(&mut model, m);
if model.drag.as_ref().is_some_and(|d| d.source == m) {
model.drag = None;
}
diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs
index 85e10b50..b9ab758e 100644
--- a/crates/benilla-ui/src/script/extract.rs
+++ b/crates/benilla-ui/src/script/extract.rs
@@ -249,11 +249,12 @@ impl UiScript {
owner_frame.map(|f| &f.kind_state)
{
let hovered = owner.is_some() && model.mouseover == owner;
- // ANY registered mouse button holds a button down, not only the left one
- // (`0x77924b`, see `button::wants_press_visual`) — which is what makes a
- // right-click on a bar or spellbook slot flash its pushed art.
- let held = owner.is_some_and(|o| super::button::press_held(&model, o));
- if !bs.region_visible(rh, hovered, held) {
+ // The PRESS is not read here. Which state texture shows is latched on the
+ // transition (`ButtonState::set_state`), so the press reaches the paint
+ // through `button::settle` at the moment the mouse moves it — not by
+ // being re-derived every frame. `hovered` survives because the Highlight
+ // is not a state texture and carries no latch.
+ if !bs.region_visible(rh, hovered) {
continue;
}
if bs.text == Some(rh) {
diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs
index 7736b3d6..690fa647 100644
--- a/crates/benilla-ui/src/script/mod.rs
+++ b/crates/benilla-ui/src/script/mod.rs
@@ -1248,7 +1248,14 @@ impl UiScript {
self.fire_drag_stop(source);
}
let mut model = self.model_mut();
+ let held: Vec = model.mouse_down_on.values().copied().collect();
model.mouse_down_on.clear();
+ // Every button that capture was holding down goes back to NORMAL — the release the OS
+ // never fed us (`0x7793c2`'s transition), without which a button walked off the window
+ // edge mid-press keeps its pushed art for the rest of the session.
+ for h in held {
+ button::settle(&mut model, h);
+ }
// …and its one-slot twin `root+0x80`, which the mouse-down raise reads: a capture left
// behind would aim the next press's raise at whatever the pointer was last holding.
model.mouse_capture = None;
diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs
index b94fe87e..af984ad8 100644
--- a/crates/benilla-ui/src/script/pointer.rs
+++ b/crates/benilla-ui/src/script/pointer.rs
@@ -288,7 +288,16 @@ impl UiScript {
.then(|| model.frame_to_id.get(&h).copied())
.flatten()
});
+ let old_handle = model.mouseover;
model.mouseover = new_handle;
+ // The client's `0x7793f0` (leave) and `0x7791ed` (enter) both call `SetState`:
+ // a press held over a button and then walked off it drops back to NORMAL, and
+ // walking back on picks PUSHED up again. Ours latches the same transition from
+ // the moved hover — both sides of the boundary, since each one's `hovered`
+ // changed.
+ for h in [old_handle, new_handle].into_iter().flatten() {
+ button::settle(&mut model, h);
+ }
(old_id, drag_start, true, slider_change, color_change)
}
};
@@ -507,13 +516,15 @@ impl UiScript {
let mut model = self.model_mut();
let hit_handle = hit_id.and_then(|id| model.id_to_frame.get(&id).copied());
if down {
- match hit_handle {
- Some(h) => {
- model.mouse_down_on.insert(button.to_string(), h);
- }
- None => {
- model.mouse_down_on.remove(button);
- }
+ let displaced = match hit_handle {
+ Some(h) => model.mouse_down_on.insert(button.to_string(), h),
+ None => model.mouse_down_on.remove(button),
+ };
+ // `0x7792ad`: the press SetState(PUSHED), unconditional past the registration
+ // and hit gates. The displaced entry is settled too — a second press of the same
+ // button elsewhere releases whatever it was holding.
+ for h in [displaced, hit_handle].into_iter().flatten() {
+ button::settle(&mut model, h);
}
// `0x7663e6` writes the resolved target into `root+0x80` — capture-else-hover, so
// this is an `or`, not an assignment: an existing capture is not displaced by a
@@ -551,6 +562,10 @@ impl UiScript {
(click, None, false, jump, None, abandoned, color_jump)
} else {
let pressed = model.mouse_down_on.remove(button);
+ // `0x7793c2`: the release SetState(NORMAL) on the frame the press captured.
+ if let Some(h) = pressed {
+ button::settle(&mut model, h);
+ }
// `root+0x80` is cleared at `0x7664bb` **only when the post-event button mask is
// zero** — a chorded release keeps the capture for the button still held. With the
// per-button map already drained above, "mask is zero" is "the map is empty".
diff --git a/crates/benilla-ui/src/script/tests/button.rs b/crates/benilla-ui/src/script/tests/button.rs
index d752dd19..3a40a8d7 100644
--- a/crates/benilla-ui/src/script/tests/button.rs
+++ b/crates/benilla-ui/src/script/tests/button.rs
@@ -88,6 +88,175 @@ fn button_state_textures_switch_with_interaction() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+/// **The shown texture is STICKY: a state with no art of its own changes nothing.**
+///
+/// `SetState 0x779790` gates the hide-old step *and* the show-new step on the NEW state's slot
+/// being non-null (wow-re `button-check-and-state-texture.md` §2), so the shown pointer `+0x4c4`
+/// only ever moves onto a real texture — it is never cleared, and there is no fallback path in the
+/// function at all. Three consequences, and they are one mechanism seen from three sides:
+///
+/// - a press with no `` keeps the normal art (which our old model hard-coded as a
+/// `pushed.or(normal)` fallback — the special case *was* this rule, seen from one side);
+/// - **`Disable()` with no `` keeps the normal art** — B369, where the reference's
+/// `ReputationDetailAtWarCheckBox` stays a visible box for a faction whose war flag is locked
+/// while ours went to a bare grey label;
+/// - a state texture assigned while the button is in a *different* state is not displayed
+/// (`0x778fd0`'s `idx == [this+0x328]` gate), so a button disabled *before* it is given normal
+/// art draws nothing — the two disabled cases differ only in whether anything was ever shown.
+#[test]
+fn a_state_with_no_texture_leaves_the_shown_one_standing() {
+ let mut s = script();
+ s.set_screen_size(800.0, 600.0);
+ s.run(
+ r#"
+ local b = CreateFrame("Button", "StickyBtn")
+ b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(100, 100)
+ b:SetNormalTexture("Interface\\N.blp")
+ "#,
+ )
+ .unwrap();
+ s.resolve();
+
+ let visible = |s: &UiScript, owner: &str| -> Vec {
+ s.extract()
+ .iter()
+ .filter(|q| s.quad_owner_name(q.target).as_deref() == Some(owner))
+ .filter_map(|q| match &q.content {
+ QuadContent::Texture { path: Some(p), .. } => Some(p.clone()),
+ _ => None,
+ })
+ .collect()
+ };
+ let n = vec!["Interface\\N.blp".to_string()];
+
+ assert_eq!(visible(&s, "StickyBtn"), n, "resting on its normal art");
+
+ // Pressed with no PushedTexture: SetState(2) finds a null slot, so nothing moves.
+ s.mouse_move(50.0, 50.0);
+ s.mouse_button(50.0, 50.0, "LeftButton", true);
+ assert_eq!(visible(&s, "StickyBtn"), n, "a press with no pushed art");
+ s.mouse_button(50.0, 50.0, "LeftButton", false);
+ s.mouse_move(400.0, 400.0);
+
+ // Disabled with no DisabledTexture: the same null slot, the same nothing. **B369.**
+ s.run("StickyBtn:Disable()").unwrap();
+ assert_eq!(
+ visible(&s, "StickyBtn"),
+ n,
+ "a disable with no disabled art leaves the box on screen"
+ );
+ // Giving it disabled art NOW does show it — `0x778fd0` pushes to `+0x4c4` because the slot
+ // being written IS the current state's.
+ s.run("StickyBtn:SetDisabledTexture(\"Interface\\\\D.blp\")")
+ .unwrap();
+ assert_eq!(
+ visible(&s, "StickyBtn"),
+ vec!["Interface\\D.blp".to_string()],
+ "and real disabled art displaces it on the spot"
+ );
+ s.run("StickyBtn:Enable()").unwrap();
+ assert_eq!(visible(&s, "StickyBtn"), n, "back to normal on Enable");
+
+ // The other side of the same gate: a button disabled BEFORE it has normal art shows nothing,
+ // because the write lands in a slot that is not the current state's.
+ s.run(
+ r#"
+ local b = CreateFrame("Button", "BornDeadBtn")
+ b:SetPoint("BOTTOMLEFT", 200, 0); b:SetSize(100, 100)
+ b:Disable()
+ b:SetNormalTexture("Interface\\N.blp")
+ "#,
+ )
+ .unwrap();
+ s.resolve();
+ assert!(
+ visible(&s, "BornDeadBtn").is_empty(),
+ "art set while in another state is stored, not shown"
+ );
+ assert!(s.errors().is_empty(), "{:?}", s.errors());
+}
+
+/// **`Disable()` switches off the HIGHLIGHT draw LAYER, not just the HighlightTexture.**
+///
+/// The helper both `Enable`/`Disable` and the constructor reach (`0x779160`) does two things: the
+/// `SetState` above, and `0x7791bb push 4; call 0x76a730` — the per-layer enable for layer 4,
+/// written into the same `[frame+0x198]` array `Enable/DisableDrawLayer` writes. So a region the
+/// button owns in HIGHLIGHT that is not its HighlightTexture goes dark with it, and an explicit
+/// `EnableDrawLayer` brings the whole layer back on a still-disabled button. Our old rule gated
+/// the HighlightTexture alone on `enabled`, which agreed on the common case and not on this one.
+#[test]
+fn disabling_a_button_takes_its_whole_highlight_layer() {
+ let mut s = script();
+ s.set_screen_size(800.0, 600.0);
+ s.run(
+ r#"
+ local b = CreateFrame("Button", "LayerBtn")
+ b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(100, 100)
+ b:SetNormalTexture("Interface\\N.blp")
+ b:SetHighlightTexture("Interface\\H.blp")
+ local own = b:CreateTexture(nil, "HIGHLIGHT")
+ own:SetAllPoints(b)
+ own:SetTexture("Interface\\Own.blp")
+ "#,
+ )
+ .unwrap();
+ s.resolve();
+
+ let art = |s: &UiScript| -> Vec {
+ s.extract()
+ .iter()
+ .filter(|q| s.quad_owner_name(q.target).as_deref() == Some("LayerBtn"))
+ .filter_map(|q| match &q.content {
+ QuadContent::Texture { path: Some(p), .. } => Some(p.clone()),
+ _ => None,
+ })
+ .collect()
+ };
+
+ // Hovered and enabled: normal art, the button's own HIGHLIGHT region, and the HighlightTexture.
+ s.mouse_move(50.0, 50.0);
+ let v = art(&s);
+ for want in ["Interface\\N.blp", "Interface\\Own.blp", "Interface\\H.blp"] {
+ assert!(
+ v.contains(&want.to_string()),
+ "{want} draws while enabled: {v:?}"
+ );
+ }
+
+ // Disabled under the same cursor: the layer is off, so BOTH highlight-layer regions go — and
+ // the normal art stays, because no state texture moved (the sticky rule).
+ s.run("LayerBtn:Disable()").unwrap();
+ assert_eq!(
+ art(&s),
+ vec!["Interface\\N.blp".to_string()],
+ "the whole HIGHLIGHT layer goes with the disable"
+ );
+
+ // The layer is a layer: turning it back on lights both again, disabled or not.
+ s.run(r#"LayerBtn:EnableDrawLayer("HIGHLIGHT")"#).unwrap();
+ let v = art(&s);
+ assert!(
+ v.contains(&"Interface\\Own.blp".to_string())
+ && v.contains(&"Interface\\H.blp".to_string()),
+ "EnableDrawLayer restores the layer on a still-disabled button: {v:?}"
+ );
+ assert_eq!(
+ s.eval::("return LayerBtn:IsEnabled()").unwrap(),
+ 0,
+ "and it really is still disabled"
+ );
+
+ // …and Enable() writes the same one array, so it undoes an addon's DisableDrawLayer.
+ s.run(r#"LayerBtn:DisableDrawLayer("HIGHLIGHT") LayerBtn:Enable()"#)
+ .unwrap();
+ let v = art(&s);
+ assert!(
+ v.contains(&"Interface\\Own.blp".to_string()),
+ "one array, one writer: Enable() clears the addon's own disable too: {v:?}"
+ );
+ assert!(s.errors().is_empty(), "{:?}", s.errors());
+}
+
/// **A right-click lights a button up too** — the director's report, and `0x77924b`'s law.
///
/// `CButton::OnMouseDown` gates the PushedTexture on `[this+0x330] & (m | m << 8)`: the button
diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs
index 6079742c..5f7f9f9a 100644
--- a/crates/benilla-ui/src/widget/kinds/mod.rs
+++ b/crates/benilla-ui/src/widget/kinds/mod.rs
@@ -884,9 +884,28 @@ pub struct ButtonFont {
pub flags: String,
}
-/// A Button's state model: which of the state textures draws is a *function of interaction state*
-/// (the client's texture array `+0x4b8` with a current-shown pointer `+0x4c4`), so the regions all
-/// exist in the arena and [`Self::region_visible`] picks at extract time.
+/// The client's button STATE INDEX — `[CSimpleButton+0x328]`, the one variable `SetState
+/// 0x779790` writes and `IsEnabled 0x7800b0` / `GetButtonState 0x780180` read back.
+///
+/// The client numbers them 0 DISABLED / 1 NORMAL / 2 PUSHED, which is also the index into its
+/// state-texture array (`[this + state*4 + 0x4b8]`); we name the variants instead, because
+/// nothing here indexes.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum ButtonVisualState {
+ /// `Disable()` — the button fires no clicks and draws from the Disabled slot.
+ Disabled,
+ /// The resting state a button is born in.
+ #[default]
+ Normal,
+ /// A mouse press captured over the button, or `SetButtonState("PUSHED")`.
+ Pushed,
+}
+
+/// A Button's state model: which of the state textures draws is a **latched** consequence of the
+/// interaction state, not a pure function of it — the client's texture array `+0x4b8` plus its
+/// currently-shown pointer `+0x4c4`, which moves only on a transition into a state that *has* a
+/// texture ([`Self::settle`]). The regions all exist in the arena; [`Self::region_visible`] reads
+/// the pointer at extract time.
#[derive(Clone, Debug, PartialEq)]
pub struct ButtonState {
/// `Enable`/`Disable`. A disabled button shows its DisabledTexture and fires no clicks.
@@ -894,10 +913,15 @@ pub struct ButtonState {
/// The scripted PUSHED state — `SetButtonState("PUSHED"/"NORMAL") 0x780270` /
/// `GetButtonState 0x780180`, the keybind visual's engine half (ref `ActionButtonDown/Up`,
/// `ActionButton.lua:15-28`). ORs with the mouse-derived held+hovered press in
- /// [`Self::region_visible`]; the mouse press itself stays outside the widget (the app's
- /// capture), so `GetButtonState` reads only this flag — INTERIM: a mouse-held button
- /// answers "NORMAL" here where the real engine's one state variable would say "PUSHED".
- /// Nothing in the transcribed FrameXML reads the state mid-mouse-press.
+ /// [`Self::input_state`]; the mouse press itself stays outside the widget (the app's capture),
+ /// which is why the state machine takes it as an argument.
+ ///
+ /// **The reference has one variable where we have two**, and the difference is stated rather
+ /// than implied: `SetButtonState(state, locked)` writes `[+0x328]` *and* the lock at `+0x32c`,
+ /// and an unlocked scripted push is therefore cleared by the next mouse press/release
+ /// (`0x7793c2`'s `SetState(NORMAL)`, gated on `locked == 0`). Ours keeps the flag until Lua
+ /// clears it. No 1.12 caller pushes without meaning it to stick: `ActionButtonDown` pairs
+ /// every push with its own `ActionButtonUp`, and the micro buttons pass `locked = 1`.
pub pushed_state: bool,
/// [`FrameKind::LootButton`]'s own field — `CLootButton +0x4dc`, the **0-based** loot slot
/// this row takes when clicked. `None` until `SetSlot` writes it (the ctor's zero is a slot
@@ -910,16 +934,23 @@ pub struct ButtonState {
pub checked: bool,
/// ``/`SetNormalTexture` (`+0x4bc`).
pub normal: Option,
- /// `` (`+0x4c0`) — shown while the mouse is held down over the button.
+ /// `` (`+0x4c0`) — taken on the press transition. A button with none keeps
+ /// whatever it was showing ([`Self::set_state`]), which is not a fallback but the absence of
+ /// one.
pub pushed: Option,
/// `` (`+0x4b8`).
pub disabled: Option,
/// `` (`+0x4c8`) — additive over the current state texture while hovered
/// (it lives in the HIGHLIGHT draw layer, above the others, not instead of them).
pub highlight: Option,
- /// CheckButton `` (`+0x4e0`) — additive while checked.
+ /// CheckButton `` (`+0x4e0`) — additive while checked. A SEPARATE array from
+ /// the state textures, with its own rule (`0x7854c0`): hide both, then show
+ /// [`Self::disabled_checked`] if checked ∧ it exists ∧ the state is DISABLED, else this one if
+ /// it exists, else nothing. **The fallback is one-way** — a disabled checked button with no
+ /// DisabledChecked art falls back to this; a checked one never falls the other way.
pub checked_tex: Option,
/// CheckButton `` (`+0x4e4`) — replaces CheckedTexture when disabled.
+ /// The greyed tick a peace-forced faction's At War box wears (B369).
pub disabled_checked: Option,
/// The `` fontstring (`+0x338`; `SetText`). Always drawn.
pub text: Option,
@@ -989,6 +1020,16 @@ pub struct ButtonState {
/// windows lean on the second alone — a tradeskill/craft/trainer recipe row blanks its
/// highlight texture to `""` and locks the selected row anyway, purely for the white label.
pub locked_highlight: bool,
+ /// The client's **currently-shown state texture** — the pointer at `+0x4c4`. Private, and the
+ /// only thing [`Self::region_visible`] consults for the three state textures: it is written by
+ /// [`Self::set_state`] and [`Self::set_state_slot`] alone, which is what makes the transition
+ /// rule un-bypassable.
+ shown: Option,
+ /// The state [`Self::shown`] was last resolved for — the client's `[+0x328]`. Latched from the
+ /// three inputs ([`Self::enabled`], [`Self::pushed_state`] and the mouse's held+hovered) by
+ /// [`Self::settle`], so a *transition* can be detected at all; the client keeps the same one
+ /// variable for the same reason.
+ state: ButtonVisualState,
}
impl Default for ButtonState {
@@ -1017,45 +1058,133 @@ impl Default for ButtonState {
highlight_color: None,
disabled_color: None,
locked_highlight: false,
+ shown: None,
+ state: ButtonVisualState::Normal,
}
}
}
impl ButtonState {
- /// Whether a region of this button draws, given the interaction inputs (`hovered` = the cursor
- /// is over the button; `held` = a mouse press captured it and hasn't released). The exclusive
- /// state textures resolve to one "current": disabled → Disabled, with **no** Normal fallback.
- /// This is the byte-verified rule (decision 0227; wow-re
- /// `system/ui/scratch/button-check-and-state-texture.md`, `SetState 0x779790`): the shown
- /// pointer `+0x4c4` always holds the *current state's own* slot (`[this+state*4+0x4b8]`), so a
- /// null new-state slot draws nothing — the reference's empty spellbook slots are exactly this
- /// (born-disabled SpellButtons whose UI-Quickslot2 NormalTexture never shows). Held+hovered →
- /// Pushed, falling back to Normal when unset (a pressed button without pushed art keeps its
- /// normal art in the reference). Else Normal. Highlight/Checked draw additively per their own
- /// conditions. Any region that is not one of the state textures (ButtonText, user regions)
- /// always draws.
+ /// The slot a state draws from — the client's `[this + state*4 + 0x4b8]`.
+ fn state_slot(&self, state: ButtonVisualState) -> Option {
+ match state {
+ ButtonVisualState::Disabled => self.disabled,
+ ButtonVisualState::Normal => self.normal,
+ ButtonVisualState::Pushed => self.pushed,
+ }
+ }
+
+ /// The state the three inputs put the button in — disabled wins, then the press (the mouse's
+ /// held+hovered, or the scripted [`Self::pushed_state`]), else resting.
+ fn input_state(&self, hovered: bool, held: bool) -> ButtonVisualState {
+ if !self.enabled {
+ ButtonVisualState::Disabled
+ } else if (held && hovered) || self.pushed_state {
+ ButtonVisualState::Pushed
+ } else {
+ ButtonVisualState::Normal
+ }
+ }
+
+ /// **`CSimpleButton::SetState 0x779790`** — the transition, and the whole of why the shown
+ /// texture is state rather than a lookup.
///
- /// One known divergence from the byte-exact `0x779790` (INTERIM, not load-bearing): the client
- /// only *updates* `+0x4c4` when the new state HAS a texture — hiding the old is gated on that,
- /// so disabling an already-shown button with a null Disabled slot leaves the OLD texture
- /// sticky-visible. This is a pure function of the current state instead, so it hides that
- /// texture. The visible case that matters — a born-disabled slot — agrees either way (never in
- /// the normal state, so nothing was ever shown). Reproducing the sticky path needs a stateful
- /// shown-pointer this model deliberately doesn't carry yet.
- pub fn region_visible(&self, rh: RegionHandle, hovered: bool, held: bool) -> bool {
+ /// Both halves of the swap test the SAME dword — the new state's own slot: `0x7797b5` loads
+ /// `[esi + 4*edi + 0x4b8]` and `0x7797be` skips the hide when it is null; `0x7797d9`/`0x7797e2`
+ /// skips the show on the same value. The state itself is written regardless (`0x779801`). So a
+ /// transition into a state with no texture of its own **changes nothing** — the
+ /// previously-shown texture stays up, and there is no fallback path to Normal anywhere in the
+ /// function. The equality early-out sits at `0x7797a3`/`0x7797a9`, *after* the unconditional
+ /// `[+0x32c] = locked` store and *before* every texture step. wow-re
+ /// `system/ui/scratch/button-disabled-state-texture-law.md`, VERIFIED.
+ ///
+ /// **A button is NORMAL before its art is loaded, which is what makes the rule bite.** The
+ /// `CSimpleButton` ctor `0x7786a0` writes `[+0x328] = 0` and `[+0x4c4] = 0`, then ends
+ /// `push 1; call 0x779160` → `[vtbl+0x9c]` = this function with NORMAL. Only then does
+ /// `LoadXML` install the art, each child through the `0x778fd0` setter family
+ /// ([`Self::set_state_slot`]) — `` at `0x77890e` with idx 1, which matches, so
+ /// it is shown on the spot. Every button in the family therefore reaches its first `Disable()`
+ /// already wearing its normal art.
+ ///
+ /// Three things the reference draws out of that one rule, which a pure `state → slot`
+ /// resolution cannot:
+ ///
+ /// - **A press with no PushedTexture keeps its normal art.** (Our old resolution special-cased
+ /// this as `pushed.or(normal)` — the fallback was never a rule, it was this mechanism seen
+ /// from one side.)
+ /// - **`Disable()` on a button with no DisabledTexture keeps its normal art.** This is B369:
+ /// `ReputationDetailAtWarCheckBox` has a `` and no ``, and
+ /// `ReputationFrame_Update` `Disable()`s it for a faction whose war flag cannot be toggled —
+ /// in the reference the box stays on screen (greyed label, still a box); resolving the shown
+ /// texture as a pure function of the state made it vanish, leaving a bare label.
+ /// `Disable 0x77ffd0` reaches here through `0x78009a call [vtbl+0x90](0)` → `0x779160`.
+ /// - **So does an empty spellbook slot's `UI-Quickslot2` ring**, which is the same shape and
+ /// was the case decision 0227 got backwards — see 2011.
+ fn set_state(&mut self, new: ButtonVisualState) {
+ if new == self.state {
+ return;
+ }
+ self.state = new;
+ if let Some(slot) = self.state_slot(new) {
+ self.shown = Some(slot);
+ }
+ }
+
+ /// Run the state machine over the current inputs — the caller's job at every point one of them
+ /// can have moved (`script::button::settle`, which reads the mouse's two off the model).
+ ///
+ /// The client has no such call because it has no derived inputs: its mouse handlers call
+ /// `SetState` directly (`0x7791ed` enter, `0x7793f0` leave, `0x7792ad` down, `0x7793c2` up,
+ /// each gated on `locked == 0`) and so do `Enable`/`Disable`. Ours keeps the inputs as fields
+ /// and latches [`Self::state`] from them here; the transitions that result are the same ones,
+ /// in the same order.
+ pub fn settle(&mut self, hovered: bool, held: bool) {
+ self.set_state(self.input_state(hovered, held));
+ }
+
+ /// **`SetNormalTexture`/`SetPushedTexture`/`SetDisabledTexture 0x778fd0`** — the slot store,
+ /// plus the conditional push to the shown pointer: `[this+idx*4+0x4b8] = tex`, and `+0x4c4`
+ /// takes it **only when `idx == [this+0x328]`** (the gate at `0x779027`). Setting a state's
+ /// texture while the button is in a *different* state does not display it: a fresh region is
+ /// born hidden (`0x77f695` writes `+0xc4 = 0`), so a non-current slot is installed dark.
+ ///
+ /// This is also how a button's art first reaches the screen at all — see [`Self::set_state`]
+ /// for why LoadXML always finds the button in NORMAL.
+ ///
+ /// **One stated divergence.** When the write DISPLACES an occupant, the reference *destroys*
+ /// it (`0x77900a` calls the old object's `vtbl[0](1)`) and clears `+0x4c4` if that occupant
+ /// was the shown one. Ours never gets there: `script::button::ensure_slot` creates a slot's
+ /// region once and later `SetNormalTexture` calls repaint that same region, so the handle is
+ /// stable and an addon holding `GetNormalTexture()` keeps a live object where the reference
+ /// would have handed it a dead one. Nothing in the corpus reads a state texture across a
+ /// replacement; the object-identity half is not modeled.
+ pub fn set_state_slot(&mut self, state: ButtonVisualState, rh: Option) {
+ match state {
+ ButtonVisualState::Disabled => self.disabled = rh,
+ ButtonVisualState::Normal => self.normal = rh,
+ ButtonVisualState::Pushed => self.pushed = rh,
+ }
+ if state == self.state {
+ self.shown = rh;
+ }
+ }
+
+ /// Whether a region of this button draws. The three state textures answer from the shown
+ /// pointer alone ([`Self::set_state`]) — which is why the press is no longer an argument here:
+ /// it is an *input to the transition*, consumed at [`Self::settle`], not something a paint
+ /// re-derives. `hovered` stays because the Highlight is not a state texture and really does
+ /// track the cursor with no latch of its own. Checked draws additively per its own condition,
+ /// and any region that is not one of these (ButtonText, user regions) always draws.
+ pub fn region_visible(&self, rh: RegionHandle, hovered: bool) -> bool {
let some = Some(rh);
if some == self.normal || some == self.pushed || some == self.disabled {
- let current = if !self.enabled {
- self.disabled
- } else if (held && hovered) || self.pushed_state {
- self.pushed.or(self.normal)
- } else {
- self.normal
- };
- return some == current;
+ return some == self.shown;
}
if some == self.highlight {
- return self.enabled && (hovered || self.locked_highlight);
+ // No `enabled` term: a disabled button loses its highlight because `Disable()` turns
+ // the whole HIGHLIGHT draw layer off (`script::button::set_enabled`), which extract
+ // applies before it ever reaches here.
+ return hovered || self.locked_highlight;
}
if some == self.checked_tex {
return self.checked && (self.enabled || self.disabled_checked.is_none());
diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs
index f184e385..dcb1979e 100644
--- a/crates/benilla-ui/src/widget/mod.rs
+++ b/crates/benilla-ui/src/widget/mod.rs
@@ -174,13 +174,13 @@ impl Arena {
mod kinds;
pub use kinds::{
model_key, slider_fraction, slider_grab, ArmedSequence, ButtonFont, ButtonState,
- ColorSelectState, CooldownState, EditAction, EditBoxState, EditOutcome, EditUnit, FrameKind,
- InsertMode, KindState, MessageFrameState, MessageLine, MinimapState, ModelFileFacts,
- ModelPlayHead, ModelState, RegionKind, ScrollFrameState, ScrollingMessageState, SequenceFacts,
- SliderState, StatusBarState, TooltipState, COOLDOWN_FLASH_SECS, MINIMAP_DEFAULT_ARROW_MODEL,
- MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM,
- MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS,
- TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
+ ButtonVisualState, ColorSelectState, CooldownState, EditAction, EditBoxState, EditOutcome,
+ EditUnit, FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, MinimapState,
+ ModelFileFacts, ModelPlayHead, ModelState, RegionKind, ScrollFrameState, ScrollingMessageState,
+ SequenceFacts, SliderState, StatusBarState, TooltipState, COOLDOWN_FLASH_SECS,
+ MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL,
+ MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP,
+ TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
diff --git a/crates/benilla-world/src/clutter.rs b/crates/benilla-world/src/clutter.rs
index 4796d6f9..3819dfb7 100644
--- a/crates/benilla-world/src/clutter.rs
+++ b/crates/benilla-world/src/clutter.rs
@@ -40,14 +40,52 @@ impl Plugin for ClutterPlugin {
.add_systems(
Update,
(
+ remesh_on_cutout_change,
stream_chunk_clutter,
evict_clutter_geometry,
scope_clutter_geometry,
- ),
+ )
+ .chain(),
);
}
}
+/// Drop every built clutter mesh when the detail-doodad **cutout** moves, so the lazy builder
+/// re-meshes the bubble against the new reference — the alpha-test ref is baked into the material at
+/// build time (it is part of `model_material`'s dedup key), so nothing already on screen would
+/// otherwise notice. `detailDoodadAlpha` is a live console command in the reference
+/// (`0x6739a0`, registrar `0x63f9e0` — a command, not a CVar, so it never persists), where the
+/// global is read at draw time and needs no rebuild; ours costs a re-mesh of the ~30 chunks in the
+/// bubble, which the per-frame cap spreads over a few frames.
+///
+/// Watches the **value**, not `is_changed()`, for the reason `terrain_stream::rescatter_clutter`
+/// spells out: the cvar sync deref-muts every knob resource whenever any cvar moves, so the flag
+/// over-fires. First sight only arms.
+fn remesh_on_cutout_change(
+ mut commands: Commands,
+ cfg: Res,
+ mut chunks: Query<&mut ClutterChunk>,
+ mut last: Local>,
+) {
+ let Some(prev) = last.replace(cfg.alpha_ref) else {
+ return;
+ };
+ if prev == cfg.alpha_ref {
+ return;
+ }
+ let mut n = 0;
+ for mut cc in &mut chunks {
+ for e in cc.built.drain(..) {
+ commands.entity(e).try_despawn();
+ n += 1;
+ }
+ }
+ info!(
+ "clutter: detailDoodadAlpha {} — dropped {n} built mesh(es) to re-cut",
+ (cfg.alpha_ref * 255.0).round() as u32
+ );
+}
+
/// Drop the decoded clutter-geometry cache on a cross-map transition (`world_map::MapChange` —
/// see its doc): it holds CPU submesh copies of every detail M2 ever decoded, and clutter models
/// are map-flavored (a new map re-decodes its own handful on first build).
@@ -515,26 +553,28 @@ pub(crate) fn stream_chunk_clutter(
};
let build_d2 = (reach + CLUTTER_BUILD_MARGIN).powi(2);
let drop_d2 = (reach + CLUTTER_BUILD_MARGIN + CLUTTER_TEARDOWN_HYSTERESIS).powi(2);
- let mut budget = CLUTTER_BUILDS_PER_FRAME;
+ // The LATE-BUILD tripwire (2012). A chunk built while its nearest corner is ALREADY inside the
+ // fade horizon had grass the player could see before the mesh existed — the "it popped in"
+ // class, and the thing to rule out first whenever clutter is reported appearing abruptly.
+ // Straight-line distance under the horizon implies view depth under it too, so this catches
+ // every genuinely-visible case (and some invisible ones, which is the safe direction). Two
+ // causes, and the line says which: the gate let it through late, or the per-frame cap deferred
+ // it — the second is expected in the burst right after a login or a teleport, when the whole
+ // bubble is built at once, and is why this reports the backlog rather than just the count.
+ let visible_d2 = cfg.fade_far.powi(2);
+
+ // Pass 1: measure every chunk, tear down what has left, and collect what wants building.
+ // **Nearest first** (2012). The build budget is spent in whatever order the query hands the
+ // chunks over, which is archetype order — so a chunk 90 yd away, where the ramp is already
+ // alpha 0 and nobody can see it, would take a build slot ahead of one at 48 yd that is inside
+ // the visible band. On a cold fill that is a pop with a free fix: sort the candidates by
+ // distance and the cap always buys the most visible grass first.
+ let mut wanted: Vec<(f32, Entity)> = Vec::new();
for (ent, mut cc) in &mut chunks {
let d2 = box_distance_squared(cam_pos, cc.bounds);
if cc.built.is_empty() {
- if d2 <= build_d2 && budget > 0 {
- budget -= 1;
- let built = build_chunk_clutter(
- ent,
- &cc.models,
- cfg.scale,
- cfg.alpha_ref,
- cfg.fade_far,
- &mut geometry,
- &mut assets,
- &mut meshes,
- &mut images,
- &mut materials,
- &mut commands,
- );
- cc.built = built;
+ if d2 <= build_d2 {
+ wanted.push((d2, ent));
}
} else if d2 > drop_d2 {
for e in cc.built.drain(..) {
@@ -542,6 +582,46 @@ pub(crate) fn stream_chunk_clutter(
}
}
}
+ wanted.sort_by(|a, b| a.0.total_cmp(&b.0));
+ let backlog = wanted.len().saturating_sub(CLUTTER_BUILDS_PER_FRAME);
+
+ // Pass 2: spend the frame's budget on the nearest of them.
+ let (mut late, mut late_nearest) = (0usize, f32::MAX);
+ for &(d2, ent) in wanted.iter().take(CLUTTER_BUILDS_PER_FRAME) {
+ let Ok((_, mut cc)) = chunks.get_mut(ent) else {
+ continue;
+ };
+ if d2 < visible_d2 {
+ late += 1;
+ late_nearest = late_nearest.min(d2.sqrt());
+ }
+ let built = build_chunk_clutter(
+ ent,
+ &cc.models,
+ cfg.scale,
+ cfg.alpha_ref,
+ cfg.fade_far,
+ &mut geometry,
+ &mut assets,
+ &mut meshes,
+ &mut images,
+ &mut materials,
+ &mut commands,
+ );
+ cc.built = built;
+ }
+ if late > 0 {
+ warn!(
+ "clutter: {late} chunk(s) built INSIDE the {:.0} yd horizon (nearest {late_nearest:.1} yd) \
+ — grass appeared where it could already be seen; {}",
+ cfg.fade_far,
+ if backlog > 0 {
+ format!("{backlog} more still queued behind the {CLUTTER_BUILDS_PER_FRAME}/frame cap (expected in a login/teleport burst)")
+ } else {
+ "no build backlog, so the DISTANCE GATE let it through late".to_string()
+ }
+ );
+ }
}
#[cfg(test)]