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 86b5cb5b..bf24bfca 100644 --- a/crates/benilla-app/src/addon_harness/tick_bench_tests.rs +++ b/crates/benilla-app/src/addon_harness/tick_bench_tests.rs @@ -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 = 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 + ); + } +} diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 56a9ead0..3e603585 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -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) { diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index fe22dd88..f82bee68 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -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" + ); +}