the autocast shine goes stock, and a disabled button keeps its art

- the autocast shine is the reference's own Model: `UI-AutoCastButton.mdx` has no batch at all — four bones walking the corners of a 0.02-unit square on a 2 s loop, each with a spline emitter pouring 300 `GlowStar` sparks a second — and the tile renderer plays exactly that off the file's own tracks, so the hand-transcribed lane retires whole: `autocast_shine.rs` (the token texture, the extract's parked shine sites, the append-lane producer and its motion goldens), the extract's shine side channel, and `PetActionBarAdapters.xml`, since the pet button's numbers are the reference's (`setAllPoints` at `scale="1.2"`) and nothing was left to adapt; what stays is the director's two look calls, re-seated onto the reference's own objects — `SpellBookAdapters.xml` puts the stock `$parentAutoCast` Model at 37×37 CENTER (0,0) with `SetModelScale(1.48)` and the overlay at 71.53, the two tests that pin them reading the live panes, and deleting that file is the exact reference (36×36 at CENTER (1,1), 1.22, overlay 60); `assets/ui` is nine files (2014)
- a button's shown state texture is latched on the transition, not resolved at paint (B369): `ReputationDetailAtWarCheckBox` has a `<NormalTexture>` and no `<DisabledTexture>`, and once `ReputationFrame_Update` `Disable()`d it ours drew nothing — `ButtonState` resolved the shown texture as a pure function of (enabled, pushed, held+hovered); the client's `SetState` `0x779790` gates both its hide-old and show-new steps on the NEW state's slot being non-null, with no Normal fallback anywhere in the function, and the ctor `0x7786a0` ends in `SetState(NORMAL)`, so every button reaches its first `Disable()` (`0x77ffd0`) already wearing its normal art and keeps it; `ButtonState` now carries the client's two words, `shown` (`+0x4c4`) and `state` (`+0x328`), written only by `set_state` and the `0x778fd0` setter family's conditional push, with transitions applied where the client applies them — `Enable`/`Disable`/`SetButtonState` and the four mouse edges — and the old `pushed.or(normal)` special case was this rule seen from one side; the stock corpus has 70 such declarations, so empty spellbook slots wear their `UI-Quickslot2` ring again, which 0227 had removed on a premise the shipped Lua falsifies in one line (`SpellButton_UpdateButton` calls `Disable()` at runtime); and `Disable()` switches off the whole HIGHLIGHT draw layer — `0x779160` follows `SetState` with `push 4; call 0x76a730`, the per-layer enable on the same `[frame+0x198]` array `Enable/DisableDrawLayer` writes — so a `<Layer level="HIGHLIGHT">` region no longer draws over a disabled button and the `enabled &&` term on the HighlightTexture alone is gone; the reputation tests' header stops claiming the page and the watch bar were ours (2011)
- the world's mouse buttons are latched, not read raw (B364): right-clicking a Who-list row swung the camera round to behind the character — ~180° over nine seconds on a body that never turned — because the click was the UI's but the camera's input command word (`[InputControl+0x4]`) built its two mouse bits straight off `ButtonInput` with no pointer-over-UI term, `RIGHT_MOUSE` alone classifies as `Turn`, and Smart's Turn row is `(0.0, 1.0)`, so the return armed on the press edge and ran to completion; 1.12 never reads the mouse for any of this — it reads two bindings, `TurnOrAction` and `CameraOrSelectOrMove`, and a press a UI frame captured dispatches neither — so `camera::WorldMouse` latches each primary at the press and holds it to that button's own release, written in its own system ordered ahead of every reader (latched rather than re-tested, because a level test would hand a button back to the UI mid-drag the moment a frame appeared under the locked cursor, and an edge is what arms a return); five readers move onto it — the follow word's mouse bits, the both-button run, `/follow`'s both-button cancel, the look session's engage, chord cancel and other-primary tests, and the both-button → single-button handoff — and `control` drops its `PointerOverUi` and `&Camera`, the viewport-vs-UI question no longer being its to ask
- the clutter onset is the cutout, and it gets the reference's own dial: the ramp runs 52.64 → 69.86 yd but the draw alpha-tests `texel.a × ramp` against `detailDoodadAlpha` = 128/255 with the ramp capped at 252/255, so nothing survives past 61.11 yd — grass appears there already at 50.2 % opacity with 8.5 yd left to reach full, and the last third of the ramp is spent where nothing is drawn, which is exactly the "started before it became visible from another gate" the director saw; `detailDoodadAlpha` is a console command in the reference (`0x6739a0`, registered through the command table `0x63f9e0`, not `CVar::Register` `0x63db90`, so it never persists), and we had it only as the boot-time `WOW_CLUTTER_ALPHA`, which cannot be A/B'd against a running client — `/console detailDoodadAlpha <0-255>` now sets it live, bare reports, and out of range is rejected rather than clamped (`0x6739b9: cmp eax,0xff; jbe`), classified in the console drain of 2008's `ConsoleExec` seam so it never reaches the CVar store, the bubble re-cut on a change because the ref is baked into the material; at 128 grass appears at 61.11 yd at half opacity, at 8 the onset walks out to ~69.8 yd and fades in from nothing; a late-build tripwire in the streamer — a warning whenever a chunk is built whose box is already inside the fade horizon, naming whether the distance gate or the per-frame cap was the cause — fired at once on the second: the build budget was spent in query order, so a chunk 90 yd out where the ramp is already zero took a slot ahead of one at 48 yd in plain view; candidates are sorted by distance now, and a 30 s Elwynn run reports one late-build frame, the cold first, then silence (2012)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-06 19:34:43 +07:00
co-authored by Claude Fable 5.1
parent c957f98d84
commit b7cb2668e9
29 changed files with 1182 additions and 1370 deletions
@@ -1,42 +0,0 @@
<Ui>
<!--
BENILLA_ALLOW_OWN_UI=1 — the one adapter the reference's own PetActionBarFrame.xml needs
from this engine (decision 1953; 1751 §2 keeps an adapter for exactly a genuine engine
difference).
The autocast shine. The reference's `$parentAutoCast` on every pet button is a <Model>
playing `Interface\Buttons\UI-AutoCastButton.mdx`, setAllPoints on the 30x30 button at
scale 1.2, shown and hidden by its own `PetActionBar_Update` (PetActionBarFrame.lua
l.135-139). This engine's <Model> holds the scene the Lua API reads and writes and draws no
pixels (`script/modelframe.rs`); the shine benilla draws is its own lane — the
`benilla:autocast-shine:<scale>` token texture (decisions 1383/1387, `autocast_shine.rs`),
which reads the model's OWN tracks out of the shipped .m2. So each button gets a Shine
texture over its whole rect carrying the reference's own 1.2, and the reference's
`PetActionBar_Update` is wrapped to carry the shine with the model.
Nothing else. The reference's file is sourced unedited: its Models exist and are harmless.
-->
<Script><![CDATA[
for i = 1, NUM_PET_ACTION_SLOTS do
local button = getglobal("PetActionButton" .. i)
local shine = button:CreateTexture("PetActionButton" .. i .. "Shine", "OVERLAY")
shine:SetTexture("benilla:autocast-shine:1.2")
shine:SetAllPoints(button)
shine:Hide()
end
local reference_PetActionBar_Update = PetActionBar_Update
function PetActionBar_Update()
reference_PetActionBar_Update()
for i = 1, NUM_PET_ACTION_SLOTS do
local model = getglobal("PetActionButton" .. i .. "AutoCast")
local shine = getglobal("PetActionButton" .. i .. "Shine")
if model:IsShown() then
shine:Show()
else
shine:Hide()
end
end
end
]]></Script>
</Ui>
@@ -1,24 +1,27 @@
<Ui>
<!--
BENILLA_ALLOW_OWN_UI=1 — the one adapter the reference's own SpellBookFrame.xml needs from
this engine (decision 1952; 1751 §2 keeps an adapter for exactly a genuine engine difference).
BENILLA_ALLOW_OWN_UI=1 — the director's two LOOK deviations on the pet page's autocast
button, over the reference's own SpellBookFrame.xml (decisions 1392/1393; re-seated by 2014
onto the reference's own Model now that this engine renders it — 2013).
The pet page's autocast shine. The reference's `$parentAutoCast` on every SpellButton is a
<Model> playing `Interface\Buttons\UI-AutoCastButton.mdx` (36x36, CENTER (1,1), scale 1.22),
shown and hidden by its own `SpellButton_UpdateButton` (SpellBookFrame.lua l.367-377). This
engine's <Model> holds the scene the Lua API reads and writes and draws no pixels
(`script/modelframe.rs`); the shine benilla draws is its own lane — the
`benilla:autocast-shine:<scale>` token texture (decisions 1383/1392, `autocast_shine.rs`),
which reads the model's OWN tracks out of the shipped .m2. So each SpellButton gets a Shine
texture beside the model, at the numbers 1393 squared onto this button (37x37 CENTER (0,0),
scale 1.48 — the reference's 36x36 CENTER (1,1) at 1.22, one ratio each, recorded there), and
the reference's SpellButton_UpdateButton is wrapped to carry the shine with the model.
Since 2013 the `$parentAutoCast` <Model> on every SpellButton draws its own file
(Interface\Buttons\UI-AutoCastButton.mdx) as a tile: four bones chasing the corners of a
0.02-unit square, four spline emitters trailing GlowStar sparks off them, on the pane's own
clock. The reference declares it 36x36 at CENTER (1,1) at scale 1.22 — a 0.87x rim that
floats clear of the viewport edge, never clipped, washing the icon; the real 1.12 client
looks the same way (director-checked, 1392), so this is taste, not fidelity, and the
director chose the pet button's proportions instead:
1393's other number rides along for the same reason: the bracket overlay is 71.53 rather
than the reference's 60, so the art — which fills 33/64 of UI-AutoCastableOverlay.blp —
lands on this button's corners at the pet button's own 0.997x.
· the shine pane squared on the button — 37x37 at CENTER (0,0), scale 1.48 — so the rim
runs ON the viewport edge (1.024x, the pet button's own ratio) and the tile's cell, the
widget's own scissor, halves every star; and so the glow and the brackets share a centre
(1393's "top and right are off" was the reference's own +1,+1);
· the corner brackets at 71.53 rather than the reference's 60 — the art fills 33/64 of
UI-AutoCastableOverlay.blp, and 71.53 = 37 x 58/30 puts the bracket square on this
button's corners at the pet button's 0.997x.
Nothing else. The reference's file is sourced unedited: its Models exist and are harmless.
Reverting the widget to 1.12 exactly is: overlay 60, pane 36x36 CENTER (1,1), scale 1.22 —
i.e. deleting this file. Nothing else here: the reference's file is sourced unedited.
-->
<Script><![CDATA[
for i = 1, 12 do
@@ -26,30 +29,12 @@
local overlay = getglobal("SpellButton" .. i .. "AutoCastable")
overlay:SetWidth(71.53)
overlay:SetHeight(71.53)
local shine = button:CreateTexture("SpellButton" .. i .. "Shine", "OVERLAY")
shine:SetTexture("benilla:autocast-shine:1.48")
shine:SetWidth(37)
shine:SetHeight(37)
shine:SetPoint("CENTER", button, "CENTER", 0, 0)
shine:Hide()
end
local reference_SpellButton_UpdateButton = SpellButton_UpdateButton
function SpellButton_UpdateButton()
reference_SpellButton_UpdateButton()
if not this then
return
end
local name = this:GetName()
local model = getglobal(name .. "AutoCast")
local shine = getglobal(name .. "Shine")
if model and shine then
if model:IsShown() then
shine:Show()
else
shine:Hide()
end
end
local pane = getglobal("SpellButton" .. i .. "AutoCast")
pane:SetWidth(37)
pane:SetHeight(37)
pane:ClearAllPoints()
pane:SetPoint("CENTER", button, "CENTER", 0, 0)
pane:SetModelScale(1.48)
end
]]></Script>
</Ui>
+3 -7
View File
@@ -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 <Model>
# (UI-AutoCastButton.mdx) and this engine's <Model> 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
# <Model> (UI-AutoCastButton.mdx) and this engine's <Model> 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
-803
View File
@@ -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 `<Model>` 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 `<Model>` 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 `<Model>` 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 `<Model>`'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 `<Size>36×36</Size>` 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 `<Model>` 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 `:<f32>` suffix carries the `<Model>`'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<f32> {
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=<n>` stays as the A/B instrument, not as a pending question.
fn live_per_emitter() -> usize {
static N: std::sync::LazyLock<usize> = 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=<f>`, 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<f32> = 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<Vec<(f32, f32, [f32; 4])>> = 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<Rect>,
/// 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 `<Model>`'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<Image>,
}
/// 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<ShineSite>);
/// 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<ShineClock>,
sites: Res<ShineSites>,
time: Res<Time<Real>>,
mut quads: ResMut<UiQuads>,
) {
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<UiQuad>) {
// THE WIDGET IS ITS OWN SCISSOR (decision 1387). A `<Model>` 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::<ShineSites>()
.init_resource::<ShineClock>()
.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 `<Model>`'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");
}
}
-2
View File
@@ -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 `<Model>` widgets' M2s, rendered as tiles of one atlas and composited at the
// callback rank (decision 2008).
.add_plugins(ui_models::UiModelsPlugin)
+12 -1
View File
@@ -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::<crate::run_mode::CaptureMode>)),
)
// **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
+214 -22
View File
@@ -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<LookButton>,
/// **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<Vec2>,
/// 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<MouseButton>` (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<MouseButton>, 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<ButtonInput<MouseButton>>,
pointer_over_ui: Res<PointerOverUi>,
mut rig: ResMut<CameraControl>,
cameras: Query<&Camera, With<FlyCam>>,
window: Single<&Window, With<PrimaryWindow>>,
) {
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::<MouseButton>::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::<MouseButton>::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::<MouseButton>::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
+14 -13
View File
@@ -80,7 +80,6 @@ pub(super) fn control(
Res<MoveSpeed>,
Res<PlayerCapsule>,
Res<CameraProbe>,
Res<PointerOverUi>,
Res<InspectMode>,
Res<crate::ui_script::UiKeyboardCapture>,
Res<crate::ui_script::PlayerUiClickConsumed>,
@@ -108,7 +107,7 @@ pub(super) fn control(
mut rig: ResMut<CameraControl>,
// 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<Camera>>,
// **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,
+11 -6
View File
@@ -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
+16 -8
View File
@@ -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<MouseButton>,
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),
+35 -14
View File
@@ -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 `<cvar> <value>`";
let text = if cmd.is_empty() {
"console: no command given (this client implements: reloadUI, and \
`<cvar> <value>`)"
.to_string()
format!("console: no command given (this client implements: {IMPLEMENTED})")
} else {
format!(
"console: '{cmd}' is not implemented (this client implements: reloadUI, \
and `<cvar> <value>`)"
"console: '{cmd}' is not implemented (this client implements: {IMPLEMENTED})"
)
};
chat_log.push_event(super::event::ChatEvent::text_only(
+31 -2
View File
@@ -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<u8> },
/// 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::<u8>().ok()),
}
}
_ => ParsedChat::ConsoleUnknown {
cmd: line.trim().to_string(),
},
}
}
fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
use SlashIndex as S;
match index {
+41
View File
@@ -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()
}
);
}
@@ -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
}
+20 -154
View File
@@ -280,7 +280,7 @@ struct Alignment {
/// `None` — it must be converted. Kept indices are strictly increasing.
source: Vec<Option<usize>>,
/// 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<usize>,
}
@@ -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<UiQuad> = Vec::with_capacity(quads.quads.len());
let mut spans: Vec<u32> = 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<crate::run_mode::CaptureMode>>,
Res<super::UiCostWanted>,
),
// The append-lane holes this pass parks for their UiQuadAppend producers, tupled for the
// same param-budget squeeze as `run`:
// · the `<Minimap>` 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<crate::minimap::MinimapWidget>,
ResMut<crate::autocast_shine::ShineSites>,
),
// The append-lane hole this pass parks for its UiQuadAppend producer: the `<Minimap>` 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<crate::minimap::MinimapWidget>,
// 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<crate::autocast_shine::ShineSite> = 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<std::ops::Range<usize>> = 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<ResMut<WorldAssets>>,
@@ -1300,7 +1281,6 @@ fn convert_entry(
String,
)>,
minimap_slot: &mut Option<crate::minimap::MinimapSlot>,
shine_sites: &mut Vec<crate::autocast_shine::ShineSite>,
) {
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::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
@@ -2149,7 +2099,6 @@ mod clip_plumb_tests {
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
@@ -2357,7 +2306,6 @@ mod extract_gate_tests {
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
@@ -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::<crate::autocast_shine::ShineSites>();
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::<UiQuads>().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::<crate::ui_script::UiCostWanted>()
.0 = true;
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("marker:SetTexture(0, 0, 1)")
.unwrap();
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_script::UiFrameCost>()
.spliced,
1,
"the marker write splices; the token entry did not change"
);
assert_eq!(
app.world()
.resource::<crate::autocast_shine::ShineSites>()
.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::<UiScript>()
.run("getglobal('PetB'):Hide()")
.unwrap();
app.update();
assert!(
app.world()
.resource::<crate::autocast_shine::ShineSites>()
.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();
@@ -528,7 +528,6 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
@@ -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 `<OnEnter>` with `this` bound, which is the only
@@ -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 `<NormalTexture>` and
/// **no `<DisabledTexture>`**, 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<String> {
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::<i64>("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::<i64>("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
@@ -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::<f32>().ok())
// No <Size> 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
@@ -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::<bool>(&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::<bool>("return SpellButton1Shine:IsVisible()")
.eval::<bool>("return SpellButton1AutoCast:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return SpellButton3Shine:IsVisible()")
.eval::<bool>("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<f32> = ["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<f32> = ["GetWidth", "GetHeight", "GetModelScale"]
.iter()
.map(|m| {
s.eval::<f32>(&format!("return SpellButton1Shine:{m}()"))
s.eval::<f32>(&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::<f32>("return SpellButton1Shine:GetLeft() - SpellButton1:GetLeft()")
.eval::<f32>("return SpellButton1AutoCast:GetLeft() - SpellButton1:GetLeft()")
.unwrap();
let dy = s
.eval::<f32>("return SpellButton1Shine:GetBottom() - SpellButton1:GetBottom()")
.eval::<f32>("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"
);
+91 -11
View File
@@ -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<T>(
lua: &Lua,
this: &Table,
@@ -233,16 +243,86 @@ fn with_button<T>(
) -> mlua::Result<T> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().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 `<Layer level="HIGHLIGHT">` 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::<Model>().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<u32>) -> 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.
//
+4
View File
@@ -169,6 +169,10 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
.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;
}
+6 -5
View File
@@ -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) {
+7
View File
@@ -1248,7 +1248,14 @@ impl UiScript {
self.fire_drag_stop(source);
}
let mut model = self.model_mut();
let held: Vec<crate::widget::FrameHandle> = 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;
+22 -7
View File
@@ -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".
@@ -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 `<PushedTexture>` 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 `<DisabledTexture>` 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<String> {
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<String> {
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::<i64>("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
+167 -38
View File
@@ -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,
/// `<NormalTexture>`/`SetNormalTexture` (`+0x4bc`).
pub normal: Option<RegionHandle>,
/// `<PushedTexture>` (`+0x4c0`) — shown while the mouse is held down over the button.
/// `<PushedTexture>` (`+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<RegionHandle>,
/// `<DisabledTexture>` (`+0x4b8`).
pub disabled: Option<RegionHandle>,
/// `<HighlightTexture>` (`+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<RegionHandle>,
/// CheckButton `<CheckedTexture>` (`+0x4e0`) — additive while checked.
/// CheckButton `<CheckedTexture>` (`+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<RegionHandle>,
/// CheckButton `<DisabledCheckedTexture>` (`+0x4e4`) — replaces CheckedTexture when disabled.
/// The greyed tick a peace-forced faction's At War box wears (B369).
pub disabled_checked: Option<RegionHandle>,
/// The `<ButtonText>` fontstring (`+0x338`; `SetText`). Always drawn.
pub text: Option<RegionHandle>,
@@ -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<RegionHandle>,
/// 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<RegionHandle> {
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`]) — `<NormalTexture>` 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 `<NormalTexture>` and no `<DisabledTexture>`, 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<RegionHandle>) {
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());
+7 -7
View File
@@ -174,13 +174,13 @@ impl<T> Arena<T> {
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,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
+98 -18
View File
@@ -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<ClutterConfig>,
mut chunks: Query<&mut ClutterChunk>,
mut last: Local<Option<f32>>,
) {
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)]