action-bar hover stops re-deriving, and multisampling asks the GPU
- hovering the action bars re-derived the whole layout graph and hovering bags did not: `GameTooltip:SetOwner(owner, "ANCHOR_NONE")` dropped the tooltip's anchors through the conservative whole-graph derive, and with `UberTooltips` at its shipped default every action-bar button routes through that arm while a bag slot anchors `ANCHOR_RIGHT` and never does — the arm now retargets to the empty target set, and derive frames fall from 15.9 % of hovered frames to 0.2 % - `gxMultisample` clamps to what the GPU actually offers: Apple GPUs stop at 4x, so a typed 8 was a wgpu validation panic on frame one — the clamp intersects the supported counts of every format the client multisamples into and steps the request down the way the reference's device-init retry loop does, with a warning that says so - the Multisampling dropdown lands on the Graphics page with the device's own stops, built from the same enumeration that clamps a typed value on the reference's three bindings (`GetMultisampleFormats`, `GetCurrentMultisampleFormat`, `SetMultisampleFormat`) — a fixed Off/2x/4x/8x ladder would put a crash on the menu of every GPU that stops at 4 - capture hardening: a run whose `$WOW_WIN` the display clamped is refused in ~2 s instead of producing a wrong-sized shot to diff, a capture never reads the OS pointer (a cursor resting over the window arms a hover and a tooltip that a cursor an inch to the left does not), and the stall sampler writes into `benilla-config` instead of a hand-built `~/Library/Logs` path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,9 @@ pub(super) struct PointerFeed<'w> {
|
||||
/// cursor must not fight it. Same arm as `hidden` below, for the same reason: the mouse half
|
||||
/// of this pass is not ours this frame.
|
||||
synthetic: Res<'w, super::SyntheticPointer>,
|
||||
/// A capture owns the pointer for its whole run ([`super::CapturePointerPinned`]) — the OS
|
||||
/// cursor belongs to whoever is at the keyboard and must not reach the shot.
|
||||
capture_pinned: Res<'w, super::CapturePointerPinned>,
|
||||
}
|
||||
|
||||
impl PointerFeed<'_> {
|
||||
@@ -94,7 +97,10 @@ pub(super) fn feed_ui_input(
|
||||
// WHOLE mouse half out — including the else-arm below, which is the half that matters: a
|
||||
// `pointer_left_window` between the synthetic press and the synthetic release would disarm
|
||||
// the gesture the probe is in the middle of.
|
||||
let synthetic = pointer.synthetic.0;
|
||||
// The two reasons the OS pointer is not ours this frame: a probe is driving a gesture through
|
||||
// the real pointer path, or this is a capture (where it is never ours). Same treatment — skip
|
||||
// the mouse half whole, touch nothing — so they read as one condition here.
|
||||
let synthetic = pointer.synthetic.0 || pointer.capture_pinned.0;
|
||||
let (hover, click_consumed, payload_held) = (
|
||||
&mut pointer.hover,
|
||||
&mut pointer.click_consumed,
|
||||
|
||||
@@ -78,6 +78,31 @@ pub(crate) struct PointerOverUi(pub(crate) bool);
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct SyntheticPointer(pub(crate) bool);
|
||||
|
||||
/// **A capture never reads the OS pointer** — set for the whole life of a `$WOW_CAPTURE` /
|
||||
/// `$WOW_CAPTURE_UI` run, and never cleared.
|
||||
///
|
||||
/// [`input::feed_ui_input`] treats this exactly like [`SyntheticPointer`]: the mouse half of the
|
||||
/// pass is skipped entirely, touching nothing. The keyboard half is untouched.
|
||||
///
|
||||
/// **Why it is not just `SyntheticPointer`.** They are different statements. That one means "a
|
||||
/// probe is driving a gesture *right now*", and the drag probe clears it when its gesture ends
|
||||
/// (`capture::probes::act`) — so borrowing it would re-expose the real cursor for the rest of the
|
||||
/// run, which is the opposite of the guarantee wanted here.
|
||||
///
|
||||
/// **Why it exists at all.** The mouse feed reads `window.cursor_position()`, so a UI capture's
|
||||
/// pixels depended on where the person at the keyboard had left their mouse: a cursor resting over
|
||||
/// the window arms a hover and a tooltip that a cursor an inch to the left does not. The old
|
||||
/// defence was an assumption written in a comment — *"probes park the cursor outside"* — with
|
||||
/// nothing enforcing it. On 2026-08-26 a `ui-tooltip` A/B came back with an MAE of 5.275 against
|
||||
/// 0.020 for every other UI scenario; that particular anomaly turned out to be a rebuilt-between-
|
||||
/// legs mistake, and three attempts to reproduce a cursor-driven one all failed, because
|
||||
/// `CGWarpMouseCursorPosition` does not synthesise the events winit needs. So the hazard was left
|
||||
/// on the record as **suspected, unproven** — and this closes it by construction instead, which
|
||||
/// costs nothing and does not require ever winning that argument. A capture that cannot read the
|
||||
/// pointer cannot be perturbed by it.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct CapturePointerPinned(pub(crate) bool);
|
||||
|
||||
/// The egui dev overlay's half of the pointer arbitration, written each egui pass by the debug
|
||||
/// panel's `track_pointer_over_ui`. **Defined here, with the arbiter that reads it** (decision
|
||||
/// 1174 finishing 0026): the type has to exist in a build with no dev overlays compiled in, and
|
||||
@@ -297,6 +322,11 @@ impl Plugin for UiScriptPlugin {
|
||||
.init_resource::<UiCostWanted>()
|
||||
.init_resource::<PointerOverUi>()
|
||||
.init_resource::<SyntheticPointer>()
|
||||
// Not `init_resource`: the value IS the answer, read once from the env at build.
|
||||
.insert_resource(CapturePointerPinned(
|
||||
std::env::var_os("WOW_CAPTURE").is_some()
|
||||
|| std::env::var_os("WOW_CAPTURE_UI").is_some(),
|
||||
))
|
||||
.init_resource::<EguiPointerOver>()
|
||||
.init_resource::<InspectMode>()
|
||||
.init_resource::<PlayerUiHover>()
|
||||
|
||||
@@ -1096,6 +1096,91 @@ fn the_world_detail_dropdown_writes_the_cvar_and_the_capsule_follows() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The Graphics page's **Multisampling** row (decision 1631): its stops are enumerated from the
|
||||
/// DEVICE, not a fixed ladder, and picking one writes the latched `gxMultisample`.
|
||||
///
|
||||
/// The device list is seeded Apple-shaped — 1x/2x/4x and no 8x — because that is what this
|
||||
/// project's own machine reports, and because a fixed ladder offering 8x there is precisely the
|
||||
/// crash 1631 exists to prevent. A machine with 8x would show four rows off the same code.
|
||||
#[test]
|
||||
fn the_multisampling_row_offers_exactly_what_the_device_supports() {
|
||||
// No explicit `register_cvars` here: `audio_harness` registers `cvars::REGISTERED` whole, and
|
||||
// the three multisample rows live in it. That is deliberate — if they are ever dropped from
|
||||
// the registry this test fails rather than papering over it with its own registration.
|
||||
let mut s = audio_harness();
|
||||
s.set_multisample_formats(vec![
|
||||
benilla_ui::script::MultisampleFormat {
|
||||
color_bits: 32,
|
||||
depth_bits: 32,
|
||||
samples: 1,
|
||||
},
|
||||
benilla_ui::script::MultisampleFormat {
|
||||
color_bits: 32,
|
||||
depth_bits: 32,
|
||||
samples: 2,
|
||||
},
|
||||
benilla_ui::script::MultisampleFormat {
|
||||
color_bits: 32,
|
||||
depth_bits: 32,
|
||||
samples: 4,
|
||||
},
|
||||
]);
|
||||
let mut s = harness_on(s);
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowGraphics:Click()")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OptionsFrameContainerBodyGraphicsRowMultisampleLabel:GetText()")
|
||||
.unwrap(),
|
||||
"Multisampling",
|
||||
"the reference's own MULTISAMPLE global"
|
||||
);
|
||||
// "1" is the registered default and means NO multisampling — the first stop, shown in the
|
||||
// reference's own MULTISAMPLING_FORMAT_STRING wording.
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyGraphicsRowMultisampleDropdownText:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"32-bit color 32-bit depth 1x multisample"
|
||||
);
|
||||
assert!(
|
||||
s.take_cvar_changes().is_empty(),
|
||||
"reading the table on select must not write it back"
|
||||
);
|
||||
|
||||
// Three stops, because the device offered three. This is the assertion that would fail if the
|
||||
// row ever went back to a hardcoded ladder.
|
||||
s.run("OptionsFrameContainerBodyGraphicsRowMultisampleDropdownButton:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return DropDownList1.numButtons").unwrap(),
|
||||
3.0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return DropDownList1Button3:GetText()")
|
||||
.unwrap(),
|
||||
"32-bit color 32-bit depth 4x multisample"
|
||||
);
|
||||
|
||||
// Picking 4x writes the CVar — live and persisted, even though the picture waits for the next
|
||||
// launch (the 1629 latch; the camera takes its sample count at spawn).
|
||||
s.run("DropDownList1Button3:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("gxMultisample".to_string(), "4".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyGraphicsRowMultisampleDropdownText:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"32-bit color 32-bit depth 4x multisample"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The Nameplates page (0992 — live at last): the three 1.12 UnitName* checkbox rows read the
|
||||
/// table on select and write their flags on the interface panel's own click kit (checked →
|
||||
/// CheckBoxOn; these rows carry no soundQuirk).
|
||||
@@ -1925,10 +2010,10 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
// 26 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed Loot
|
||||
// 27 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed Loot
|
||||
// Information + Guild Member Alert are 1589's; Status Bar Text, Mouse Sensitivity
|
||||
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's and its Windowed Mode
|
||||
// 1627's; Camera Following Style
|
||||
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's, its Windowed Mode
|
||||
// 1627's and its Multisampling 1632's; Camera Following Style
|
||||
// 1493's; Terrain Distance 1513's) + the Combat page's 14 saved-variable rows (1134) + the Interface page's 6 (3 from
|
||||
// 1136, Buff Durations 1139, the target-of-target pair 1576), the Action Bars page's 2 (the
|
||||
// lock 1136, Always Show
|
||||
@@ -1938,7 +2023,7 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
|
||||
// OPTION_TOOLTIP_CAMERA1) and Show When on its own (Always's OPTION_TOOLTIP_TARGETOFTARGET5);
|
||||
// their other entries ride the same census as the selection moves.
|
||||
assert_eq!(checked, 54, "every tipped row carries a live 1.12 key");
|
||||
assert_eq!(checked, 55, "every tipped row carries a live 1.12 key");
|
||||
assert_eq!(
|
||||
untipped,
|
||||
vec![
|
||||
@@ -2034,15 +2119,16 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
|
||||
s.errors()
|
||||
);
|
||||
}
|
||||
// 26 of the 27 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed
|
||||
// 27 of the 28 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed
|
||||
// Loot Information + Guild Member Alert 1589's; Status Bar Text, Mouse Sensitivity and
|
||||
// Max Camera Distance 1140's; Vertical Sync 1394's and Windowed Mode 1627's; Camera Following
|
||||
// Max Camera Distance 1140's; Vertical Sync 1394's, Windowed Mode 1627's and Multisampling
|
||||
// 1632's; Camera Following
|
||||
// Style 1493's; Terrain Distance 1513's), plus the Combat page's 14 saved-variable rows (1134), the Interface
|
||||
// page's 6 (1136, + Buff Durations 1139, + the target-of-target pair 1576), Action Bars' 2
|
||||
// (the lock 1136, Always Show
|
||||
// ActionBars 1500), the Chat page's 1 (Remove Chat Hover Delay, 1589) and 6 API rows (Show
|
||||
// Cloak / Show Helm, 1472; the four multibar switches, 1500).
|
||||
assert_eq!(raised, 54, "every row but Auto Loot has a 1.12 description");
|
||||
assert_eq!(raised, 55, "every row but Auto Loot has a 1.12 description");
|
||||
}
|
||||
|
||||
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
|
||||
|
||||
@@ -643,3 +643,73 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
|
||||
roster on every slot the cursor passes over (decisions 1388, 1625)."
|
||||
);
|
||||
}
|
||||
|
||||
/// **The action-bar hover law** (decision 1630, ledger B06): sweeping the cursor across ACTION BAR
|
||||
/// buttons must cost **zero** derivations of the layout graph.
|
||||
///
|
||||
/// The two guards above drive `SetOwner(button, "ANCHOR_RIGHT")` — the bag slot's idiom. The bars
|
||||
/// do not use it. With `UberTooltips` at its shipped default of `"1"`, every action button routes
|
||||
/// through `GameTooltip_SetDefaultAnchor` (`ActionBar.xml:510`, and the stance/pet/bonus bars the
|
||||
/// same), which is `SetOwner(owner, "ANCHOR_NONE")` followed by an explicit `SetPoint` — a
|
||||
/// completely different arm of the same verb, and the one that DROPS the tooltip's anchors.
|
||||
///
|
||||
/// That drop took the conservative touch, so it re-derived the whole graph on every button the
|
||||
/// cursor crossed — and on nothing else, which is why it survived two rounds of fixing and a
|
||||
/// live probe: the director's own recording is what named it, every derive frame owned by a
|
||||
/// `MultiBarBottomLeftButton*` or `BonusActionButton*`. The lesson worth keeping is in the guards
|
||||
/// as much as the fix: a hover guard that only drives one of two anchor idioms is testing the
|
||||
/// idiom, not the gesture.
|
||||
#[test]
|
||||
fn an_action_bar_hover_sweep_costs_no_graph_derivation() {
|
||||
let mut s = settled_default_ui();
|
||||
s.run(
|
||||
r#"
|
||||
for i = 1, 12 do
|
||||
local b = CreateFrame("Button", "BarOwner" .. i)
|
||||
b:SetPoint("CENTER", 0, 0); b:SetSize(36, 36)
|
||||
end
|
||||
bar_n = 0
|
||||
-- `GameTooltip_SetDefaultAnchor`'s body, which is what every action button actually runs:
|
||||
-- own the tooltip WITHOUT an anchor, then point it by hand.
|
||||
function bar_hover()
|
||||
bar_n = bar_n + 1
|
||||
local owner = getglobal("BarOwner" .. (math.mod(bar_n, 12) + 1))
|
||||
GameTooltip:SetOwner(owner, "ANCHOR_NONE")
|
||||
GameTooltip:ClearAllPoints()
|
||||
GameTooltip:SetPoint("BOTTOMRIGHT", "UIParent", "BOTTOMRIGHT", -70, 80)
|
||||
GameTooltip:AddLine("Spell " .. bar_n, 1, 1, 1)
|
||||
if math.mod(bar_n, 2) == 0 then
|
||||
GameTooltip:AddLine("Blasts the enemy for 20 damage.", 1, 1, 1, 1)
|
||||
else
|
||||
GameTooltip:AddLine("Instant", 1, 1, 1)
|
||||
end
|
||||
GameTooltip:Show()
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Positive control across the births and the line pool's growth — both structural.
|
||||
let born_at = s.layout_derivations();
|
||||
for _ in 0..40 {
|
||||
s.run("bar_hover()").unwrap();
|
||||
app_frame(&mut s);
|
||||
}
|
||||
assert!(
|
||||
s.layout_derivations() > born_at,
|
||||
"creating twelve buttons must derive the graph — zero here makes the assertion vacuous."
|
||||
);
|
||||
|
||||
let derives_before = s.layout_derivations();
|
||||
for _ in 0..24 {
|
||||
s.run("bar_hover()").unwrap();
|
||||
app_frame(&mut s);
|
||||
}
|
||||
let derives = s.layout_derivations() - derives_before;
|
||||
assert_eq!(
|
||||
derives, 0,
|
||||
"24 action-bar hovers derived the layout graph {derives} times. `SetOwner`'s ANCHOR_NONE \
|
||||
arm drops the tooltip's anchors, which is a retarget to the EMPTY target set and names \
|
||||
its node like any other (decision 1630, extending 1625)."
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user