the interface labels announce their writes, and the idle tick goes quiet

- a text write names its own label, so the per-frame measure sweep drains a short ledger instead of hashing every font string twice — ~0.3 ms a frame at the Stormwind gates, gone
- the idle interface tick stops re-writing settled state: buff durations and the weapon-enchant frame write only on change, and the fade and unit-popup drivers park until woken
- the interface extract skips a hidden region before cloning its row, not after
- WOW_UPLOAD_BUDGET=<MB> brackets the arrival burst's upload pacing — a lever for measuring world entry, not a fix yet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-20 18:48:12 +07:00
co-authored by Claude Fable 5
parent b932302d79
commit 19e861bb96
25 changed files with 923 additions and 77 deletions
+125 -46
View File
@@ -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)
+8 -2
View File
@@ -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
+9 -2
View File
@@ -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 @@
]]></Script>
<!-- The fade kit's tick source — the ref hangs UIFrameFadeUpdate on UIParent's OnUpdate
(UIParent.xml l.16-22); benilla has no UIParent frame, so a dedicated 1x1 driver ticks it. -->
(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). -->
<Frame name="BenillaFadeDriver">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts><OnUpdate>UIFrameFadeUpdate(arg1)</OnUpdate></Scripts>
<Scripts><OnUpdate>if not FADEFRAMES[1] then this:Hide() return end UIFrameFadeUpdate(arg1)</OnUpdate></Scripts>
</Frame>
<!--
+12 -1
View File
@@ -442,6 +442,10 @@
end
function UnitPopup_ShowMenu(dropdownMenu, which, unit, name, userData)
-- Wake the enable-pass driver: it hides itself the moment no unit menu of ours is
-- open (UnitPopup_OnUpdate's early-out), and this function is the one opener of
-- every unit menu — the six dropdowns' initialize functions all land here.
BenillaUnitPopupDriver:Show();
dropdownMenu.which = which;
dropdownMenu.unit = unit;
if ( unit and not name ) then
@@ -624,6 +628,10 @@
-- BenillaUnitPopupDriver below — the ref hooks it off UIParent's OnUpdate).
function UnitPopup_OnUpdate(elapsed)
if ( not DropDownList1:IsVisible() or not ownsOpenMenu() ) then
-- Nothing of ours to scan: put the driver back to sleep until the next
-- UnitPopup_ShowMenu (visible frames are the only ones ticked — tick.rs — so
-- this stops the per-frame pass entirely; decision 1396's class).
BenillaUnitPopupDriver:Hide();
return;
end
@@ -803,7 +811,10 @@
]]></Script>
<!-- The OnUpdate driver (ref UIParent.xml l.21 runs UnitPopup_OnUpdate from UIParent's own
OnUpdate; ours has none — the DeathFrame hidden-driver pattern instead). -->
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. -->
<Frame name="BenillaUnitPopupDriver" parent="UIParent" hidden="false">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
@@ -139,6 +139,7 @@ fn drive_live_fps(
mut key_events: MessageWriter<bevy::input::keyboard::KeyboardInput>,
mut exit: MessageWriter<AppExit>,
mut occlusions: MessageReader<bevy::window::WindowOccluded>,
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),
@@ -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::<f64>("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::<f64>("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::<i64>("return TempEnchant1:GetID()").unwrap(),
17,
"off hand first: slot 1 is the OffHandSlot id"
);
assert_eq!(s.eval::<i64>("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::<f64>("return TemporaryEnchantFrame:GetWidth()")
.unwrap(),
64.0,
"two 32px columns"
);
let both = s.eval::<f64>("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::<f64>("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.
///
@@ -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::<bool>("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::<bool>("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::<bool>("return ChatFrame1BottomButtonFlash:IsVisible()")
.unwrap(),
"returning to the bottom hides a lit flash"
);
assert_eq!(
s.eval::<f64>("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());
}
@@ -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::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"an empty FADEFRAMES parks the driver"
);
assert_eq!(
s.eval::<i64>("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::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap());
s.run("UIFrameFadeIn(BenillaFadeProbe, 1.0)").unwrap();
assert!(
s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"starting a fade wakes the driver"
);
assert_eq!(
s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap(),
0.0,
"the fade armed at its IN startAlpha"
);
s.tick(0.5);
s.resolve();
let mid = s.eval::<f64>("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::<f64>("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::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"the driver re-parks once the list is empty"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -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::<bool>("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::<i64>("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::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the menu opened"
);
assert!(
s.eval::<bool>("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::<i64>("return BENILLA_TEST_POPUP_TICKS").unwrap() > 0,
"an open menu ticks the enable pass"
);
assert!(
!s.eval::<bool>("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::<bool>("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::<bool>("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}"));
+2
View File
@@ -121,6 +121,7 @@ fn link_label_to_font_object(lua: &Lua, text: Option<RegionHandle>, 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::<Model>().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(())
})?,
)?;
+1
View File
@@ -365,6 +365,7 @@ fn sync_text_region(lua: &Lua, h: FrameHandle) {
if let Some(display) = display {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.region_data.entry(rh).or_default().text = Some(display);
model.touch_measure(rh);
}
}
+8 -3
View File
@@ -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.
+4
View File
@@ -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.
@@ -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 })
},
)?,
+62 -12
View File
@@ -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<RegionHandle> = 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<RegionHandle> = 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<RegionHandle> = 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) {
+2
View File
@@ -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
+39
View File
@@ -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<Vec<u32>>,
/// 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<Vec<crate::widget::RegionHandle>>,
/// `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.
@@ -286,8 +286,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let mut model = lua.app_data_mut::<Model>().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(())
})?,
+1
View File
@@ -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(())
}
+12 -3
View File
@@ -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(())
})?,
+7 -6
View File
@@ -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::<Model>()
.expect("model")
.region_data
.entry(rh)
.or_default()
.text_height = Some(height);
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().text_height = Some(height);
model.touch_measure(rh);
Ok(())
})?,
)?;
@@ -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);
@@ -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());
}
@@ -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 {
+15
View File
@@ -243,6 +243,21 @@ impl Plugin for WorldFoundation {
{
app.insert_resource(bevy::time::Time::<bevy::time::Fixed>::from_hz(hz));
}
// `WOW_UPLOAD_BUDGET=<MB>` — 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::<usize>().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 —