From 993adf79b9a4a9072db32684e35c54d504fb16b8 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 28 Jul 2026 21:56:26 +0700 Subject: [PATCH] an idle frame skips the quiet UI, and a lighting hunt closes clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The UI stopped paying to discover that nothing changed. Layout used to hash ~1,900 frames' worth of inputs every frame just to decide "quiet"; it now tracks a mutation epoch and answers with one integer compare, and a settled frame reuses last frame's rendered quads instead of re-rasterizing every glyph. ~0.9 ms off every idle frame — with a verify mode, forced on across all 489 UI tests, that re-runs every skipped resolve in full and asserts it would have produced identical results. - Across this run of snapshots, the one number everything is graded by: CPU work per frame at the crowded dungeon spot went from ~29 ms to ~16.5 ms. - A lighting question got the full treatment and an honest answer. Units briefly took a different day/night lighting law; it read wrong on screen the same evening, and a build-by-build hunt a full week back confirmed the world's look never regressed in the first place — the change is reverted, nothing about the look changes in this snapshot, and the question stays parked until the one remaining unknown in the reference client is pinned. - The character pane's model booth grew a headless eye — a dump of exactly what it draws, no live session needed — built for that hunt, kept for the next one. Co-Authored-By: Claude Fable 5 --- crates/benilla-ui/src/script/button.rs | 1 + crates/benilla-ui/src/script/editbox.rs | 15 +- crates/benilla-ui/src/script/layout.rs | 97 +++- crates/benilla-ui/src/script/mod.rs | 27 +- crates/benilla-ui/src/script/model.rs | 34 ++ crates/benilla-ui/src/script/object.rs | 1 + .../src/script/object/frame_state.rs | 31 +- .../src/script/object/layout_methods.rs | 97 +++- crates/benilla-ui/src/script/region.rs | 91 +++- crates/benilla-ui/src/script/scrollframe.rs | 21 +- crates/benilla-ui/src/script/slider.rs | 1 + crates/benilla-ui/src/script/statusbar.rs | 1 + .../src/script/tests/layout_gate.rs | 84 ++++ crates/benilla-ui/src/script/tooltip/mod.rs | 13 + crates/benilla-ui/src/script/tooltip/verbs.rs | 29 +- crates/benilla-ui/src/script/tooltip_unit.rs | 48 +- crates/benilla/src/portrait/mod.rs | 20 +- crates/benilla/src/portrait/test_bake.rs | 51 ++ .../benilla/src/ui_script/extract/cooldown.rs | 287 +++++++++++ crates/benilla/src/ui_script/extract/mod.rs | 464 +++++++----------- 20 files changed, 1022 insertions(+), 391 deletions(-) create mode 100644 crates/benilla/src/ui_script/extract/cooldown.rs diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index ba9f4ade..ff6cbe18 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -125,6 +125,7 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result { ..Default::default() }, ); + model.touch_layout(); // a region entered the layout gate's read set (decision 0740) if let Some(frame) = model.arena.frame_mut(h) { if let KindState::Button(bs) = &mut frame.kind_state { slot.set(bs, rh); diff --git a/crates/benilla-ui/src/script/editbox.rs b/crates/benilla-ui/src/script/editbox.rs index 6a51ea66..822d8833 100644 --- a/crates/benilla-ui/src/script/editbox.rs +++ b/crates/benilla-ui/src/script/editbox.rs @@ -382,11 +382,21 @@ fn set_text_insets(lua: &Lua, h: FrameHandle, l: f32, r: f32, t: f32, b: f32) { fn write_inset_anchors(lua: &Lua, h: FrameHandle, rh: RegionHandle, [l, r, t, b]: [f32; 4]) { let owner = frame_id_of(lua, h); let mut model = lua.app_data_mut::().expect("model app_data"); - let data = model.region_data.entry(rh).or_default(); - data.anchors = vec![ + let pair = [ Anchor::new(Point::TopLeft, owner, Point::TopLeft, l, -t), Anchor::new(Point::BottomRight, owner, Point::BottomRight, -r, b), ]; + let data = model.region_data.entry(rh).or_default(); + let same = data.anchors.len() == 2 + && data + .anchors + .iter() + .zip(&pair) + .all(|(a, b)| super::object::anchor_bits_eq(a, b)); + if !same { + data.anchors = pair.to_vec(); + model.touch_layout(); + } } /// The loader's special-`` slot assignment: adopt `region` as `frame`'s text region, @@ -434,6 +444,7 @@ pub(super) fn ensure_text_region(lua: &Lua, h: FrameHandle) -> Option bool { static ON: std::sync::OnceLock = std::sync::OnceLock::new(); - *ON.get_or_init(|| std::env::var("WOW_LAYOUT_VERIFY").as_deref() == Ok("1")) + *ON.get_or_init(|| cfg!(test) || std::env::var("WOW_LAYOUT_VERIFY").as_deref() == Ok("1")) } impl UiScript { @@ -121,6 +127,18 @@ impl UiScript { /// it fresh. Cheap to call on demand: the fixpoint early-exits in one round on a quiet graph /// (this function's own doc). pub(super) fn resolve_layout(model: &mut Model) { + // ── Tier 1: the mutation epoch ──────────────────────────────────────────────────────── + // Nothing has called `touch_layout` since the last converged resolve ⇒ the read set the + // fingerprint below hashes cannot have moved ⇒ skip everything, including the tooltip + // pre-pass and the fingerprint itself (which was the whole idle cost of this function — + // ~0.6 ms/frame at the LBRS pin, hashing ~2k inputs per frame to decide "quiet"). Under + // verify, fall through: tier 2 then proves the claim (`gate_skips` must agree). + let verify = layout_verify_enabled(); + let tier1_clean = model.layout_epoch_resolved == Some(model.layout_epoch); + if tier1_clean && !verify { + return; + } + let epoch_at_entry = model.layout_epoch; // The GameTooltip auto-size + right-flush pre-pass (decision 0274): writes tooltip frame // sizes + right-column anchor offsets from the measure round-trip's cached extents, so // the graph below solves them like any other frame. @@ -138,6 +156,7 @@ impl UiScript { warnings, solver, layout_fingerprint, + layout_epoch_resolved, layout_solves, .. } = model; @@ -271,8 +290,16 @@ impl UiScript { None => fp.feed(u64::MAX), } } - fp.feed(region_data.len() as u64); + let mut fed_regions = 0u64; for (&rh, data) in region_data.iter() { + // Anchor-less entries are invisible to the rounds — the sweep `continue`s on empty + // anchors and the seed retain drops them — so they are not inputs and must not be + // hashed: a paint-only setter creating a region's entry (`SetTexture`'s + // `or_default()`) would otherwise read as a layout change no touch site can own. + if data.anchors.is_empty() { + continue; + } + fed_regions += 1; fp.feed(rh.fingerprint_bits()); fp.anchors(&data.anchors); match data.size { @@ -298,6 +325,9 @@ impl UiScript { // it and the sweep's `continue` would go unnoticed. fp.feed(u64::from(arena.region(rh).is_some())); } + // The count of FED entries (not the map's len — see the vacuous-entry skip above), so an + // entry leaving the anchored set is a change even if a sibling enters the same frame. + fp.feed(fed_regions); for (&id, rh) in id_to_region.iter() { if let Some(r) = region_resolved.get(rh) { fp.feed(u64::from(id)); @@ -305,8 +335,28 @@ impl UiScript { } } let gate_skips = *layout_fingerprint == Some(fp); - if gate_skips && !layout_verify_enabled() { - return; + // The tier-1/tier-2 cross-check (only reachable under verify when tier 1 judged quiet): + // the epoch said nothing layout-visible was written, so the fingerprint must agree — a + // divergence is a mutation path missing its `Model::touch_layout()`, named here at the + // frame that proves it instead of shipping as a silently stale rect. + if tier1_clean { + assert!( + gate_skips, + "WOW_LAYOUT_VERIFY: the layout epoch judged this frame quiet but the input \ + fingerprint moved — a layout write path is missing its touch_layout()" + ); + } + // Tier 1 closes ONLY on a settled frame (the fingerprint verdict `gate_skips`): the fp is + // hashed over the SEEDS (last pass's outputs), so the first resolve after a real change + // stores a fingerprint the very next, mutation-free resolve would not reproduce — the + // seeds grew under it. That next resolve pays one fingerprint (exactly today's price), + // proves the input set has settled, and closes the epoch here; every later quiet frame + // skips at the u64 compare. + if gate_skips { + *layout_epoch_resolved = Some(epoch_at_entry); + if !verify { + return; + } } // Under `WOW_LAYOUT_VERIFY=1` a skippable resolve runs anyway, against a copy of the rects // it is supposed to reproduce — see [`layout_verify_enabled`]. @@ -314,8 +364,10 @@ impl UiScript { // Cleared, not stored, until the fixpoint actually CONVERGES: a run that exhausts the // round cap (a genuine anchor cycle) leaves its rects mid-flight, and the 0294 cross-frame // seed is what lets the next frame carry them further. Storing here would freeze a - // pathological graph at whatever partial state it reached. + // pathological graph at whatever partial state it reached. The epoch mirror follows the + // same law: a cycle-bailed pass must re-enter tier 1 dirty next frame. *layout_fingerprint = None; + *layout_epoch_resolved = None; // Counted on the gate's DECISION, not on the fixpoint running: under // `WOW_LAYOUT_VERIFY=1` a skipped resolve still runs below, and a counter that moved for // it would mean something different in the two modes — exactly the ambiguity the tests @@ -461,6 +513,13 @@ impl UiScript { ); } *layout_fingerprint = Some(fp); + // Tier 1 closes only on a `gate_skips` frame: a real solve (`!gate_skips`) hashed + // `fp` over now-outgrown seeds — see the gate above. The re-store here is for the + // VERIFY path, whose full re-run of a settled frame passed through the state + // clears; production returned at the gate. Either mode leaves the same state. + if gate_skips { + *layout_epoch_resolved = Some(epoch_at_entry); + } return; } if round + 1 == round_cap { diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index d84f27ca..953f75aa 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -310,9 +310,16 @@ impl UiScript { } /// Set the screen-root rect from a pixel size (`[0,0]` origin, y-up). Top-level frames anchor to - /// it; changing it invalidates the next `resolve`. + /// it; changing it invalidates the next `resolve`. The app calls this every frame with an + /// almost-always-identical size — compared before writing so the per-frame idiom doesn't + /// dirty the layout gate's tier 1 (`Model::touch_layout`). pub fn set_screen_size(&mut self, width: f32, height: f32) { - self.model_mut().screen = Rect::new(0.0, 0.0, height, width); + let new = Rect::new(0.0, 0.0, height, width); + let mut model = self.model_mut(); + if model.screen != new { + model.screen = new; + model.touch_layout(); + } } /// Push the modifier-key state (shift, ctrl, alt) behind `IsShiftKeyDown`/`IsControlKeyDown`/ @@ -467,14 +474,21 @@ impl UiScript { /// the request). The next [`UiScript::resolve`] uses them as the FontStrings' implicit size. pub fn set_measured_text(&mut self, measures: &[(u32, f32, f32, u64)]) { let mut model = self.model_mut(); + let mut changed = false; for &(id, w, h, key) in measures { let Some(&rh) = model.id_to_region.get(&id) else { continue; }; if let Some(d) = model.region_data.get_mut(&rh) { - d.measured = Some(MeasuredText { w, h, key }); + let new = MeasuredText { w, h, key }; + changed |= d.measured != Some(new); + d.measured = Some(new); } } + if changed { + // Measured extents are the auto-size axes' inputs — the layout gate's read set. + model.touch_layout(); + } } /// Feed the number font's per-digit advance widths (logical px, `'0'..='9'` in order) as the @@ -566,6 +580,13 @@ impl UiScript { .is_some_and(|h| model.arena.frame(h).is_some_and(|f| f.effective_visible)) } + /// How many resolves the layout change gate has let through (`Model::layout_solves`) — the + /// gate's effectiveness, readable by tests and the app's cost meters (a per-frame delta of 0 + /// means the fingerprint judged the frame quiet). + pub fn layout_solves(&self) -> u64 { + self.model_ref().layout_solves + } + /// A snapshot of the script errors collected so far (from `pcall`'d handlers). pub fn errors(&self) -> Vec { self.model_ref().errors.clone() diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 0de27c57..07ccbd62 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -35,6 +35,19 @@ pub(crate) struct Model { /// initial state, and what a non-converging (cyclic) pass leaves behind so its progress can /// carry into the next frame. pub(crate) layout_fingerprint: Option, + /// The layout mutation epoch — tier 1 of the resolve change gate (the fingerprint is tier 2). + /// Bumped by [`Model::touch_layout`] at every write path that can move the anchor solve's + /// read set; `resolve` returns immediately while it still equals [`Self::layout_epoch_resolved`], + /// paying a `u64` compare instead of fingerprinting ~2k inputs. An idempotent-write burst (the + /// bag-hover re-enter loop) bumps this every frame and falls through to the fingerprint, which + /// absorbs it exactly as before — the tiers are complementary, not redundant. + pub(crate) layout_epoch: u64, + /// The [`Self::layout_epoch`] value the last SETTLED resolve closed on — one whose + /// fingerprint matched, proving the input set (seeds included) has stopped moving. `None` + /// forces the next resolve through tier 1: the initial state, a cycle-bailed pass, and every + /// real solve (whose stored fingerprint answers for now-outgrown seeds — layout.rs, "Tier 1 + /// closes ONLY on a settled frame"). + pub(crate) layout_epoch_resolved: Option, /// How many times the change gate has LET A RESOLVE THROUGH. `UiScript::resolve` is called /// unconditionally every frame, so the gap between call count and this is the gate's whole /// value; it is also what lets a test assert that a no-op resolve really was one, rather than @@ -620,6 +633,8 @@ impl Model { layout_inputs: HashMap::new(), solver: LayoutSolver::new(), layout_fingerprint: None, + layout_epoch: 0, + layout_epoch_resolved: None, layout_solves: 0, resolved: HashMap::new(), link_spans: HashMap::new(), @@ -812,6 +827,10 @@ impl Model { self.next_id += 1; self.frame_to_id.insert(h, id); self.id_to_frame.insert(id, h); + // A fresh id means a frame just entered the layout graph (`frame_to_id` is the resolve's + // roster) — the mint is the one chokepoint every creation path funnels through (bindings, + // the XML loader, anchor-target resolution), so the tier-1 gate can't miss a birth. + self.touch_layout(); id } @@ -824,6 +843,21 @@ impl Model { self.next_id += 1; self.region_to_id.insert(h, id); self.id_to_region.insert(id, h); + // A fresh region id can seat the region in the resolve's external set — same birth + // chokepoint as `frame_id`'s. + self.touch_layout(); id } + + /// A write that can move [`UiScript::resolve_layout`]'s read set happened: anchors, sizes, + /// measured text, scale, clamp, scroll state, frame/region births, or the screen rect. Every + /// mutating BINDING (and app-facing setter) on that set calls this — tier 1 of the resolve + /// change gate. The resolve's own pre-pass (`tooltip::layout_tooltips`) deliberately does NOT: + /// its writes are derived from state that already arrived through touched paths, and a + /// self-touch would pin the gate open forever. A missed site is a silently stale layout — + /// exactly the failure `WOW_LAYOUT_VERIFY` (on for every benilla-ui test) exists to catch + /// loudly, by proving the fingerprint still matches whenever tier 1 claims quiet. + pub(crate) fn touch_layout(&mut self) { + self.layout_epoch = self.layout_epoch.wrapping_add(1); + } } diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index 59ed74de..89ffee92 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -28,6 +28,7 @@ use crate::widget::{FrameHandle, FrameKind}; mod events_regions; mod frame_state; mod layout_methods; +pub(crate) use layout_methods::anchor_bits_eq; // ───────────────────────────────────────────────────────────────────────────────────────────── // id ↔ lightuserdata diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index 59b3ba9c..ef2c13a6 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -123,7 +123,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { }; let changed = { let mut model = lua.app_data_mut::().expect("model"); - model.arena.set_parent(h, new_parent) + let before = model.arena.frame(h).and_then(|f| f.parent); + let changed = model.arena.set_parent(h, new_parent); + // A real reparent can move the subtree's effective scale — a layout-gate input + // (the arena rejects same-parent/cycle calls, so `before` moving is the test). + if model.arena.frame(h).and_then(|f| f.parent) != before { + model.touch_layout(); + } + changed }; event::fire_visibility_changes(lua, changed); Ok(()) @@ -178,10 +185,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { "SetScale", lua.create_function(|lua, (this, scale): (Table, f32)| { let h = frame_handle_of(lua, &this)?; - lua.app_data_mut::() - .expect("model") - .arena - .set_scale(h, scale); + let mut model = lua.app_data_mut::().expect("model"); + // Effective-scale changes ride the propagation's own eps gate; the own-scale compare + // is the cheap superset (same own scale => no effective change is possible). + let changed = model.arena.frame(h).is_some_and(|f| f.scale != scale); + model.arena.set_scale(h, scale); + if changed { + model.touch_layout(); + } Ok(()) })?, )?; @@ -329,10 +340,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { "SetClampedToScreen", lua.create_function(|lua, (this, clamp): (Table, bool)| { let h = frame_handle_of(lua, &this)?; - lua.app_data_mut::() - .expect("model") - .arena - .set_clamped_to_screen(h, clamp); + let mut model = lua.app_data_mut::().expect("model"); + let changed = model.arena.is_clamped_to_screen(h) != clamp; + model.arena.set_clamped_to_screen(h, clamp); + if changed { + model.touch_layout(); + } Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index b602c0ff..841268d7 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -27,8 +27,20 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); - if let Some(input) = model.layout_inputs.get_mut(&h) { - input.anchors.clear(); + // Every layout setter here follows one law: mutate ONLY on an actual value change, + // and report the change to the tier-1 epoch (`touch_layout`). The compare is what + // keeps an idempotent per-frame caller (the classic OnUpdate re-SetPoint idiom) from + // pinning the gate open — the same absorption the fingerprint gives, paid once at + // the write instead of per-frame over the whole model. + let changed = match model.layout_inputs.get_mut(&h) { + Some(input) if !input.anchors.is_empty() => { + input.anchors.clear(); + true + } + _ => false, + }; + if changed { + model.touch_layout(); } Ok(()) })?, @@ -97,22 +109,22 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { } _ => default_parent_id(&mut model, h), }; + let pair = [ + Anchor::new(Point::TopLeft, rel_id, Point::TopLeft, 0.0, 0.0), + Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0), + ]; let input = model.layout_inputs.entry(h).or_default(); - input.anchors.clear(); - input.anchors.push(Anchor::new( - Point::TopLeft, - rel_id, - Point::TopLeft, - 0.0, - 0.0, - )); - input.anchors.push(Anchor::new( - Point::BottomRight, - rel_id, - Point::BottomRight, - 0.0, - 0.0, - )); + let same = input.anchors.len() == 2 + && input + .anchors + .iter() + .zip(&pair) + .all(|(a, b)| anchor_bits_eq(a, b)); + if !same { + input.anchors.clear(); + input.anchors.extend_from_slice(&pair); + model.touch_layout(); + } Ok(()) })?, )?; @@ -121,7 +133,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, (this, w): (Table, f32)| { let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); - model.layout_inputs.entry(h).or_default().width = w; + let input = model.layout_inputs.entry(h).or_default(); + let changed = input.width.to_bits() != w.to_bits(); + input.width = w; + if changed { + model.touch_layout(); + } Ok(()) })?, )?; @@ -130,7 +147,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, (this, ht): (Table, f32)| { let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); - model.layout_inputs.entry(h).or_default().height = ht; + let input = model.layout_inputs.entry(h).or_default(); + let changed = input.height.to_bits() != ht.to_bits(); + input.height = ht; + if changed { + model.touch_layout(); + } Ok(()) })?, )?; @@ -140,8 +162,13 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); let input = model.layout_inputs.entry(h).or_default(); + let changed = + input.width.to_bits() != w.to_bits() || input.height.to_bits() != ht.to_bits(); input.width = w; input.height = ht; + if changed { + model.touch_layout(); + } Ok(()) })?, )?; @@ -340,13 +367,39 @@ fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Re let y = rest.get(cursor + 1).map(as_f32).unwrap_or(0.0); let input = model.layout_inputs.entry(h).or_default(); - input.anchors.retain(|a| a.point != point); - input + let new = Anchor::new(point, rel_to_id, rel_point, x, y); + // No-op detection must mirror the retain+push below EXACTLY (the fingerprint hashes the vec + // in order, so a same-anchor call that would still REORDER the vec is a real change): a call + // is idempotent only when the identical anchor already sits at the tail and no earlier entry + // carries this point. Anchor's derived PartialEq compares f32 by value, which calls -0.0 == + // 0.0 where the fingerprint's bit-compare would not — compare bits, or the verify assert + // trips on that (authored-XML-real) edge. + let same_at_tail = input .anchors - .push(Anchor::new(point, rel_to_id, rel_point, x, y)); + .last() + .is_some_and(|a| anchor_bits_eq(a, &new)) + && !input.anchors[..input.anchors.len() - 1] + .iter() + .any(|a| a.point == point); + if !same_at_tail { + input.anchors.retain(|a| a.point != point); + input.anchors.push(new); + model.touch_layout(); + } Ok(()) } +/// Bit-exact anchor equality — the same lens the layout gate's fingerprint reads anchors through +/// (`InputFingerprint::anchors` feeds `f32::to_bits`), so the setters' no-op detection and the +/// gate can never disagree about whether a write "changed" something. +pub(crate) fn anchor_bits_eq(a: &Anchor, b: &Anchor) -> bool { + a.point == b.point + && a.relative_to == b.relative_to + && a.relative_point == b.relative_point + && a.x_off.to_bits() == b.x_off.to_bits() + && a.y_off.to_bits() == b.y_off.to_bits() +} + /// The default `relativeTo` id for a `SetPoint` with no explicit target: the frame's parent id, or /// [`SCREEN`] if it is top-level (the client anchors top-level frames to `UIParent`/the screen root). fn default_parent_id(model: &mut Model, h: FrameHandle) -> u32 { diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index c4738215..2777f1f6 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -6,7 +6,9 @@ use mlua::{Lua, Table, Value}; -use super::object::{as_f32, decode_id, draw_layer_from_str, id_to_lud, point_from_str}; +use super::object::{ + anchor_bits_eq, as_f32, decode_id, draw_layer_from_str, id_to_lud, point_from_str, +}; use super::{ JustifyH, JustifyV, Model, Outline, TexCoords, REG_REGION_META, REG_REGION_METHODS, REG_WRAPPERS, SCREEN, @@ -321,7 +323,12 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { let rh = region_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); let d = model.region_data.entry(rh).or_default(); - d.size = Some((w, d.size.map_or(0.0, |s| s.1))); + let new = Some((w, d.size.map_or(0.0, |s| s.1))); + let changed = !size_bits_eq(d.size, new); + d.size = new; + if changed { + model.touch_layout(); + } Ok(()) })?, )?; @@ -331,7 +338,12 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { let rh = region_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); let d = model.region_data.entry(rh).or_default(); - d.size = Some((d.size.map_or(0.0, |s| s.0), h)); + let new = Some((d.size.map_or(0.0, |s| s.0), h)); + let changed = !size_bits_eq(d.size, new); + d.size = new; + if changed { + model.touch_layout(); + } Ok(()) })?, )?; @@ -340,7 +352,13 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, (this, w, h): (Table, f32, f32)| { let rh = region_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); - model.region_data.entry(rh).or_default().size = Some((w, h)); + let d = model.region_data.entry(rh).or_default(); + let new = Some((w, h)); + let changed = !size_bits_eq(d.size, new); + d.size = new; + if changed { + model.touch_layout(); + } Ok(()) })?, )?; @@ -439,7 +457,12 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let rh = region_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); - model.region_data.entry(rh).or_default().anchors.clear(); + let d = model.region_data.entry(rh).or_default(); + let changed = !d.anchors.is_empty(); + d.anchors.clear(); + if changed { + model.touch_layout(); + } Ok(()) })?, )?; @@ -450,22 +473,22 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { let mut model = lua.app_data_mut::().expect("model"); let owner = region_owner_id(&mut model, rh); let rel_id = resolve_target(&mut model, &target, owner); + let pair = [ + Anchor::new(Point::TopLeft, rel_id, Point::TopLeft, 0.0, 0.0), + Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0), + ]; let data = model.region_data.entry(rh).or_default(); - data.anchors.clear(); - data.anchors.push(Anchor::new( - Point::TopLeft, - rel_id, - Point::TopLeft, - 0.0, - 0.0, - )); - data.anchors.push(Anchor::new( - Point::BottomRight, - rel_id, - Point::BottomRight, - 0.0, - 0.0, - )); + let same = data.anchors.len() == 2 + && data + .anchors + .iter() + .zip(&pair) + .all(|(a, b)| anchor_bits_eq(a, b)); + if !same { + data.anchors.clear(); + data.anchors.extend_from_slice(&pair); + model.touch_layout(); + } Ok(()) })?, )?; @@ -764,6 +787,19 @@ fn resolve_target(model: &mut Model, target: &Value, owner: u32) -> u32 { } } +/// Bit-exact equality for a region's explicit size — the layout gate's own lens +/// (`InputFingerprint::input` feeds `f32::to_bits`), so a setter's no-op detection and the gate +/// can never disagree; see [`anchor_bits_eq`]. +fn size_bits_eq(a: Option<(f32, f32)>, b: Option<(f32, f32)>) -> bool { + match (a, b) { + (None, None) => true, + (Some((aw, ah)), Some((bw, bh))) => { + aw.to_bits() == bw.to_bits() && ah.to_bits() == bh.to_bits() + } + _ => false, + } +} + /// `Region:SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` — the region twin of /// [`super::object`]'s frame `SetPoint`, writing [`super::RegionData::anchors`]. The overload is /// disambiguated by argument *type* exactly as the frame version. @@ -796,8 +832,17 @@ fn region_set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> m let y = rest.get(cursor + 1).map(as_f32).unwrap_or(0.0); let data = model.region_data.entry(rh).or_default(); - data.anchors.retain(|a| a.point != point); - data.anchors - .push(Anchor::new(point, rel_to_id, rel_point, x, y)); + let new = Anchor::new(point, rel_to_id, rel_point, x, y); + // Same no-op law as the frame twin (`layout_methods::set_point`): idempotent only when the + // bit-identical anchor already holds the tail and no earlier entry carries this point. + let same_at_tail = data.anchors.last().is_some_and(|a| anchor_bits_eq(a, &new)) + && !data.anchors[..data.anchors.len() - 1] + .iter() + .any(|a| a.point == point); + if !same_at_tail { + data.anchors.retain(|a| a.point != point); + data.anchors.push(new); + model.touch_layout(); + } Ok(()) } diff --git a/crates/benilla-ui/src/script/scrollframe.rs b/crates/benilla-ui/src/script/scrollframe.rs index 29ad12e6..792f5311 100644 --- a/crates/benilla-ui/src/script/scrollframe.rs +++ b/crates/benilla-ui/src/script/scrollframe.rs @@ -82,7 +82,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { _ => None, } }; - with_scroll(lua, &this, |s| s.child = new_child) + let changed = with_scroll(lua, &this, |s| { + let changed = s.child != new_child; + s.child = new_child; + changed + })?; + if changed { + // The child override is part of the resolve's read set (decision 0112's local + // anchor map — a fingerprint input), so a real re-target dirties tier 1. + lua.app_data_mut::().expect("model").touch_layout(); + } + Ok(()) })?, )?; m.set( @@ -113,7 +123,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { scroll_range(&model, h) }; let clamped = px.clamp(0.0, range); - with_scroll(lua, &this, |s| s.vertical = clamped)?; + let changed = with_scroll(lua, &this, |s| { + let changed = s.vertical.to_bits() != clamped.to_bits(); + s.vertical = clamped; + changed + })?; + if changed { + lua.app_data_mut::().expect("model").touch_layout(); + } fire_vertical_scroll(lua, &this, clamped) })?, )?; diff --git a/crates/benilla-ui/src/script/slider.rs b/crates/benilla-ui/src/script/slider.rs index 48958462..efcac7d6 100644 --- a/crates/benilla-ui/src/script/slider.rs +++ b/crates/benilla-ui/src/script/slider.rs @@ -88,6 +88,7 @@ fn ensure_thumb(lua: &Lua, this: &Table, layer: Option) -> mlua::Resu ) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; model.region_data.insert(rh, RegionData::default()); + model.touch_layout(); // a region entered the layout gate's read set (decision 0740) if let Some(frame) = model.arena.frame_mut(h) { if let KindState::Slider(s) = &mut frame.kind_state { s.thumb = Some(rh); diff --git a/crates/benilla-ui/src/script/statusbar.rs b/crates/benilla-ui/src/script/statusbar.rs index c574193b..7bb938e7 100644 --- a/crates/benilla-ui/src/script/statusbar.rs +++ b/crates/benilla-ui/src/script/statusbar.rs @@ -88,6 +88,7 @@ fn ensure_bar(lua: &Lua, this: &Table, layer: Option) -> mlua::Result ) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; model.region_data.insert(rh, RegionData::default()); + model.touch_layout(); // a region entered the layout gate's read set (decision 0740) if let Some(frame) = model.arena.frame_mut(h) { if let KindState::StatusBar(sb) = &mut frame.kind_state { sb.bar = Some(rh); diff --git a/crates/benilla-ui/src/script/tests/layout_gate.rs b/crates/benilla-ui/src/script/tests/layout_gate.rs index 287d0e20..83fc7565 100644 --- a/crates/benilla-ui/src/script/tests/layout_gate.rs +++ b/crates/benilla-ui/src/script/tests/layout_gate.rs @@ -303,3 +303,87 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() { ); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// Tier 1 of the gate (decision 0740): the mutation epoch. The epoch closes only on a SETTLED +/// resolve (one whose fingerprint matched — the first resolve after a change hashes outgrown +/// seeds, so it may not close), and from then on a quiet frame skips at a `u64` compare without +/// computing the fingerprint at all. +fn tier_one_closed(s: &UiScript) -> bool { + let m = s.lua().app_data_ref::().expect("model app_data"); + m.layout_epoch_resolved == Some(m.layout_epoch) +} + +#[test] +fn a_settled_resolve_closes_tier_one_and_a_real_write_reopens_it() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + setup(&s); + + s.resolve(); // the change-driven solve (fingerprint hashed over pre-solve seeds) + s.resolve(); // the settling pass — fingerprint matches, tier 1 closes + assert!( + tier_one_closed(&s), + "a settled resolve must close the epoch" + ); + + s.run("parent:SetWidth(150)").expect("resize"); + assert!( + !tier_one_closed(&s), + "a real layout write must reopen tier 1" + ); + s.resolve(); + s.resolve(); + assert!(tier_one_closed(&s), "and settling closes it again"); +} + +#[test] +fn an_idempotent_setter_call_leaves_tier_one_closed() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + setup(&s); + s.resolve(); + s.resolve(); + assert!(tier_one_closed(&s)); + + // The classic per-frame OnUpdate idiom: re-assert the exact same geometry every frame. The + // setters' compare-before-write keeps the epoch untouched, so an idle UI never re-enters + // the fingerprint path at all. + s.run( + r#" + parent:SetWidth(100); parent:SetHeight(40) + parent:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10) + child:SetPoint("TOPLEFT", parent, "BOTTOMRIGHT", 0, 0) + "#, + ) + .expect("idempotent re-set"); + assert!( + tier_one_closed(&s), + "value-identical setter calls must not dirty the epoch" + ); + let before = solves(&s); + s.resolve(); + assert_eq!(solves(&s), before, "and the resolve stays skipped"); +} + +#[test] +fn a_paint_only_write_leaves_tier_one_closed() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + setup(&s); + // A paint region with NO anchors: its region_data entry is created by a paint setter and + // must stay invisible to the layout gate (the resolve sweep skips anchor-less entries). + s.run(r#"tex = parent:CreateTexture(nil, "ARTWORK")"#) + .expect("region"); + s.resolve(); + s.resolve(); + assert!(tier_one_closed(&s)); + + s.run(r#"tex:SetTexture(1, 0, 0)"#).expect("paint"); + assert!( + tier_one_closed(&s), + "creating/painting an anchor-less region is not a layout change" + ); + let before = solves(&s); + s.resolve(); + assert_eq!(solves(&s), before); +} diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index 6af46419..9429f3b2 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -198,6 +198,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { } }]; model.region_data.insert(left, d); + model.touch_layout(); // a line row entered the layout graph (decision 0740) } let left_id = model.region_id(left); @@ -224,6 +225,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { d.justify_h = super::JustifyH::Right; d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)]; model.region_data.insert(right, d); + model.touch_layout(); // a line row entered the layout graph (decision 0740) } let right_id = model.region_id(right); @@ -272,8 +274,10 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) { Some(KindState::Tooltip(t)) => (t.left_lines.clone(), t.right_lines.clone()), _ => return, }; + let mut any_size_dropped = false; for rh in lefts.into_iter().chain(rights) { if let Some(d) = model.region_data.get_mut(&rh) { + any_size_dropped |= d.size.is_some(); d.text = None; d.hidden = true; // `measured` is deliberately KEPT. It is a cache whose validity gate is its own @@ -297,6 +301,11 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) { d.size = None; } } + if any_size_dropped { + // The size wipes above are gate-read-set writes; in the hover re-enter loop this fires + // every frame and tier 2 (the fingerprint) absorbs the burst — see the epoch's doc. + model.touch_layout(); + } if let Ok(t) = tip_mut(model, h) { t.num_lines = 0; t.min_width = 0.0; @@ -484,6 +493,10 @@ pub(super) fn append_line( if wrap { if let Some(d) = model.region_data.get_mut(&lh) { d.size = Some((crate::widget::TOOLTIP_WRAP_WIDTH, 0.0)); + // A size write straight into the gate's read set. In the bag-hover re-enter loop + // this re-pins every frame after clear_content's wipe — tier 1 goes dirty and the + // FINGERPRINT (tier 2) absorbs the idempotent burst, exactly the pre-epoch behavior. + model.touch_layout(); } } if let Some((text, color)) = right { diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index 686d281b..49573b2e 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -60,20 +60,31 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { }; match pts { Some((own, rel)) => { + let new = + Anchor::new(own, owner_id, rel, x.unwrap_or(0.0), y.unwrap_or(0.0)); let input = model.layout_inputs.entry(h).or_default(); - input.anchors = vec![Anchor::new( - own, - owner_id, - rel, - x.unwrap_or(0.0), - y.unwrap_or(0.0), - )]; + // The no-op compare keeps the per-frame SetOwner idiom (the bag + // hover's OnUpdate re-enter) from dirtying tier 1 by itself; the + // content clear above already reports its own writes. + let same = input.anchors.len() == 1 + && crate::script::object::anchor_bits_eq(&input.anchors[0], &new); + if !same { + input.anchors = vec![new]; + model.touch_layout(); + } } None if anchor.eq_ignore_ascii_case("ANCHOR_NONE") => { // The caller points it (ClearAllPoints+SetPoint) — drop ours now so a // stale owner anchor never wins the frame the caller forgets to. - if let Some(input) = model.layout_inputs.get_mut(&h) { - input.anchors.clear(); + let dropped = match model.layout_inputs.get_mut(&h) { + Some(input) if !input.anchors.is_empty() => { + input.anchors.clear(); + true + } + _ => false, + }; + if dropped { + model.touch_layout(); } } None => {} // ANCHOR_PRESERVE diff --git a/crates/benilla-ui/src/script/tooltip_unit.rs b/crates/benilla-ui/src/script/tooltip_unit.rs index a5941b23..f74aa0d9 100644 --- a/crates/benilla-ui/src/script/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tooltip_unit.rs @@ -350,13 +350,15 @@ impl super::UiScript { let input = model.layout_inputs.entry(h).or_default(); // Anchor only — the screen clamp is the tooltip FRAME's own flag // (`Frame::clamped_to_screen`), synced with live extents at every resolve. - input.anchors = vec![Anchor::new( - Point::Bottom, - root_id, - Point::BottomLeft, - ui_x, - ui_y, - )]; + let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y); + // Compare-then-touch: a still cursor re-seats the plate at the same point every + // frame — tier 1 of the layout gate stays quiet unless the pointer actually moved. + let same = + input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new); + if !same { + input.anchors = vec![new]; + model.touch_layout(); + } } let wrapper = match super::object::frame_wrapper(&self.lua, id) { Ok(w) => w, @@ -430,13 +432,15 @@ impl super::UiScript { // edge slides back in instead of clipping, director's report at the screen-right // minimap) is the tooltip FRAME's own flag (`Frame::clamped_to_screen`), synced with // live extents at every resolve. - input.anchors = vec![Anchor::new( - Point::Bottom, - root_id, - Point::BottomLeft, - ui_x, - ui_y, - )]; + let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y); + // Compare-then-touch: a still cursor re-seats the plate at the same point every + // frame — tier 1 of the layout gate stays quiet unless the pointer actually moved. + let same = + input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new); + if !same { + input.anchors = vec![new]; + model.touch_layout(); + } } fire_cleared(&self.lua, h); // The blip name renders GOLD like the reference (the engine tooltip's SetText line — @@ -491,13 +495,15 @@ impl super::UiScript { let input = model.layout_inputs.entry(h).or_default(); // Anchor only — the frame's own clamp flag keeps the plate on screen (see // `Frame::clamped_to_screen`). - input.anchors = vec![Anchor::new( - Point::Bottom, - root_id, - Point::BottomLeft, - ui_x, - ui_y, - )]; + let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y); + // Compare-then-touch, as in the seat/blip paths above: the follow fires per pointer + // event, and a still cursor must not dirty the layout gate's tier 1. + let same = + input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new); + if !same { + input.anchors = vec![new]; + model.touch_layout(); + } } /// Arm the mouseover tooltip's fade-out (hover loss — the byte law arms a timestamped fade, diff --git a/crates/benilla/src/portrait/mod.rs b/crates/benilla/src/portrait/mod.rs index 7048cdf4..d776f7b3 100644 --- a/crates/benilla/src/portrait/mod.rs +++ b/crates/benilla/src/portrait/mod.rs @@ -308,7 +308,11 @@ impl Plugin for PortraitPlugin { // globals; unordered w.r.t. the syncs — a fresh card just faces forward one frame). .add_systems(Update, booth::face_booth_billboards) // The phase-3 preview instrument (`WOW_CREATE_TEST`, decision 0423): inert without the env. - .add_systems(Update, glue_booth::drive_create_test); + .add_systems(Update, glue_booth::drive_create_test) + // `WOW_BOOTH_DUMP=::` — photograph a booth's render target to disk + // (the headless eye on "what is the paperdoll actually showing right now"; the + // first-login black-pane hunt). Inert without the env. + .add_systems(Update, test_bake::dump_booth_target); } } @@ -1014,6 +1018,20 @@ fn gate_booth_cameras( } let live_scene = token.as_str() == GLUE_SLOT && preview.scene.is_some(); let active = test || live_scene || booth.wake > 0 || !booth.pending.is_empty(); + // `WOW_BOOTH_LOG=1`: the gate's timeline — every activity flip and every armed frame, + // wall-stamped (the first-login black-pane hunt). + static LOG: std::sync::OnceLock = std::sync::OnceLock::new(); + if *LOG.get_or_init(|| std::env::var_os("WOW_BOOTH_LOG").is_some()) + && (cam.is_active != active || active) + { + eprintln!( + "[booth] {} active={} wake={} pending={}", + token.as_str(), + active, + booth.wake, + booth.pending.len(), + ); + } if cam.is_active != active { cam.is_active = active; } diff --git a/crates/benilla/src/portrait/test_bake.rs b/crates/benilla/src/portrait/test_bake.rs index 24bd41e9..20abfe0b 100644 --- a/crates/benilla/src/portrait/test_bake.rs +++ b/crates/benilla/src/portrait/test_bake.rs @@ -216,3 +216,54 @@ fn bake_test( } true } + +/// `WOW_BOOTH_DUMP=::`: once `secs` of app time have elapsed, screenshot the +/// named booth's render target (e.g. `paperdoll`) to `path`. A probe run can then look at the +/// pane a live session would see under the character window — without a UI click path (the +/// first-login black-pane hunt). One shot per run; inert without the env. +pub(super) fn dump_booth_target( + mut commands: Commands, + booths: Res, + time: Res>, + mut fired: Local, +) { + static SPEC: std::sync::OnceLock> = std::sync::OnceLock::new(); + let Some((token, path, secs)) = SPEC.get_or_init(|| { + let v = std::env::var("WOW_BOOTH_DUMP").ok()?; + let mut it = v.splitn(3, ':'); + Some(( + it.next()?.to_string(), + it.next()?.to_string(), + it.next()?.parse().ok()?, + )) + }) else { + return; + }; + if *fired || time.elapsed_secs() < *secs { + return; + } + *fired = true; + let Some(booth) = booths.0.get(token.as_str()) else { + warn!("WOW_BOOTH_DUMP: no booth named {token:?}"); + return; + }; + use bevy::render::view::window::screenshot::{Screenshot, ScreenshotCaptured}; + info!("WOW_BOOTH_DUMP: shooting booth {token:?} -> {path}"); + let out = std::path::PathBuf::from(path.clone()); + commands + .spawn(Screenshot::image(booth.target.clone())) + .observe(move |shot: On| { + // The booth target is deliberately non-sRGB (`new_target_image`), which bevy's stock + // `save_to_disk` refuses to convert — relabel the identical bytes as sRGB for the PNG. + let mut img = shot.image.clone(); + img.texture_descriptor.format = + bevy::render::render_resource::TextureFormat::Rgba8UnormSrgb; + match img.try_into_dynamic() { + Ok(dyn_img) => match dyn_img.save(&out) { + Ok(()) => info!("WOW_BOOTH_DUMP: saved {}", out.display()), + Err(e) => warn!("WOW_BOOTH_DUMP: save failed: {e}"), + }, + Err(e) => warn!("WOW_BOOTH_DUMP: convert failed: {e}"), + } + }); +} diff --git a/crates/benilla/src/ui_script/extract/cooldown.rs b/crates/benilla/src/ui_script/extract/cooldown.rs new file mode 100644 index 00000000..9bb72ee5 --- /dev/null +++ b/crates/benilla/src/ui_script/extract/cooldown.rs @@ -0,0 +1,287 @@ +//! The Cooldown widget's pixels (decision 0137 phase 4): the radial dark wipe + the finish +//! flash, rebuilt natively from the byte-read `UI-Cooldown-Indicator.m2`. Split from [`super`] +//! purely for size — [`cooldown_quads`] is the only face, called from the extract loop's +//! `QuadContent::Cooldown` arm. + +use bevy::prelude::*; + +use crate::assets::WorldAssets; +use crate::ui_pass::UiQuad; + +/// The cooldown wipe's paint: flat black at α = 0x99/255 — the byte content of +/// `Interface\Cooldown\cooldown.blp` (a uniform 32² black texel field at alpha 0x99, with only a +/// transparent clamp column for the sweep edge), constant through the sweep (the model's +/// quadrant color-alpha tracks hold 1.0 for all of sequence 0 — the m2anim key dump). +const COOLDOWN_WIPE_ALPHA: f32 = 153.0 / 255.0; + +/// The finish-flash star's bone-scale pulse — sequence 1's 5 keys, byte-read off +/// `UI-Cooldown-Indicator.m2` with the `m2anim` bone-scale dump (uniform XYZ, so one scalar per +/// key): grow to 1.85× with the alpha rise, then a shrinking double-bounce while it fades. +const STAR_SCALE_KEYS: [(f32, f32); 5] = [ + (0.0, 1.0), + (0.333, 1.853), + (0.666, 1.305), + (0.833, 1.605), + (1.0, 1.155), +]; + +/// Linear sample of [`STAR_SCALE_KEYS`] at flash progress `t` (the M2 linear-track read). +fn star_scale(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + for w in STAR_SCALE_KEYS.windows(2) { + let ((t0, v0), (t1, v1)) = (w[0], w[1]); + if t <= t1 { + return v0 + (v1 - v0) * ((t - t0) / (t1 - t0)); + } + } + STAR_SCALE_KEYS[4].1 +} + +/// The Cooldown widget's pixels (decision 0137 phase 4): the radial dark wipe + the finish flash, +/// rebuilt natively from the byte-read `UI-Cooldown-Indicator.m2`. +/// +/// **The wipe.** The model draws 4 quadrant quads whose UVs rotate over a clamp-edged +/// half-transparent texture — 2001's way of sweeping a straight edge through each quadrant; with +/// a *flat* texture behind it (`cooldown.blp` is uniform black at α 0x99), the composed result is +/// exactly a uniform dark pie whose bright sector grows **clockwise from 12 o'clock** as the +/// cooldown elapses. Rebuilt as ≤4 quads: one full-dark rect per still-covered quadrant, plus +/// the active quadrant's **wedge** — an exact convex fan (`c → ray exit → [outer corner] → +/// quadrant-end edge midpoint`) through [`UiQuad::corners`]. Pixel-equivalent, no scissor +/// interplay. (The wedge bypasses the scroll clip — no cooldown sits in a ScrollFrame; the +/// full-dark rects still carry it.) +/// +/// **The flash.** The model's sequence 1 (authored 1.000 s): the additive `star4` burst whose +/// alpha is the byte-read texture-weight ramp — linear 0→1 over the first third, hold 1 to the +/// half, linear 1→0 over the back half — scaled by the star bone's byte-read 5-key pulse +/// ([`STAR_SCALE_KEYS`], the `m2anim` bone-scale dump): grow to 1.85× with the alpha rise, then +/// a shrinking double-bounce as it fades. +#[allow(clippy::too_many_arguments)] // one draw arm's full input set +pub(super) fn cooldown_quads( + rect: Rect, + z_key: u64, + frame_alpha: f32, + fraction: f32, + flash: Option, + clip: Option, + assets: &mut Option>, + images: &mut Assets, + out: &mut Vec, +) { + let c = rect.center(); + let dark = [0.0, 0.0, 0.0, COOLDOWN_WIPE_ALPHA * frame_alpha]; + + if let Some(progress) = flash { + // The finish flash: the byte-read weight ramp (keys 0→1 over 0..⅓, hold to ½, →0 at 1). + let ramp = if progress < 1.0 / 3.0 { + progress * 3.0 + } else if progress < 0.5 { + 1.0 + } else { + (1.0 - progress) * 2.0 + }; + let handle = assets + .as_mut() + .and_then(|a| a.sprite_texture("Interface\\Cooldown\\star4", images)); + if let Some(handle) = handle { + let s = star_scale(progress); + let half = rect.half_size() * s; + out.push(UiQuad { + rect: Rect::from_center_half_size(c, half), + z_key, + texture: Some(handle), + color: [1.0, 1.0, 1.0, ramp * frame_alpha], + additive: true, + clip, + ..default() + }); + } + return; + } + + // The sweep: bright sector [0, θ) clockwise from 12 o'clock; quadrants wholly past the edge + // are full-dark rects, the active one carries the exact wedge. + let theta = fraction.clamp(0.0, 1.0) * std::f32::consts::TAU; + let active = ((theta / std::f32::consts::FRAC_PI_2) as usize).min(3); + // Clockwise from 12 in y-down screen space: top-right, bottom-right, bottom-left, top-left. + let quadrant = |k: usize| match k { + 0 => Rect::new(c.x, rect.min.y, rect.max.x, c.y), + 1 => Rect::new(c.x, c.y, rect.max.x, rect.max.y), + 2 => Rect::new(rect.min.x, c.y, c.x, rect.max.y), + _ => Rect::new(rect.min.x, rect.min.y, c.x, c.y), + }; + for k in (active + 1)..4 { + out.push(UiQuad { + rect: quadrant(k), + z_key, + color: dark, + clip, + ..default() + }); + } + + // The active quadrant's wedge. The sweep ray from c at angle θ has direction + // d(θ) = (sin θ, −cos θ) (y-down); per quadrant it can exit through two edges — the "first" + // one (adjacent to the sweep's entry axis, whose crossing keeps the quadrant's outer corner + // dark) or the "second" (past the corner). Vertices fan from c, clockwise: + // c → E (ray exit) → [K (outer corner) if the ray exits the first edge] → M (the + // quadrant-end axis point). A three-vertex wedge repeats M (the fan's degenerate second + // triangle draws nothing). + let d = Vec2::new(theta.sin(), -theta.cos()); + // Per active quadrant: (first-edge t, second-edge t, K, M). A division by ±0 yields ±inf, + // which the min-pick handles (the boundary angles resolve to the finite edge). + let (t_first, t_second, k_corner, m_point) = match active { + 0 => ( + (rect.min.y - c.y) / d.y, + (rect.max.x - c.x) / d.x, + Vec2::new(rect.max.x, rect.min.y), + Vec2::new(rect.max.x, c.y), + ), + 1 => ( + (rect.max.x - c.x) / d.x, + (rect.max.y - c.y) / d.y, + Vec2::new(rect.max.x, rect.max.y), + Vec2::new(c.x, rect.max.y), + ), + 2 => ( + (rect.max.y - c.y) / d.y, + (rect.min.x - c.x) / d.x, + Vec2::new(rect.min.x, rect.max.y), + Vec2::new(rect.min.x, c.y), + ), + _ => ( + (rect.min.x - c.x) / d.x, + (rect.min.y - c.y) / d.y, + Vec2::new(rect.min.x, rect.min.y), + Vec2::new(c.x, rect.min.y), + ), + }; + let e_point = c + d * t_first.min(t_second); + let corners = if t_first <= t_second { + [c, e_point, k_corner, m_point] + } else { + [c, e_point, m_point, m_point] + }; + out.push(UiQuad { + rect: quadrant(active), + z_key, + color: dark, + corners: Some(corners), + ..default() + }); +} + +#[cfg(test)] +mod cooldown_quad_tests { + use super::{cooldown_quads, star_scale, STAR_SCALE_KEYS}; + use bevy::prelude::*; + + /// Emit the sweep for `fraction` on a unit-friendly 40×40 button at (0,0)..(40,40). + fn sweep(fraction: f32) -> Vec { + let mut out = Vec::new(); + cooldown_quads( + Rect::new(0.0, 0.0, 40.0, 40.0), + 7, + 1.0, + fraction, + None, + None, + &mut None, + &mut Assets::::default(), + &mut out, + ); + out + } + + /// Total dark coverage of the emitted quads (rect quads + the corner wedge's shoelace area). + fn dark_area(quads: &[crate::ui_pass::UiQuad]) -> f32 { + quads + .iter() + .map(|q| match q.corners { + None => q.rect.width() * q.rect.height(), + Some(c) => { + // Shoelace over the fan's two triangles (0,1,2) + (0,2,3). + let tri = |a: Vec2, b: Vec2, d: Vec2| ((b - a).perp_dot(d - a) / 2.0).abs(); + tri(c[0], c[1], c[2]) + tri(c[0], c[2], c[3]) + } + }) + .sum() + } + + /// The square-cornered pie's exact per-phase geometry (a square pie's area is not linear in + /// the swept angle, so the checkpoints below are the analytically known shapes: full, the + /// 45° corner ray, the 180° half, the end sliver). + #[test] + fn cooldown_sweep_geometry_is_the_exact_square_pie() { + // Fraction 0: everything dark — 3 full quadrant rects + a wedge covering the fourth. + let q0 = sweep(0.0); + assert_eq!(q0.len(), 4); + assert!( + (dark_area(&q0) - 1600.0).abs() < 1e-2, + "fully dark at start" + ); + + // Fraction 1/8 (45° — the ray through quadrant 0's outer corner): the bright half of + // quadrant 0 is gone; the wedge is the triangle {center, corner, right-edge midpoint}. + let q = sweep(0.125); + assert_eq!(q.len(), 4); + assert!( + (dark_area(&q) - (1600.0 - 200.0)).abs() < 1.0, + "45°: half of one 400px² quadrant is bright, got {}", + dark_area(&q) + ); + + // Fraction 0.5 (180°): the right half is bright — two full-dark quadrants + a wedge + // that spans exactly quadrant 2 (t ties pick the corner-inclusive fan; area 400). + let q = sweep(0.5); + assert!((dark_area(&q) - 800.0).abs() < 1.0, "half bright at 180°"); + + // Fraction ~1: a sliver — near-zero dark area, no full quadrants left. + let q = sweep(0.999); + assert!(dark_area(&q) < 20.0, "almost done — a sliver remains"); + assert_eq!(q.len(), 1, "only the active quadrant's wedge remains"); + + // The wedge always fans from the button center. + for f in [0.05, 0.3, 0.6, 0.9] { + let q = sweep(f); + let wedge = q + .iter() + .find_map(|u| u.corners) + .expect("a wedge each phase"); + assert_eq!(wedge[0], Vec2::new(20.0, 20.0), "fan apex at the center"); + } + } + + /// The star pulse samples its byte-read keys exactly and lerps between them — the ⅓ peak + /// coincides with the alpha ramp's own peak. + #[test] + fn star_scale_follows_the_five_key_curve() { + for (t, v) in STAR_SCALE_KEYS { + assert!((star_scale(t) - v).abs() < 1e-4, "key at {t}"); + } + // Midway up the rise: halfway between 1.0 and 1.853. + let mid = star_scale(0.333 / 2.0); + assert!((mid - (1.0 + 1.853) / 2.0).abs() < 1e-2, "got {mid}"); + // Clamped outside the band. + assert!((star_scale(-1.0) - 1.0).abs() < 1e-4); + assert!((star_scale(2.0) - 1.155).abs() < 1e-4); + } + + /// The flash phase draws no dark pie — only the additive star (skipped here: no assets in a + /// unit test — the pie's absence is the machine-checkable half). + #[test] + fn cooldown_flash_phase_emits_no_dark_pie() { + let mut out = Vec::new(); + cooldown_quads( + Rect::new(0.0, 0.0, 40.0, 40.0), + 7, + 1.0, + 1.2, + Some(0.4), + None, + &mut None, + &mut Assets::::default(), + &mut out, + ); + assert!(out.is_empty(), "no assets → no star; and never a dark quad"); + } +} diff --git a/crates/benilla/src/ui_script/extract/mod.rs b/crates/benilla/src/ui_script/extract/mod.rs index 9691d656..0753b66f 100644 --- a/crates/benilla/src/ui_script/extract/mod.rs +++ b/crates/benilla/src/ui_script/extract/mod.rs @@ -15,7 +15,19 @@ use crate::assets::WorldAssets; use crate::ui_pass::{UiQuad, UiQuads, UvRect}; use crate::ui_text::UiFontAtlas; +mod cooldown; mod text; +use cooldown::cooldown_quads; + +/// `WOW_UI_COST=1` — the untraced per-frame cost meter for this system's phases (the premise +/// instrument for the UI epoch-gate lane, 0730's warm slice): one `[ui-cost]` line per frame with +/// each phase's wall μs, the quad counts, the layout gate's decision, and whether the diff found +/// the produced quads changed. Untraced by design — the campaign grades untraced cpu_ms, and +/// `trace_chrome` inflates exactly the fine-grained spans this measures (0718's calibration). +fn ui_cost_enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var("WOW_UI_COST").as_deref() == Ok("1")) +} /// Per frame: screen size → `tick` (OnUpdate) → `resolve` → `extract` → [`UiQuads`]. Script errors /// drain to the log (throttled by being drained — each fires once). @@ -48,6 +60,16 @@ pub(super) fn drive_script( mut digits_fed: Local, // The uiScale dial folded into the seam scale (decision 0584). ui_scale: Res, + // ── The extract gate's memory (decision 0740): last frame's conversion inputs ───────────── + // The conversion loop below is a pure function of (extracted, text_ui, window dims × seam + // scale, the portrait token map) — the glyph atlas bakes once at Startup and the sprite + // caches are monotone path→handle, so equal inputs reproduce the same `UiQuads` the diff + // would then discard. Capture mode never skips (the harness wants exact per-frame output, + // including the cursor-icon quad's live mouse position). + mut prev_extracted: Local>, + mut prev_text_ui: Local>, + mut prev_dims: Local>, + mut prev_portraits: Local>, ) { let Some(mut script) = script else { return; @@ -67,15 +89,30 @@ pub(super) fn drive_script( // Phase spans (visible under `bevy/trace_chrome`): this system is the biggest flat CPU cost // on an idle frame, and the ledger can only rank what has a name — tick (Lua OnUpdate), // resolve (layout), measure (the text round-trips), extract (tree walk + rasterize), diff. + let cost_on = ui_cost_enabled(); + let solves_before = cost_on.then(|| script.layout_solves()); + let mut t_mark = cost_on.then(std::time::Instant::now); + // Marks phase boundaries under the meter: returns μs since the previous mark and re-arms. + // With the meter off it does nothing (a run carries six no-op calls, not six clock reads). + let mut lap = move || -> u128 { + if !cost_on { + return 0; + } + t_mark + .replace(std::time::Instant::now()) + .map_or(0, |t| t.elapsed().as_micros()) + }; { let _span = bevy::log::info_span!("ui_script: tick").entered(); script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 }); script.tick(time.delta_secs()); } + let us_tick = lap(); { let _span = bevy::log::info_span!("ui_script: resolve").entered(); script.resolve(); } + let us_resolve = lap(); let measure_span = bevy::log::info_span!("ui_script: measure").entered(); // The digit-advance feed (the synchronous half of the money layout's metrics): measure // NumberFontNormal's '0'..'9' once per atlas scale and push them as `BENILLA_DIGIT_W`, so @@ -173,6 +210,7 @@ pub(super) fn drive_script( } } drop(measure_span); + let us_measure = lap(); for err in script.take_errors() { warn!("ui_script: {err}"); } @@ -227,7 +265,41 @@ pub(super) fn drive_script( // x-spans within it — advance-derived engine-side (the blink phase too, `0x77a790`'s 0.5 s // law in the engine tick), matched in the Text arm below. let text_ui = script.focused_editbox_text_ui(); - for eq in script.extract() { + let _ = lap(); // re-arm: the editbox seam above is not the walk's cost + let extracted = script.extract(); + let us_exm = lap(); + let n_extracted = extracted.len(); + // ── The extract gate (decision 0740) ────────────────────────────────────────────────────── + // 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`/`minimap_widget`/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()); + let settled = capture.is_none() + && *prev_dims == Some(dims) + && text_ui == *prev_text_ui + && portraits.0 == *prev_portraits + && extracted == *prev_extracted; + if settled { + drop(extract_span); + let us_cmp = lap(); + if cost_on { + let solves = script.layout_solves() - solves_before.unwrap_or(0); + eprintln!( + "[ui-cost] tick={us_tick} resolve={us_resolve} measure={us_measure} \ + exm={us_exm} exa={us_cmp} diff=0 eq={n_extracted} quads={} \ + solves={solves} changed=0 skip=1", + quads.quads.len() + ); + } + return; + } + *prev_dims = Some(dims); + *prev_text_ui = text_ui.clone(); + *prev_portraits = portraits.0.clone(); + *prev_extracted = extracted.clone(); + for eq in extracted { let Some(r) = eq.rect else { continue }; // WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is // y-down window px from the top-left — scale ×s, then flip through the window height. @@ -474,12 +546,25 @@ pub(super) fn drive_script( // `minimap::emit_minimap` runs later in the frame (UiQuadAppend) and fills the hole. minimap_widget.0 = minimap_slot; drop(extract_span); + let us_exa = lap(); let _span = bevy::log::info_span!("ui_script: diff").entered(); - if quads.quads != out { + let n_quads = out.len(); + let changed = quads.quads != out; + if changed { quads.quads = out; quads.dirty = true; } + if cost_on { + let us_diff = lap(); + let solves = script.layout_solves() - solves_before.unwrap_or(0); + eprintln!( + "[ui-cost] tick={us_tick} resolve={us_resolve} measure={us_measure} \ + exm={us_exm} exa={us_exa} diff={us_diff} eq={n_extracted} quads={n_quads} \ + solves={solves} changed={} skip=0", + u8::from(changed) + ); + } } /// The held-cursor icon quad (CAPTURE-ONLY — see the module doc): a 32×32 icon TOP-LEFT anchored @@ -499,284 +584,6 @@ fn cursor_icon_quad(pos: Vec2, texture: Handle) -> UiQuad { } } -/// The cooldown wipe's paint: flat black at α = 0x99/255 — the byte content of -/// `Interface\Cooldown\cooldown.blp` (a uniform 32² black texel field at alpha 0x99, with only a -/// transparent clamp column for the sweep edge), constant through the sweep (the model's -/// quadrant color-alpha tracks hold 1.0 for all of sequence 0 — the m2anim key dump). -const COOLDOWN_WIPE_ALPHA: f32 = 153.0 / 255.0; - -/// The finish-flash star's bone-scale pulse — sequence 1's 5 keys, byte-read off -/// `UI-Cooldown-Indicator.m2` with the `m2anim` bone-scale dump (uniform XYZ, so one scalar per -/// key): grow to 1.85× with the alpha rise, then a shrinking double-bounce while it fades. -const STAR_SCALE_KEYS: [(f32, f32); 5] = [ - (0.0, 1.0), - (0.333, 1.853), - (0.666, 1.305), - (0.833, 1.605), - (1.0, 1.155), -]; - -/// Linear sample of [`STAR_SCALE_KEYS`] at flash progress `t` (the M2 linear-track read). -fn star_scale(t: f32) -> f32 { - let t = t.clamp(0.0, 1.0); - for w in STAR_SCALE_KEYS.windows(2) { - let ((t0, v0), (t1, v1)) = (w[0], w[1]); - if t <= t1 { - return v0 + (v1 - v0) * ((t - t0) / (t1 - t0)); - } - } - STAR_SCALE_KEYS[4].1 -} - -/// The Cooldown widget's pixels (decision 0137 phase 4): the radial dark wipe + the finish flash, -/// rebuilt natively from the byte-read `UI-Cooldown-Indicator.m2`. -/// -/// **The wipe.** The model draws 4 quadrant quads whose UVs rotate over a clamp-edged -/// half-transparent texture — 2001's way of sweeping a straight edge through each quadrant; with -/// a *flat* texture behind it (`cooldown.blp` is uniform black at α 0x99), the composed result is -/// exactly a uniform dark pie whose bright sector grows **clockwise from 12 o'clock** as the -/// cooldown elapses. Rebuilt as ≤4 quads: one full-dark rect per still-covered quadrant, plus -/// the active quadrant's **wedge** — an exact convex fan (`c → ray exit → [outer corner] → -/// quadrant-end edge midpoint`) through [`UiQuad::corners`]. Pixel-equivalent, no scissor -/// interplay. (The wedge bypasses the scroll clip — no cooldown sits in a ScrollFrame; the -/// full-dark rects still carry it.) -/// -/// **The flash.** The model's sequence 1 (authored 1.000 s): the additive `star4` burst whose -/// alpha is the byte-read texture-weight ramp — linear 0→1 over the first third, hold 1 to the -/// half, linear 1→0 over the back half — scaled by the star bone's byte-read 5-key pulse -/// ([`STAR_SCALE_KEYS`], the `m2anim` bone-scale dump): grow to 1.85× with the alpha rise, then -/// a shrinking double-bounce as it fades. -#[allow(clippy::too_many_arguments)] // one draw arm's full input set -fn cooldown_quads( - rect: Rect, - z_key: u64, - frame_alpha: f32, - fraction: f32, - flash: Option, - clip: Option, - assets: &mut Option>, - images: &mut Assets, - out: &mut Vec, -) { - let c = rect.center(); - let dark = [0.0, 0.0, 0.0, COOLDOWN_WIPE_ALPHA * frame_alpha]; - - if let Some(progress) = flash { - // The finish flash: the byte-read weight ramp (keys 0→1 over 0..⅓, hold to ½, →0 at 1). - let ramp = if progress < 1.0 / 3.0 { - progress * 3.0 - } else if progress < 0.5 { - 1.0 - } else { - (1.0 - progress) * 2.0 - }; - let handle = assets - .as_mut() - .and_then(|a| a.sprite_texture("Interface\\Cooldown\\star4", images)); - if let Some(handle) = handle { - let s = star_scale(progress); - let half = rect.half_size() * s; - out.push(UiQuad { - rect: Rect::from_center_half_size(c, half), - z_key, - texture: Some(handle), - color: [1.0, 1.0, 1.0, ramp * frame_alpha], - additive: true, - clip, - ..default() - }); - } - return; - } - - // The sweep: bright sector [0, θ) clockwise from 12 o'clock; quadrants wholly past the edge - // are full-dark rects, the active one carries the exact wedge. - let theta = fraction.clamp(0.0, 1.0) * std::f32::consts::TAU; - let active = ((theta / std::f32::consts::FRAC_PI_2) as usize).min(3); - // Clockwise from 12 in y-down screen space: top-right, bottom-right, bottom-left, top-left. - let quadrant = |k: usize| match k { - 0 => Rect::new(c.x, rect.min.y, rect.max.x, c.y), - 1 => Rect::new(c.x, c.y, rect.max.x, rect.max.y), - 2 => Rect::new(rect.min.x, c.y, c.x, rect.max.y), - _ => Rect::new(rect.min.x, rect.min.y, c.x, c.y), - }; - for k in (active + 1)..4 { - out.push(UiQuad { - rect: quadrant(k), - z_key, - color: dark, - clip, - ..default() - }); - } - - // The active quadrant's wedge. The sweep ray from c at angle θ has direction - // d(θ) = (sin θ, −cos θ) (y-down); per quadrant it can exit through two edges — the "first" - // one (adjacent to the sweep's entry axis, whose crossing keeps the quadrant's outer corner - // dark) or the "second" (past the corner). Vertices fan from c, clockwise: - // c → E (ray exit) → [K (outer corner) if the ray exits the first edge] → M (the - // quadrant-end axis point). A three-vertex wedge repeats M (the fan's degenerate second - // triangle draws nothing). - let d = Vec2::new(theta.sin(), -theta.cos()); - // Per active quadrant: (first-edge t, second-edge t, K, M). A division by ±0 yields ±inf, - // which the min-pick handles (the boundary angles resolve to the finite edge). - let (t_first, t_second, k_corner, m_point) = match active { - 0 => ( - (rect.min.y - c.y) / d.y, - (rect.max.x - c.x) / d.x, - Vec2::new(rect.max.x, rect.min.y), - Vec2::new(rect.max.x, c.y), - ), - 1 => ( - (rect.max.x - c.x) / d.x, - (rect.max.y - c.y) / d.y, - Vec2::new(rect.max.x, rect.max.y), - Vec2::new(c.x, rect.max.y), - ), - 2 => ( - (rect.max.y - c.y) / d.y, - (rect.min.x - c.x) / d.x, - Vec2::new(rect.min.x, rect.max.y), - Vec2::new(rect.min.x, c.y), - ), - _ => ( - (rect.min.x - c.x) / d.x, - (rect.min.y - c.y) / d.y, - Vec2::new(rect.min.x, rect.min.y), - Vec2::new(c.x, rect.min.y), - ), - }; - let e_point = c + d * t_first.min(t_second); - let corners = if t_first <= t_second { - [c, e_point, k_corner, m_point] - } else { - [c, e_point, m_point, m_point] - }; - out.push(UiQuad { - rect: quadrant(active), - z_key, - color: dark, - corners: Some(corners), - ..default() - }); -} - -#[cfg(test)] -mod cooldown_quad_tests { - use super::{cooldown_quads, star_scale, STAR_SCALE_KEYS}; - use bevy::prelude::*; - - /// Emit the sweep for `fraction` on a unit-friendly 40×40 button at (0,0)..(40,40). - fn sweep(fraction: f32) -> Vec { - let mut out = Vec::new(); - cooldown_quads( - Rect::new(0.0, 0.0, 40.0, 40.0), - 7, - 1.0, - fraction, - None, - None, - &mut None, - &mut Assets::::default(), - &mut out, - ); - out - } - - /// Total dark coverage of the emitted quads (rect quads + the corner wedge's shoelace area). - fn dark_area(quads: &[crate::ui_pass::UiQuad]) -> f32 { - quads - .iter() - .map(|q| match q.corners { - None => q.rect.width() * q.rect.height(), - Some(c) => { - // Shoelace over the fan's two triangles (0,1,2) + (0,2,3). - let tri = |a: Vec2, b: Vec2, d: Vec2| ((b - a).perp_dot(d - a) / 2.0).abs(); - tri(c[0], c[1], c[2]) + tri(c[0], c[2], c[3]) - } - }) - .sum() - } - - /// The square-cornered pie's exact per-phase geometry (a square pie's area is not linear in - /// the swept angle, so the checkpoints below are the analytically known shapes: full, the - /// 45° corner ray, the 180° half, the end sliver). - #[test] - fn cooldown_sweep_geometry_is_the_exact_square_pie() { - // Fraction 0: everything dark — 3 full quadrant rects + a wedge covering the fourth. - let q0 = sweep(0.0); - assert_eq!(q0.len(), 4); - assert!( - (dark_area(&q0) - 1600.0).abs() < 1e-2, - "fully dark at start" - ); - - // Fraction 1/8 (45° — the ray through quadrant 0's outer corner): the bright half of - // quadrant 0 is gone; the wedge is the triangle {center, corner, right-edge midpoint}. - let q = sweep(0.125); - assert_eq!(q.len(), 4); - assert!( - (dark_area(&q) - (1600.0 - 200.0)).abs() < 1.0, - "45°: half of one 400px² quadrant is bright, got {}", - dark_area(&q) - ); - - // Fraction 0.5 (180°): the right half is bright — two full-dark quadrants + a wedge - // that spans exactly quadrant 2 (t ties pick the corner-inclusive fan; area 400). - let q = sweep(0.5); - assert!((dark_area(&q) - 800.0).abs() < 1.0, "half bright at 180°"); - - // Fraction ~1: a sliver — near-zero dark area, no full quadrants left. - let q = sweep(0.999); - assert!(dark_area(&q) < 20.0, "almost done — a sliver remains"); - assert_eq!(q.len(), 1, "only the active quadrant's wedge remains"); - - // The wedge always fans from the button center. - for f in [0.05, 0.3, 0.6, 0.9] { - let q = sweep(f); - let wedge = q - .iter() - .find_map(|u| u.corners) - .expect("a wedge each phase"); - assert_eq!(wedge[0], Vec2::new(20.0, 20.0), "fan apex at the center"); - } - } - - /// The star pulse samples its byte-read keys exactly and lerps between them — the ⅓ peak - /// coincides with the alpha ramp's own peak. - #[test] - fn star_scale_follows_the_five_key_curve() { - for (t, v) in STAR_SCALE_KEYS { - assert!((star_scale(t) - v).abs() < 1e-4, "key at {t}"); - } - // Midway up the rise: halfway between 1.0 and 1.853. - let mid = star_scale(0.333 / 2.0); - assert!((mid - (1.0 + 1.853) / 2.0).abs() < 1e-2, "got {mid}"); - // Clamped outside the band. - assert!((star_scale(-1.0) - 1.0).abs() < 1e-4); - assert!((star_scale(2.0) - 1.155).abs() < 1e-4); - } - - /// The flash phase draws no dark pie — only the additive star (skipped here: no assets in a - /// unit test — the pie's absence is the machine-checkable half). - #[test] - fn cooldown_flash_phase_emits_no_dark_pie() { - let mut out = Vec::new(); - cooldown_quads( - Rect::new(0.0, 0.0, 40.0, 40.0), - 7, - 1.0, - 1.2, - Some(0.4), - None, - &mut None, - &mut Assets::::default(), - &mut out, - ); - assert!(out.is_empty(), "no assets → no star; and never a dark quad"); - } -} - #[cfg(test)] mod cursor_quad_tests { use super::cursor_icon_quad; @@ -947,3 +754,100 @@ mod clip_plumb_tests { assert_eq!(marker.clip, None); } } + +/// The extract gate (decision 0740): a settled frame skips the whole conversion loop, and — the +/// dangerous direction — any extract-visible change must reopen it, INCLUDING paint-only writes +/// that never dirty the layout gate. Uses the same minimal headless app as `clip_plumb_tests`. +#[cfg(test)] +mod extract_gate_tests { + use bevy::prelude::*; + use bevy::window::PrimaryWindow; + + use benilla_ui::script::UiScript; + + use super::{drive_script, UiQuads}; + use crate::portrait::PortraitImages; + + fn app_with_marker() -> App { + let script = UiScript::new().unwrap(); + script + .run( + r#" + local plain = CreateFrame("Frame", "Plain") + plain:SetPoint("TOPLEFT", 0, 0) + plain:SetSize(50, 50) + marker = plain:CreateTexture(nil, "ARTWORK") + marker:SetTexture(1, 0, 0) + "#, + ) + .unwrap(); + let mut app = App::new(); + app.insert_non_send_resource(script); + app.init_resource::(); + app.init_resource::>(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::