A unit that walks is still the same unit, so its plate stays up
Mousing over a patrolling NPC cost grom about sixty frames a second. The hover driver rebuilds a unit plate when its subject changes, and `lines_view` — the key that decides that — carried where the unit stands, twice: `UnitState:: position`, which is the interpolated `Transform`, and `UnitState::fields`, whose raw `posX`/`posY` columns are the same thing again. `sample_splines` rewrites that transform every frame a creature is on a move path, so the key changed every frame and the plate was torn down and built again at the frame rate. A rebuild is a deliberate hide/show pair plus `clear_content` and a full line walk — `tooltip_unit.rs` says so in as many words, "a hide/show pair per new subject, not per frame" — so every tooltip hook in the interface ran again: pfUI's skin, pfQuest's annotation, both `OnShow` GUID readers. The bench added here prices it on the player's own 94-addon interface at 6.63 ms/frame under forty churning nameplates and 6.78 ms/frame with none up, against whole-frame baselines of 1.84 ms and 0.85 ms. The guess was that the two shapes would differ, because a pull re-derives the layout graph anyway; they do not, so the cost is the plate's own line walk and the hooks on it, flat. The live asset probes are installed and cost 0.001 ms/frame, and the plate re-measures no text, so neither the patch chain nor the font engine is in it — the rest of grom's 90-to-31 fps is app-side, outside this harness. His video says the same thing from the other end: the *same* Ironforge Guard read 89.7 fps standing at a waypoint and 31.3 walking, Katrina Shimmerstar and Hemming Silverbeard never moved and never cost anything, and Myra Tyrngaarde walked across frame with the cursor tracking her, 50.7 to 39.4 to 35.7. A mob you are fighting stands in melee, which is why those never did it either. `lines_view` now also drops the whereabouts, the raw columns and the four power slots, leaving only what can draw a line. A late name or creature answer still rebuilds the plate, which is the whole reason the key is a snapshot and not the GUID; both halves have a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -1188,3 +1188,157 @@ fn a_resolved_item_load_leaves_no_poll_running_on_the_players_whole_interface()
|
||||
{timers_us:.1} us/tick"
|
||||
);
|
||||
}
|
||||
|
||||
/// The unit grom's video holds the pointer on: an Ironforge Guard, whose plate is the four lines
|
||||
/// his tooltip shows (name, level, faction, PvP). The line count is the rebuild's payload, so a
|
||||
/// bare `live()` unit would price a plate nobody hovers.
|
||||
fn the_hovered_guard() -> UnitState {
|
||||
UnitState {
|
||||
exists: true,
|
||||
has_object: true,
|
||||
name: Some("Ironforge Guard".to_string()),
|
||||
health: 5130,
|
||||
max_health: 5130,
|
||||
level: 55,
|
||||
guid: 0x3002,
|
||||
pvp: true,
|
||||
reaction: 5,
|
||||
faction_name: Some("Ironforge".to_string()),
|
||||
creature_type_name: Some("Humanoid".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of the player's interface with the pointer held on a unit, optionally rebuilding its
|
||||
/// plate as the hover driver does when it decides the subject changed
|
||||
/// (`ui_tooltip::drive_mouseover_tooltip`).
|
||||
fn hover_frame(s: &mut UiScript, company: Company, plates: &[PlateFeed], rebuild: bool) -> usize {
|
||||
feed_units(s, company);
|
||||
// Held whatever the company: a solo player standing in a city still has a mouseover.
|
||||
s.set_unit("mouseover", Some(the_hovered_guard()));
|
||||
s.sync_nameplates(GEOMETRY, plates);
|
||||
if rebuild {
|
||||
s.clear_mouseover_override();
|
||||
s.world_tooltip_unit("mouseover");
|
||||
}
|
||||
s.tick(1.0 / 60.0);
|
||||
// The count is the answer to "does a rebuild re-measure its lines" — the one part of the
|
||||
// live cost this harness's 6-units-a-character stand-in cannot price itself.
|
||||
let measured = answer_measures(s);
|
||||
s.resolve();
|
||||
let _ = s.extract();
|
||||
measured
|
||||
}
|
||||
|
||||
/// What it costs to hold a mouseover plate over a unit, built once, versus rebuilt every frame.
|
||||
///
|
||||
/// The rebuild is a hide/show pair plus `clear_content` and a full line walk, so every tooltip
|
||||
/// hook in the interface — pfUI's skin, pfQuest's objective annotation, the two `OnShow` GUID
|
||||
/// readers — runs again. The driver is written to do it once per subject (`tooltip_unit.rs:311`:
|
||||
/// "a hide/show pair per new subject, not per frame"), and the whole question this prices is what
|
||||
/// happens when that gate stops holding, which is what `ui_tooltip`'s `lines_view` carrying
|
||||
/// the unit's position did to every NPC on a patrol.
|
||||
///
|
||||
/// Two shapes, because the guess was that they would differ: a crowded pull re-derives the layout
|
||||
/// graph every frame anyway (four plates a frame are strangers), so a rebuild's own dirtying
|
||||
/// looked like it would be partly already paid, where a player standing in a city has a settled
|
||||
/// graph and would pay for the whole resolve. **They do not differ.** Measured on grom's
|
||||
/// interface the rebuild costs 6.63 ms/frame under forty churning plates and 6.78 ms/frame with
|
||||
/// none up, against whole-frame baselines of 1.84 ms and 0.85 ms — so the cost is the plate's own
|
||||
/// line walk and the hooks hanging off it, flat, and not the graph it dirties. Both windows are
|
||||
/// kept because that is the finding.
|
||||
///
|
||||
/// The live asset probes are installed and report 0.001 ms/frame, and the plate re-measures no
|
||||
/// 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.
|
||||
#[test]
|
||||
#[ignore = "bench — needs WOW_DATA and the player's AddOns; run explicitly with --release"]
|
||||
fn a_mouseover_plate_rebuilt_every_frame() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let Some(root) = crate::ui_script::addons::root() else {
|
||||
eprintln!("mouseover bench: no benilla-config/AddOns — nothing to measure");
|
||||
return;
|
||||
};
|
||||
let mut s = load_the_players_ui(&root);
|
||||
// The three host asset probes the live client installs, over the real patch chain: a rebuild
|
||||
// re-sets every line's font and the plate's backdrop, and without them those verbs are free,
|
||||
// which is the one shape the live client never runs.
|
||||
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 plates = plate_feeds();
|
||||
let per = f64::from(FRAMES);
|
||||
|
||||
for (shape, company, feed) in [
|
||||
("pull (40 plates)", Company::Grouped, &plates[..]),
|
||||
("city (no plates)", Company::Solo, &[][..]),
|
||||
] {
|
||||
let (mut held, mut churning) = (0.0, 0.0);
|
||||
for (label, rebuild, out) in [
|
||||
("held ", false, &mut held),
|
||||
("rebuilt", true, &mut churning),
|
||||
] {
|
||||
// Settle into this shape, and raise the plate outside the timing so the held window
|
||||
// measures a hover that is *held*, not one that was never raised.
|
||||
for _ in 0..30 {
|
||||
hover_frame(&mut s, company, feed, rebuild);
|
||||
}
|
||||
s.world_tooltip_unit("mouseover");
|
||||
let _ = s.take_errors();
|
||||
let before = {
|
||||
let p = PROBES.lock().unwrap();
|
||||
(
|
||||
p.texture_calls,
|
||||
p.size_calls,
|
||||
p.font_calls,
|
||||
p.texture_us + p.size_us + p.font_us,
|
||||
)
|
||||
};
|
||||
let mut measures = 0usize;
|
||||
let t = Instant::now();
|
||||
for _ in 0..FRAMES {
|
||||
measures += hover_frame(&mut s, company, feed, rebuild);
|
||||
}
|
||||
*out = ms(t) / per;
|
||||
let after = {
|
||||
let p = PROBES.lock().unwrap();
|
||||
(
|
||||
p.texture_calls,
|
||||
p.size_calls,
|
||||
p.font_calls,
|
||||
p.texture_us + p.size_us + p.font_us,
|
||||
)
|
||||
};
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let measures_per = measures as f64 / per;
|
||||
println!(
|
||||
"[hover] {shape} {label}: {:.3} ms/frame — probes {:.1}+{:.1}+{:.1} calls/f \
|
||||
{:.3} ms/f · {measures_per:.1} text measures/f",
|
||||
*out,
|
||||
(after.0 - before.0) as f64 / per,
|
||||
(after.1 - before.1) as f64 / per,
|
||||
(after.2 - before.2) as f64 / per,
|
||||
(after.3 - before.3) / per / 1000.0,
|
||||
);
|
||||
let errs = s.take_errors();
|
||||
if !errs.is_empty() {
|
||||
println!(
|
||||
"[hover] {shape} {label} {} error(s); first: {}",
|
||||
errs.len(),
|
||||
errs[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"[hover] {shape} the rebuild costs {:.3} ms/frame",
|
||||
churning - held
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,12 +586,39 @@ fn note_corpse_miss(last: &mut Option<(u64, &'static str)>, guid: u64, why: &'st
|
||||
info!("corpse plate: {guid:#x} draws none — {why}");
|
||||
}
|
||||
|
||||
/// The part of a snapshot a unit plate's **lines** are drawn from: everything but the live
|
||||
/// quantities, which the bar watcher drives, and everything but the unit's whereabouts.
|
||||
///
|
||||
/// This is the rebuild key, so every field left in it is a reason to tear the plate down and build
|
||||
/// it again — and a rebuild is a hide/show pair through every tooltip hook in the interface
|
||||
/// (`tooltip_unit.rs:311`). So a field that churns while the subject has not changed turns
|
||||
/// "once per hover" into "every frame", which is what mousing over a *walking* NPC did:
|
||||
/// [`UnitState::position`] is its interpolated `Transform`, and [`UnitState::fields`] carries the
|
||||
/// same thing again as the raw `posX`/`posY` columns `GetUnitField` answers from. Neither draws a
|
||||
/// line, and both move every frame a guard is on patrol, so the plate was rebuilt at the frame
|
||||
/// rate for as long as the pointer stayed on one. The bench
|
||||
/// (`addon_harness::tick_bench_tests::a_mouseover_plate_rebuilt_every_frame`) prices that at
|
||||
/// 6.7 ms a frame of VM time on grom's interface, flat whether forty nameplates are churning or
|
||||
/// nothing is; his own session fell from ~90 fps to ~31, so the app-side half of a plate being
|
||||
/// hidden and shown again costs more still.
|
||||
///
|
||||
/// Zeroed rather than listed positively: the struct gains fields, and a new one that does draw a
|
||||
/// line should rebuild the plate by default. Only what cannot draw one is taken out.
|
||||
fn lines_view(s: &UnitState) -> UnitState {
|
||||
UnitState {
|
||||
// The bar watcher's own inputs (`tooltip_unit::on_unit_push`), which is why they were the
|
||||
// first fields out of this key.
|
||||
health: 0,
|
||||
max_health: 0,
|
||||
power: 0,
|
||||
max_power: 0,
|
||||
powers: [0; 5],
|
||||
max_powers: [0; 5],
|
||||
// Raw descriptor columns for `GetUnitData`/`GetUnitField`. No plate line reads them, and
|
||||
// they carry `posX`/`posY`.
|
||||
fields: Vec::new(),
|
||||
// Where the unit stands. SuperWoW's `UnitPosition` reads it; no line does.
|
||||
position: None,
|
||||
..s.clone()
|
||||
}
|
||||
}
|
||||
@@ -674,7 +701,9 @@ fn drive_mouseover_tooltip(
|
||||
|
||||
if let Some((guid, state)) = unit.filter(|_| go.is_none()) {
|
||||
// Push first: the builder reads the token. Rebuild on a new target or a late name or
|
||||
// creature answer; health and power stay out of the key, since the watcher drives the bar.
|
||||
// creature answer, and on nothing else: health and power stay out of the key because the
|
||||
// watcher drives the bar, and position stays out because a unit that walks is still the
|
||||
// same subject ([`lines_view`]).
|
||||
let key = lines_view(&state);
|
||||
script.set_unit("mouseover", Some(state));
|
||||
if *last != LastHover::Unit(guid) || last_lines.as_ref() != Some(&key) {
|
||||
|
||||
@@ -558,3 +558,73 @@ fn the_spell_feed_runs_after_the_trainer_feed() {
|
||||
feed_spell_tooltips
|
||||
));
|
||||
}
|
||||
|
||||
/// A unit plate is rebuilt when its *subject* changes, and a unit that walks is the same subject.
|
||||
///
|
||||
/// The rebuild is a hide/show pair through every tooltip hook in the interface, so the driver gates
|
||||
/// it on [`lines_view`] — and `lines_view` once carried the unit's whereabouts twice over: the
|
||||
/// resolved [`UnitState::position`] and the raw `posX`/`posY` columns inside
|
||||
/// [`UnitState::fields`]. Both move every frame a guard is on patrol, so the gate never held and
|
||||
/// the plate was torn down and rebuilt at the frame rate. grom's symptom was exactly that shape:
|
||||
/// mousing over a walking NPC dropped him from ~90 fps to ~31, mousing over a standing one cost
|
||||
/// nothing, and the *same* Ironforge Guard was free while it stood at a waypoint.
|
||||
#[test]
|
||||
fn a_unit_that_walks_does_not_rebuild_its_plate() {
|
||||
let store = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(22u16, 3000u32), // UNIT_FIELD_HEALTH
|
||||
(24u16, 3000u32), // UNIT_FIELD_MAXHEALTH
|
||||
]));
|
||||
let here = crate::ui_unit::snapshot(
|
||||
&store,
|
||||
Some("Ironforge Guard".into()),
|
||||
4,
|
||||
None,
|
||||
Some((-4900.0, -960.0, 500.0)),
|
||||
);
|
||||
// One frame of patrol: about 14 cm at a walk, which is what the interpolated `Transform`
|
||||
// moves between two 60 Hz frames.
|
||||
let there = crate::ui_unit::snapshot(
|
||||
&store,
|
||||
Some("Ironforge Guard".into()),
|
||||
4,
|
||||
None,
|
||||
Some((-4900.117, -960.0, 500.0)),
|
||||
);
|
||||
assert_ne!(
|
||||
here, there,
|
||||
"the snapshots must differ, or this proves nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
lines_view(&here),
|
||||
lines_view(&there),
|
||||
"a step of patrol is not a new subject: it must not rebuild the plate"
|
||||
);
|
||||
assert!(
|
||||
lines_view(&here).position.is_none() && lines_view(&here).fields.is_empty(),
|
||||
"neither copy of the unit's whereabouts may sit in the rebuild key"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the same gate: a late answer under one hover *must* rebuild the plate, which
|
||||
/// is the whole reason the key is a snapshot and not just the GUID. A creature query lands a few
|
||||
/// frames after the hover and fills the subtitle line.
|
||||
#[test]
|
||||
fn a_late_creature_answer_still_rebuilds_the_plate() {
|
||||
let store = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
|
||||
22u16, 3000u32,
|
||||
)]));
|
||||
let bare = crate::ui_unit::snapshot(&store, None, 4, None, Some((0.0, 0.0, 0.0)));
|
||||
let mut named = crate::ui_unit::snapshot(
|
||||
&store,
|
||||
Some("Myra Tyrngaarde".into()),
|
||||
4,
|
||||
None,
|
||||
Some((0.0, 0.0, 0.0)),
|
||||
);
|
||||
named.subtitle = Some("Bread Vendor".into());
|
||||
assert_ne!(
|
||||
lines_view(&bare),
|
||||
lines_view(&named),
|
||||
"a name and a subtitle are lines: they must rebuild the plate"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user