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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -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}"));
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
)?,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user