diff --git a/crates/benilla-app/assets/ui/PetActionBarAdapters.xml b/crates/benilla-app/assets/ui/PetActionBarAdapters.xml deleted file mode 100644 index 7edd4b10..00000000 --- a/crates/benilla-app/assets/ui/PetActionBarAdapters.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - diff --git a/crates/benilla-app/assets/ui/SpellBookAdapters.xml b/crates/benilla-app/assets/ui/SpellBookAdapters.xml index d37fa3be..80dfc700 100644 --- a/crates/benilla-app/assets/ui/SpellBookAdapters.xml +++ b/crates/benilla-app/assets/ui/SpellBookAdapters.xml @@ -1,24 +1,27 @@ diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 76095f09..13092b22 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -446,10 +446,6 @@ Interface\FrameXML\MultiActionBars.xml # right-click dialogs — ABANDON_PET / RENAME_PET / PETRENAMECONFIRM — now live). 0982's # transcription is gone. Interface\FrameXML\PetActionBarFrame.xml -# The one adapter the reference's file needs from this engine: its autocast shine is a -# (UI-AutoCastButton.mdx) and this engine's draws no pixels; the shine benilla draws is -# its own token texture (1383). BENILLA_ALLOW_OWN_UI=1. -PetActionBarAdapters.xml # The micro-button row in the bar's right-hand recess — the reference's own file (1751; # decision 1987), which its toc seats right after MainMenuBar (l.34-35). After MainMenuBar.xml: # the eight buttons carry parent="MainMenuBarArtFrame", resolved by name at load (1734), and a @@ -823,9 +819,9 @@ Interface\FrameXML\DressUpFrame.xml # action-bar files it calls into and after MainMenuBarMicroButtons (UpdateMicroButtons on show). The 'P' binding # calls its own ToggleSpellBook(BOOKTYPE_SPELL). 0216's transcription is gone. Interface\FrameXML\SpellBookFrame.xml -# The one adapter the reference's file needs from this engine: its pet-page autocast shine is a -# (UI-AutoCastButton.mdx) and this engine's draws no pixels; the shine benilla -# draws is its own token texture (1383/1392/1393). BENILLA_ALLOW_OWN_UI=1. +# The director's two look deviations on the pet page's autocast button (1392/1393), re-seated onto +# the reference's own Model now that this engine renders it (2013/2014): the corner brackets at +# 71.53 and the shine pane squared on the button at scale 1.48. BENILLA_ALLOW_OWN_UI=1. SpellBookAdapters.xml # ══ THE SOCIAL WINDOW — the reference's own, off the player's chain (1751; decision 1959) ══ # FriendsFrame.xml + FriendsFrame.lua: the O-key window's four tabs — friends/ignore, who, the diff --git a/crates/benilla-app/src/autocast_shine.rs b/crates/benilla-app/src/autocast_shine.rs deleted file mode 100644 index 07dc5dba..00000000 --- a/crates/benilla-app/src/autocast_shine.rs +++ /dev/null @@ -1,803 +0,0 @@ -//! The **autocast shine** — the flaming rim that runs round an autocasting pet button — drawn -//! natively on the UI quad APPEND lane (decisions 1383, 1386; ledger B282, B228). -//! -//! ## Why native (B282) -//! -//! Until 1383 the shine was ~92 script-layer `Texture` regions per button, `SetPoint`ed every -//! frame from a Lua `OnUpdate`. Moving a region dirties the layout epoch, so the first layout -//! getter later in the same tick forced a real anchor solve — whose cost is the whole-roster -//! preamble + fingerprint walk, NOT the ~92 moved leaves (decision 1350 scopes the *rounds*, not -//! the gate walk) — and the seeds-grown law forced a second full walk before the gate could -//! close. Measured at the SW gates pin on a default roster: **+4.25 ms cpu/frame for ONE -//! autocasting button**, spark-count-independent (23 sparks cost the same as 92), scaling with -//! the whole UI (an addon-laden roster pays multiples — nazriel's ~11 ms, B282). The reference -//! never pays any of this: its shine is a `` widget playing an M2 *outside* the FrameXML -//! layout tree. This module is that architecture on our substrate: the script layer carries one -//! **token texture** per button ([`SHINE_TOKEN`], shown/hidden on the autocast *edge*), the -//! extract's conversion records where it sits ([`ShineSite`] — the minimap-slot pattern), and -//! [`emit_shine`] appends the spark quads per frame with zero layout traffic: a shine-only frame -//! leaves the script pipeline fully settled. -//! -//! ## What is drawn — the M2's own law -//! -//! The reference's "autocast is on" mark is a `` playing -//! `Interface\Buttons\UI-AutoCastButton.mdx`; what that model actually is came out of the shipped -//! .m2 with `benilla-extract m2bones`/`m2part` (this block moved whole from `PetActionBar.xml`, -//! which owned the drawing until 1383): -//! -//! - 4 bones, pivoting at the corners of a 0.02 × 0.02 model-unit square, each keyed with the -//! SAME 5-key 2.000 s translation loop walking the square edge by edge (0.5 s per edge), the -//! four phase-shifted by one corner each so they chase one another round it. The tracks are -//! `interp == 1`, LINEAR: constant speed along each edge, no easing into the corners. -//! - 4 particle emitters, one per bone: 300/s, life 1.000 s, Add blend, unlit, texture -//! `Interface\Buttons\GlowStar.blp`, size half-extent 0.005 → 0.0015 → 0.001 over life, colour -//! (0.976,0.875,0.192,1) → (0.996,0.945,0.745,1) → (1,1,1,0). The spline's 4 control points are -//! ALL at the origin — a particle never travels: it is born where the bone is and shrinks away -//! there. The effect is four comet trails running round the button's rim, not a rotating ring. -//! -//! MODEL UNITS → FrameXML units is the widget's own projection, not a calibration (wow-re -//! `system/ui/scratch/modelframe-animation-clock.md`, decision 1321's fold-back): the ortho leg -//! collapses to **1280 × modelScale × layoutScale** FrameXML units per model unit, independent of -//! aspect and resolution; `scale=` on the ref's `` is SetModelScale, and the model ORIGIN -//! is the frame rect's BOTTOM-LEFT, which is why a square authored 0..0.02 lands across the whole -//! rect instead of in one quadrant. -//! -//! **The ``'s rect and scale are per-TEMPLATE, and the reference does not repeat itself** -//! (decision 1391) — which is why they ride the [`ShineSite`] and not a constant: -//! -//! - **pet button** — `setAllPoints` on 30×30 at `scale="1.2"`: the path is **30.72 units**, -//! 1.024× the button, overhanging its top and right by 2.4% and clipped there. -//! - **spell book button** — its own `36×36` at `CENTER (1,1)` on a 37×37 button, at -//! `scale="1.22"`: the path is **31.232 units** inside a 36-unit rect that itself sits 1.5 units -//! in from the button's bottom-left. -//! -//! **The stars on that path do NOT take the same projection** ([`STAR`], decisions 1386/1390): -//! their half-extent is an EYE-SPACE length added after the transform, so it misses `modelScale` -//! — the square is scaled by 1.2 and the stars standing on it are not — and, unlike the square, -//! it keeps the ortho leg's aspect term: `2 · half · √(W² + H²)` **window px**, free of UI scale -//! and frame scale. 12.80 px at 1024×768; 14.69 at 1280×720. 1386 froze the 4:3 case into a -//! constant, which is why our 16:9 stars were 22 % small until 1390. -//! -//! **"Free of UI scale" is byte-law, not a loose phrase** (wow-re -//! `system/ui/scratch/modelframe-uiscale-law.md`, a §5 cross-check dispatched *because* the -//! phrasing looked too loose to rest on). `uiScale` is an ordinary `SetScale` on the frame named -//! `UIParent` (`0x494550` → `0x76ac10(ecx = [0xb4b44c])`, and `0x494590` "GetUiScale" is literally -//! `fld [[0xb4b44c]+0x7c]`), and it *does* reach both the widget's rect and the model root -//! (`76d1dd fmul [esi+0x7c]`). It cancels anyway: `0x76d240` builds the ortho `m00 = 2/w` **and** -//! the viewport from the SAME rect, so px per eye unit is `√(W² + H²)` whatever `w` is. So: -//! -//! > **the button scales with the dial and the star does not.** At 2560×1440 a newborn star is -//! > **29.37 px at every setting**, against a 37-unit button of 69.4 / 62.4 / 44.4 px at `uiScale` -//! > 1.0 / 0.9 / 0.64 — 42 % / 47 % / **66 %** of the button. -//! -//! And the consequence that actually reads on screen is not that ratio but this: the four emitters -//! sit on a path that DOES shrink with the dial (`31.2 · uiScale` units), so at 0.64 four -//! fixed-size 29 px stars pack into a 37 px square instead of a 59 px one and **overlap into one -//! blob**. Every bit of that is the reference's own behaviour; there is nothing to fix here, and a -//! low-`uiScale` complaint about this widget is a taste call, not a defect. -//! -//! **And the widget is its own scissor** ([`site_quads`], decision 1387): a `` installs its -//! frame rect as the VIEWPORT for the duration of its draw and restores it after, so the model -//! cannot paint one pixel outside the button. With the path running on the rim and the stars -//! straddling it, that clip is half the silhouette — the reference's flat outer edge with the -//! flames licking inward, against the soft halo an unclipped draw hangs round the whole button. -//! -//! **We draw the authored population, not a sample of it** (decision 1386). `rate × life` is -//! 300 live particles per emitter, 1200 for the model, and that number IS the look: the bone -//! lays one down every 0.205 units of rim while a newborn star is 12.8 units across at 4:3 (more -//! on a widescreen — [`STAR`]), so ~62 of them pile onto any point of the band and ADD saturates -//! it into one continuous flaming rim — -//! unevenly by channel (the ramp is gold, so R and G clip where B does not, which is why it -//! reads gold and not white), and what travels is the brightness envelope, not any one star. -//! 1383 shipped a 23-sample stand-in for that population, chosen to keep the *coverage* -//! unbroken, and coverage was the wrong invariant: the samples tile the same path at 1/13th the -//! intensity, so the band came apart into legible marching sparkles (B228, the director's A/B -//! against the reference). Sampling saved cost the script layer was charging us; native, the -//! authored count is affordable, so there is nothing left to approximate. -//! -//! ## The clock — truncation and all -//! -//! The reference does NOT play this loop at wall-clock rate (decision 1321, B228's second round): -//! `CSimpleModel` advances its private scene clock by `__ftol(elapsed * 1000.0)` — truncated, -//! no fractional carry (`0x76d846`; the WORLD driver adds 0.5 first, `0x48366b`) — so a 2000 ms -//! band takes `2000·T/floor(T)` ms at frame time T ms: 2083 ms at 60 fps, 2315 at 144, never -//! less than 2000. [`ShineClock`] is that integer-millisecond accumulator, advanced the -//! reference's way. All shine sites share one clock: the ref's per-button Models are all created -//! at UI load with `SetSequence(0)` and never drift, the loop being exact — one shared clock IS -//! that behaviour. - -use bevy::prelude::*; - -use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads}; - -/// The script layer's registration token: a `Texture` region whose path starts with this converts -/// to **no quads** — the conversion records a [`ShineSite`] at its rect/z instead, and -/// [`emit_shine`] draws there. Ours, not the install's — the string never reaches the resolver. -/// -/// An optional `:` suffix carries the ``'s **`scale=`** ([`token_model_scale`]), which -/// is `SetModelScale` and is not the same on every button that wears this shine (1391). -pub(crate) const SHINE_TOKEN: &str = "benilla:autocast-shine"; - -/// Read a token's model scale, or `None` if `path` is not a shine token at all — the single -/// definition of what the token's grammar is, shared by the conversion (which records the site) -/// and the shipped-XML sweep (which must exempt exactly this and nothing that merely resembles -/// it, so a typo still fails the archive check). -pub(crate) fn token_model_scale(path: &str) -> Option { - match path.strip_prefix(SHINE_TOKEN)? { - "" => Some(1.2), - rest => rest.strip_prefix(':')?.parse().ok(), - } -} - -/// The spark art — the M2's own emitter texture, resolved once per site at conversion time -/// through the same resolver every UI texture uses. -pub(crate) const STAR_TEXTURE: &str = "Interface\\Buttons\\GlowStar"; - -/// FrameXML units per model unit — `768 · 5/3`, **aspect- and resolution-independent** (wow-re -/// `modelframe-clip-and-scale.md` C2, confirmed exactly as 1321 recorded it): 1 model unit is -/// *not* 1 layout unit, because `0x76d1a0` always folds in `G48 · 5/3` (`76d1c6 0x41ae70(1.0)` = -/// G48 · `76d1cb fmul [0x80655c]` = f32(5/3)), and that `G48` cancels the `768·√(a²+1)` in the -/// FrameXML-unit definition. The star's half-extent ([`STAR`]) escapes that fold, which is the -/// whole reason the two constants no longer live in the same space. -const PER_MODEL_UNIT: f32 = 1280.0; - -/// The rim square's side in FrameXML units at a given `SetModelScale`: the M2's authored -/// 0.02-unit bone square through [`PER_MODEL_UNIT`]. **30.72 at the pet bar's `scale="1.2"`, -/// 31.23 at the spell book's `scale="1.22"`** — the ref writes a different number on each -/// template and 1391 stopped averaging them into one constant. -fn side(model_scale: f32) -> f32 { - 0.02 * PER_MODEL_UNIT * model_scale -} -/// The size ramp's authored **half-extents, in MODEL units** (`file+0x104`) — deliberately not -/// pre-projected like [`side`], because they do not take [`side`]'s projection (decision 1390; -/// wow-re `modelframe-clip-and-scale.md` C3, which corrects the FrameXML-unit constant 1386 wrote -/// here and confirms everything else in that record). -/// -/// A particle quad is not built in model space and transformed with the model: `0x7b3d20` -/// installs an **identity view** for the whole of the emitter's vertex generation (`7b3e41` save -/// / `7b3e46` install / restored at the tail), so the `±half` that `0x7b2a50` adds from the -/// `±1.0` corner table to the already-transformed position is an **eye-space length**; and file -/// flag `+0x04` bit `0x200` gates off the one multiply by the emitter scale (`0x7b2ba6 je`, taken -/// because `0x7b2ba3 test ah,0x2` finds the bit clear — the two *ungated* readers of -/// `[emitter+0x264]` are the bounding sphere). So `SetModelScale(1.2)` reaches the bone square -/// but **not** the stars standing on it. -/// -/// The consequence, and the reason this is a model-unit constant now: an eye-space length is -/// projected by the ortho leg, which maps the frame's *diagonal-normalized* layout rect to NDC -/// ±1 — so a star's size is **aspect-dependent where the path's is not**. -/// -/// > full width = `2 · half · 768·√(a²+1)` FrameXML units = **`2 · half · √(W² + H²)` window px** -/// -/// The pixel form is the one [`site_quads`] uses: no UI scale, no frame scale, just the screen -/// diagonal. That is 12.80 px at 1024×768 — the number 1386 froze into a constant — but **14.69 -/// px at 1280×720**, 22 % larger against a button 4 % *smaller*. The reference's shine really is -/// fatter on a widescreen; the 1280 that 1321 folded back is the 4:3 case of a law, not the law. -const STAR: [f32; 3] = [0.005, 0.0015, 0.001]; -/// The in-plane billboard spin, radians per second of age (`file+0x198`, `angle = spin · age`, -/// closed form at `0x7b2ddc`) — 0.1 rad = 5.73° over a particle's whole life. The per-particle -/// sign flip that `0x7b2dea` would apply is gated by runtime bit `0x8000` ← file `+0x04` bit -/// `0x200`, which is CLEAR here, so **every** star turns the same way (wow-re C4: the `test -/// bl,0x20` behind it reads slot parity, not a stored field — there is no RNG in it). -const SPIN: f32 = 0.1; -/// The over-life colour ramp's three keys, `{r, g, b, a}` — the emitter's own. -const COLOR: [[f32; 4]; 3] = [ - [0.976, 0.875, 0.192, 1.0], - [0.996, 0.945, 0.745, 1.0], - [1.000, 1.000, 1.000, 0.0], -]; -/// The bone loop, milliseconds (seq 0's authored 2.000 s band). -const PERIOD_MS: u32 = 2000; -/// The bone loop in seconds, for the phase/age arithmetic. -const PERIOD: f32 = 2.0; -/// A particle's lifespan, seconds (`file+0xc0`). -const LIFE: f32 = 1.0; -/// The authored emission rate, particles per second (`file+0xdc`). -const RATE: f32 = 300.0; -/// One emitter per bone corner. -const EMITTERS: usize = 4; -/// Where the over-life ramps break from key 0→1 to key 1→2 (`file+0x14c`). -const MID: f32 = 0.5; - -/// Sample a 3-key over-life ramp at normalized age `u` — the reference's own two-segment law -/// (`age > midPoint·lifespan` picks the segment, `0x7b5aaa`) with the inset it applies to the -/// segment-local time (`t = u·0.99 + 0.005`, `0x7b9b10`), so the authored endpoint values are -/// **never exactly reached** — a newborn star is already 0.5 % of the way toward the mid key. -/// Tiny, and carried because it is free and because a golden that pins the authored endpoints -/// pins something the reference never draws. -fn ramp(keys: [f32; 3], u: f32) -> f32 { - let (a, b, seg) = if u <= MID { - (keys[0], keys[1], u / MID) - } else { - (keys[1], keys[2], (u - MID) / (1.0 - MID)) - }; - let t = seg * 0.99 + 0.005; - a + (b - a) * t -} - -/// [`ramp`] over the colour keys, all four lanes. -fn ramp_color(age: f32) -> [f32; 4] { - std::array::from_fn(|i| ramp([COLOR[0][i], COLOR[1][i], COLOR[2][i]], age)) -} - -/// Where an emitter sits at lap fraction `f` (wrapped into 0..1), as a FrameXML-unit offset from -/// the button's BOTTOM-LEFT (the model's own origin — module doc). The bones walk the square -/// clockwise on screen from that corner, one edge per quarter-lap. -fn point(f: f32, side: f32) -> (f32, f32) { - let f = f - f.floor(); - let edge = f * 4.0; - let leg = edge.floor(); - let t = edge - leg; - match leg as u32 { - 0 => (0.0, t * side), // bottom-left → top-left - 1 => (t * side, side), // top-left → top-right - 2 => (side, side - t * side), // top-right → bottom-right - _ => (side - t * side, 0.0), // bottom-right → bottom-left - } -} - -/// How many particles one emitter keeps alive — the emitter's own steady state, `rate × life` -/// (decision 1386: the population IS the look, and 1383 shipped a 23-sample stand-in for it). -/// -/// **Both of the spawn driver's multipliers are byte-confirmed 1.0 for this widget** (wow-re -/// `modelframe-particle-density.md`): the camera-distance LOD -/// (`clamp(1 − (dist − 50)·0.02, 0.25, 1.0)`) sits at 1.0 *structurally*, because -/// `CSimpleModel::Draw` ZEROES the eye it is measured against (`0x76d3da`), leaving -/// `dist ≈ 0.02` layout units against a 50-unit inner radius — a UI model would have to be 50 -/// screen diagonals away to fall off; and `particleDensity` defaults to 1.0 (`0x82e92c`) with no -/// UI exemption. The emitter's pool is `trunc(life·rate·1.15)` = 345 slots, so the 300 never hit -/// a cap either. `WOW_SHINE_N=` stays as the A/B instrument, not as a pending question. -fn live_per_emitter() -> usize { - static N: std::sync::LazyLock = std::sync::LazyLock::new(|| { - std::env::var("WOW_SHINE_N") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or((RATE * LIFE).round() as usize) - }); - *N -} - -/// A multiplier on the authored star width (`WOW_SHINE_SIZE=`, default 1.0) — an A/B -/// instrument, not a pending question: the projection it used to bracket is settled (1390). -/// -/// The band's saturated THICKNESS is set by the quad width and its brightness by width × count, -/// so the two trade off and neither can be read off a capture alone — which is exactly why this -/// stays. `WOW_SHINE_SIZE=0.817` reproduces the pre-1390 4:3 sizing on a 16:9 window, so a -/// reference shot captured at 4:3 can be compared against ours without rebuilding. -/// **Never a tuning knob to leave dialled**: the shipped value is the one the bytes say. -fn size_scale() -> f32 { - static S: std::sync::LazyLock = std::sync::LazyLock::new(|| { - std::env::var("WOW_SHINE_SIZE") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1.0) - }); - *S -} - -/// The live population, computed once: one entry per particle, oldest last, carrying everything -/// its FIXED age fixes — its age, its **model-unit half-extent** ([`STAR`]; the site's screen -/// diagonal turns it into pixels at emit), and its colour+alpha. Only the position moves per -/// frame, which is what keeps a 1200-quad emitter cheap. -/// -/// The ages are the steady state of a `rate`/s emitter: one particle born every `1/rate` seconds, -/// none older than `life`. **Our one departure from the reference's own timeline**: it spawns in -/// per-frame batches (≈5 coincident particles per frame at 60 fps, at whatever position the bone -/// held that frame), where this spreads the same population evenly along the path. At the -/// authored star width against a 0.2-unit spawn pitch the two are the same picture; unlike a -/// sample count, it costs nothing to revisit, because the population is the file's. -static PARTICLES: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - let size_scale = size_scale(); - (0..live_per_emitter()) - .map(|k| { - let age = k as f32 / RATE; - let u = (age / LIFE).min(1.0); - (age, ramp(STAR, u) * size_scale, ramp_color(u)) - }) - .collect() -}); - -/// One autocasting button's shine site, recorded by the extract's conversion when it meets a -/// shown [`SHINE_TOKEN`] texture (the minimap-slot pattern): everything [`emit_shine`] needs to -/// draw there without asking the script layer anything. -#[derive(Clone, PartialEq)] -pub(crate) struct ShineSite { - /// The token region's resolved rect — the BUTTON's rect (the token is SetAllPoints on it) — - /// in window pixels, y-down. - pub(crate) rect: Rect, - /// The token's paint-order key, verbatim — sparks draw exactly where the old spark textures - /// drew (the button's OVERLAY layer). - pub(crate) z: u64, - /// The token's clip rect, if any (a scroll-framed ancestor), window px y-down. - pub(crate) clip: Option, - /// The token's effective UI alpha (parent chain folded), multiplied into every spark. - pub(crate) alpha: f32, - /// FrameXML-units → window-pixels factor at the conversion that recorded this site (the - /// extract's `s`); a resize re-extracts and re-records. Scales the bone PATH ([`side`]). - pub(crate) scale: f32, - /// The ``'s `SetModelScale`, read off the token ([`token_model_scale`]) — the ref - /// writes `1.2` on the pet button and `1.22` on the spell book's, so it belongs to the SITE - /// and not to a module constant (1391). Scales the bone path only; never the stars. - pub(crate) model_scale: f32, - /// The window's diagonal in the same pixels, `√(W² + H²)` — what scales the STARS, and - /// nothing else does (decision 1390; [`STAR`]'s law is in screen px, free of UI scale and - /// frame scale). Recorded beside `scale` so a resize re-answers both at once. - pub(crate) diag: f32, - /// `Interface\Buttons\GlowStar`, resolved at record time. - pub(crate) texture: Handle, -} - -/// This frame's shine sites. Refilled by every FULL conversion (and left alone by the settled / -/// spliced paths — the token entries didn't change, so last conversion's sites are still true; -/// the token arm is deliberately not splice-simple, so any token edge reaches the full path). -#[derive(Resource, Default)] -pub(crate) struct ShineSites(pub(crate) Vec); - -/// The shared integer-millisecond loop clock (module doc: the reference's truncating -/// accumulator, decision 1321 — `floor(elapsed_ms)` added per frame, no fractional carry, so the -/// lap tracks the player's frame rate exactly as the reference widget's does). -#[derive(Resource, Default)] -pub(crate) struct ShineClock { - ms: u32, -} - -impl ShineClock { - /// Advance by one frame's wall delta (seconds) and return the clock, in `0..PERIOD_MS`. - fn advance(&mut self, elapsed: f64) -> u32 { - self.ms = (self.ms + (elapsed * 1000.0).floor() as u32) % PERIOD_MS; - self.ms - } -} - -/// Append every site's sparks to the UI quad overlay lane — [`UiQuadAppend`], beside the minimap -/// and the V-plates: re-emitted each frame, diffed by the mesh rebuild, never touching the -/// script layout. Runs (and advances the clock) unconditionally; with no sites it is two reads -/// and an add. -fn emit_shine( - mut clock: ResMut, - sites: Res, - time: Res>, - mut quads: ResMut, -) { - let ms = clock.advance(time.delta_secs_f64()); - for site in &sites.0 { - site_quads(site, ms, &mut quads.overlays); - } -} - -/// One site's sparks at clock `ms`, appended to `out` — [`emit_shine`]'s whole body, pure so the -/// tests drive it at exact millisecond clocks the way the old Lua goldens drove the `OnUpdate`. -fn site_quads(site: &ShineSite, ms: u32, out: &mut Vec) { - // THE WIDGET IS ITS OWN SCISSOR (decision 1387). A `` does not draw into the UI's - // shared space: `0x76d240` saves the viewport, installs the frame's OWN device rect as the - // viewport for the duration of the model's draw, and restores it after - // (`76d3cd` save · `76d5d7 0x58af60(left·x1, right·x1, bottom·y1, top·y1, 0, 1)` · - // `76d66c` restore — wow-re `system/ui/scratch/modelframe-camera-law.md` §4 steps 3/6/9), - // while the ortho leg maps that same rect to NDC ±1 (§4b). Everything the model draws outside - // the rect is therefore cut by the clip volume, with a hard flat boundary at the rect edge. - // - // That is not cosmetic here, it IS the shine's silhouette: the authored 0.02 square projects - // to 30.72 units on a 30-unit button, so the bone path runs ON the rim and each 12.8-unit - // newborn star straddles it — half in, half out. Clipped, only the inward half survives, and - // the rim reads as the reference's does: a flat outer edge at the button border with ragged - // flame tongues bleeding inward over the icon. Unclipped, the same stars spill a soft halo a - // fifth of a button wide on all four sides — the director's A/B against the real client, - // which shows the stone behind the button completely unlit (B228). - let viewport = match site.clip { - Some(inherited) => site.rect.intersect(inherited), - None => site.rect, - }; - if viewport.is_empty() { - return; - } - let lap = ms as f32 / PERIOD_MS as f32; - let s = site.scale; - let side = side(site.model_scale); - // The model origin: the button rect's bottom-left, in y-down px. - let (ox, oy) = (site.rect.min.x, site.rect.max.y); - for e in 0..EMITTERS { - // Bone `e` LAGS bone `e-1` by 500 ms — a quarter lap *behind*, not ahead (wow-re's bone - // table: bone 0 opens at BL, bone 1 at BR, bone 2 at TR, bone 3 at TL, all walking the - // one clockwise cycle BL→TL→TR→BR). Four identical emitters at four phases draw the same - // picture whichever way the index runs, which is exactly why the old `+e/4` could be - // wrong for two of them and look right; the law is worth being right about anyway. - let corner = -(e as f32) / EMITTERS as f32; - for &(age, w, c) in PARTICLES.iter() { - // The particle's fixed place in the loop: its emitter's quarter-lap offset, less how - // far the bone has moved on since this star was born (it is baked into the model - // frame at birth and never rides the bone — wow-re §7, flags bit 0x10 clear). - let (x, y) = point(lap + corner - age / PERIOD, side); - let (cx, cy) = (ox + x * s, oy - y * s); - // The path took `s`; the star takes the screen diagonal instead ([`STAR`]) — the two - // are different projections of the same model, not a shared one. - let half = w * site.diag; - out.push(UiQuad { - rect: Rect::new(cx - half, cy - half, cx + half, cy + half), - z_key: site.z, - texture: Some(site.texture.clone()), - color: [c[0], c[1], c[2], c[3] * site.alpha], - additive: true, - // The in-plane billboard spin ([`SPIN`]) — the one field 1321 recorded as live - // and left unmodelled for want of a rotation to hang it on. `UiQuad::rotation` - // is that rotation, and it turns the corners with their UVs, which is what the - // reference's screen-plane rotate does. - rotation: SPIN * age, - clip: Some(viewport), - ..Default::default() - }); - } - } -} - -/// Resource + producer registration (the [`UiQuadAppend`] window — `ui_pass` sorts the lanes -/// together by `z_key`, so the sparks interleave exactly at the button's overlay layer). -pub(crate) struct AutocastShinePlugin; - -impl Plugin for AutocastShinePlugin { - fn build(&self, app: &mut App) { - app.init_resource::() - .init_resource::() - .add_systems(Update, emit_shine.in_set(UiQuadAppend)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The pet button's rim square — the geometry every golden below was taken at - /// (`scale="1.2"`, the ref's own value on that template). - fn s12() -> f32 { - side(1.2) - } - - /// The token's grammar (1391): it carries the ``'s `scale=`, because the reference - /// writes a different one on each template that wears this shine — and it still refuses - /// anything that merely looks like the token, which is what keeps the shipped-XML archive - /// sweep honest about a typo. - #[test] - fn the_token_carries_the_models_scale_and_nothing_else_parses_as_one() { - assert_eq!(token_model_scale("benilla:autocast-shine:1.2"), Some(1.2)); - assert_eq!(token_model_scale("benilla:autocast-shine:1.22"), Some(1.22)); - assert_eq!( - token_model_scale(SHINE_TOKEN), - Some(1.2), - "bare = the pet bar's" - ); - for bad in [ - "benilla:autocast-shin", - "benilla:autocast-shine2", - "benilla:autocast-shine:", - "benilla:autocast-shine:x", - "benilla:autocast-shine1.22", - "Interface\\Buttons\\GlowStar", - ] { - assert_eq!(token_model_scale(bad), None, "{bad} must not parse"); - } - // The two rim squares the shipped templates actually ask for. - assert!((side(1.2) - 30.72).abs() < 1e-4, "pet button"); - assert!((side(1.22) - 31.232).abs() < 1e-4, "spell book"); - } - - /// The population golden (1386): the emitter keeps `rate × life` particles alive, aged one - /// spawn interval apart, none past its life — the file's own numbers, so a constant edit - /// cannot quietly re-open the sampling era. - #[test] - fn the_population_is_the_files_own_steady_state() { - assert_eq!(PARTICLES.len(), 300, "300/s × 1.0 s life"); - assert_eq!(PARTICLES[0].0, 0.0, "the newest particle is newborn"); - for w in PARTICLES.windows(2) { - let step = w[1].0 - w[0].0; - assert!( - (step - 1.0 / RATE).abs() < 1e-6, - "spawn interval {step} ≠ 1/{RATE}" - ); - } - assert!(PARTICLES.iter().all(|p| p.0 < LIFE)); - // Density is what the look rests on: the bone lays a particle every 0.20 units of rim - // while a newborn star is 12.8 units wide at 4:3, so ~60 stars pile onto any point of - // the band. Additive, that saturates — the reference's thick continuous rim, and exactly - // what 1383's 23 samples (7.7 units apart, one star deep) could not produce. Both terms - // are in FrameXML units here, so the star takes the 4:3 case of its own law (1390). - let pitch = 4.0 * s12() / PERIOD / RATE; - let depth = 2.0 * ramp(STAR, 0.0) * 1280.0 / pitch; - assert!( - depth > 50.0, - "only {depth:.0} stars deep — that will not saturate" - ); - } - - /// The newborn particle's quad, generated through the REAL producer body at exact - /// millisecond clocks: origin at the site rect's bottom-left, the quarter-edge midpoint - /// exactly half way (LINEAR, `interp == 1`), the ramps' own size and colour, the site's - /// alpha folded, and `4 × population` quads per site. - #[test] - fn a_sites_particles_come_out_where_the_bone_walked() { - let site = ShineSite { - rect: Rect::new(72.0, 56.0, 102.0, 86.0), // the pet bar's button 1, y-down px - z: 42, - clip: None, - alpha: 1.0, - scale: 1.0, - model_scale: 1.2, - diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case - texture: Handle::default(), - }; - let at = |ms: u32| { - let mut out = Vec::new(); - site_quads(&site, ms, &mut out); - assert_eq!(out.len(), EMITTERS * PARTICLES.len()); - // Emitter 0's newest particle is the first quad; its centre is the bone itself. - let r = out[0].rect; - ( - (r.min.x + r.max.x) * 0.5 - site.rect.min.x, - site.rect.max.y - (r.min.y + r.max.y) * 0.5, - ) - }; - // The old Lua golden's own 0.01-px tolerance: the centre is reconstructed from the - // quad's f32 edges, so exact-bit equality is not the claim — the corner is. - let near = |got: (f32, f32), want: (f32, f32), what: &str| { - assert!( - (got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01, - "{what}: at {got:?}, expected {want:?}" - ); - }; - near(at(0), (0.0, 0.0), "clock 0: the bottom-left origin"); - near( - at(250), - (0.0, s12() / 2.0), - "quarter edge in: the LEFT edge's midpoint (LINEAR)", - ); - near(at(500), (0.0, s12()), "clock 500 ms: top-left"); - near(at(1000), (s12(), s12()), "clock 1000 ms: top-right"); - near(at(1500), (s12(), 0.0), "clock 1500 ms: bottom-right"); - near(at(2000), (0.0, 0.0), "clock 2000 ms wraps onto clock 0"); - // The newborn quad wears the ramp's birth values (the inset included — it is 0.5 % of - // the way to the mid key already), the additive blend the emitter authors, and the - // site's own paint order. - let mut out = Vec::new(); - site_quads(&site, 0, &mut out); - let head = &out[0]; - let width = head.rect.max.x - head.rect.min.x; - assert!((width - 2.0 * ramp(STAR, 0.0) * site.diag).abs() < 0.01); - assert!(width < 2.0 * STAR[0] * site.diag, "the inset bites"); - assert_eq!(head.color, ramp_color(0.0)); - assert!(head.additive); - assert_eq!(head.z_key, 42); - // The site's alpha folds into every particle on top of the ramp's own. - let dim = ShineSite { alpha: 0.5, ..site }; - let mut dimmed = Vec::new(); - site_quads(&dim, 0, &mut dimmed); - assert_eq!(dimmed[0].color[3], ramp_color(0.0)[3] * 0.5); - } - - /// The widget's viewport IS the silhouette (1387): every spark carries the site rect as its - /// clip, an inherited scroll clip intersects with it rather than replacing it, and — the - /// point of the whole thing — the sparks really do straddle the rim, so the clip is load - /// bearing and not a decorative no-op. - #[test] - fn nothing_the_model_draws_escapes_the_widgets_rect() { - let site = ShineSite { - rect: Rect::new(72.0, 56.0, 102.0, 86.0), - z: 42, - clip: None, - alpha: 1.0, - scale: 1.0, - model_scale: 1.2, - diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case - texture: Handle::default(), - }; - let mut out = Vec::new(); - site_quads(&site, 0, &mut out); - assert!(out.iter().all(|q| q.clip == Some(site.rect))); - - // Unclipped, a newborn star hangs a long way past the border — half its 12.8 units plus - // the 0.36 the 30.72 path already overhangs. That overhang is the halo the reference - // does not have; the clip above is what removes it. - let escaping = out - .iter() - .filter(|q| !site.rect.contains(q.rect.min) || !site.rect.contains(q.rect.max)) - .count(); - assert!( - escaping > out.len() / 2, - "only {escaping}/{} sparks cross the rim — the path is not on the rim any more", - out.len() - ); - let worst = out - .iter() - .map(|q| site.rect.min.x - q.rect.min.x) - .fold(f32::MIN, f32::max); - assert!( - (worst - ramp(STAR, 0.0) * site.diag).abs() < 0.01, - "the newborn star should hang exactly half its width past the left rim, not {worst}" - ); - - // A scroll-framed ancestor narrows the viewport; it never widens it. - let scrolled = ShineSite { - clip: Some(Rect::new(0.0, 0.0, 90.0, 200.0)), - ..site.clone() - }; - let mut out = Vec::new(); - site_quads(&scrolled, 0, &mut out); - assert!(out - .iter() - .all(|q| q.clip == Some(Rect::new(72.0, 56.0, 90.0, 86.0)))); - - // Scrolled fully out of view, the site draws nothing at all. - let hidden = ShineSite { - clip: Some(Rect::new(0.0, 0.0, 10.0, 10.0)), - ..site - }; - let mut out = Vec::new(); - site_quads(&hidden, 0, &mut out); - assert!(out.is_empty()); - } - - /// The two projections and the spin, pinned where they can't drift back into one another - /// (1386, corrected by 1390): the bone square is 1280/model-unit × the Model's 1.2 at every - /// aspect, the star is an eye-space length that takes the screen diagonal and neither scale, - /// and every particle spins the same way at `spin · age`. - #[test] - fn the_star_skips_the_models_scale_and_every_spin_turns_one_way() { - // The bone square: 1280 per model unit and the Model's 1.2, at EVERY aspect (C2). - assert!( - (s12() - 30.72).abs() < 1e-4, - "the bone square DOES take the 1.2" - ); - // The star: `2 · half · √(W² + H²)` window px, no 1.2 and no UI scale — so unlike the - // square it MOVES with the window's shape (C3). 1024×768 is the case 1386 mistook for - // the whole law; 1280×720 is 22 % wider on a button 4 % narrower. - let width = |w: f32, h: f32| 2.0 * STAR[0] * (w * w + h * h).sqrt(); - assert!((width(1024.0, 768.0) - 12.80).abs() < 0.01, "4:3"); - assert!((width(1280.0, 720.0) - 14.69).abs() < 0.01, "16:9"); - assert!( - width(1280.0, 720.0) > 1.14 * width(1024.0, 768.0), - "the aspect term is what 1386 froze out; it must be live" - ); - // The square is 2.4 star-widths across at 4:3 — 1321's 2.0 was the 1.2 leaking into the - // star. It is only 2.1 at 16:9, and that drift IS the law, not a rounding. - assert!((s12() / width(1024.0, 768.0) - 2.4).abs() < 1e-3); - - let site = ShineSite { - rect: Rect::new(0.0, 0.0, 30.0, 30.0), - z: 0, - clip: None, - alpha: 1.0, - scale: 1.0, - model_scale: 1.2, - diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case - texture: Handle::default(), - }; - let mut out = Vec::new(); - site_quads(&site, 0, &mut out); - // Rotation is a pure function of age, one direction, reaching 0.1 rad at the end of life. - assert_eq!(out[0].rotation, 0.0, "a newborn star has not turned yet"); - let oldest = &out[PARTICLES.len() - 1]; - assert!( - (oldest.rotation - SPIN * (1.0 - 1.0 / RATE)).abs() < 1e-6, - "the oldest star has turned spin × its age" - ); - assert!( - out.iter().all(|q| q.rotation >= 0.0), - "the sign-flip bit is clear on this asset — nothing counter-rotates" - ); - } - - /// The corner walk and the four bones' phasing — wow-re's own bone table - /// (`modelframe-animation-clock.md` §6): one clockwise cycle BL→TL→TR→BR walked by all four, - /// each successive bone index LAGGING the last by 500 ms, so at clock 0 bone 0 sits at BL, - /// bone 1 at BR, bone 2 at TR and bone 3 at TL. - #[test] - fn the_bones_walk_one_clockwise_cycle_each_lagging_the_last() { - assert_eq!(point(0.0, s12()), (0.0, 0.0), "bottom-left at 0 s"); - assert_eq!(point(0.25, s12()), (0.0, s12()), "top-left at 0.5 s"); - assert_eq!(point(0.5, s12()), (s12(), s12()), "top-right at 1.0 s"); - assert_eq!(point(0.75, s12()), (s12(), 0.0), "bottom-right at 1.5 s"); - assert_eq!(point(1.0, s12()), (0.0, 0.0), "home at 2.0 s"); - - // Where each emitter's newborn particle sits at clock 0, straight off the producer. - let site = ShineSite { - rect: Rect::new(0.0, 0.0, 30.0, 30.0), - z: 0, - clip: None, - alpha: 1.0, - scale: 1.0, - model_scale: 1.2, - diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case - texture: Handle::default(), - }; - let mut out = Vec::new(); - site_quads(&site, 0, &mut out); - let born = |e: usize| { - let r = out[e * PARTICLES.len()].rect; - ( - (r.min.x + r.max.x) * 0.5, - site.rect.max.y - (r.min.y + r.max.y) * 0.5, - ) - }; - let near = |got: (f32, f32), want: (f32, f32), what: &str| { - assert!( - (got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01, - "{what}: at {got:?}, expected {want:?}" - ); - }; - near(born(0), (0.0, 0.0), "bone 0 opens at BL"); - near( - born(1), - (s12(), 0.0), - "bone 1 opens at BR — a quarter lap BEHIND bone 0", - ); - near(born(2), (s12(), s12()), "bone 2 opens at TR"); - near(born(3), (0.0, s12()), "bone 3 opens at TL"); - } - - /// Decision 1321's clock law, on the native accumulator: at a exact 60 fps the floor of - /// every 16.666… ms delta is 16 ms, so the 2000 ms band takes ⌈2000/16⌉ = 125 frames — - /// 2083 ms of wall time, never less than authored. - #[test] - fn the_clock_truncates_like_the_reference_widget() { - let mut clock = ShineClock::default(); - let dt = 1.0 / 60.0; - let mut frames = 0; - loop { - frames += 1; - let before = clock.ms; - let now = clock.advance(dt); - if now < before { - break; // wrapped: one full lap - } - assert!(frames < 1000, "clock never wrapped"); - } - assert_eq!( - frames, 125, - "a 2000 ms band takes 125 frames at 60 fps (2083 ms)" - ); - // 144 fps truncates harder — 6.944 ms banked as 6 — so the same lap takes 334 frames - // (2315 ms of wall clock). A frame-rate-independent clock would land both at 2000 ms. - let mut clock = ShineClock::default(); - let dt = 1.0 / 144.0; - let mut frames = 0; - loop { - frames += 1; - let before = clock.ms; - if clock.advance(dt) < before { - break; - } - assert!(frames < 1000, "clock never wrapped"); - } - assert_eq!( - frames, 334, - "the same band takes 334 frames at 144 fps (2315 ms)" - ); - } - - /// The ramps read the M2's keys through the reference's own two-segment sampler — including - /// its `t = u·0.99 + 0.005` inset, which means the authored endpoints are approached and - /// never reached. Pinned as inequalities plus the inset's exact arithmetic, so the law is - /// what is asserted rather than three literals. - #[test] - fn the_ramps_read_the_m2s_keys_through_the_references_inset() { - let near = |got: f32, want: f32| assert!((got - want).abs() < 1e-4, "{got} vs {want}"); - // Segment-local time 0 lands 0.5 % in; segment-local time 1 lands 0.5 % short. The - // reference's own branch is `age > midPoint·lifespan`, so age == mid is still the FIRST - // segment — its 0.995 end, not the second segment's 0.005 start. - near(ramp(STAR, 0.0), STAR[0] + (STAR[1] - STAR[0]) * 0.005); - near(ramp(STAR, MID), STAR[0] + (STAR[1] - STAR[0]) * 0.995); - near(ramp(STAR, 1.0), STAR[1] + (STAR[2] - STAR[1]) * 0.995); - assert!(ramp(STAR, 0.0) < STAR[0], "the newborn star never hits k0"); - assert!(ramp(STAR, 1.0) > STAR[2], "the dying star never reaches k2"); - // The size ramp only ever shrinks, and the alpha only ever fades after the mid key — - // the two facts the population's brightness envelope rests on. - let mut prev = f32::INFINITY; - for k in 0..=100 { - let w = ramp(STAR, k as f32 / 100.0); - assert!(w < prev, "the size ramp must shrink monotonically"); - prev = w; - } - // Alpha holds at the authored 1.0 across the whole first segment (k0 and k1 are both - // opaque) and only fades over the second — the reason the band's bright half is its - // freshest half and the wave reads as travelling. - near(ramp_color(0.0)[3], 1.0); - near(ramp_color(MID)[3], 1.0); - assert!(ramp_color(1.0)[3] < 0.01, "a dying particle is ~invisible"); - } -} diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 88774630..0bd008b5 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -46,7 +46,6 @@ mod area_trigger; #[cfg(feature = "dev")] mod asset_churn; mod aura_visual; -mod autocast_shine; mod bindings; mod blob_shadow; mod bowstring; @@ -606,7 +605,6 @@ pub fn run(build: BuildId) -> AppExit { .add_plugins(minimap::MinimapPlugin) // The pet-bar / spellbook autocast shine, drawn on the append lane from the conversion's // parked sites — zero per-frame script-layout traffic (decision 1383, B282). - .add_plugins(autocast_shine::AutocastShinePlugin) // The `` widgets' M2s, rendered as tiles of one atlas and composited at the // callback rank (decision 2008). .add_plugins(ui_models::UiModelsPlugin) diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index fb31cf7a..094d145d 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -35,7 +35,6 @@ use bevy::window::{CursorOptions, PrimaryWindow}; use crate::creature_anim::{move_flags, wrap_pi, BodyTwist, MovementState}; use crate::net::{ClientCommand, Embodied, NetCommands, TeleportMessage, WorldportMessage}; use crate::ui_script::InspectMode; -use crate::ui_script::PointerOverUi; use benilla_assets::coords::wow_to_bevy; use benilla_assets::AssetSet; use benilla_world::interact::{WorldClick, WorldRightClick, WorldRightPress}; @@ -433,6 +432,18 @@ impl Plugin for PlayerPlugin { // could nudge is not a regression baseline any more. .run_if(not(resource_exists::)), ) + // **Which mouse buttons the world owns** (ledger B364) — decoded once, ahead of all + // three readers: `/follow`'s both-button cancel below, the look session, and the camera's + // input command word. `control` cannot own it, because `steer_follow` runs before + // `control` and would then read it a frame late. + .add_systems( + Update, + camera::latch_world_mouse + .in_set(WorldStage::Input) + .before(control) + .before(follow::steer_follow) + .run_if(in_state(crate::char_select::ClientState::InWorld)), + ) // `/follow` (decision 0890): steer the facing and decide this tick's synthesized forward // input immediately BEFORE the controller, which folds the flag into its forward axis. // The player's own turn input therefore runs after us and wins, which is exactly what diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 1b59056a..bba4fa70 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -8,12 +8,13 @@ use bevy::ecs::entity::EntityHashSet; use bevy::input::mouse::AccumulatedMouseMotion; use bevy::mesh::MeshTag; use bevy::prelude::*; -use bevy::window::{CursorGrabMode, CursorOptions}; +use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow}; use avian3d::prelude::*; use crate::creature_anim::wrap_pi; use crate::net::Embodied; +use crate::ui_script::PointerOverUi; use benilla_assets::materials::WowModelMaterial; use benilla_world::interact::{WorldClick, WorldRightClick, WorldRightPress}; use benilla_world::model_fade::{ @@ -761,6 +762,11 @@ pub(crate) struct CameraControl { pub(super) collision_distance: f32, /// The button currently held for look, or `None`. pub(super) look: Option, + /// **Which mouse buttons the world owns** this frame ([`WorldMouse`]) — the player side's one + /// answer to "did the UI eat that press?", written by [`latch_world_mouse`] before anything + /// reads a button. The look session, the camera's input command word and the both-button run + /// all read it instead of `ButtonInput`. + pub(super) world_mouse: WorldMouse, /// Logical cursor position captured when look began, to restore on release. pub(super) cursor_stash: Option, /// The self-avatar's render alpha for this frame, from the camera-to-pivot distance @@ -824,6 +830,88 @@ impl LookButton { } } +/// **The mouse buttons the world owns**, latched at the press — where the player side asks +/// about a primary *press*, instead of `ButtonInput` (ledger B364). The raw buttons +/// stay readable for the hold and release of a gesture already claimed, which is +/// [`run_look_session`]'s business and no one else's. +/// +/// 1.12 never reads the mouse for any of this. It reads two *bindings* — `TurnOrAction` (right) +/// and `CameraOrSelectOrMove` (left) — and a press a UI frame captured never dispatches them: it +/// sets neither of `[InputControl+0x4]`'s mouse bits, engages no look session, and classifies as +/// no [`FollowState`]. That is the observable rule, and it is why a right-click in a bag has never +/// turned anyone. Only the look session applied it here; the command word and the both-button run +/// read the raw buttons, so a right-click on a Who-list row was a `Turn`, whose Smart row is +/// `(0.0, 1.0)` — an immediate return that ran to completion and swung the camera round to behind +/// the character (B364). The same raw read had both primaries over a bag running the avatar +/// forward, through [`super::state::forward_axis`]'s both-button term. +/// +/// **Latched, not re-tested each frame**, because the reference latches: the down that reached the +/// world sets the bit, and that button's *up* clears it. A level test would hand the button back +/// to the UI mid-gesture the moment a frame appeared under the (stationary, locked) cursor — an +/// edge on the command word from nothing the player did. +#[derive(Default)] +pub(super) struct WorldMouse { + /// Held by the world right now, indexed by [`LookButton`]. + held: [bool; 2], + /// Took its DOWN edge from the world this frame, same index. + down: [bool; 2], +} + +impl WorldMouse { + /// Is the world holding this button? + pub(super) fn held(&self, b: LookButton) -> bool { + self.held[b as usize] + } + + /// Did the world take this button's DOWN edge this frame? + pub(super) fn down(&self, b: LookButton) -> bool { + self.down[b as usize] + } + + /// Both primaries in the world's hand — vanilla's both-button run, minus the presses the UI ate. + pub(super) fn both(&self) -> bool { + self.held(LookButton::Right) && self.held(LookButton::Left) + } + + /// Latch this frame. `world_press` says whether a DOWN edge *now* belongs to the world; the + /// held bits then ride to their own release, whatever the cursor is over by then — including + /// the release a cover synthesises by emptying the button planes, which is what keeps a + /// loading screen from stranding a latched bit. + fn update(&mut self, buttons: &ButtonInput, world_press: bool) { + for b in [LookButton::Right, LookButton::Left] { + let i = b as usize; + self.down[i] = world_press && buttons.just_pressed(b.button()); + self.held[i] = (self.held[i] || self.down[i]) && buttons.pressed(b.button()); + } + } +} + +/// Decide, once per frame, which mouse buttons the world owns — [`CameraControl::world_mouse`]. +/// +/// **Its own system**, ahead of every reader, rather than a call inside [`super::control`]: the +/// readers are not all in the controller. The look session and the camera's command word are, but +/// `/follow`'s both-button cancel ([`super::follow::steer_follow`]) is a system that runs *before* +/// it — reading a latch the controller wrote would put that cancel a frame behind, and a frame +/// late on an edge-driven cancel is the wrong frame entirely. +/// +/// A press belongs to the world when it lands in the viewport off the UI — or whenever a look +/// session already owns the (hidden, locked) cursor, because the second button of a chord joins a +/// gesture the world already has. +pub(super) fn latch_world_mouse( + buttons: Res>, + pointer_over_ui: Res, + mut rig: ResMut, + cameras: Query<&Camera, With>, + window: Single<&Window, With>, +) { + let Ok(camera) = cameras.single() else { + return; + }; + let world_press = + rig.look.is_some() || (cursor_in_viewport(&window, camera) && !pointer_over_ui.0); + rig.world_mouse.update(&buttons, world_press); +} + #[derive(Component)] // `pub(crate)` on the TYPE only — the scripted camera park has to name it in a query. Its fields // stay `pub(super)`; [`FlyCam::park`] is the whole surface an instrument gets (decision 1174). @@ -875,8 +963,6 @@ pub(super) fn run_look_session( face_yaw: &mut f32, window: &mut Window, cursor_opts: &mut CursorOptions, - camera: &Camera, - pointer_over_ui: bool, inspect_enabled: bool, // A left press this frame the UI already consumed as a cursor-payload world drop (0216 §3) — // the left click test must yield to it exactly as it yields to a UI hover, so dropping a held @@ -892,19 +978,19 @@ pub(super) fn run_look_session( now: f32, ) { // The right button's DOWN edge, before any click-vs-drag classification — the reference's - // WorldFrame OnMouseDown fires at the press whether it becomes a click or a turn. It belongs - // to the world when the press lands in the viewport off the UI, or whenever a look session - // already owns the (hidden, locked) cursor — a right join into a left-orbit is still a world - // press. Ground-targeting's cancel reads this edge (decision 0792). - if buttons.just_pressed(MouseButton::Right) - && (rig.look.is_some() || (cursor_in_viewport(window, camera) && !pointer_over_ui)) - { + // WorldFrame OnMouseDown fires at the press whether it becomes a click or a turn. Whether the + // press was the world's at all is [`latch_world_mouse`]'s single answer, shared with the + // camera's command word: the viewport off the UI, or a right join into a left-orbit, whose + // session already owns the cursor. Ground-targeting's cancel reads this edge (decision 0792). + if rig.world_mouse.down(LookButton::Right) { world_right_press.write(WorldRightPress); } // A chord — both primaries down — is a both-button run, never a select. The reference kills the // pending click on the *second* press and refuses to arm a new one while another primary is held // (`0x514ac1`, `0x51481a`), so neither release of a chord can dispatch. Cancel both tests. - if buttons.pressed(MouseButton::Left) && buttons.pressed(MouseButton::Right) { + // The world's pair, not the device's: what those two sites test is the *binding* state, so a + // primary a UI frame is holding has never been half of a chord (ledger B364). + if rig.world_mouse.both() { *left_click = None; *right_click = None; } @@ -947,14 +1033,16 @@ pub(super) fn run_look_session( } } } - // The latched button went up. If the *other* look button is still held (both-button run - // → single-button), hand the look session off to it rather than ending it — vanilla keeps - // turning/orbiting seamlessly on the remaining button, cursor staying hidden throughout. + // The latched button went up. If the *other* look button is still held **by the + // world** (both-button run → single-button), hand the look session off to it rather + // than ending it — vanilla keeps turning/orbiting seamlessly on the remaining button, + // cursor staying hidden throughout. A primary the UI is holding is not a candidate: + // its binding never fired, so the reference has nothing to hand off to (B364). let other = match active { LookButton::Right => LookButton::Left, LookButton::Left => LookButton::Right, }; - if buttons.pressed(other.button()) { + if rig.world_mouse.held(other) { rig.look = Some(other); } else { rig.look = None; @@ -967,18 +1055,19 @@ pub(super) fn run_look_session( } } } else { - // A press over the egui dev UI (the overlaid debug panel, the perf pill) or outside the world - // viewport is not ours — this keeps a slider-drag from grabbing the cursor into mouse-look. - let world_press = cursor_in_viewport(window, camera) && !pointer_over_ui; + // A press over the egui dev UI (the overlaid debug panel, the perf pill), over a + // mouse-enabled player frame, or outside the world viewport is not ours — the whole content + // of [`WorldMouse`], and what keeps a slider-drag from grabbing the cursor into mouse-look. // Right-drag turn. Arms its context-click test too; not armed when left is already down (a // chord is never a click). - if buttons.just_pressed(MouseButton::Right) && world_press { + if rig.world_mouse.down(LookButton::Right) { rig.look = Some(LookButton::Right); rig.cursor_stash = window.cursor_position(); cursor_opts.grab_mode = CursorGrabMode::Locked; cursor_opts.visible = false; - *right_click = (!buttons.pressed(MouseButton::Left)).then(|| PressGesture::new(now)); - } else if buttons.just_pressed(MouseButton::Left) && world_press && !inspect_enabled { + *right_click = + (!rig.world_mouse.held(LookButton::Left)).then(|| PressGesture::new(now)); + } else if rig.world_mouse.down(LookButton::Left) && !inspect_enabled { // Left-drag orbit — engaged on the press, exactly like right, because the reference // engages on the press (`0x51491f`). The select is not deferred behind it; it rides // along and settles at the release. While the inspector is armed left belongs to it @@ -990,7 +1079,7 @@ pub(super) fn run_look_session( // A press the UI already consumed as a cursor-payload world drop (0216 §3) still orbits // — the reference's orbit is unconditional on the down edge — but must not also select. *right_click = None; - *left_click = (!click_consumed && !buttons.pressed(MouseButton::Right)) + *left_click = (!click_consumed && !rig.world_mouse.held(LookButton::Right)) .then(|| PressGesture::new(now)); } } @@ -1867,6 +1956,109 @@ mod tests { ); } + /// **B364** (MarcusAga): right-clicking a row of the Who list swung the camera round to + /// behind the character — over ~180°, on a body that never turned. The click itself was the + /// UI's (1816's no-fall-through hit test opened the dropdown); only the camera's command word + /// saw it, because the word's two mouse bits were built from `ButtonInput` with no UI term. + /// `RIGHT_MOUSE` alone is a [`FollowState::Turn`], and Smart's Turn row is `(0.0, 1.0)` — no + /// delay, full factor — so the return armed on the press edge and ran to completion. + /// + /// The decode is pinned end to end, from the latch to the row the classifier picks, because + /// each half was individually right: the classifier is the reference's (the test below), and + /// the look session's own gate was already there. What was missing was the word reading the + /// same gate. + #[test] + fn a_press_the_ui_ate_never_reaches_the_camera_command_word() { + let word = |world_press: bool| { + let mut rig = CameraControl::default(); + let mut buttons = ButtonInput::::default(); + buttons.press(MouseButton::Right); + rig.world_mouse.update(&buttons, world_press); + super::super::input::look_input( + &crate::bindings::BindingsState::default(), + &super::super::Player::default(), + &rig, + ) + .follow_command + }; + let classify = |command| { + FollowInput { + cfg: FollowConfig::default(), + face_yaw: 0.0, + command, + } + .state(false) + }; + + // The report: the press landed on a UI row. + assert_eq!(word(false), 0, "a captured press sets no mouse bit"); + assert_eq!(classify(word(false)), FollowState::Idle); + assert_eq!( + FollowStyle::Smart.row(FollowState::Idle), + (0.0, 0.0), + "and Idle is the row that arms nothing — the swing has no source" + ); + + // The control that must not change: the same press in the world still turns. + assert_eq!(word(true), follow_cmd::RIGHT_MOUSE); + assert_eq!(classify(word(true)), FollowState::Turn); + assert_eq!(FollowStyle::Smart.row(FollowState::Turn), (0.0, 1.0)); + } + + /// The latch's own law: a button is claimed at the DOWN edge and held to its own release. + /// + /// Level-testing "is the cursor over UI *right now*" instead would drop the bit mid-drag the + /// moment a frame appeared under the (stationary, locked) cursor — a phantom edge on the + /// command word, which is exactly what arms a return. And the second button of a chord has to + /// join: the reference's both-button run is both bindings held, and the second press lands on + /// a cursor the first one already hid. + #[test] + fn the_world_holds_a_button_from_its_press_to_its_release() { + let mut rig = CameraControl::default(); + let mut buttons = ButtonInput::::default(); + + // Pressed over a UI row: never claimed, and no amount of later frames claims it. + buttons.press(MouseButton::Right); + rig.world_mouse.update(&buttons, false); + assert!(!rig.world_mouse.held(LookButton::Right)); + buttons.clear(); + rig.world_mouse.update(&buttons, true); + assert!( + !rig.world_mouse.held(LookButton::Right), + "a press the UI ate is never handed back mid-hold" + ); + buttons.release(MouseButton::Right); + buttons.clear(); + + // Pressed in the world: claimed, and it survives the UI arriving under the locked cursor. + buttons.press(MouseButton::Right); + rig.world_mouse.update(&buttons, true); + assert!(rig.world_mouse.down(LookButton::Right)); + buttons.clear(); + rig.world_mouse.update(&buttons, false); + assert!(rig.world_mouse.held(LookButton::Right)); + assert!( + !rig.world_mouse.down(LookButton::Right), + "the edge is one frame" + ); + + // The chord's second button joins the gesture the world already holds… + buttons.press(MouseButton::Left); + rig.world_mouse.update(&buttons, true); + assert!( + rig.world_mouse.both(), + "both primaries = the both-button run" + ); + + // …and the release is what ends it — including the synthetic one a cover produces by + // emptying the button planes, which is a release of both without a `just_released`. + buttons = ButtonInput::::default(); + rig.world_mouse.update(&buttons, false); + assert!(!rig.world_mouse.both()); + assert!(!rig.world_mouse.held(LookButton::Right)); + assert!(!rig.world_mouse.held(LookButton::Left)); + } + /// The state classifier's three vanilla input rules (wow-re `camera-smooth-style.md` §6.2) — /// the ones a client that keys off character *velocity* cannot reproduce, because they are /// read off the camera's own command word: right-mouse alone is a `Turn`, a turn key held diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index d56f4cf6..a37bc08a 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -80,7 +80,6 @@ pub(super) fn control( Res, Res, Res, - Res, Res, Res, Res, @@ -108,7 +107,7 @@ pub(super) fn control( mut rig: ResMut, // Avian's kinematic move-and-slide: sweeps the capsule against the streamed colliders (decision 0009). collide: benilla_world::collision::WorldCollision, - mut cameras: Query<(&mut Transform, &mut FlyCam, &Camera)>, + mut cameras: Query<(&mut Transform, &mut FlyCam), With>, // **The body in our hands** — see [`BodyQuery`] for what rides on it and why a possessed // creature needs nothing special here. mut body: BodyQuery, @@ -140,27 +139,26 @@ pub(super) fn control( ) { let (world, transports, child_of) = (&world_q.0, &world_q.1, &world_q.2); let (left_click, right_click) = (&mut *click_test.0, &mut *click_test.1); - let Ok((mut cam_t, mut cam, camera)) = cameras.single_mut() else { + let Ok((mut cam_t, mut cam)) = cameras.single_mut() else { return; }; let (mut window, mut cursor_opts) = window.into_inner(); let mouse_motion = &pointer.0; let look_cfg = *pointer.1; let zoom_max = pointer.2.max; - let (move_speed, capsule, cam_probe, pointer_over_ui, inspect, ui_capture, click_consumed) = ( + let (move_speed, capsule, cam_probe, inspect, ui_capture, click_consumed) = ( &speed_capsule.0, &speed_capsule.1 .0, &speed_capsule.2 .0, &speed_capsule.3, &speed_capsule.4, &speed_capsule.5, - &speed_capsule.6, ); - let binds = &speed_capsule.7; - let view_subject = &speed_capsule.8; - let self_guid = speed_capsule.9 .0; - let scoped = &speed_capsule.10; - let covered = speed_capsule.11.covering(); + let binds = &speed_capsule.6; + let view_subject = &speed_capsule.7; + let self_guid = speed_capsule.8 .0; + let scoped = &speed_capsule.9; + let covered = speed_capsule.10.covering(); // The auto-follow knobs (decisions 1493/1502), with far sight's one exception folded in here so // both camera seats below agree: while the rig orbits somebody ELSE's body (Mind Vision, Sentry // Totem), our own facing is not what "behind" means, so the return is forced off rather than @@ -190,13 +188,18 @@ pub(super) fn control( // anymore. Nothing here reads a bare key any more (decision 1043) — the free-fly toggle is on // the dev chord, and the Ctrl run boost is gone. + // Which mouse buttons the world owns this frame is [`camera::latch_world_mouse`]'s, decided + // in a system ahead of this one (ledger B364). Every *press* below is taken from that latch — + // the look session's engage, the camera's command word, the both-button run — so a press the + // UI ate reaches none of them. The raw buttons are still read below, but only for the hold and + // release of a gesture the world already owns. // The both-button state and the camera's input command word ([`input::look_input`]) — read // here, before the look session, because the not-driving path below seats its camera from the // same word and returns without ever reaching the movement axes. let input::LookInput { both_buttons, follow_command, - } = input::look_input(&buttons, binds, &player); + } = input::look_input(binds, &player, &rig); // The look session gets a SHADOW copy of `CursorOptions`, written back only on a real change: // handing it the component's `Mut` directly reborrowed mutably every frame, which marks it @@ -292,8 +295,6 @@ pub(super) fn control( &mut player.face_yaw, &mut window, &mut opts_shadow, - camera, - pointer_over_ui.0, inspect.enabled, click_consumed.0, &mut world_clicks.0, diff --git a/crates/benilla-app/src/player/follow.rs b/crates/benilla-app/src/player/follow.rs index 88950536..6c77a07b 100644 --- a/crates/benilla-app/src/player/follow.rs +++ b/crates/benilla-app/src/player/follow.rs @@ -255,9 +255,12 @@ pub(super) struct FollowInput<'w, 's> { /// It already carries the typing gate this struct used to hold `UiKeyboardCapture` for /// (typing "we should go" in chat must not break the follow). binds: Res<'w, crate::bindings::BindingsState>, - /// The camera rig, for the mouse-look level. Read one frame behind `control`'s own update, + /// The camera rig, for the mouse-look level and the world's own mouse buttons + /// ([`super::camera::WorldMouse`]). The look level is read one frame behind `control`'s own update, /// which is deliberate and harmless: the cancel then lands on the frame *after* the look - /// begins, versus the reference's same-frame commit. + /// begins, versus the reference's same-frame commit. The button latch is **not** behind — + /// [`super::camera::latch_world_mouse`] is ordered ahead of this system precisely so the both-button + /// cancel below sees this frame's edge. rig: Res<'w, CameraControl>, /// The **mover's** descriptor block, for the terms of the cancel set that are not inputs: /// its health and its stun. See [`FollowInput::input_torn_down`]. @@ -341,10 +344,12 @@ pub(super) fn steer_follow( return; } // ── The cancel set ── before anything else this frame (see the doc above). - let both_engaged = input.buttons.pressed(MouseButton::Left) - && input.buttons.pressed(MouseButton::Right) - && (input.buttons.just_pressed(MouseButton::Left) - || input.buttons.just_pressed(MouseButton::Right)); + // Off the WORLD's buttons ([`super::camera::WorldMouse`], ledger B364), not the device's: the + // reference's both-button run is its two bindings held, and a press a UI frame captured + // dispatches neither — so two primaries pressed over a bag are two clicks, not a cancel. + let both_engaged = input.rig.world_mouse.both() + && (input.rig.world_mouse.down(LookButton::Left) + || input.rig.world_mouse.down(LookButton::Right)); if follow_cancelled( input.move_start(), // The ON edge only: `control` toggles `autorun` AFTER this runs, so the flag we read here diff --git a/crates/benilla-app/src/player/input.rs b/crates/benilla-app/src/player/input.rs index b4f921b7..970fc324 100644 --- a/crates/benilla-app/src/player/input.rs +++ b/crates/benilla-app/src/player/input.rs @@ -4,7 +4,10 @@ //! - [`look_input`] — the both-button state and the reference's own **camera command word** //! (1.12's `[InputControl+0x4]`, decision 1502), which the auto-follow is armed by. Read //! *before* the look session, because both camera seats want it — including the one on the -//! not-driving path, which returns before the axes below are ever computed. +//! not-driving path, which returns before the axes below are ever computed. Its two mouse +//! terms come off [`camera::WorldMouse`], never `ButtonInput`: the reference's mouse bits are +//! two *bindings* the UI can eat, and reading the device instead swung the camera on every +//! right-click of a UI row (ledger B364). //! - [`move_axes`] — the **netted** forward/back and strafe axes plus the modes that select //! between them (mouselook, keyboard turn), and the autorun latch with its cancel set. Netted //! once here so that the direction we move, the speed we pick, the swim amounts and the flags we @@ -27,19 +30,24 @@ pub(super) struct LookInput { /// The mouse/binding state the camera needs, built before the look session so the not-driving /// path (which returns without ever reaching [`move_axes`]) can seat its camera from the same word. +/// +/// Takes the rig for one field only — [`camera::WorldMouse`], already latched this frame by +/// [`camera::latch_world_mouse`]. There is deliberately no `ButtonInput` parameter left: every +/// mouse fact here is one the UI gets first refusal on. pub(super) fn look_input( - buttons: &ButtonInput, binds: &crate::bindings::BindingsState, player: &Player, + rig: &CameraControl, ) -> LookInput { // Both mouse buttons held together = vanilla's "both-button run": the avatar runs forward while // the character steers with the mouse (turns like a right-drag), regardless of which button went - // down first. Checked directly here rather than through the controller's single-button look mode. + // down first. Off the world-owned pair, not the device's: both primaries pressed over a bag are + // two clicks the UI took, and the reference's run is its two *bindings* held, neither of which + // a captured press dispatches. // MOVEANDSTEER (default Middle Mouse) is the same state through a binding — 1.12's own body // runs the identical CameraOrSelectOrMove + TurnOrAction pair a both-button press does. let steer_held = binds.pressed(crate::bindings::cmd::MOVE_AND_STEER); - let both_buttons = - (buttons.pressed(MouseButton::Left) && buttons.pressed(MouseButton::Right)) || steer_held; + let both_buttons = rig.world_mouse.both() || steer_held; // The camera's **input command word** (decision 1502) — 1.12's `[InputControl+0x4]`, bit for // bit. The auto-follow is armed by *edges on this word* and its state is classified from it, so @@ -55,11 +63,11 @@ pub(super) fn look_input( } }; set( - buttons.pressed(MouseButton::Right) || steer_held, + rig.world_mouse.held(LookButton::Right) || steer_held, bit::RIGHT_MOUSE, ); set( - buttons.pressed(MouseButton::Left) || steer_held, + rig.world_mouse.held(LookButton::Left) || steer_held, bit::LEFT_MOUSE, ); // `/follow` is the forward bit in the reference too — the same setter the W key drives @@ -187,7 +195,7 @@ pub(super) fn move_axes( // Deliberately absent, each VERIFIED as a *survivor*: a jump, a chat EditBox taking focus, and // a zone change. Mounting is genuinely unsettled in the reference and left alone here. let both_buttons_engaged = (both_buttons - && (buttons.just_pressed(MouseButton::Left) || buttons.just_pressed(MouseButton::Right))) + && (rig.world_mouse.down(LookButton::Left) || rig.world_mouse.down(LookButton::Right))) || binds.just_pressed(crate::bindings::cmd::MOVE_AND_STEER); if state::autorun_cancelled( binds.just_pressed(crate::bindings::cmd::MOVE_FORWARD), diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index 96c3dc23..23cc7488 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -15,7 +15,7 @@ use bevy::prelude::*; mod parse; #[cfg(test)] pub(super) use parse::lua_long_string; -pub(super) use parse::{parse_line, ParsedChat}; +pub(super) use parse::{console_command, parse_line, ParsedChat}; use crate::creature_anim::{move_flags, MovementState}; use crate::net::{ClientCommand, NetCommands, SelfPlayer}; @@ -136,6 +136,10 @@ pub(super) struct ChatOut<'w> { /// since a resource reachable twice from one system is a `B0002` panic on the first live frame /// (1903). Nothing else in this system touches `UiErrorKeys`. ui_errors: ResMut<'w, crate::ui_action::UiErrorKeys>, + /// `/console detailDoodadAlpha` — the ground-clutter cutout reference. Rides this bundle for + /// the same arity reason as `ui_errors` above, and like it, nothing else in this system + /// touches `ClutterConfig` (a resource reachable twice from one system is a `B0002` panic). + clutter: ResMut<'w, benilla_world::clutter::ClutterConfig>, } // One parameter per concern — the chat drain fans out to every command's consumer. @@ -167,14 +171,11 @@ fn engine_verbs( } for line in script.take_console_lines() { // `ConsoleExec` already wrote the CVar lines to the store; what reaches here is a - // console COMMAND, of which this client implements the one the reference's `/reload` - // alias spells out. - let cmd = line.trim().to_string(); - if cmd.eq_ignore_ascii_case("reloadui") { - out.push((line, ParsedChat::ReloadUi)); - } else { - out.push((line, ParsedChat::ConsoleUnknown { cmd })); - } + // console COMMAND — a name the engine's own command table owns rather than + // `CVar::Register`'s, which is why `detailDoodadAlpha` lives here and not in the CVar + // store (wow-re: registrar `0x63f9e0`, so it never persists — 2012). + let parsed = console_command(&line); + out.push((line, parsed)); } for cmd in script.take_channel_commands() { out.push((String::new(), ParsedChat::Channel(cmd))); @@ -916,15 +917,35 @@ pub(super) fn drain_chat_input( ParsedChat::ReloadUi => { script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi) } + // `/console detailDoodadAlpha [0..255]` — the reference's own console command + // (`0x6739a0`), and the dial that decides where ground clutter first appears: the + // detail-doodad draw alpha-tests `texel.a x distance_ramp` against it, so 128 (the + // default) hides everything past ~61 yd of the 70 yd fade horizon and a lower value + // walks that onset out toward the horizon. Non-persistent, exactly as the reference's + // command table is. + ParsedChat::DetailDoodadAlpha { value } => { + let text = match value { + Some(v) => { + chat_out.clutter.alpha_ref = f32::from(v) / 255.0; + format!("detailDoodadAlpha set to {v}") + } + None => format!( + "detailDoodadAlpha is {} (usage: /console detailDoodadAlpha 0-255)", + (chat_out.clutter.alpha_ref * 255.0).round() as u32 + ), + }; + chat_log.push_event(super::event::ChatEvent::text_only( + super::event::ChatEventKind::System, + text, + )); + } ParsedChat::ConsoleUnknown { cmd } => { + const IMPLEMENTED: &str = "reloadUI, detailDoodadAlpha, and ` `"; let text = if cmd.is_empty() { - "console: no command given (this client implements: reloadUI, and \ - ` `)" - .to_string() + format!("console: no command given (this client implements: {IMPLEMENTED})") } else { format!( - "console: '{cmd}' is not implemented (this client implements: reloadUI, \ - and ` `)" + "console: '{cmd}' is not implemented (this client implements: {IMPLEMENTED})" ) }; chat_log.push_event(super::event::ChatEvent::text_only( diff --git a/crates/benilla-app/src/ui_chat/input/parse.rs b/crates/benilla-app/src/ui_chat/input/parse.rs index dcf7057c..32a884c2 100644 --- a/crates/benilla-app/src/ui_chat/input/parse.rs +++ b/crates/benilla-app/src/ui_chat/input/parse.rs @@ -171,8 +171,16 @@ pub(in crate::ui_chat) enum ParsedChat { /// session seam like [`ParsedChat::Logout`]; [`crate::ui_script::run_pending_reload`] runs it /// at the top of the next frame. ReloadUi, - /// A `/console` command that is not `reloadUI` — the engine console this client does not - /// have. Answered with a system line naming what was asked, because a claimed alias that + /// `/console detailDoodadAlpha [0..255]` — the **ground-clutter cutout reference**, a real + /// console command in the reference (`0x6739a0`; registrar `0x63f9e0`, a command table and not + /// `CVar::Register`, so it never persists — 1804 does not apply to it). It decides where grass + /// first appears: the detail-doodad draw alpha-tests `texel.a x distance_ramp` against this, so + /// at the default 128 nothing survives past ~61 yd of the 70 yd horizon, and lowering it walks + /// that onset out toward the horizon. Bare = report the current value (the reference reads an + /// uninitialised stack slot there; a readout is the useful reading of "no argument"). + DetailDoodadAlpha { value: Option }, + /// A `/console` command this client does not implement — the engine console is otherwise not + /// here. Answered with a system line naming what was asked, because a claimed alias that /// silently drops its argument reads as a hang. ConsoleUnknown { cmd: String }, /// A slash line matching neither a chat command nor an `EmotesText` name — dropped. @@ -242,6 +250,27 @@ pub(in crate::ui_chat) fn lua_long_string(s: &str) -> String { /// The per-command argument grammar. Each arm is the reference handler's own body reduced to what /// it does with `msg` — the aliases that reach it are the table's business, never this function's. +/// Classify one **console command** line — what is left after `ConsoleExec` has written the CVar +/// lines (2008), i.e. a name the engine's own command table owns rather than `CVar::Register`'s. +/// Pure, so the arms are testable without a VM; called from `engine_verbs`'s console drain. +pub(in crate::ui_chat) fn console_command(line: &str) -> ParsedChat { + let mut words = line.split_whitespace(); + match words.next() { + Some(cmd) if cmd.eq_ignore_ascii_case("reloadui") => ParsedChat::ReloadUi, + // The reference rejects an out-of-range value rather than saturating + // (`0x6739b9: cmp eax,0xff; jbe`), so out-of-range and unparseable are the same `None` + // here and the handler says so. A bare name reports the current value. + Some(cmd) if cmd.eq_ignore_ascii_case("detaildoodadalpha") => { + ParsedChat::DetailDoodadAlpha { + value: words.next().and_then(|v| v.parse::().ok()), + } + } + _ => ParsedChat::ConsoleUnknown { + cmd: line.trim().to_string(), + }, + } +} + fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { use SlashIndex as S; match index { diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 09d2839f..5c438b40 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -2186,3 +2186,44 @@ fn level_up_gains_are_matched_by_level_and_a_miss_is_not_an_absence() { "the entry was taken, not copied" ); } + +/// `detailDoodadAlpha` — the reference's own console command (`0x6739a0`), and the dial that +/// decides where ground clutter first appears (the detail-doodad draw alpha-tests +/// `texel.a × distance_ramp` against it). It is a **console command**, not a CVar (registrar +/// `0x63f9e0`, not `CVar::Register`), so it arrives through the console drain rather than the +/// CVar store `ConsoleExec` writes — this pins the classifier that splits the two. The three +/// cases the reference distinguishes: in range sets it, out of range is REJECTED rather than +/// saturated (`0x6739b9: cmp eax,0xff; jbe`), and unparseable is that same rejection. +#[test] +fn console_detail_doodad_alpha_parses() { + use super::input::{console_command, ParsedChat}; + assert_eq!( + console_command("detailDoodadAlpha 8"), + ParsedChat::DetailDoodadAlpha { value: Some(8) } + ); + // The console is case-insensitive, like the engine's own table lookup. + assert_eq!( + console_command("DETAILDOODADALPHA 255"), + ParsedChat::DetailDoodadAlpha { value: Some(255) } + ); + assert_eq!( + console_command("detailDoodadAlpha 256"), + ParsedChat::DetailDoodadAlpha { value: None } + ); + assert_eq!( + console_command("detailDoodadAlpha wat"), + ParsedChat::DetailDoodadAlpha { value: None } + ); + // Bare = report the current value; and the sibling command still routes. + assert_eq!( + console_command("detailDoodadAlpha"), + ParsedChat::DetailDoodadAlpha { value: None } + ); + assert_eq!(console_command("reloadUI"), ParsedChat::ReloadUi); + assert_eq!( + console_command("nosuchthing"), + ParsedChat::ConsoleUnknown { + cmd: "nosuchthing".to_string() + } + ); +} diff --git a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs index 9fec8868..de48510b 100644 --- a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs +++ b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs @@ -79,7 +79,7 @@ fn shipped_xml() -> Vec<(String, String)> { out.sort(); // A sanity floor for the walk, not a census: `assets/ui` retires file by file (1751), so // the floor sits well under the count rather than one step above it (1956). - assert!(out.len() >= 10, "only {} xml files swept", out.len()); + assert!(out.len() >= 6, "only {} xml files swept", out.len()); out } diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 720b025b..4d3fb944 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -280,7 +280,7 @@ struct Alignment { /// `None` — it must be converted. Kept indices are strictly increasing. source: Vec>, /// The OLD entries nothing reuses. Their quads leave the list, so their conversion's side - /// effects (a parked minimap slot, a shine site, a link span) would have to be *undone* — the + /// effects (a parked minimap slot, a link span, a tile request) would have to be *undone* — the /// splice cannot, so it only proceeds when every one of them was quads-only. dropped: Vec, } @@ -348,10 +348,9 @@ fn span_bounds(spans: &[u32], i: usize) -> (usize, usize) { } /// Whether an entry's conversion writes **quads and nothing else** — the splice's admission test. -/// The full path plumbs four side channels (the engine's link spans, the parked minimap slot, the -/// shine sites, the booth panes); the splice keeps last frame's values for all of them, which is -/// only sound for entries that never write one. The autocast-shine token (decision 1383) is a -/// side-channel kind wearing a Texture's clothes, so it is excluded by path. +/// The full path plumbs three side channels (the engine's link spans, the parked minimap slot, +/// the booth panes and tile requests); the splice keeps last frame's values for all of them, +/// which is only sound for entries that never write one, or write them idempotently. fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool { match &eq.content { QuadContent::Frame @@ -374,11 +373,8 @@ fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool { QuadContent::Text { .. } => matches!(eq.target, benilla_ui::order::ZTarget::Region(_)), QuadContent::Texture { portrait_unit: None, - path, .. - } => !path - .as_deref() - .is_some_and(|p| crate::autocast_shine::token_model_scale(p).is_some()), + } => true, _ => false, } } @@ -413,7 +409,7 @@ fn verify_splice( ) { let mut check: Vec = Vec::with_capacity(quads.quads.len()); let mut spans: Vec = Vec::with_capacity(prev.extracted.len()); - let (mut links, mut slot, mut shine) = (Vec::new(), None, Vec::new()); + let (mut links, mut slot) = (Vec::new(), None); for eq in prev.extracted.iter().cloned() { convert_entry( eq, @@ -429,7 +425,6 @@ fn verify_splice( &mut check, &mut links, &mut slot, - &mut shine, ); spans.push(check.len() as u32); } @@ -528,16 +523,10 @@ pub(super) fn drive_script( Option>, Res, ), - // The append-lane holes this pass parks for their UiQuadAppend producers, tupled for the - // same param-budget squeeze as `run`: - // · the `` widget slot (`minimap::emit_minimap` fills it with tile/arrow quads — - // decision 0203 phase 1); - // · the autocast shine sites (`autocast_shine::emit_shine` draws the spark trails there — - // decision 1383, B282). - mut parked: ( - ResMut, - ResMut, - ), + // The append-lane hole this pass parks for its UiQuadAppend producer: the `` widget + // slot (`minimap::emit_minimap` fills it with tile/arrow quads — decision 0203 phase 1). (The + // autocast shine's sites used to ride beside it; the shine is a model tile since 2014.) + mut parked_minimap: ResMut, // The seam scale the engine's text-metric caches were answered under. When `s` moves (window // resize / fullscreen toggle / uiScale change), every cached measure is stale — see the // invalidation below. @@ -781,9 +770,6 @@ pub(super) fn drive_script( let mut out = Vec::new(); let mut assets = world_assets; let mut minimap_slot = None; - // This frame's autocast shine sites (decision 1383) — refilled by the loop below like the - // minimap slot; the settled and spliced paths above keep last conversion's set. - let mut shine_sites: Vec = Vec::new(); // Hyperlink spans collected while rasterizing message-line Text quads (frame-targeted only — // chat lines; FontString-region links are a later arc), fed back to the engine's click // hit-test after the loop. Rects flip back to the engine's y-up space. @@ -838,7 +824,7 @@ pub(super) fn drive_script( // Every input the conversion below reads, compared against last frame's. Equal inputs make // the loop a pure re-derivation of `UiQuads` the diff at the bottom would discard — at the // LBRS pin that was every single settled frame, ~0.3 ms/frame of glyph re-rasterization for - // an identical `Vec`. On a skip, `quads`/the parked minimap slot and shine sites/the + // an identical `Vec`. On a skip, `quads`/the parked minimap slot/the // engine's link spans all keep last frame's values, which the equal inputs prove are this // frame's values too. let dims = (w.to_bits(), h.to_bits(), s.to_bits(), dpi.to_bits()); @@ -973,7 +959,7 @@ pub(super) fn drive_script( no_splice!("no entry differs (a comparison razor slipped)"); } // Checked on both ends: every entry being converted, and every entry LEAVING the list — - // a departing minimap slot or shine site would have to be un-parked, which the splice has + // a departing minimap slot would have to be un-parked, which the splice has // no way to do. if let Some(eq) = changed .iter() @@ -994,7 +980,6 @@ pub(super) fn drive_script( let mut scratch_ranges: Vec> = Vec::with_capacity(changed.len()); let mut scratch_links = Vec::new(); let mut scratch_slot = None; - let mut scratch_shine = Vec::new(); for &j in &changed { let at = scratch.len(); convert_entry( @@ -1011,11 +996,10 @@ pub(super) fn drive_script( &mut scratch, &mut scratch_links, &mut scratch_slot, - &mut scratch_shine, ); scratch_ranges.push(at..scratch.len()); } - if !scratch_links.is_empty() || scratch_slot.is_some() || !scratch_shine.is_empty() { + if !scratch_links.is_empty() || scratch_slot.is_some() { no_splice!("a re-converted entry wrote a side channel"); } // The in-place case: nothing was inserted or removed, so every kept entry is still at @@ -1164,7 +1148,6 @@ pub(super) fn drive_script( &mut out, &mut link_spans, &mut minimap_slot, - &mut shine_sites, ); spans.push(out.len() as u32); } @@ -1203,11 +1186,9 @@ pub(super) fn drive_script( // (`OnHyperlinkClick`) hit-tests against exactly what is on screen. script.set_link_spans(link_spans); - // Park this frame's Minimap widget slot and autocast shine sites (or clear them — a hidden - // cluster extracts nothing); `minimap::emit_minimap` / `autocast_shine::emit_shine` run - // later in the frame (UiQuadAppend) and fill the holes. - parked.0 .0 = minimap_slot; - parked.1 .0 = shine_sites; + // Park this frame's Minimap widget slot (or clear it — a hidden cluster extracts nothing); + // `minimap::emit_minimap` runs later in the frame (UiQuadAppend) and fills the hole. + parked_minimap.0 = minimap_slot; drop(extract_span); let us_exa = lap(); @@ -1272,8 +1253,8 @@ pub(super) fn drive_script( /// One extracted entry converted to its screen quads, pushed onto `out` — with the side channels /// some arms carry: chat link spans (Text), the minimap widget slot (Minimap), the booth pane -/// aspects (portrait-bound Texture), the autocast shine sites (the token-path Texture, decision -/// 1383). The ONE conversion body: the full pass and the per-entry +/// aspects (portrait-bound Texture), the model tile requests (ModelPane, decision 2013). The ONE +/// conversion body: the full pass and the per-entry /// splice both call this, which is what makes the splice's output equal the full path's by /// construction. An arm is splice-eligible only if it writes nothing but `out` — keep /// [`splice_simple`] in agreement when an arm's side effects change. @@ -1282,8 +1263,8 @@ fn convert_entry( eq: benilla_ui::script::ExtractedQuad, s: f32, // The window, logical px. `h` flips y-up WoW space into the y-down quad pass; `w` is here for - // the one producer whose law is in SCREEN pixels rather than FrameXML units — the autocast - // shine's particle size, which the reference projects by the screen diagonal (decision 1390). + // the one producer whose law needs the SCREEN's shape — a model tile's layout unit and + // particle unit run on the screen diagonal (decision 2013). w: f32, h: f32, assets: &mut Option>, @@ -1300,7 +1281,6 @@ fn convert_entry( String, )>, minimap_slot: &mut Option, - shine_sites: &mut Vec, ) { let Some(r) = eq.rect else { return }; // WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is @@ -1497,35 +1477,6 @@ fn convert_entry( rotation, desaturated, } => { - // The autocast-shine token (decision 1383, B282): a shown marker registers WHERE the - // shine plays — rect, paint order, clip, alpha, and the star art resolved through - // the same resolver as any texture — and draws nothing itself; - // `autocast_shine::emit_shine` (UiQuadAppend) animates the sparks there with zero - // per-frame script-layout traffic. A side-channel arm, so deliberately NOT - // splice-simple (the `simple` predicate above excludes the token by path). - if let Some(model_scale) = path - .as_deref() - .and_then(crate::autocast_shine::token_model_scale) - { - // A no-assets context (the extract test harness; a capture booted without the - // archive) records the site with the default handle — the site's GEOMETRY is - // the registration, and the live app always has the resolver. - let star = assets - .as_mut() - .and_then(|a| a.sprite_texture(crate::autocast_shine::STAR_TEXTURE, images)) - .unwrap_or_default(); - shine_sites.push(crate::autocast_shine::ShineSite { - rect, - z: eq.z, - clip, - alpha: eq.alpha, - scale: s, - model_scale, - diag: (w * w + h * h).sqrt(), - texture: star, - }); - return; - } // A live unit portrait (`SetPortraitTexture(region, unit)`): sample this token's // source ([`crate::portrait::PortraitImages`]) — the off-screen model bake, or the // ref's 2D TemporaryPortrait stand-in while the model streams in. Absent entry (no @@ -2061,7 +2012,6 @@ mod clip_plumb_tests { app.init_resource::(); app.init_resource::(); app.init_resource::(); - app.init_resource::(); app.init_resource::(); app.init_resource::(); app.init_resource::