Price the rest of the tooltip path, and name what a raise costs

He said the tooltip lag is not only signs, so the two hover benches now tally
layout derives and the live frame count per window, which is what turned the
sign case from a hypothesis into a number.

What they say. A held hover is clean: 1.7 ms/frame under forty plates, 0.76
in a city, and no per-frame derive. A rebuild costs 6.1 ms and gives up
naming the graph exactly once, always at frame_id — the mint of a frame the
model has not seen, which is the one write that genuinely cannot be named,
since a new node has to join the cached graph.

The frame is not ours. OctoPawn hooks GameTooltip's OnShow and creates an
anonymous frame on every show, purely to own a one-shot OnUpdate delay —
twice over, from ScoringCore.lua:540 and Pdmo.lua:101, which is the ordinary
1.12 way to write C_Timer.After. So every tooltip raise on his interface
mints frames and re-derives, on the reference client too. Live frames rise by
far less than the number created (137 against 480), so Lua reclaims most of
them: a cost per raise, not an unbounded leak.

Not acted on, deliberately. The lever would be letting a brand-new leaf join
the cached graph without a full derive, which is a layout-engine change and
wants its own measurement, not a change tacked onto a bug session.

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 09:55:07 -06:00
co-authored by Claude Opus 5
parent fff48cd75c
commit 96e95a51ec
@@ -1252,6 +1252,23 @@ fn hover_frame(s: &mut UiScript, company: Company, plates: &[PlateFeed], rebuild
/// text (0.1 requests a frame, the same held or rebuilt): neither the patch chain nor the font
/// engine is in this cost, so neither can explain the part of grom's 90-to-31 fps that the VM
/// does not account for. Whatever the rest is, it is app-side, outside this harness.
///
/// **What the derive tally and the frame count add** (both printed per window): a rebuild gives up
/// naming the layout graph exactly **once each**, always at `model.rs`'s `frame_id` — which is the
/// mint of a frame the model has not seen before, and the one write that genuinely cannot be named
/// precisely, since a new node has to join the cached graph. A held hover does not.
///
/// The frame is not ours. `OctoPawn` hangs an `OnShow` on `GameTooltip` and, on every show,
/// creates an anonymous `CreateFrame("Frame")` purely to own a one-shot `OnUpdate` delay — twice,
/// from `Scoring/ScoringCore.lua:540` and `UI/Paperdoll/Pdmo.lua:101`, which is the ordinary 1.12
/// way to write `C_Timer.After`. So **every** tooltip raise on his interface mints frames and
/// therefore re-derives, on the reference client too. The live frame count rises by far less than
/// the number created (137 against 480 here), so Lua reclaims most of them; it is a cost per
/// raise, not an unbounded leak.
///
/// Kept as instrumentation rather than acted on: the lever would be letting a brand-new leaf node
/// join the graph without a full derive, which is a layout-engine change and wants its own
/// measurement.
#[test]
#[ignore = "bench — needs WOW_DATA and the player's AddOns; run explicitly with --release"]
fn a_mouseover_plate_rebuilt_every_frame() {
@@ -1273,6 +1290,7 @@ fn a_mouseover_plate_rebuilt_every_frame() {
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 plates = plate_feeds();
let per = f64::from(FRAMES);
@@ -1327,6 +1345,17 @@ fn a_mouseover_plate_rebuilt_every_frame() {
(after.2 - before.2) as f64 / per,
(after.3 - before.3) / per / 1000.0,
);
for (site, n) in s.take_layout_derive_sites().iter().take(5) {
println!("[hover] {shape} {label} {n:5} derives from {site}");
}
println!(
"[hover] {shape} {label} counts {:?}",
s.tick_profile_counts()
.iter()
.filter(|(l, _)| *l == "frames" || *l == "regions")
.map(|(l, n)| format!("{l} {n:.0}"))
.collect::<Vec<_>>()
);
let errs = s.take_errors();
if !errs.is_empty() {
println!(