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:
@@ -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`)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user