From 0c698b8797a0fe37b95d97b1c0a52946b046cf97 Mon Sep 17 00:00:00 2001 From: grom Date: Thu, 29 Oct 2026 07:59:13 -0600 Subject: [PATCH] Hovering a sign re-derived the whole layout graph, every frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grom reports a frame drop on hovering signs, and asked for that one first. A signpost is the only GameObject whose plate follows the cursor — GO_TYPE_GENERIC with floating_tooltip, the `cursor_seated` leg of drive_mouseover_tooltip — so every frame the hand on the mouse moves, GameTooltip is re-anchored. That re-anchor called the whole-graph Model::touch_layout for a write that moves two offsets and nothing else. Measured on his own interface, not guessed at: a new bench window prices a held plate against a drifting one against no plate at all. A drifting pointer cost +8.6 ms a frame over the baseline; a held one cost +0.0, so the plate is not the cost, the re-anchor is. It is now +0.2 ms. tooltip_unit::seat_on_cursor is the shared seat for both arms (the raise in world_tooltip_gameobject and the follow in world_tooltip_move). An offset-only move names the frame — touch_layout_frame, exactly as SetPoint's own write_anchor does for a dragged window — and only a roster or target change gives up naming. The bench asserts the ratio rather than the absolute, so a loaded machine scales every window together, and reads GetLeft at the end: a "fix" that stopped the plate moving would price identically, and this test would otherwise applaud it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- .../src/addon_harness/tick_bench_tests.rs | 153 ++++++++++++++++++ crates/benilla-ui/src/script/tooltip_unit.rs | 63 ++++++-- 2 files changed, 199 insertions(+), 17 deletions(-) diff --git a/crates/benilla-app/src/addon_harness/tick_bench_tests.rs b/crates/benilla-app/src/addon_harness/tick_bench_tests.rs index bf24bfca..8080c53e 100644 --- a/crates/benilla-app/src/addon_harness/tick_bench_tests.rs +++ b/crates/benilla-app/src/addon_harness/tick_bench_tests.rs @@ -1342,3 +1342,156 @@ fn a_mouseover_plate_rebuilt_every_frame() { ); } } + +/// One frame with a cursor-seated GameObject plate up — a signpost — at `cursor`. +/// +/// This is the live shape of `ui_tooltip::drive_mouseover_tooltip`'s GENERIC arm: the plate is +/// built once and then only re-seated, every frame, by `world_tooltip_move`. No unit mouseover is +/// fed, because a sign is picked when nothing else was. +fn sign_frame(s: &mut UiScript, company: Company, cursor: (f32, f32)) { + feed_units(s, company); + s.set_unit("mouseover", None); + s.sync_nameplates(GEOMETRY, &[]); + s.world_tooltip_move(cursor.0, cursor.1); + s.tick(1.0 / 60.0); + // The measures are answered as the live host answers them, or the plate stays zero-sized and + // has no rect at all — which is a plate this bench would price far too cheaply. + answer_measures(s); + s.resolve(); + let _ = s.extract(); +} + +/// What a signpost's plate costs while the pointer sits on it, still versus drifting. +/// +/// grom reports a frame drop on hovering signs specifically, and a sign is the one GameObject kind +/// whose plate follows the cursor: `GO_TYPE_GENERIC` with `floating_tooltip` set is the only arm of +/// the driver that calls [`UiScript::world_tooltip_move`] every frame +/// (`ui_tooltip::drive_mouseover_tooltip`, the `cursor_seated` leg). Every other hover — a unit, a +/// chest, a corpse — seats its plate once and returns. +/// +/// Three windows, so the cost can be attributed rather than guessed at: +/// * **no plate** — the same interface with nothing hovered, the baseline. +/// * **still** — the plate up and the pointer not moving. `world_tooltip_move` compares before it +/// touches, so this window should cost the baseline; if it does not, the compare is broken. +/// * **drifting** — the pointer moving a pixel a frame, which is what a hand on a mouse does. +/// This is the window that prices the re-anchor, and the one that has to explain his report. +/// +/// **It did.** Measured on grom's interface the drifting window cost **+8.6 ms a frame** over the +/// baseline while the still window cost +0.0: the re-anchor itself, not the plate. The cause was +/// `world_tooltip_move` reaching for the whole-graph `Model::touch_layout` on a move that changes +/// only two offsets; `tooltip_unit::seat_on_cursor` now names the frame, and the same window costs +/// +0.2 ms. Both numbers are from this bench with the fix reverted and applied. +/// +/// The ratio is asserted rather than the absolute, so a loaded machine scales every window +/// together; and `GetLeft` is read at the end, because a "fix" that stopped the plate moving would +/// price identically and this test would otherwise applaud it. +#[test] +fn a_cursor_seated_sign_plate_prices_its_own_re_anchor() { + let data = benilla_formats::wow_data_or_skip!(); + let Some(root) = crate::ui_script::addons::root() else { + eprintln!("sign bench: no benilla-config/AddOns — nothing to measure"); + return; + }; + let mut s = load_the_players_ui(&root); + static PROBES: std::sync::Mutex = std::sync::Mutex::new(ProbeTally { + texture_calls: 0, + texture_us: 0.0, + size_calls: 0, + size_us: 0.0, + font_calls: 0, + font_us: 0.0, + }); + install_live_asset_probes(&mut s, &data, &root, &PROBES); + let _ = s.take_layout_derive_sites(); // the first take is what arms the tally + let per = f64::from(FRAMES); + let at = (400.0, 300.0); + + for (shape, company) in [("city ", Company::Solo), ("pull ", Company::Grouped)] { + let (mut baseline, mut drifting) = (0.0, 0.0); + for (label, plate, drift) in [ + ("no plate", false, false), + ("still ", true, false), + ("drifting", true, true), + ] { + // Raise or drop the plate outside the timing: this prices holding one, not raising it. + if plate { + s.world_tooltip_gameobject("Stormwind", None, &[], Some(at)); + } else { + s.world_tooltip_fade(); + } + for _ in 0..30 { + sign_frame(&mut s, company, at); + } + let _ = s.take_errors(); + let before = { + let p = PROBES.lock().unwrap(); + (p.texture_calls, p.size_calls, p.font_calls) + }; + let t = Instant::now(); + for frame in 0..FRAMES { + let c = if drift { + #[allow(clippy::cast_precision_loss)] + let d = (frame % 40) as f32; + (at.0 + d, at.1 + d) + } else { + at + }; + sign_frame(&mut s, company, c); + } + let out = ms(t) / per; + if !plate { + baseline = out; + } + if drift { + drifting = out; + } + let after = { + let p = PROBES.lock().unwrap(); + (p.texture_calls, p.size_calls, p.font_calls) + }; + #[allow(clippy::cast_precision_loss)] + let probes = ( + (after.0 - before.0) as f64 / per, + (after.1 - before.1) as f64 / per, + (after.2 - before.2) as f64 / per, + ); + println!( + "[sign] {shape} {label}: {out:.3} ms/frame ({:+.3} vs no plate) — probes \ + {:.1}+{:.1}+{:.1} calls/f", + out - baseline, + probes.0, + probes.1, + probes.2, + ); + for (site, n) in s.take_layout_derive_sites().iter().take(4) { + println!("[sign] {shape} {label} {n:5} derives from {site}"); + } + let errs = s.take_errors(); + if !errs.is_empty() { + println!( + "[sign] {shape} {label} {} error(s); first: {}", + errs.len(), + errs[0] + ); + } + } + // The plate still followed the pointer: the last drifting frame put it 39 px along, and a + // `GetLeft` is the read that has to see it. Without this, a re-anchor that quietly stopped + // working would read as the fastest window of the three. + let left = s + .eval::("return GameTooltip:GetLeft()") + .expect("GameTooltip:GetLeft()"); + assert!( + left > f64::from(at.0), + "[sign] {shape}: the plate did not follow the pointer — GetLeft {left} is not past \ + the seat {}", + at.0, + ); + assert!( + drifting <= baseline * 2.0 + 1.0, + "[sign] {shape}: a drifting pointer costs {drifting:.3} ms/frame against a \ + {baseline:.3} ms baseline — the cursor re-anchor is deriving the whole layout graph \ + again (it did, at +8.9 ms/frame, until `tooltip_unit::seat_on_cursor`)", + ); + } +} diff --git a/crates/benilla-ui/src/script/tooltip_unit.rs b/crates/benilla-ui/src/script/tooltip_unit.rs index fbbb1e76..aa745359 100644 --- a/crates/benilla-ui/src/script/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tooltip_unit.rs @@ -403,14 +403,7 @@ impl super::UiScript { if let Ok(t) = tip_mut(&mut model, h) { t.owner = Some(root); } - let input = model.layout_inputs.entry(h).or_default(); - let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y); - let same = input.anchors.len() == 1 - && super::object::anchor_bits_eq(&input.anchors[0], &new); - if !same { - input.anchors = vec![new]; - model.touch_layout(); - } + seat_on_cursor(&mut model, h, root_id, ui_x, ui_y); } // The corner arm: the default-anchor handler the unit flow fires too. None => { @@ -560,15 +553,9 @@ impl super::UiScript { return; } let root_id = model.frame_id(root); - let input = model.layout_inputs.entry(h).or_default(); - let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y); - // Compare-then-touch: this runs on every pointer event. - let same = - input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new); - if !same { - input.anchors = vec![new]; - model.touch_layout(); - } + // Compare-then-touch, and when it does touch, name the frame: this runs on every pointer + // event, which is what made it the hover path's whole cost. See [`seat_on_cursor`]. + seat_on_cursor(&mut model, h, root_id, ui_x, ui_y); } /// Whether `GameTooltip` is still the shown plate a world hover put up, with no Lua @@ -603,6 +590,48 @@ impl super::UiScript { } } +/// Re-seat a plate on the pointer: one anchor, `Bottom` to `UIParent`'s `BottomLeft`. +/// +/// An **offset-only** move takes [`Model::touch_layout_frame`], never the whole-graph +/// [`Model::touch_layout`]. The target, both points and the roster are all unchanged, which is the +/// exact case `SetPoint`'s own `write_anchor` calls value-only — a dragged window moves this way +/// every frame and has always been cheap; this path was the one that re-derived. +/// +/// What that cost: a signpost is the one GameObject whose plate follows the cursor +/// (`GO_TYPE_GENERIC` with `floating_tooltip`, `ui_tooltip::drive_mouseover_tooltip`), so hovering +/// one re-anchors `GameTooltip` every frame the hand on the mouse moves. Measured on grom's +/// interface (`addon_harness::tick_bench_tests::a_cursor_seated_sign_plate_prices_its_own_re_anchor`) +/// the full derive cost **+8.6 ms a frame** over the same interface with no plate up, against a +/// held-still plate's +0.0 — which is his "frame drops on hovering signs", and nothing else in the +/// hover path showed a cost at all. +/// +/// Returns without touching anything when the pointer has not moved, as before. +fn seat_on_cursor(model: &mut Model, h: FrameHandle, root_id: u32, ui_x: f32, ui_y: f32) { + let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y); + let value_only = { + let input = model.layout_inputs.entry(h).or_default(); + let verdict = match &input.anchors[..] { + // A still pointer never re-layouts. + [only] if super::object::anchor_bits_eq(only, &new) => return, + [only] => { + only.point == new.point + && only.relative_to == new.relative_to + && only.relative_point == new.relative_point + } + // Seated by another arm, or not seated at all: the roster changes, so the graph has + // to be derived in full. This is the raise, which happens once per hover. + _ => false, + }; + input.anchors = vec![new]; + verdict + }; + if value_only { + model.touch_layout_frame(h); + } else { + model.touch_layout(); + } +} + /// Register the unit content channel into the GameTooltip kind method table. pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // GameTooltip:SetUnit(token) → 1 or nil, which `UnitFrame_OnEnter` reads to arm its re-poll.