diff --git a/crates/benilla-app/assets/ui/BuffFrame.xml b/crates/benilla-app/assets/ui/BuffFrame.xml
index a55bbeea..8b8fc159 100644
--- a/crates/benilla-app/assets/ui/BuffFrame.xml
+++ b/crates/benilla-app/assets/ui/BuffFrame.xml
@@ -243,6 +243,18 @@
end
end
+ -- Void a button's memory of what its duration region last had WRITTEN to it (the
+ -- benillaDur* fields BuffFrame_UpdateDuration gates on). Called by everything ELSE that
+ -- touches the region — the aura event's repaint here, the enchant row's clear — so the
+ -- per-frame gate can never skip a write against a region somebody moved under it; a
+ -- forgotten state just means the next poll writes once.
+ local function buffDurationForget(button)
+ button.benillaDurShown = nil
+ button.benillaDurText = nil
+ button.benillaDurWarn = nil
+ button.benillaDurSetting = nil
+ end
+
-- The button's aura, resolved on the discrete-change edge and CACHED AS A POSITION
-- (ref BuffButton_Update, l.58-107). `this:GetID()` is the 0-based ordinal within
-- this.buffFilter; buffIndex is the cache position every read below uses.
@@ -250,21 +262,25 @@
local buffIndex, untilCancelled = GetPlayerBuff(this:GetID(), this.buffFilter)
this.buffIndex = buffIndex
this.untilCancelled = untilCancelled
- local buffDuration = getglobal(this:GetName() .. "Duration")
+ local buffDuration = this.durationFS or getglobal(this:GetName() .. "Duration")
+ this.durationFS = buffDuration
-- -1, not nil: the miss sentinel the whole family agrees on.
if buffIndex < 0 then
this:Hide()
buffDuration:Hide()
+ buffDurationForget(this)
return
end
this:SetAlpha(1.0)
+ this.benillaLastAlpha = 1.0
this:Show()
if SHOW_BUFF_DURATIONS == "1" then
buffDuration:Show()
else
buffDuration:Hide()
end
+ buffDurationForget(this)
getglobal(this:GetName() .. "Icon"):SetTexture(GetPlayerBuffTexture(buffIndex) or BENILLA_FALLBACK_ICON)
@@ -343,10 +359,20 @@
-- The flash is NOT gated on the timer setting: the reference pulses a button in its last
-- 31s whether or not a number is drawn under it, and with the timers off it is the only
-- thing left saying "about to drop".
+ --
+ -- Written only when it moves (decision 1396's class — a settled bar must not rewrite
+ -- per frame what changes only on an edge). Inside the warning window BUFF_ALPHA_VALUE
+ -- is a fresh ramp value every frame, so the pulse flows through the compare untouched;
+ -- outside it the value is the 1.0 that is already there, forever.
+ local alpha
if timeLeft < BUFF_WARNING_TIME then
- button:SetAlpha(BUFF_ALPHA_VALUE)
+ alpha = BUFF_ALPHA_VALUE
else
- button:SetAlpha(1.0)
+ alpha = 1.0
+ end
+ if alpha ~= button.benillaLastAlpha then
+ button.benillaLastAlpha = alpha
+ button:SetAlpha(alpha)
end
BuffFrame_UpdateDuration(button, timeLeft)
@@ -363,66 +389,91 @@
CancelPlayerBuff(this.buffIndex)
end
- -- Clear enchant slots `first`..2 — the tail of the row nothing is using this frame.
+ -- Clear enchant slots `first`..2 — the tail of the row nothing is using this frame. The
+ -- Hide moves the Duration region under the poll's write gate, so the gate's memory is
+ -- voided with it.
local function buffEnchantClear(first)
for slot = first, 2 do
- getglobal("TempEnchant" .. slot):Hide()
+ local button = getglobal("TempEnchant" .. slot)
+ button:Hide()
getglobal("TempEnchant" .. slot .. "Duration"):Hide()
+ buffDurationForget(button)
end
end
+ -- One hand's six steps (the same block the reference spells out twice, ref l.180-207):
+ -- an enchanted hand packs into the next slot rightwards and keeps its countdown and
+ -- warning pulse live; a bare hand packs nothing. Returns the new fill count. A plain
+ -- function of its arguments so the per-frame path allocates nothing — this used to be a
+ -- two-row table built fresh every frame.
+ --
+ -- `slot` is the live API's inventory id, which is also what the button's hover reads
+ -- back off itself to ask for the ITEM's tooltip.
+ local function buffEnchantHand(filled, enchanted, expiration, slot)
+ if not enchanted then
+ return filled
+ end
+ filled = filled + 1
+ local button = getglobal("TempEnchant" .. filled)
+ button:SetID(slot)
+ getglobal(button:GetName() .. "Icon"):SetTexture(
+ GetInventoryItemTexture("player", slot) or BENILLA_FALLBACK_ICON)
+ button:Show()
+
+ -- The wire counts an enchant down in MILLISECONDS; everything the row draws is
+ -- in seconds. A nil expiration is "no countdown known", which the timer and the
+ -- warning flash both read as "nothing to warn about".
+ local timeLeft = expiration and (expiration / 1000)
+ BuffFrame_UpdateDuration(button, timeLeft)
+ if timeLeft and timeLeft < BUFF_WARNING_TIME then
+ button:SetAlpha(BUFF_ALPHA_VALUE)
+ else
+ button:SetAlpha(1.0)
+ end
+ return filled
+ end
+
-- The temporary-weapon-enchant row (ref l.162-233). GetWeaponEnchantInfo is the whole data
-- path: two flags, two remaining-MILLISECOND counts, two charge counts (the charges are
-- read by nobody here, and are named only to spell the API's shape out).
--
- -- The two hands are the same six steps on different numbers, so they are one loop over a
- -- two-row table rather than the reference's two copies of the block. OFF HAND FIRST is the
- -- part that matters: the row packs from slot 1 rightwards, so with both weapons enchanted
- -- the main hand lands in slot 2 and therefore sits to the LEFT of the off hand.
+ -- OFF HAND FIRST is the part that matters: the row packs from slot 1 rightwards, so with
+ -- both weapons enchanted the main hand lands in slot 2 and therefore sits to the LEFT of
+ -- the off hand.
function BuffFrame_Enchant_OnUpdate(elapsed)
local hasMainHandEnchant, mainHandExpiration, mainHandCharges,
hasOffHandEnchant, offHandExpiration, offHandCharges = GetWeaponEnchantInfo()
- -- Nothing enchanted: clear the whole row and park BuffFrame back on its resting point.
+ -- Nothing enchanted: clear the whole row and park BuffFrame back on its resting
+ -- point — ONCE per transition, latched on benillaCleared (decision 1396's class:
+ -- re-deriving per frame what changes only on an edge). The latch starts unset, so
+ -- the FIRST pass still writes — the load-time hide the header's TempEnchant note
+ -- leans on — and any enchant appearing clears it below.
if not hasMainHandEnchant and not hasOffHandEnchant then
- buffEnchantClear(1)
- BuffFrame:SetPoint("TOPRIGHT", "TemporaryEnchantFrame", "TOPRIGHT", 0, 0)
+ if not TemporaryEnchantFrame.benillaCleared then
+ TemporaryEnchantFrame.benillaCleared = true
+ -- Forget the last row width too: after a park, the next enchanted pass must
+ -- re-write the width AND the bar's TOPLEFT anchor whatever its fill count is.
+ TemporaryEnchantFrame.benillaFilled = nil
+ buffEnchantClear(1)
+ BuffFrame:SetPoint("TOPRIGHT", "TemporaryEnchantFrame", "TOPRIGHT", 0, 0)
+ end
return
end
+ TemporaryEnchantFrame.benillaCleared = nil
- local filled = 0
- for _, hand in ipairs({
- -- `slot` is the live API's inventory id, which is also what the button's hover
- -- reads back off itself to ask for the ITEM's tooltip.
- { enchanted = hasOffHandEnchant, expiration = offHandExpiration, slot = 17 },
- { enchanted = hasMainHandEnchant, expiration = mainHandExpiration, slot = 16 },
- }) do
- if hand.enchanted then
- filled = filled + 1
- local button = getglobal("TempEnchant" .. filled)
- button:SetID(hand.slot)
- getglobal(button:GetName() .. "Icon"):SetTexture(
- GetInventoryItemTexture("player", hand.slot) or BENILLA_FALLBACK_ICON)
- button:Show()
-
- -- The wire counts an enchant down in MILLISECONDS; everything the row draws is
- -- in seconds. A nil expiration is "no countdown known", which the timer and the
- -- warning flash both read as "nothing to warn about".
- local timeLeft = hand.expiration and (hand.expiration / 1000)
- BuffFrame_UpdateDuration(button, timeLeft)
- if timeLeft and timeLeft < BUFF_WARNING_TIME then
- button:SetAlpha(BUFF_ALPHA_VALUE)
- else
- button:SetAlpha(1.0)
- end
- end
- end
+ local filled = buffEnchantHand(0, hasOffHandEnchant, offHandExpiration, 17)
+ filled = buffEnchantHand(filled, hasMainHandEnchant, mainHandExpiration, 16)
buffEnchantClear(filled + 1)
-- Position buff frame: the row is `filled` icons wide, and the bar starts one 5px
- -- gutter to its left.
- TemporaryEnchantFrame:SetWidth(filled * 32)
- BuffFrame:SetPoint("TOPRIGHT", "TemporaryEnchantFrame", "TOPLEFT", -5, 0)
+ -- gutter to its left. The geometry depends on nothing but `filled`, so it is written
+ -- only when that changes.
+ if TemporaryEnchantFrame.benillaFilled ~= filled then
+ TemporaryEnchantFrame.benillaFilled = filled
+ TemporaryEnchantFrame:SetWidth(filled * 32)
+ BuffFrame:SetPoint("TOPRIGHT", "TemporaryEnchantFrame", "TOPLEFT", -5, 0)
+ end
end
function BuffFrame_EnchantButton_OnUpdate()
@@ -442,19 +493,47 @@
-- BuffFrame_UpdateDuration (ref l.247-260), gate and all: the timer draws only when the
-- setting is on AND there is a live one. `timeLeft` nil is the enchant row's "no countdown
-- known" case. Hidden, not blanked — see the header's deviation note.
+ --
+ -- The POLL stays per-frame (the re-read is load-bearing — decision 0846); only the WRITES
+ -- are gated, on what was last written: shown, the abbreviated text, the warning band, and
+ -- the SHOW_BUFF_DURATIONS value itself (so the settings toggle stays live). Deliberately
+ -- never on a measured extent (1396's trap: a fixture with no font engine measures 0
+ -- forever); the compare is against the STRING this function computed, nothing the host
+ -- had to shape. The benillaDur* memory is voided by whoever else touches the region
+ -- (buffDurationForget), so a stale skip is unrepresentable.
function BuffFrame_UpdateDuration(buffButton, timeLeft)
- local duration = getglobal(buffButton:GetName() .. "Duration")
+ local duration = buffButton.durationFS or getglobal(buffButton:GetName() .. "Duration")
+ buffButton.durationFS = duration
+ local setting = SHOW_BUFF_DURATIONS
-- Two ways to have no number to draw: the setting is off, or nothing has told us how
-- long is left (the enchant row's "no countdown known").
- if SHOW_BUFF_DURATIONS ~= "1" or not timeLeft then
+ if setting ~= "1" or not timeLeft then
+ -- == false, not falsey: nil is "no memory", and the first pass must write.
+ if buffButton.benillaDurShown == false and buffButton.benillaDurSetting == setting then
+ return
+ end
+ buffButton.benillaDurShown = false
+ buffButton.benillaDurText = nil
+ buffButton.benillaDurWarn = nil
+ buffButton.benillaDurSetting = setting
duration:Hide()
return
end
- duration:SetText(SecondsToTimeAbbrev(timeLeft))
+ local text = SecondsToTimeAbbrev(timeLeft)
-- Gold most of the way, white inside the last minute — the number's own warning, a
-- half-minute ahead of the icon starting to pulse.
+ local warning = timeLeft < BUFF_DURATION_WARNING_TIME
+ if buffButton.benillaDurShown == true and buffButton.benillaDurText == text
+ and buffButton.benillaDurWarn == warning and buffButton.benillaDurSetting == setting then
+ return
+ end
+ buffButton.benillaDurShown = true
+ buffButton.benillaDurText = text
+ buffButton.benillaDurWarn = warning
+ buffButton.benillaDurSetting = setting
+ duration:SetText(text)
local color = NORMAL_FONT_COLOR
- if timeLeft < BUFF_DURATION_WARNING_TIME then
+ if warning then
color = HIGHLIGHT_FONT_COLOR
end
duration:SetVertexColor(color.r, color.g, color.b)
diff --git a/crates/benilla-app/assets/ui/ChatFrame.xml b/crates/benilla-app/assets/ui/ChatFrame.xml
index 1b2d500a..260cf386 100644
--- a/crates/benilla-app/assets/ui/ChatFrame.xml
+++ b/crates/benilla-app/assets/ui/ChatFrame.xml
@@ -459,8 +459,14 @@
local flash = getglobal(frame:GetName() .. "BottomButtonFlash")
if not flash then return end
if frame:AtBottom() then
- flash:Hide()
- frame.flashTimer = 0
+ -- The reference's own gate (ChatFrame.lua): a settled view writes nothing. Our
+ -- flashTimer reset rides inside it — so a return-to-bottom during a hidden blink
+ -- phase keeps the residual phase (a divergence that restarts the blink mid-phase
+ -- next scroll-up, invisible at rest and costing zero writes there).
+ if flash:IsVisible() then
+ flash:Hide()
+ frame.flashTimer = 0
+ end
return
end
frame.flashTimer = (frame.flashTimer or 0) + elapsed
diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml
index 80ae6b31..27c67ec6 100644
--- a/crates/benilla-app/assets/ui/UiPanels.xml
+++ b/crates/benilla-app/assets/ui/UiPanels.xml
@@ -1283,6 +1283,10 @@
end
frame:Show()
table.insert(FADEFRAMES, frame)
+ -- Wake the driver: it hides itself the moment the list empties (its OnUpdate gate),
+ -- so the ONE insertion point is where it is shown again. Every other verb only
+ -- removes or rewrites entries.
+ BenillaFadeDriver:Show()
end
-- The two directions are one call with the mode flipped; whatever endpoints the caller
@@ -1559,11 +1563,14 @@
]]>
+ (UIParent.xml l.16-22); benilla has no UIParent frame, so a dedicated 1x1 driver ticks it.
+ It runs only while FADEFRAMES has entries: the engine dispatches OnUpdate to visible
+ frames only (tick.rs), so hiding itself on an empty list stops the per-frame walk cold,
+ and UIFrameFade — the single inserter — shows it again (decision 1396's class). -->
- UIFrameFadeUpdate(arg1)
+ if not FADEFRAMES[1] then this:Hide() return end UIFrameFadeUpdate(arg1)
+ OnUpdate; ours has none — the DeathFrame hidden-driver pattern instead). Awake only while
+ a unit menu of ours is open: UnitPopup_ShowMenu shows it, its own early-out hides it, and
+ a hidden frame is never ticked (tick.rs). It ships shown so the first tick runs the
+ early-out once and parks it. -->
diff --git a/crates/benilla-app/src/capture/probes/live_fps.rs b/crates/benilla-app/src/capture/probes/live_fps.rs
index 570ae30c..1d0c99ba 100644
--- a/crates/benilla-app/src/capture/probes/live_fps.rs
+++ b/crates/benilla-app/src/capture/probes/live_fps.rs
@@ -139,6 +139,7 @@ fn drive_live_fps(
mut key_events: MessageWriter,
mut exit: MessageWriter,
mut occlusions: MessageReader,
+ monitors: Query<&bevy::window::Monitor>,
) {
// Drain every frame so the state is current whichever phase we're in — the window can be
// occluded before sampling ever starts (a detached launch spawns behind whatever is open).
@@ -231,6 +232,45 @@ fn drive_live_fps(
.single()
.map(|w| format!(" present={:?}", w.present_mode))
.unwrap_or_default();
+ // Which display the window sits on, and that display's refresh rate — the regime a
+ // railed leg is railed AT. Two legs of one sitting have read 126.5 vs exactly 60.0 fps
+ // under the same uncapped present mode (1388 round 3, 1395's pin), and nothing on the
+ // line said why: this machine drives a 60 Hz external beside a 120 Hz built-in, and
+ // present=AutoNoVsync resolves to Metal displaySync=false either way — the rail, when
+ // one appears, is the WindowServer's, keyed to the display. Stamp it so a regime split
+ // reads off the line instead of costing a discarded round.
+ let display = {
+ let center = windows.single().ok().and_then(|w| match w.position {
+ bevy::window::WindowPosition::At(p) => {
+ Some(p + bevy::math::IVec2::new(px.0 as i32, px.1 as i32) / 2)
+ }
+ _ => None,
+ });
+ match center {
+ Some(c) => monitors
+ .iter()
+ .find(|m| {
+ let min = m.physical_position;
+ let max = min
+ + bevy::math::IVec2::new(
+ m.physical_width as i32,
+ m.physical_height as i32,
+ );
+ c.x >= min.x && c.x < max.x && c.y >= min.y && c.y < max.y
+ })
+ .map(|m| {
+ format!(
+ " display={}@{}",
+ m.name.as_deref().unwrap_or("?").replace(' ', "-"),
+ m.refresh_rate_millihertz
+ .map(|mhz| format!("{:.0}", mhz as f64 / 1000.0))
+ .unwrap_or_else(|| "?".into())
+ )
+ })
+ .unwrap_or_else(|| " display=none-contains-center".to_string()),
+ None => " display=unpositioned".to_string(),
+ }
+ };
// CPU cost per frame across every thread — the load-robust half of the measurement
// (`perf::process_cpu_secs`), and directly comparable with a reporter's CPU %.
let cpu = match (probe.cpu_at_start, crate::perf::process_cpu_secs()) {
@@ -307,7 +347,7 @@ fn drive_live_fps(
seen.mats, seen.meshes, seen.images, seen.uv_anims, seen.tint_anims,
);
println!(
- "FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present} occluded_frames={}{at_pin}{gate}{sky}{ribbons}{culled}",
+ "FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present}{display} occluded_frames={}{at_pin}{gate}{sky}{ribbons}{culled}",
v.len(),
at(0.50),
at(0.95),
diff --git a/crates/benilla-app/src/ui_script/buff_tests.rs b/crates/benilla-app/src/ui_script/buff_tests.rs
index 99624afe..b9e594c2 100644
--- a/crates/benilla-app/src/ui_script/buff_tests.rs
+++ b/crates/benilla-app/src/ui_script/buff_tests.rs
@@ -750,6 +750,186 @@ fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside(
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+/// **The idle enchant row writes nothing** (decision 1396's class, the audit's item on
+/// `TemporaryEnchantFrame:OnUpdate` — 1396 measured it at ~14 µs/frame as the post-fix lead). With
+/// neither hand enchanted, `BuffFrame_Enchant_OnUpdate` used to clear both slots and re-park the
+/// bar every frame of every session; now the no-enchant branch latches on `benillaCleared` after
+/// its first pass.
+///
+/// The probes are sentinels the branch would overwrite if it still ran: the bar re-anchored 41px
+/// left of its park, and slot 1 shown. Ten ticks later both survive. Then the row is DRIVEN — both
+/// hands at once — proving the latch releases, the straight-line hand packer keeps the reference's
+/// off-hand-first order, and the `filled`-gated width/anchor writes fire on the 0→2 edge; the drop
+/// back to empty re-parks the bar (the first pass after a transition always writes).
+#[test]
+fn an_idle_enchant_row_stops_rewriting_the_bar() {
+ let mut s = harness();
+ push(&mut s, mixed_bar());
+ frame(&mut s, 0.1); // first pass: the clear + park write once and latch
+ frame(&mut s, 0.1);
+ s.resolve();
+ let resting = s.eval::("return BuffFrame:GetRight()").unwrap();
+
+ // Sentinels the no-enchant branch can never leave standing if it still writes.
+ s.run(
+ r#"BuffFrame:SetPoint("TOPRIGHT", "TemporaryEnchantFrame", "TOPRIGHT", -41, 0)
+ TempEnchant1:Show()"#,
+ )
+ .unwrap();
+ for _ in 0..10 {
+ frame(&mut s, 0.1);
+ }
+ assert!(s.errors().is_empty(), "{:?}", s.errors());
+ assert!(
+ shown(&s, "TempEnchant1"),
+ "a latched idle row must not re-hide the slot every frame"
+ );
+ let displaced = s.eval::("return BuffFrame:GetRight()").unwrap();
+ assert!(
+ (displaced - (resting - 41.0)).abs() < 1e-3,
+ "a latched idle row must not re-park the bar every frame — got {displaced}, resting {resting}"
+ );
+
+ // The control: both hands enchant. Off hand packs slot 1 (the reference's order — main hand
+ // sits to its LEFT in slot 2), the row is 64 wide, the bar starts one 5px gutter left of it.
+ s.set_weapon_enchants(
+ Some(benilla_ui::script::WeaponEnchant {
+ remaining_ms: Some(480_000),
+ charges: 0,
+ }),
+ Some(benilla_ui::script::WeaponEnchant {
+ remaining_ms: Some(120_000),
+ charges: 0,
+ }),
+ );
+ frame(&mut s, 0.1);
+ assert!(shown(&s, "TempEnchant1") && shown(&s, "TempEnchant2"));
+ assert_eq!(
+ s.eval::("return TempEnchant1:GetID()").unwrap(),
+ 17,
+ "off hand first: slot 1 is the OffHandSlot id"
+ );
+ assert_eq!(s.eval::("return TempEnchant2:GetID()").unwrap(), 16);
+ assert_eq!(text(&s, "TempEnchant1Duration"), "2 m");
+ assert_eq!(text(&s, "TempEnchant2Duration"), "8 m");
+ s.resolve();
+ assert_eq!(
+ s.eval::("return TemporaryEnchantFrame:GetWidth()")
+ .unwrap(),
+ 64.0,
+ "two 32px columns"
+ );
+ let both = s.eval::("return BuffFrame:GetRight()").unwrap();
+ assert!(
+ (resting - both - 69.0).abs() < 1e-3,
+ "the bar clears both columns + the gutter: {resting} -> {both}"
+ );
+
+ // And back to empty: the transition still writes (the latch re-arms, not re-fires).
+ s.set_weapon_enchants(None, None);
+ frame(&mut s, 0.1);
+ assert!(!shown(&s, "TempEnchant1") && !shown(&s, "TempEnchant2"));
+ s.resolve();
+ let parked = s.eval::("return BuffFrame:GetRight()").unwrap();
+ assert!(
+ (parked - resting).abs() < 1e-3,
+ "the drop-to-empty pass re-parks the bar: {parked} vs {resting}"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+/// **A settled buff button writes nothing per frame** (the same 1396 class, one row down): the
+/// poll (`GetPlayerBuffTimeLeft` every frame — load-bearing, decision 0846) stays, but the alpha
+/// and the duration-region writes are gated on what was last written. The probes are sentinels the
+/// handler can never produce: alpha 0.42 (it writes only 1.0 or the warning ramp, and at 5 min out
+/// the ramp is not running) and the text "X" (it writes only `SecondsToTimeAbbrev` strings — and
+/// the compare is against the computed STRING, never a measured extent, which is 1396's fixture
+/// trap). The controls: the minute rollover still rewrites the text, the warning band still turns
+/// the number white, and inside the last 31s the pulse still writes a fresh alpha per tick.
+#[test]
+fn a_settled_buff_button_stops_rewriting_alpha_and_duration() {
+ let mut s = harness();
+ push(
+ &mut s,
+ vec![aura(
+ 1126,
+ "Mark of the Wild",
+ "Interface\\Icons\\Spell_Nature_Regeneration",
+ true,
+ 1,
+ None,
+ 300.0,
+ true,
+ )],
+ );
+ frame(&mut s, 0.1); // the event repaint + the poll's first write ("5 m", alpha 1.0)
+ frame(&mut s, 0.1);
+ assert_eq!(text(&s, "BuffButton0Duration"), "5 m");
+
+ s.run(r#"BuffButton0:SetAlpha(0.42); BuffButton0Duration:SetText("X")"#)
+ .unwrap();
+ for _ in 0..10 {
+ frame(&mut s, 0.016);
+ }
+ assert!(s.errors().is_empty(), "{:?}", s.errors());
+ assert!(
+ (alpha(&s, "BuffButton0") - 0.42).abs() < 1e-6,
+ "a settled button must not rewrite its alpha every frame — got {}",
+ alpha(&s, "BuffButton0")
+ );
+ assert_eq!(
+ text(&s, "BuffButton0Duration"),
+ "X",
+ "a settled button must not rewrite its unchanged duration text every frame"
+ );
+
+ // Control 1: a REAL text change flows — the abbreviation drops to "4 m" past the boundary.
+ s.tick(60.0);
+ assert_eq!(
+ text(&s, "BuffButton0Duration"),
+ "4 m",
+ "the minute rollover overwrites the sentinel: the gate passes real changes"
+ );
+
+ // Control 2: inside the last 31s the pulse writes a fresh ramp value every tick, and the
+ // number wears the sub-minute HIGHLIGHT white (the band is part of the write tuple).
+ s.tick(210.0); // ~29.5s left
+ s.resolve();
+ let a1 = alpha(&s, "BuffButton0");
+ s.tick(0.2);
+ let a2 = alpha(&s, "BuffButton0");
+ s.tick(0.3);
+ let a3 = alpha(&s, "BuffButton0");
+ assert!(
+ a1 != a2 && a2 != a3,
+ "the warning pulse flows through the gate per tick: {a1} {a2} {a3}"
+ );
+ s.resolve();
+ let seconds_text = text(&s, "BuffButton0Duration");
+ assert!(
+ seconds_text.ends_with(" s"),
+ "inside the last minute the timer counts seconds: {seconds_text}"
+ );
+ let white = s.extract().iter().any(|q| match &q.content {
+ QuadContent::Text {
+ text: Some(t),
+ color: Some(c),
+ ..
+ } => {
+ *t == seconds_text
+ && (c[0] - 1.0).abs() < 1e-3
+ && (c[1] - 1.0).abs() < 1e-3
+ && (c[2] - 1.0).abs() < 1e-3
+ }
+ _ => false,
+ });
+ assert!(
+ white,
+ "the sub-minute band rewrote the vertex color to HIGHLIGHT white"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
/// The duration line against the **real shipped strings**, end to end — the leg the engine's own
/// synthetic-template tests cannot reach.
///
diff --git a/crates/benilla-app/src/ui_script/chat_tests.rs b/crates/benilla-app/src/ui_script/chat_tests.rs
index a539bf56..ea7cdad8 100644
--- a/crates/benilla-app/src/ui_script/chat_tests.rs
+++ b/crates/benilla-app/src/ui_script/chat_tests.rs
@@ -716,3 +716,69 @@ fn hovering_the_dock_still_reveals_the_tabs() {
"a stationary hover reveals the selected tab at full alpha — got {alpha}"
);
}
+
+/// **A view at the bottom writes nothing** — `ChatFrame_OnUpdate`'s at-bottom branch used to call
+/// `flash:Hide()` and zero `flashTimer` every frame; the 1.12 reference gates both behind
+/// `flash:IsVisible()` (1396's residual item 3, now restored — our `flashTimer = 0` rides inside
+/// the reference's gate, so a return-to-bottom during a hidden blink phase keeps the residual
+/// phase, a divergence that costs nothing at rest).
+///
+/// The probe is a sentinel the gated branch can only zero: `flashTimer = 0.42` with the flash
+/// hidden survives ten ticks. The controls: scrolled up, the bottom-button blink still runs its
+/// 0.5s toggle; and scrolling back while the flash is LIT still hides it and zeroes the timer.
+#[test]
+fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
+ let mut s = chat_frame();
+ for t in ["L0", "L1", "L2"] {
+ s.add_chat_message("ChatFrame1", t, 1.0, 1.0, 1.0);
+ }
+ for _ in 0..3 {
+ s.tick(0.016);
+ s.resolve();
+ }
+
+ // At the bottom, flash hidden: the sentinel must survive.
+ s.run("ChatFrame1.flashTimer = 0.42").unwrap();
+ for _ in 0..10 {
+ s.tick(0.016);
+ s.resolve();
+ }
+ assert!(s.errors().is_empty(), "{:?}", s.errors());
+ let timer: f64 = s.eval("return ChatFrame1.flashTimer").unwrap();
+ assert!(
+ (timer - 0.42).abs() < 1e-6,
+ "a settled view must not rewrite flashTimer every frame — got {timer}"
+ );
+ assert!(
+ !s.eval::("return ChatFrame1BottomButtonFlash:IsVisible()")
+ .unwrap(),
+ "the flash stays hidden at rest"
+ );
+
+ // Control 1: scrolled up, the blink still runs — the timer accumulates past 0.5 and shows
+ // the flash (a point inside the docked frame, as the wheel test uses).
+ s.mouse_wheel(100.0, 150.0, 1.0);
+ s.tick(0.3); // 0.42 + 0.3 = 0.72 >= 0.5 -> toggle on
+ s.resolve();
+ assert!(
+ s.eval::("return ChatFrame1BottomButtonFlash:IsVisible()")
+ .unwrap(),
+ "scrolled up, the bottom-button blink still lights"
+ );
+
+ // Control 2: back to the bottom while the flash is LIT — the transition still writes.
+ s.mouse_wheel(100.0, 150.0, -1.0);
+ s.tick(0.016);
+ s.resolve();
+ assert!(
+ !s.eval::("return ChatFrame1BottomButtonFlash:IsVisible()")
+ .unwrap(),
+ "returning to the bottom hides a lit flash"
+ );
+ assert_eq!(
+ s.eval::("return ChatFrame1.flashTimer").unwrap(),
+ 0.0,
+ "and zeroes the timer with it (our reset rides inside the reference's gate)"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
diff --git a/crates/benilla-app/src/ui_script/panel_tests.rs b/crates/benilla-app/src/ui_script/panel_tests.rs
index 92fde6b2..eb64a80f 100644
--- a/crates/benilla-app/src/ui_script/panel_tests.rs
+++ b/crates/benilla-app/src/ui_script/panel_tests.rs
@@ -979,3 +979,99 @@ fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
s.run("HideUIPanel(AddonPanel)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+
+// ── The fade kit's driver (UIFrameFade / BenillaFadeDriver) ──────────────────────────────────
+
+/// **The idle fade driver parks itself** (decision 1396's class, the audit's driver-hide item):
+/// with `FADEFRAMES` empty there is nothing to walk, so the driver's OnUpdate hides its own frame
+/// — and the engine dispatches OnUpdate only to visible frames (`tick.rs`), which stops the
+/// per-frame walk cold. The probe wraps `UIFrameFadeUpdate` in a counter BEFORE the first tick:
+/// ten idle ticks later it has never run, and the driver is hidden.
+#[test]
+fn an_idle_fade_driver_parks_itself_off_the_tick() {
+ let mut s = UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ load_xml(&s, "UiPanels.xml");
+ s.resolve();
+ s.run(
+ "BENILLA_TEST_FADE_TICKS = 0\n\
+ local real = UIFrameFadeUpdate\n\
+ function UIFrameFadeUpdate(elapsed)\n\
+ BENILLA_TEST_FADE_TICKS = BENILLA_TEST_FADE_TICKS + 1\n\
+ real(elapsed)\n\
+ end",
+ )
+ .unwrap();
+ for _ in 0..10 {
+ s.tick(0.016);
+ s.resolve();
+ }
+ assert!(s.errors().is_empty(), "{:?}", s.errors());
+ assert!(
+ !s.eval::("return BenillaFadeDriver:IsShown()")
+ .unwrap(),
+ "an empty FADEFRAMES parks the driver"
+ );
+ assert_eq!(
+ s.eval::("return BENILLA_TEST_FADE_TICKS").unwrap(),
+ 0,
+ "a parked driver never runs the fade walk"
+ );
+}
+
+/// The control for the guard above: the gate must not cost the fades it gates. `UIFrameFade` (the
+/// kit's single insertion point — `UIFrameFadeIn`/`Out` both land there) wakes the driver beside
+/// its `table.insert`; the fade then ramps on real ticks, completes, and the driver re-parks on
+/// the first empty tick after.
+#[test]
+fn a_started_fade_still_ramps_and_the_driver_reparks_after() {
+ let mut s = UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ load_xml(&s, "UiPanels.xml");
+ s.resolve();
+ s.run(r#"CreateFrame("Frame", "BenillaFadeProbe")"#)
+ .unwrap();
+ for _ in 0..2 {
+ s.tick(0.016); // settle: the driver parks
+ s.resolve();
+ }
+ assert!(!s
+ .eval::("return BenillaFadeDriver:IsShown()")
+ .unwrap());
+
+ s.run("UIFrameFadeIn(BenillaFadeProbe, 1.0)").unwrap();
+ assert!(
+ s.eval::("return BenillaFadeDriver:IsShown()")
+ .unwrap(),
+ "starting a fade wakes the driver"
+ );
+ assert_eq!(
+ s.eval::("return BenillaFadeProbe:GetAlpha()").unwrap(),
+ 0.0,
+ "the fade armed at its IN startAlpha"
+ );
+
+ s.tick(0.5);
+ s.resolve();
+ let mid = s.eval::("return BenillaFadeProbe:GetAlpha()").unwrap();
+ assert!(
+ (mid - 0.5).abs() < 1e-3,
+ "half the timeToFade in, half the ramp: {mid}"
+ );
+
+ s.tick(0.6); // past timeToFade: the fade completes and leaves the list
+ s.resolve();
+ assert_eq!(
+ s.eval::("return BenillaFadeProbe:GetAlpha()").unwrap(),
+ 1.0,
+ "the fade completes at its endAlpha"
+ );
+ s.tick(0.016); // the first empty tick re-parks the driver
+ s.resolve();
+ assert!(
+ !s.eval::("return BenillaFadeDriver:IsShown()")
+ .unwrap(),
+ "the driver re-parks once the list is empty"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
diff --git a/crates/benilla-app/src/ui_script/unit_popup_tests.rs b/crates/benilla-app/src/ui_script/unit_popup_tests.rs
index 6de51ec8..1fb69907 100644
--- a/crates/benilla-app/src/ui_script/unit_popup_tests.rs
+++ b/crates/benilla-app/src/ui_script/unit_popup_tests.rs
@@ -727,6 +727,146 @@ fn the_pet_details_row_opens_the_pet_paper_doll() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+// ── The enable-pass driver (BenillaUnitPopupDriver) ─────────────────────────────────────────────
+
+/// **The idle popup driver parks itself** (decision 1396's class, the audit's driver-hide item):
+/// with no unit menu of ours open, `UnitPopup_OnUpdate`'s early-out hides the driver's own frame —
+/// and the engine dispatches OnUpdate only to visible frames (`tick.rs`), so the per-frame scan
+/// stops entirely. `UnitPopup_ShowMenu` — the one opener every unit menu goes through (grep:
+/// UnitFrames/PartyFrame/FriendsFrame/ItemRef all call it) — wakes it again.
+///
+/// The probe wraps `UnitPopup_OnUpdate` in a counter: after the first tick parks the shipped-shown
+/// driver, ten idle ticks never run the pass. The control: an open target menu still runs the
+/// per-frame enable pass — a disconnected target's Whisper row greys, and greys back live when the
+/// unit reconnects mid-open — and closing the menu re-parks the driver.
+#[test]
+fn an_idle_unit_popup_driver_parks_itself_off_the_tick() {
+ let mut s = UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ bake_strings(&s);
+ load_popup_frames(&s);
+
+ s.set_unit(
+ "player",
+ Some(UnitState {
+ exists: true,
+ name: Some("Me".into()),
+ is_player: true,
+ ..UnitState::default()
+ }),
+ );
+ // A friendly player target, DISCONNECTED (is_connected defaults to false): the Whisper row's
+ // grey predicate is live from the first enable pass.
+ s.set_unit(
+ "target",
+ Some(UnitState {
+ exists: true,
+ name: Some("Ally".into()),
+ health: 40,
+ max_health: 40,
+ is_player: true,
+ reaction: 5,
+ ..UnitState::default()
+ }),
+ );
+ s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
+ s.resolve();
+ assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
+
+ s.run(
+ "BENILLA_TEST_POPUP_TICKS = 0\n\
+ local real = UnitPopup_OnUpdate\n\
+ function UnitPopup_OnUpdate(elapsed)\n\
+ BENILLA_TEST_POPUP_TICKS = BENILLA_TEST_POPUP_TICKS + 1\n\
+ real(elapsed)\n\
+ end",
+ )
+ .unwrap();
+
+ // The driver ships shown; the first tick's early-out parks it, and then nothing runs.
+ s.tick(0.016);
+ s.resolve();
+ assert!(
+ !s.eval::("return BenillaUnitPopupDriver:IsShown()")
+ .unwrap(),
+ "no menu open: the first tick parks the driver"
+ );
+ s.run("BENILLA_TEST_POPUP_TICKS = 0").unwrap();
+ for _ in 0..10 {
+ s.tick(0.016);
+ s.resolve();
+ }
+ assert_eq!(
+ s.eval::("return BENILLA_TEST_POPUP_TICKS").unwrap(),
+ 0,
+ "a parked driver never runs the enable pass"
+ );
+
+ // Open the PLAYER menu through the real hit path: the driver wakes...
+ let (cx, cy) = s
+ .eval::<(f64, f64)>("return TargetFrame:GetCenter()")
+ .unwrap();
+ s.mouse_button(cx as f32, cy as f32, "RightButton", true);
+ s.mouse_button(cx as f32, cy as f32, "RightButton", false);
+ s.resolve();
+ assert!(
+ s.eval::("return DropDownList1:IsVisible()").unwrap(),
+ "the menu opened"
+ );
+ assert!(
+ s.eval::("return BenillaUnitPopupDriver:IsShown()")
+ .unwrap(),
+ "UnitPopup_ShowMenu wakes the driver"
+ );
+
+ // ...and the pass runs per frame: the disconnected target's Whisper row greys...
+ s.tick(0.016);
+ s.resolve();
+ assert!(
+ s.eval::("return BENILLA_TEST_POPUP_TICKS").unwrap() > 0,
+ "an open menu ticks the enable pass"
+ );
+ assert!(
+ !s.eval::("return DropDownList1Button2:IsEnabled()")
+ .unwrap(),
+ "Whisper greys against a disconnected target"
+ );
+
+ // ...and un-greys LIVE when the unit reconnects while the menu is up — the whole reason the
+ // pass is per-frame rather than baked at open.
+ s.set_unit(
+ "target",
+ Some(UnitState {
+ exists: true,
+ name: Some("Ally".into()),
+ health: 40,
+ max_health: 40,
+ is_player: true,
+ reaction: 5,
+ is_connected: true,
+ ..UnitState::default()
+ }),
+ );
+ s.tick(0.016);
+ s.resolve();
+ assert!(
+ s.eval::("return DropDownList1Button2:IsEnabled()")
+ .unwrap(),
+ "Whisper enables the frame the target reconnects"
+ );
+
+ // Closing the menu re-parks the driver on its next tick.
+ s.run("CloseDropDownMenus()").unwrap();
+ s.tick(0.016);
+ s.resolve();
+ assert!(
+ !s.eval::("return BenillaUnitPopupDriver:IsShown()")
+ .unwrap(),
+ "a closed menu parks the driver again"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
/// Click an open dropdown row through the real hit path.
fn click_row(s: &mut UiScript, row: u32) {
click_frame(s, &format!("DropDownList1Button{row}"));
diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs
index 9403fdf6..27509c3b 100644
--- a/crates/benilla-ui/src/script/button.rs
+++ b/crates/benilla-ui/src/script/button.rs
@@ -121,6 +121,7 @@ fn link_label_to_font_object(lua: &Lua, text: Option, name: Option
let d = model.region_data.entry(rh).or_default();
d.font_object = Some(name.to_string());
super::font::repaint(d, &fo);
+ model.touch_measure(rh);
}
/// Run `f` over a frame's Button state under one short write borrow.
@@ -271,6 +272,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let mut model = lua.app_data_mut::().expect("model app_data");
let rh = *model.id_to_region.get(&id).expect("text region id");
model.region_data.entry(rh).or_default().text = text;
+ model.touch_measure(rh);
Ok(())
})?,
)?;
diff --git a/crates/benilla-ui/src/script/editbox.rs b/crates/benilla-ui/src/script/editbox.rs
index 434b1476..147b97e3 100644
--- a/crates/benilla-ui/src/script/editbox.rs
+++ b/crates/benilla-ui/src/script/editbox.rs
@@ -365,6 +365,7 @@ fn sync_text_region(lua: &Lua, h: FrameHandle) {
if let Some(display) = display {
let mut model = lua.app_data_mut::().expect("model app_data");
model.region_data.entry(rh).or_default().text = Some(display);
+ model.touch_measure(rh);
}
}
diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs
index 9c2ecda7..a7ab06fa 100644
--- a/crates/benilla-ui/src/script/extract.rs
+++ b/crates/benilla-ui/src/script/extract.rs
@@ -221,11 +221,16 @@ impl UiScript {
if region.map(|r| r.kind) == Some(crate::widget::RegionKind::Title) {
continue;
}
- let mut data = model.region_data.get(&rh).cloned().unwrap_or_default();
- // Region-level Hide (the VisibleRegion bit): no quad at all.
- if data.hidden {
+ // Region-level Hide (the VisibleRegion bit): no quad at all — checked on the
+ // borrow, BEFORE the clone below. `RegionData` is a fat row (text `String`,
+ // paths, the anchors `Vec`), and paying its clone for a row whose next line
+ // discards it was a per-hidden-region-per-frame allocation tax the extract
+ // walk never noticed it was paying.
+ let data_ref = model.region_data.get(&rh);
+ if data_ref.is_some_and(|d| d.hidden) {
continue;
}
+ let mut data = data_ref.cloned().unwrap_or_default();
// The single-hop draw multiply (`propagation.md`): the region's own alpha times
// its immediate owner's — never a product up the tree, because the owner's own
// `effective_alpha` was already overwritten by any ancestor's SetAlpha.
diff --git a/crates/benilla-ui/src/script/font.rs b/crates/benilla-ui/src/script/font.rs
index 7f004e9b..7beffc3d 100644
--- a/crates/benilla-ui/src/script/font.rs
+++ b/crates/benilla-ui/src/script/font.rs
@@ -237,6 +237,10 @@ pub(crate) fn propagate(model: &mut Model, name: &str) {
repaint(d, &fo);
}
}
+ // A fan-out cannot name its regions one by one without a second pass; a font-object edit is
+ // a human-rate action, so the measure ledger goes conservative and the next sweep walks the
+ // roster once, exactly as every sweep did before the ledger existed.
+ model.touch_measure_all();
}
/// Run `f` over the named font object's record, then propagate the change to its dependants.
diff --git a/crates/benilla-ui/src/script/font_block.rs b/crates/benilla-ui/src/script/font_block.rs
index c72dadef..cd61d4b7 100644
--- a/crates/benilla-ui/src/script/font_block.rs
+++ b/crates/benilla-ui/src/script/font_block.rs
@@ -200,6 +200,7 @@ pub(super) fn install(
// cleared by each local setter and never restored — so a property the widget set for
// itself stays severed across a later `SetFontObject`.
super::font::repaint(d, &fo);
+ model.touch_measure(rh);
Ok(())
})?,
)?;
@@ -258,6 +259,7 @@ pub(super) fn install(
d.outline = Outline::flags(&f);
d.font_explicit.outline = true;
}
+ model.touch_measure(rh);
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
},
)?,
diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs
index 6a8bc691..b63cd6c6 100644
--- a/crates/benilla-ui/src/script/layout.rs
+++ b/crates/benilla-ui/src/script/layout.rs
@@ -1476,18 +1476,68 @@ impl UiScript {
// declared needs no measure for its *layout*, but `GetStringWidth` still has to answer
// with the string's natural width, and only a measure can supply it (decision 0997: a
// kit that reads that number and then SETS a width on the string would otherwise stop
- // receiving measures the instant it did so, and start reading its own box back). The
- // key cache is what keeps this from costing a re-measure per frame — and the staleness
- // check runs allocation-free ([`stale_measure_key`]) so the whole-roster sweep stays
- // cheap: the request build (text/font clones, the id mint) is paid only by rows that
- // actually need a measure, which on a quiet frame is none.
- let needy: Vec = model
- .region_data
- .iter()
- .filter(|(_, d)| d.text.as_deref().is_some_and(|t| !t.is_empty()))
- .map(|(&rh, _)| rh)
- .filter(|&rh| stale_measure_key(&model, rh).is_some())
- .collect();
+ // receiving measures the instant it did so, and start reading its own box back).
+ //
+ // Asked off the MEASURE LEDGER, not the roster (the 1388 shape one lane over): the
+ // sweep existed to *discover* silent writes — `SetText` touches no layout until the
+ // extent moves — and discovery cost two whole-roster walks per frame at a city pin
+ // (~300 µs of the `resolve` lap, decision 1370) to conclude, almost always, that
+ // nothing was written. The write sites announce themselves now
+ // ([`Model::touch_measure`]); draining the ledger re-arms it, so the second ask of the
+ // same frame (`fill_measures`, inside `resolve`) sees only what was written *between*
+ // the asks — same-frame semantics unchanged. `None` (a fan-out that could not name its
+ // regions, or the initial state) is the old whole-roster walk, verbatim.
+ let ledger = model.measure_dirty.take();
+ model.measure_dirty = Some(Vec::new());
+ let needy: Vec = match ledger {
+ Some(mut dirty) => {
+ dirty.sort_unstable_by_key(|rh| rh.fingerprint_bits());
+ dirty.dedup();
+ dirty.retain(|&rh| stale_measure_key(&model, rh).is_some());
+ // The completeness falsifier (on for every benilla-ui test): the ledger's whole
+ // correctness claim is that its enrolled sites cover every measure-key writer,
+ // and that claim is checked, not argued — the old discovery walk runs beside
+ // the drain and any row it finds that the ledger missed is a stale label
+ // waiting to ship, so it panics with the region named. The other direction is
+ // legal by construction (an enrolled same-value write drops out at the
+ // staleness check above).
+ if layout_verify_enabled() {
+ let roster: Vec = model
+ .region_data
+ .iter()
+ .filter(|(_, d)| d.text.as_deref().is_some_and(|t| !t.is_empty()))
+ .map(|(&rh, _)| rh)
+ .filter(|&rh| stale_measure_key(&model, rh).is_some())
+ .collect();
+ for rh in &roster {
+ assert!(
+ dirty.contains(rh),
+ "measure ledger missed a write: region {rh:?} (id {:?}) needs a \
+ measure the drained ledger never named — a measure-key write site \
+ is not enrolled in Model::touch_measure",
+ model.region_to_id.get(rh),
+ );
+ }
+ }
+ dirty
+ }
+ None => model
+ .region_data
+ .iter()
+ .filter(|(_, d)| d.text.as_deref().is_some_and(|t| !t.is_empty()))
+ .map(|(&rh, _)| rh)
+ .filter(|&rh| stale_measure_key(&model, rh).is_some())
+ .collect(),
+ };
+ // Re-enroll everything still stale: a request handed out is not an answer. The old
+ // discovery walk re-found an unanswered region every frame until `set_measured_text`
+ // landed (the no-measurer engine-test path answers a frame later; a dropped request
+ // would otherwise go stale FOREVER now that nothing walks the roster), so the ledger
+ // keeps asking too. With a synchronous measurer the answer lands before the next drain
+ // and the staleness check drops the re-enrolled handle for free.
+ if let Some(list) = &mut model.measure_dirty {
+ list.extend(needy.iter().copied());
+ }
let mut out = Vec::with_capacity(needy.len());
for rh in needy {
if let Some(req) = measure_request_for(&mut model, rh) {
diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs
index 6afbf988..e32ca999 100644
--- a/crates/benilla-ui/src/script/mod.rs
+++ b/crates/benilla-ui/src/script/mod.rs
@@ -992,6 +992,8 @@ impl UiScript {
// Measured extents are auto-size inputs — the layout gate's read set, same as
// [`Self::set_measured_text`]'s.
model.touch_layout();
+ // Every stored measure was just wiped; only the whole-roster sweep can re-request them.
+ model.touch_measure_all();
}
// ── Input: pointer-leaves-window cleanup (decision 0216 §3; the hit-test/mouse dispatch that
diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs
index 2ff129e0..dd574db3 100644
--- a/crates/benilla-ui/src/script/model.rs
+++ b/crates/benilla-ui/src/script/model.rs
@@ -107,6 +107,25 @@ pub(crate) struct Model {
/// node is in the cached roster before they may name it, and that proof is a `node_of[id]`
/// probe — which hands back the roster row, and with it the handle, for free at resolve time.
pub(crate) layout_touched: Option>,
+ /// The MEASURE lane's ledger — [`Self::layout_touched`]'s twin for the text-measure sweep
+ /// (`UiScript::fontstrings_needing_measure`), which used to walk every `region_data` row
+ /// twice a frame to *discover* silent writes (`SetText` touches no layout until the extent
+ /// moves — that silence is the lane's defining property, decision 1370's ~300 µs standing
+ /// `resolve` lap at a city pin). `Some(handles)` = every write since the last sweep that
+ /// could move a region's measure key ([`crate::script::types::RegionData::measure_key`]:
+ /// text, font path/height, text height, outline, wrap width, owner scale) named its region
+ /// here, so the sweep asks exactly these. `None` = some write could not name one (a
+ /// font-object fan-out, a scale propagation, `invalidate_text_measures`) and the next sweep
+ /// walks the whole roster exactly as it always did — which is also the initial state.
+ ///
+ /// UNLIKE the layout ledger, `None` is not re-entered by unmigrated sites automatically:
+ /// nothing at a `region_data` field write *had* to announce itself before this existed, so
+ /// completeness of the enrolled sites is the correctness claim — and it is machine-checked,
+ /// not argued: with `WOW_LAYOUT_VERIFY` on (forced for every benilla-ui test) the sweep
+ /// re-runs the whole-roster walk beside the drained ledger and panics on any row the ledger
+ /// missed. A candidate the ledger names that the roster walk doesn't is fine (a same-value
+ /// write — the staleness check absorbs it); the reverse is a stale label waiting to ship.
+ pub(crate) measure_dirty: Option>,
/// `WOW_LAYOUT_VERIFY`'s flag that the resolve just taken was the incremental one and owes a
/// full-derive re-run to prove it (see `layout::UiScript::resolve_layout`). Always `false` in
/// production — nothing reads it unless the verify build set it.
@@ -1161,6 +1180,7 @@ impl Model {
layout_fingerprint: None,
layout_epoch: 0,
layout_touched: None,
+ measure_dirty: None,
layout_verify_recheck: false,
layout_derives: 0,
layout_scope: super::layout::LayoutScope::default(),
@@ -1501,6 +1521,25 @@ impl Model {
}
}
+ /// A write that could move region `rh`'s measure key — its text, its font face/height/
+ /// outline, its text height, its explicit size (the wrap width) — names the region on the
+ /// measure ledger ([`Self::measure_dirty`]). Push-only and unfiltered: non-FontStrings and
+ /// same-value writes cost one `Vec` push here and one staleness probe at the next sweep,
+ /// which is the price of keeping every enrolled site a one-liner.
+ pub(crate) fn touch_measure(&mut self, rh: RegionHandle) {
+ if let Some(list) = &mut self.measure_dirty {
+ list.push(rh);
+ }
+ }
+
+ /// A write that moves measure keys WITHOUT being able to name the regions — a font-object
+ /// edit fanning out to every inheriting region, a scale change propagating down a subtree,
+ /// [`super::UiScript::invalidate_text_measures`]. The next sweep walks the whole roster,
+ /// exactly as every sweep did before the ledger existed. All human-rate paths.
+ pub(crate) fn touch_measure_all(&mut self) {
+ self.measure_dirty = None;
+ }
+
/// Name a node **without opening tier 1** — for the resolve's own pre-pass
/// (`tooltip::layout_tooltips`), which writes layout inputs derived from state that already
/// arrived through touched paths.
diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs
index c01f6c02..6f741f3e 100644
--- a/crates/benilla-ui/src/script/object/frame_state.rs
+++ b/crates/benilla-ui/src/script/object/frame_state.rs
@@ -286,8 +286,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let mut model = lua.app_data_mut::().expect("model");
let shown = model.arena.reparent_finish(h, new_parent, was_visible);
// A real reparent moves the subtree's effective scale and anchors — a
- // layout-gate input.
+ // layout-gate input, and the scale is a measure-key input the subtree's
+ // FontStrings cannot name one by one (human-rate; the sweep walks once).
model.touch_layout();
+ model.touch_measure_all();
shown
};
event::fire_visibility_changes(lua, shown);
@@ -350,6 +352,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
model.arena.set_scale(h, scale);
if changed {
model.touch_layout();
+ // The owner's effective scale is in every descendant FontString's measure key.
+ model.touch_measure_all();
}
Ok(())
})?,
diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs
index d7636eda..88954f6f 100644
--- a/crates/benilla-ui/src/script/region.rs
+++ b/crates/benilla-ui/src/script/region.rs
@@ -64,6 +64,7 @@ pub(crate) fn apply_font_parts(
d.outline = super::Outline::flags(&f);
d.font_explicit.outline = true;
}
+ model.touch_measure(rh);
Ok(())
}
diff --git a/crates/benilla-ui/src/script/region/layout.rs b/crates/benilla-ui/src/script/region/layout.rs
index de67ed07..a5a1d0e7 100644
--- a/crates/benilla-ui/src/script/region/layout.rs
+++ b/crates/benilla-ui/src/script/region/layout.rs
@@ -26,8 +26,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let changed = !size_bits_eq(d.size, new);
d.size = new;
if changed {
- // A size write moves no edge and no roster membership (decision 1388).
+ // A size write moves no edge and no roster membership (decision 1388) — and on
+ // a FontString the width is the WRAP width, a measure-key input, so it names
+ // itself on the measure ledger too.
model.touch_layout_region(rh);
+ model.touch_measure(rh);
}
Ok(())
})?,
@@ -43,8 +46,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let changed = !size_bits_eq(d.size, new);
d.size = new;
if changed {
- // A size write moves no edge and no roster membership (decision 1388).
+ // A size write moves no edge and no roster membership (decision 1388) — and on
+ // a FontString the width is the WRAP width, a measure-key input, so it names
+ // itself on the measure ledger too.
model.touch_layout_region(rh);
+ model.touch_measure(rh);
}
Ok(())
})?,
@@ -60,8 +66,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let changed = !size_bits_eq(d.size, new);
d.size = new;
if changed {
- // A size write moves no edge and no roster membership (decision 1388).
+ // A size write moves no edge and no roster membership (decision 1388) — and on
+ // a FontString the width is the WRAP width, a measure-key input, so it names
+ // itself on the measure ledger too.
model.touch_layout_region(rh);
+ model.touch_measure(rh);
}
Ok(())
})?,
diff --git a/crates/benilla-ui/src/script/region/text.rs b/crates/benilla-ui/src/script/region/text.rs
index ae9a458e..76b135af 100644
--- a/crates/benilla-ui/src/script/region/text.rs
+++ b/crates/benilla-ui/src/script/region/text.rs
@@ -34,6 +34,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
data.text = text;
// Fresh text draws whole — an armed write-on gradient belongs to the old string.
data.alpha_gradient = None;
+ // SetText is the measure lane's canonical SILENT write (it must not touch the
+ // layout — the extent hasn't moved yet), so it names itself on the measure ledger.
+ model.touch_measure(rh);
Ok(())
})?,
)?;
@@ -53,6 +56,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let data = model.region_data.entry(rh).or_default();
data.text = Some(text);
data.alpha_gradient = None;
+ model.touch_measure(rh);
Ok(())
})?,
)?;
@@ -247,12 +251,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"SetTextHeight",
lua.create_function(|lua, (this, height): (Table, f32)| {
let rh = region_handle_of(lua, &this)?;
- lua.app_data_mut::()
- .expect("model")
- .region_data
- .entry(rh)
- .or_default()
- .text_height = Some(height);
+ let mut model = lua.app_data_mut::().expect("model");
+ model.region_data.entry(rh).or_default().text_height = Some(height);
+ model.touch_measure(rh);
Ok(())
})?,
)?;
diff --git a/crates/benilla-ui/src/script/simplehtml/mod.rs b/crates/benilla-ui/src/script/simplehtml/mod.rs
index 958cef22..57de82ca 100644
--- a/crates/benilla-ui/src/script/simplehtml/mod.rs
+++ b/crates/benilla-ui/src/script/simplehtml/mod.rs
@@ -327,6 +327,7 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
d.font_explicit.justify_h = true;
d.font_explicit.justify_v = true;
model.region_data.insert(rh, d);
+ model.touch_measure(rh); // a text block arrives text-in-hand
prev_block = Some(id);
next_y = -paint.spacing;
made.push(rh);
diff --git a/crates/benilla-ui/src/script/tests/measure.rs b/crates/benilla-ui/src/script/tests/measure.rs
index e0912343..b9ba4766 100644
--- a/crates/benilla-ui/src/script/tests/measure.rs
+++ b/crates/benilla-ui/src/script/tests/measure.rs
@@ -490,3 +490,83 @@ fn a_synchronous_measure_satisfies_the_batch_request_too() {
"a stale measure is not this string's metric"
);
}
+
+/// The measure LEDGER's completeness guard: `fontstrings_needing_measure` no longer walks the
+/// roster on the armed path — a measure-input write the ledger never hears about is a label
+/// whose box silently never updates again. The exposing sequence is SETTLE (drain the ledger
+/// dry), then one silent write, then sweep: enrolled, the sweep names exactly that region;
+/// missed, it returns empty and this test is the red light. (Watched red with the `SetText`
+/// enrollment removed — the earlier tests in this file stay green through that removal, because
+/// their regions ride the ledger's own re-enrollment of still-unanswered requests.)
+#[test]
+fn a_settled_region_is_refound_after_each_measure_input_write() {
+ let mut s = script();
+ s.set_screen_size(800.0, 600.0);
+ s.run(
+ r#"
+ local f = CreateFrame("Frame", "Host")
+ f:SetPoint("TOPLEFT"); f:SetSize(400, 300)
+ local fs = f:CreateFontString("Label", "ARTWORK")
+ fs:SetPoint("TOPLEFT")
+ fs:SetText("one")
+ "#,
+ )
+ .unwrap();
+ let writes: &[(&str, &str)] = &[
+ ("SetText", "Label:SetText('two')"),
+ ("SetFormattedText", "Label:SetFormattedText('n=%d', 3)"),
+ (
+ "SetFont",
+ r#"Label:SetFont("Fonts\\FRIZQT__.TTF", 14, "OUTLINE")"#,
+ ),
+ ("SetTextHeight", "Label:SetTextHeight(20)"),
+ ("SetWidth (the wrap width)", "Label:SetWidth(123)"),
+ // The conservative lane: a scale write cannot name descendants one by one, so it must
+ // put the ledger back on the whole-roster walk — which refinds Label (scale is in the
+ // measure key).
+ ("SetScale (conservative)", "Host:SetScale(2.0)"),
+ ];
+ for (label, lua) in writes {
+ // Settle: answer whatever is pending, then prove the sweep is dry.
+ loop {
+ let reqs = s.fontstrings_needing_measure();
+ if reqs.is_empty() {
+ break;
+ }
+ let answers: Vec<_> = reqs.iter().map(|r| (r.id, 10.0, 16.0, r.key)).collect();
+ s.set_measured_text_unwrapped(&answers);
+ }
+ assert!(s.fontstrings_needing_measure().is_empty(), "settled");
+ s.run(lua).unwrap();
+ let reqs = s.fontstrings_needing_measure();
+ assert_eq!(
+ reqs.len(),
+ 1,
+ "{label}: a silent measure-input write must re-surface its region to the sweep"
+ );
+ }
+ // The button label lane writes through its own binding (`Button:SetText` routes to the
+ // ButtonText region) — same guard, second write path.
+ s.run(
+ r#"
+ local b = CreateFrame("Button", "Btn")
+ b:SetPoint("TOPLEFT", 0, -50); b:SetSize(80, 22)
+ "#,
+ )
+ .unwrap();
+ while !{
+ let reqs = s.fontstrings_needing_measure();
+ let answers: Vec<_> = reqs.iter().map(|r| (r.id, 10.0, 16.0, r.key)).collect();
+ s.set_measured_text_unwrapped(&answers);
+ reqs.is_empty()
+ } {}
+ s.run("Btn:SetText('Go')").unwrap();
+ let reqs = s.fontstrings_needing_measure();
+ assert_eq!(
+ reqs.len(),
+ 1,
+ "Button:SetText: the label region must be re-surfaced to the sweep"
+ );
+ assert_eq!(reqs[0].text, "Go");
+ assert!(s.errors().is_empty(), "{:?}", s.errors());
+}
diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs
index 712a0cbb..8cfad1e4 100644
--- a/crates/benilla-ui/src/script/tooltip/mod.rs
+++ b/crates/benilla-ui/src/script/tooltip/mod.rs
@@ -203,6 +203,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
}
}];
model.region_data.insert(left, d);
+ model.touch_measure(left); // an adopted cell can arrive text-in-hand
model.touch_layout(); // a line row entered the layout graph (decision 0740)
}
let left_id = model.region_id(left);
@@ -230,6 +231,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
d.justify.set_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_measure(right); // an adopted cell can arrive text-in-hand
model.touch_layout(); // a line row entered the layout graph (decision 0740)
}
let right_id = model.region_id(right);
@@ -274,6 +276,7 @@ fn write_cell(model: &mut Model, rh: crate::widget::RegionHandle, text: &str, co
// `GameTooltipText:SetTextColor(…)` would repaint every red/green line in the tooltip.
d.font_explicit.color = true;
d.hidden = false;
+ model.touch_measure(rh);
}
/// Hide + blank every line cell and zero the counters. The caller fires `OnTooltipCleared`
@@ -599,6 +602,8 @@ pub(super) fn append_line(
if d.size != pin {
d.size = pin;
model.touch_layout();
+ // The wrap pin is the measure key's wrap-width input.
+ model.touch_measure(lh);
}
}
if let Some((text, color)) = right {
diff --git a/crates/benilla-world/src/world_plugins.rs b/crates/benilla-world/src/world_plugins.rs
index 799a49ba..8ffcc2be 100644
--- a/crates/benilla-world/src/world_plugins.rs
+++ b/crates/benilla-world/src/world_plugins.rs
@@ -243,6 +243,21 @@ impl Plugin for WorldFoundation {
{
app.insert_resource(bevy::time::Time::::from_hz(hz));
}
+ // `WOW_UPLOAD_BUDGET=` — cap the render app's per-frame GPU asset upload (bevy's
+ // `RenderAssetBytesPerFrame`, unlimited by default; only assets implementing `byte_len`
+ // participate, which covers meshes and images — exactly the streaming payload). An
+ // EXPERIMENT lever for the arrival burst: the LBRS arrival trace (2026-08-17) put the
+ // fat frames in drawable/render-schedule WAITS while streamed uploads land — every
+ // suspect system's own CPU was flat, avian's whole stack included — so pacing the
+ // uploads is the candidate fix, and this knob prices it before anything is redesigned.
+ if let Some(mb) = std::env::var("WOW_UPLOAD_BUDGET")
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ {
+ app.insert_resource(bevy::render::render_asset::RenderAssetBytesPerFrame::new(
+ mb * 1024 * 1024,
+ ));
+ }
// Direct draws on EVERY camera — the default, not a knob (1374/1376). Bevy's indirect
// lane is a per-draw indirect encode loop on Metal (no MULTI_DRAW_INDIRECT) plus the GPU
// preprocessing dispatches, and the 1374 bracket priced it ~4.3 cpu_ms at LBRS on 0.18 —