From 19e861bb96a35b680e51bb055b282dbc6abb74ae Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 20 Aug 2026 18:48:12 +0700 Subject: [PATCH] the interface labels announce their writes, and the idle tick goes quiet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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= brackets the arrival burst's upload pacing — a lever for measuring world entry, not a fix yet Co-Authored-By: Claude Fable 5 --- crates/benilla-app/assets/ui/BuffFrame.xml | 171 ++++++++++++----- crates/benilla-app/assets/ui/ChatFrame.xml | 10 +- crates/benilla-app/assets/ui/UiPanels.xml | 11 +- crates/benilla-app/assets/ui/UnitPopup.xml | 13 +- .../src/capture/probes/live_fps.rs | 42 +++- .../benilla-app/src/ui_script/buff_tests.rs | 180 ++++++++++++++++++ .../benilla-app/src/ui_script/chat_tests.rs | 66 +++++++ .../benilla-app/src/ui_script/panel_tests.rs | 96 ++++++++++ .../src/ui_script/unit_popup_tests.rs | 140 ++++++++++++++ crates/benilla-ui/src/script/button.rs | 2 + crates/benilla-ui/src/script/editbox.rs | 1 + crates/benilla-ui/src/script/extract.rs | 11 +- crates/benilla-ui/src/script/font.rs | 4 + crates/benilla-ui/src/script/font_block.rs | 2 + crates/benilla-ui/src/script/layout.rs | 74 +++++-- crates/benilla-ui/src/script/mod.rs | 2 + crates/benilla-ui/src/script/model.rs | 39 ++++ .../src/script/object/frame_state.rs | 6 +- crates/benilla-ui/src/script/region.rs | 1 + crates/benilla-ui/src/script/region/layout.rs | 15 +- crates/benilla-ui/src/script/region/text.rs | 13 +- .../benilla-ui/src/script/simplehtml/mod.rs | 1 + crates/benilla-ui/src/script/tests/measure.rs | 80 ++++++++ crates/benilla-ui/src/script/tooltip/mod.rs | 5 + crates/benilla-world/src/world_plugins.rs | 15 ++ 25 files changed, 923 insertions(+), 77 deletions(-) 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 —