Hovering a sign re-derived the whole layout graph, every frame

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-29 08:06:48 -06:00
co-authored by Claude Opus 5
parent 17ee517602
commit 0c698b8797
2 changed files with 199 additions and 17 deletions
@@ -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<ProbeTally> = 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::<f64>("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`)",
);
}
}
+46 -17
View File
@@ -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.