an idle frame skips the quiet UI, and a lighting hunt closes clean

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-28 21:56:26 +07:00
co-authored by Claude Fable 5
parent 80978df983
commit 993adf79b9
20 changed files with 1022 additions and 391 deletions
+1
View File
@@ -125,6 +125,7 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result<u32> {
..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);
+13 -2
View File
@@ -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::<Model>().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-`<FontString>` slot assignment: adopt `region` as `frame`'s text region,
@@ -434,6 +444,7 @@ pub(super) fn ensure_text_region(lua: &Lua, h: FrameHandle) -> Option<RegionHand
};
apply_text_region_justify(&mut data, multi_line);
model.region_data.insert(rh, data);
model.touch_layout(); // a region entered the layout gate's read set (decision 0740)
if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
eb.text_region = Some(rh);
}
+78 -19
View File
@@ -13,16 +13,19 @@ use super::{
/// A 128-bit rolling fingerprint of everything [`UiScript::resolve_layout`] *reads*, so a resolve
/// whose inputs are byte-identical to the last one can be skipped outright.
///
/// ## Why a fingerprint and not a dirty flag
/// ## Tier 2 of a two-tier gate (decision 0740)
///
/// A dirty flag has to be *set* at every mutation site, and the resolve's read set is spread over
/// `layout_inputs`, the arena (scale, clamp, ScrollFrame child + offset, frame/region liveness),
/// `region_data` (anchors, size, measured), and `screen`. Missing one site is a silently stale
/// layout — the worst failure mode this module has, and one no test reliably catches because the
/// staleness only shows on the frame the missed setter fires. A fingerprint has no sites to miss:
/// it is computed FROM the read set, so any change to any of it changes the value by construction.
/// It also absorbs *idempotent* writes for free — the GameTooltip pre-pass rewrites tooltip sizes
/// and anchor offsets every single call, which would pin a mutation-tracked flag permanently dirty.
/// This fingerprint began as the WHOLE gate, chosen over a dirty flag because a flag has sites to
/// miss and a fingerprint is computed FROM the read set — any change moves it by construction.
/// That safety cost ~0.6 ms *every idle frame* at a live pin (hashing ~2k inputs to conclude
/// "quiet"), which is why the mutation epoch ([`Model::touch_layout`], tier 1) now sits in front:
/// a frame with no layout-visible write skips at a `u64` compare and never reaches this hash. The
/// fingerprint remains load-bearing where the flag is structurally weak — a *dirty* frame whose
/// writes were idempotent (the bag-hover re-enter loop clears and rebuilds identical content
/// every frame; the GameTooltip pre-pass rewrites tooltip sizes every call) hashes clean here and
/// still skips the solve. And the flag's own failure mode — a missed touch site, a silently stale
/// layout — is machine-checked: under [`layout_verify_enabled`] (forced on in this crate's tests)
/// a tier-1-quiet frame asserts the fingerprint agrees, so the tiers police each other.
///
/// ## Why 128 bits
///
@@ -97,15 +100,18 @@ impl InputFingerprint {
/// posture). When set, a resolve the gate wants to SKIP runs in full anyway and asserts it
/// reproduced the previous rects exactly; a divergence means the fingerprint's read set is
/// incomplete, and it fails loudly at the resolve that proves it rather than silently rendering a
/// stale frame days later.
/// stale frame days later. It also checks tier 1 against tier 2: a frame the epoch judged quiet
/// whose fingerprint nevertheless moved names a mutation path missing its `touch_layout()`.
///
/// This is what makes the gate's central claim — "the fingerprint covers everything the rounds
/// read" — machine-checked instead of argued: run the workspace suite under it and every UI test
/// (the shipped windows, driven through real FrameXML + Lua) becomes a probe for a missed input.
/// Read once; when off it costs a relaxed atomic load per resolve.
/// This is what makes the gates' central claims — "the fingerprint covers everything the rounds
/// read" and "every fingerprint-visible write bumps the epoch" — machine-checked instead of
/// argued: it is FORCED ON for this crate's own tests, so every UI test (the shipped windows,
/// driven through real FrameXML + Lua) is a standing probe for a missed input or a missed touch;
/// set the env to extend that to a live run or another crate's suite. Read once; when off it
/// costs a relaxed atomic load per resolve.
fn layout_verify_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = 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 {
+24 -3
View File
@@ -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<String> {
self.model_ref().errors.clone()
+34
View File
@@ -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<super::layout::InputFingerprint>,
/// 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<u64>,
/// 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);
}
}
+1
View File
@@ -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
@@ -123,7 +123,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
};
let changed = {
let mut model = lua.app_data_mut::<Model>().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::<Model>()
.expect("model")
.arena
.set_scale(h, scale);
let mut model = lua.app_data_mut::<Model>().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::<Model>()
.expect("model")
.arena
.set_clamped_to_screen(h, clamp);
let mut model = lua.app_data_mut::<Model>().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(())
})?,
)?;
@@ -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::<Model>().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::<Model>().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::<Model>().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::<Model>().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 {
+68 -23
View File
@@ -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::<Model>().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::<Model>().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::<Model>().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::<Model>().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::<Model>().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(())
}
+19 -2
View File
@@ -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::<Model>().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::<Model>().expect("model").touch_layout();
}
fire_vertical_scroll(lua, &this, clamped)
})?,
)?;
+1
View File
@@ -88,6 +88,7 @@ fn ensure_thumb(lua: &Lua, this: &Table, layer: Option<DrawLayer>) -> 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);
@@ -88,6 +88,7 @@ fn ensure_bar(lua: &Lua, this: &Table, layer: Option<DrawLayer>) -> 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);
@@ -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::<Model>().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);
}
@@ -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 {
+20 -9
View File
@@ -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
+27 -21
View File
@@ -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,
+19 -1
View File
@@ -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=<token>:<path>:<secs>` — 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<bool> = 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;
}
+51
View File
@@ -216,3 +216,54 @@ fn bake_test(
}
true
}
/// `WOW_BOOTH_DUMP=<token>:<path>:<secs>`: 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<Booths>,
time: Res<Time<bevy::time::Real>>,
mut fired: Local<bool>,
) {
static SPEC: std::sync::OnceLock<Option<(String, String, f32)>> = 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<ScreenshotCaptured>| {
// 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}"),
}
});
}
@@ -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<f32>,
clip: Option<Rect>,
assets: &mut Option<ResMut<WorldAssets>>,
images: &mut Assets<Image>,
out: &mut Vec<UiQuad>,
) {
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<crate::ui_pass::UiQuad> {
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::<Image>::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::<Image>::default(),
&mut out,
);
assert!(out.is_empty(), "no assets → no star; and never a dark quad");
}
}
+184 -280
View File
@@ -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<bool> = 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<bool>,
// The uiScale dial folded into the seam scale (decision 0584).
ui_scale: Res<super::UiScaleCvar>,
// ── 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<Vec<benilla_ui::script::ExtractedQuad>>,
mut prev_text_ui: Local<Option<benilla_ui::script::EditBoxTextUi>>,
mut prev_dims: Local<Option<(u32, u32, u32)>>,
mut prev_portraits: Local<std::collections::HashMap<String, crate::portrait::PortraitSource>>,
) {
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<Image>) -> 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<f32>,
clip: Option<Rect>,
assets: &mut Option<ResMut<WorldAssets>>,
images: &mut Assets<Image>,
out: &mut Vec<UiQuad>,
) {
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<crate::ui_pass::UiQuad> {
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::<Image>::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::<Image>::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::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
resolution: UVec2::new(1024, 768).into(),
..default()
},
PrimaryWindow,
));
app.add_systems(Update, drive_script);
app
}
#[test]
fn a_settled_frame_skips_and_a_paint_write_reopens_the_gate() {
let mut app = app_with_marker();
app.update(); // frame 1: builds the quads
assert!(app.world().resource::<UiQuads>().dirty, "first frame draws");
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.update(); // frame 2: identical inputs — the gate must leave the resource alone
assert!(
!app.world().resource::<UiQuads>().dirty,
"a settled frame must not re-mark the quads dirty"
);
// A PAINT-ONLY write: invisible to the layout gate (no anchors/size moved), so only the
// extracted-list compare can reopen extraction. If the gate wrongly ate it, the marker
// would stay red on screen.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("marker:SetTexture(0, 0, 1)")
.unwrap();
app.update();
let quads = app.world().resource::<UiQuads>();
assert!(quads.dirty, "a paint write must reopen the extract gate");
assert!(
quads
.quads
.iter()
.any(|q| q.color[0] == 0.0 && q.color[2] == 1.0),
"and the produced quads carry the new color"
);
}
#[test]
fn a_layout_write_reopens_the_gate_too() {
let mut app = app_with_marker();
app.update();
app.update();
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("getglobal('Plain'):SetPoint('TOPLEFT', 40, -40)")
.unwrap();
app.update();
assert!(
app.world().resource::<UiQuads>().dirty,
"a moved frame must re-extract"
);
}
}