Hover leaves publish the world mouseover loss before the next OnEnter (#935)

Moving from the world onto a window, the world mouseover's loss reached the tooltip a frame after the window's OnEnter, so a gate kept the fade off any tooltip the world had not put up.

In 1.12.1 the hover walk runs the old frame's leave before the new frame's enter (`0x766229`, `0x766239`). The WorldFrame's leave (`0x483b10`) runs OnLeave, then publishes the empty mouseover, whose publisher fades GameTooltip whoever holds it (`0x492890` → `0x530ae0`). A nameplate's leave publishes first (`0x7cb8b0`); the Minimap's hides the tooltip (`0x4eb880`), and its probe drops a held blip on a miss (`0x4eb0c0`).

The VM now holds the published mouseover and runs these leaves in the walk, the hide and every mouse disable, freelook's plates included (`0x60f830`); the ownership gate is gone.

Tests: OnEnter no longer sees "mouseover", an addon tooltip fades on a change, blip and Minimap leaves hide it.

Fixes #901

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 03:28:42 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 377542daeb
commit 6fff0fa7a1
23 changed files with 900 additions and 258 deletions
+15 -14
View File
@@ -399,20 +399,23 @@ pub(super) fn emit_party_arrows(
}
}
/// Show the hovered blip's name; a unit's resolves through the name cache. Hover loss hides the
/// tooltip at once (`0x4eb0c0`). Runs after `UnitFeed`, so a same-frame move from a world hover to
/// a blip ends on the blip's tooltip.
/// Show the hovered blip's name; a unit's resolves through the name cache. This is the in-rect
/// half of the Minimap's probe `0x4eb0c0`, which the hover walk runs on the Minimap while it holds
/// the hover or the capture ([`UiScript::minimap_probed`]). A new blip shows; no blip where one
/// was held hides `GameTooltip`, whoever holds it (`0x4eb55f`). The cursor off the Minimap is the
/// walk's: the probe's miss and the Minimap's leave.
pub(super) fn drive_blip_tooltip(
script: Option<NonSendMut<UiScript>>,
hover: Res<MinimapBlipHover>,
names: Res<NameCache>,
commands: Res<NetCommands>,
mut last: Local<crate::ui_script::VmMemo<Option<String>>>,
) {
let Some(mut script) = script else {
return;
};
let last = last.get(&script);
if !script.minimap_probed() {
return;
}
let show = match &*hover {
MinimapBlipHover::None => None,
MinimapBlipHover::Landmark(name) => Some((name.clone(), false)),
@@ -421,22 +424,20 @@ pub(super) fn drive_blip_tooltip(
.map(|n| (n.to_string(), *grey)),
MinimapBlipHover::TrackedGo(name, grey) => Some((name.clone(), *grey)),
};
match (&show, &*last) {
match (show, script.minimap_blip()) {
// Same blip: the plate, owned in mode 6, follows the cursor in its own update.
(Some((t, _)), Some(lt)) if t == lt => {}
(Some((t, _)), Some(held)) if t == held => {}
(Some((t, grey)), _) => {
script.minimap_tooltip(t, *grey);
*last = Some(t.clone());
}
// No blip hovered: `0x4eb0c0` hides the plate directly, with no fade.
(None, Some(_)) => {
script.world_tooltip_hide();
*last = None;
script.minimap_tooltip(&t, grey);
}
(None, Some(_)) => script.minimap_blip_lost(),
(None, None) => {}
}
}
#[cfg(test)]
mod tooltip_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,68 @@
//! The blip tooltip driver against the UI's own hover: the Minimap's probe and leave.
use benilla_ui::script::UiScript;
use bevy::prelude::*;
use super::{drive_blip_tooltip, MinimapBlipHover};
use crate::names::NameCache;
use crate::net::NetCommands;
/// A full-screen `UIParent`, a mouse-enabled `Minimap` at (600, 400)–(740, 540) with the cursor on
/// it, and a `GameTooltip`; the driver runs each update.
fn minimap_app() -> App {
let mut app = App::new();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx))
.init_resource::<NameCache>()
.init_resource::<MinimapBlipHover>()
.add_systems(Update, drive_blip_tooltip);
let mut script = UiScript::new().unwrap();
script.set_screen_size(800.0, 600.0);
script
.run(
r#"
UIParent = CreateFrame("Frame", "UIParent"); UIParent:SetAllPoints()
local map = CreateFrame("Minimap", "Minimap", UIParent)
map:SetPoint("BOTTOMLEFT", 600, 400); map:SetWidth(140); map:SetHeight(140)
map:EnableMouse(true)
CreateFrame("GameTooltip", "GameTooltip", UIParent):Hide()
"#,
)
.unwrap();
script.resolve();
script.mouse_move(670.0, 470.0);
app.insert_non_send_resource(script);
app
}
fn blip(app: &mut App, hover: MinimapBlipHover) {
*app.world_mut().resource_mut::<MinimapBlipHover>() = hover;
app.update();
}
fn shown(app: &mut App) -> bool {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<bool>("return GameTooltip:IsShown() == 1")
.unwrap()
}
/// Off every blip, the probe `0x4eb0c0` hides `GameTooltip` directly (`0x4eb55f`–`0x4eb567`),
/// whoever holds it: an addon tooltip taken over the blip's goes too.
#[test]
fn a_blip_hover_loss_hides_whoever_holds_the_tooltip() {
let mut app = minimap_app();
blip(&mut app, MinimapBlipHover::Landmark("Stormwind".into()));
assert!(shown(&mut app), "the blip tooltip is up");
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run(r#"GameTooltip:SetOwner(UIParent, "ANCHOR_NONE"); GameTooltip:SetText("Addon")"#)
.unwrap();
blip(&mut app, MinimapBlipHover::None);
assert!(!shown(&mut app), "the addon's tooltip is hidden");
assert!(app
.world_mut()
.non_send_resource_mut::<UiScript>()
.take_errors()
.is_empty());
}
+2 -2
View File
@@ -901,8 +901,8 @@ impl Plugin for MinimapPlugin {
.run_if(crate::ui_script::ingame_ui_up),
// Before the script tick, so GameTimeFrame reads this frame's minute.
feed_game_time.in_set(crate::ui_script::UiFeed),
// After the world-mouseover drive (UnitFeed): a same-frame world-hover→blip
// transition must end with the blip tooltip shown, not the fade.
// In the feed, off the hover the last walk left; after the world drive only so
// the two VM writers keep one order.
blips::drive_blip_tooltip
.after(crate::ui_unit::UnitFeed)
.in_set(crate::ui_script::UiFeed),
+15 -1
View File
@@ -182,7 +182,7 @@ pub(super) fn update_hover(
rig: Res<CameraControl>,
pointer_over_ui: Res<PointerOverUi>,
occlusion: Res<PickOcclusion>,
// The unit whose plate holds the pointer, as of last frame's plate layout.
// The unit whose plate holds the hover after this frame's UI walk.
plate_hover: Res<crate::vplates::PlateHover>,
mut hovered: ResMut<Hovered>,
mesh_assets: Res<Assets<Mesh>>,
@@ -235,6 +235,7 @@ pub(super) fn update_hover(
hovered.corpse_guid = None;
hovered.distance = f32::MAX;
hovered.refused = false;
hovered.plate = false;
// A plate under the pointer is the mouseover, read before the UI gate below, which the plate
// itself trips: the reference's plate OnEnter (`0x7cb850`) publishes `[0xb4e2c8]` ungraded,
// with no world pick behind it. Freelook wins, as the plates hand the mouse back (`0x60f830`).
@@ -243,6 +244,7 @@ pub(super) fn update_hover(
hovered.target = Some(entity);
hovered.guid = units.get(entity).ok().map(|(g, _)| g.0);
hovered.distance = 0.0; // topmost UI: it beats any world GameObject at a tie
hovered.plate = true;
*last_pick = Some(entity);
return;
}
@@ -791,6 +793,18 @@ mod tests {
use crate::spell::targeting::corpse_fixture as fx;
use bevy::ecs::system::RunSystemOnce;
/// The plate hover the pick reads is this frame's walk's: a plate's leave publishes the loss
/// inside the walk (`0x7cb8b0`), so a pick a frame behind would publish the plate again.
#[test]
fn the_pick_reads_the_plate_hover_of_this_frames_walk() {
let mut app = crate::game_plugins::schedule_tests::headless_client();
assert!(crate::test_support::runs_before(
&mut app,
crate::vplates::seed_plate_hover,
update_hover
));
}
fn card(bone: u16, owner: Entity) -> BillboardCard {
let info = benilla_assets::BillboardInfo {
pivot: Vec3::ZERO,
+3
View File
@@ -113,6 +113,9 @@ pub(crate) struct Hovered {
/// [`Self::distance`] holds the hit, so a chest behind the unit does not inherit the mouseover.
/// To [`click::select_on_click`] it is an object hit, which clears no selection.
pub(crate) refused: bool,
/// The pick is the hovered plate's unit, the plate's enter publishing it (`0x7cb850`), not the
/// world's own pick: it stands only while that plate still holds the hover.
pub(crate) plate: bool,
}
impl Hovered {
+3 -1
View File
@@ -346,6 +346,8 @@ impl Plugin for UiScriptPlugin {
(
extract::tick_script,
input::feed_ui_input.in_set(crate::char_select::InWorldGated),
// Off the walk that just ran, for the pick and the arbiter after the pass.
crate::vplates::seed_plate_hover,
)
.chain()
.in_set(UiInput)
@@ -396,7 +398,7 @@ fn arbitrate_pointer_over_ui(
) {
// No cinematic term: the hit test finds the full-screen, mouse-enabled `CinematicFrame`.
over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some();
// Chrome excludes the plates; the plate hover is last frame's, as the plate driver runs later.
// Chrome excludes the plates, whose hover is this frame's walk's.
panel.0 = over.0 && plate_hover.0.is_none();
}
@@ -0,0 +1,199 @@
//! The world mouseover's loss against the UI hover walk: the driver and the walk together, as a
//! frame runs them.
use benilla_ui::script::UiScript;
use bevy::prelude::*;
use super::tests::{eval, hover, hover_unit, mouseover_app, WOLF};
use crate::target::{Hovered, HoveredObject};
/// A full-screen `UIParent` and `WorldFrame`, a `GameTooltip` whose default anchor seats it in
/// `anchor`, and a bag button at (100, 100)–(140, 140) whose `OnEnter` records what it found and
/// takes the tooltip, as a stock slot does.
fn world_and_bag(app: &mut App, anchor: &str) {
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
script.set_screen_size(800.0, 600.0);
script
.run(&format!(
r#"
UIParent = CreateFrame("Frame", "UIParent"); UIParent:SetAllPoints()
WorldFrame = CreateFrame("WorldFrame", "WorldFrame"); WorldFrame:SetAllPoints()
local tt = CreateFrame("GameTooltip", "GameTooltip", UIParent); tt:Hide()
tt:SetScript("OnTooltipSetDefaultAnchor", function()
GameTooltip:SetOwner(UIParent, "{anchor}")
end)
Bag = CreateFrame("Button", "Bag", UIParent)
Bag:SetPoint("BOTTOMLEFT", 100, 100); Bag:SetWidth(40); Bag:SetHeight(40)
Bag:EnableMouse(true)
Bag:SetScript("OnEnter", function()
entered_mouseover = UnitExists("mouseover")
entered_visible = GameTooltip:IsVisible()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText("Bag slot")
end)
"#
))
.unwrap();
script.resolve();
// The cursor on the world: the WorldFrame holds the hover.
script.mouse_move(400.0, 300.0);
}
fn script(app: &mut App) -> Mut<'_, UiScript> {
app.world_mut().non_send_resource_mut::<UiScript>()
}
/// From a world unit onto a window: the walk runs the WorldFrame's leave before the button's
/// enter (`0x766229`, then `0x766239`), and that leave publishes the empty mouseover
/// (`0x483b10` → `0x483b46`), whose change requests the fade (`0x492909` → `0x530ae0`). So the
/// button's `OnEnter` already finds `"mouseover"` naming nobody and a cursor-seated plate gone,
/// and its own tooltip, taken after, is not faded by the hover loss.
#[test]
fn a_window_entered_from_a_world_unit_keeps_its_tooltip() {
let (mut app, wolf, _) = mouseover_app();
world_and_bag(&mut app, "ANCHOR_CURSOR");
hover_unit(&mut app, wolf, WOLF);
assert_eq!(
eval(&mut app, "return GameTooltip:IsShown()"),
Some(1),
"the world plate is up"
);
// This frame's walk moves onto the bag; the world pick then stands down over the UI.
script(&mut app).mouse_move(120.0, 120.0);
assert_eq!(
eval(&mut app, "return entered_mouseover"),
None,
"the WorldFrame's leave cleared the mouseover before the button's enter"
);
assert_eq!(
eval(&mut app, "return entered_visible"),
None,
"the fade request hid the cursor-seated plate before the button's enter"
);
hover(&mut app, Hovered::default(), HoveredObject::default());
script(&mut app).tick(2.5);
assert_eq!(
eval(
&mut app,
r#"return GameTooltip:IsShown() and GameTooltip:IsOwned(Bag)
and GameTooltipTextLeft1:GetText() == "Bag slot"
and GameTooltip:GetAlpha() == 1 and 1"#
),
Some(1),
"the button's tooltip stays at full alpha"
);
assert!(script(&mut app).take_errors().is_empty());
}
/// With the cursor on the world, every mouseover change requests the fade on `GameTooltip`
/// (`0x492909`), whoever holds it: an addon's tooltip shown over the world fades with the hover.
#[test]
fn an_addon_tooltip_over_the_world_fades_when_the_mouseover_changes() {
let (mut app, wolf, _) = mouseover_app();
world_and_bag(&mut app, "ANCHOR_NONE");
hover_unit(&mut app, wolf, WOLF);
script(&mut app)
.run(r#"GameTooltip:SetOwner(UIParent, "ANCHOR_NONE"); GameTooltip:SetText("Addon")"#)
.unwrap();
hover(&mut app, Hovered::default(), HoveredObject::default());
script(&mut app).tick(2.5);
assert_eq!(
eval(&mut app, "return GameTooltip:IsShown()"),
None,
"the addon's tooltip faded with the hover"
);
assert!(script(&mut app).take_errors().is_empty());
}
/// A hovered plate retired by its unit leaving takes the plate's leave (`0x764cce` → `0x7cb8b0`),
/// which publishes the empty mouseover and fades the plate's tooltip; the hover then re-picks to
/// the WorldFrame. The pick taken before the retire still names the plate's unit, and must not
/// publish it again: no second build, no second `UPDATE_MOUSEOVER_UNIT`, no `"mouseover"`.
#[test]
fn a_retired_hovered_plate_is_not_published_again() {
use benilla_ui::script::{PlateGeometry, PlateState};
let (mut app, wolf, _) = mouseover_app();
world_and_bag(&mut app, "ANCHOR_CURSOR");
let geometry = PlateGeometry {
width: 128.0,
height: 32.0,
bar_off_x: 4.0,
bar_off_y: 4.0,
bar_width: 103.0,
bar_height: 9.0,
level_off_x: 11.8,
level_off_y: 9.1,
skull_size: 12.8,
raid_size: 25.6,
name_height: 12.8,
level_height: 11.0,
shadow_offset: 1.0,
};
let plate = PlateState {
key: WOLF,
top_centre: (400.0, 400.0),
health: 30.0,
max_health: 40.0,
bar_colour: [1.0, 0.0, 0.0],
name: "Wolf".into(),
level: Some(12),
skull: false,
level_colour: [1.0, 1.0, 0.0],
raid_icon: None,
alpha: 1.0,
lit: false,
hovered: false,
};
{
let mut s = script(&mut app);
s.run(
r#"
builds, updates = 0, 0
GameTooltip:SetScript("OnShow", function() builds = builds + 1 end)
GameTooltip:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
GameTooltip:SetScript("OnEvent", function() updates = updates + 1 end)
"#,
)
.unwrap();
s.sync_nameplates(geometry, std::slice::from_ref(&plate));
s.resolve();
s.mouse_move(400.0, 384.0);
assert_eq!(
s.hovered_nameplate(),
Some(WOLF),
"the plate holds the hover"
);
}
// The plate's pick, as the pick takes it off this frame's walk.
let on_plate = Hovered {
target: Some(wolf),
guid: Some(WOLF),
distance: 0.0,
plate: true,
..Default::default()
};
hover(&mut app, on_plate, HoveredObject::default());
assert_eq!(eval(&mut app, "return builds"), Some(1));
// The plate driver retires it after the pick, in the same frame.
script(&mut app).sync_nameplates(geometry, &[]);
assert_eq!(
eval(&mut app, "return GameTooltip:IsVisible()"),
None,
"faded"
);
// The next frame's driver still reads that pick.
hover(&mut app, on_plate, HoveredObject::default());
hover(&mut app, Hovered::default(), HoveredObject::default());
assert_eq!(
eval(
&mut app,
"return builds == 1 and updates == 1 and UnitExists('mouseover') == nil \
and not GameTooltip:IsVisible() and 1 or nil"
),
Some(1),
"built once, announced once, never named again"
);
assert!(script(&mut app).take_errors().is_empty());
}
+37 -58
View File
@@ -1,7 +1,11 @@
//! The world-mouseover tooltip and the spell tooltip views. The world tooltip rebuilds once per
//! hover-target change and a lost hover arms a fade; the pick is [`Hovered`] or
//! [`HoveredObject`], arbitrated by [`go_is_nearest`] as the click router does. The spell views
//! are built and pushed ahead of a hover by [`spell_feed`].
//! hover-target change, published through the VM ([`UiScript::publish_world_mouseover`]), whose
//! change requests the fade; the pick is [`Hovered`] or [`HoveredObject`], arbitrated by
//! [`go_is_nearest`] as the click router does. The pick stands for the WorldFrame's own update
//! (`0x481790`), which picks only while the WorldFrame holds the hover (`0x4817ae`); the loss when
//! the cursor moves onto a window is published by the WorldFrame's leave in the UI's walk, before
//! the window's `OnEnter`. The spell views are built and pushed ahead of a hover by
//! [`spell_feed`].
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
@@ -47,31 +51,11 @@ fn keyed(get: &dyn Fn(&str) -> Option<String>, key: &str, args: &[Arg<'_>]) -> O
(!text.is_empty()).then_some(text)
}
/// What the world tooltip was last built for; the reference rebuilds once per hover-target change.
#[derive(Default, PartialEq, Clone, Copy)]
enum LastHover {
#[default]
None,
Unit(u64),
Go(u64),
/// A hovered corpse object, keyed on the corpse's guid.
Corpse(u64),
}
/// The publisher's teardown (`0x4928d5`–`0x492909`): every change of the mouseover away from a
/// non-null one requests the fade (`0x530ae0`) before the new plate builds, so a cursor-seated
/// plate hides and the next one shows afresh; a first hover has nothing to tear down.
fn fades_on_change(last: LastHover, next: LastHover) -> bool {
last != LastHover::None && last != next
}
/// The world-hover driver's memory, one `SystemParam` because the driver is at Bevy's 16-param
/// ceiling.
/// ceiling. What it last published is the VM's ([`UiScript::world_mouseover`]), which a hover
/// leave in the UI's walk also writes.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct HoverMemo<'s> {
/// Which world plate is up. On an unchanged mouseover the reference makes no call (`0x482090`
/// returns at `0x4820b5`), so a plate Lua takes mid-hover stays gone.
last: Local<'s, crate::ui_script::VmMemo<LastHover>>,
/// The line-affecting fields the current unit plate was built from, so a late-arriving name or
/// creature-info rebuilds it under the same hover.
last_lines: Local<'s, crate::ui_script::VmMemo<Option<UnitState>>>,
@@ -129,14 +113,25 @@ fn drive_mouseover_tooltip(
let Some(mut script) = script else {
return;
};
let (last, last_lines, trace) = (&mut memo.last, &mut memo.last_lines, &mut *memo.trace);
let last = last.get(&script);
let (last_lines, trace) = (&mut memo.last_lines, &mut *memo.trace);
let last_lines = last_lines.get(&script);
// The publisher's change check (`0x482090`) compares against the VM's last publish.
let last = script.world_mouseover();
let published = memo.published.get(&script);
let self_store = self_q.iter().next();
let chr = tables.classes();
let types = tables.types(&names);
// A plate's pick stands for its enter's publish (`0x7cb850`), so it holds only while that plate
// holds the hover: a plate retired after the pick was taken has run its leave (`0x7cb8b0`),
// which published the loss, and the hover re-picks to the WorldFrame.
if hovered.plate && script.hovered_nameplate() != hovered.guid {
if std::mem::take(published) {
script.set_unit("mouseover", None);
}
script.publish_world_mouseover(None);
return;
}
let unit = hovered.mouseover(&hovered_go).and_then(|(entity, guid)| {
let store = stores.get(entity).ok()?;
let name = names
@@ -164,18 +159,15 @@ fn drive_mouseover_tooltip(
});
if let Some((guid, state)) = unit {
// 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.
// Publish, which tears the old hover down, then push: the builder reads the token. Rebuild
// on a new target or a late name or creature answer, which rebuilds under the same hover
// with no teardown; health and power stay out of the key, since the watcher drives the bar.
let key = lines_view(&state);
let changed = script.publish_world_mouseover(Some(guid));
script.set_unit("mouseover", Some(state));
*published = true;
if *last != LastHover::Unit(guid) || last_lines.as_ref() != Some(&key) {
// A late name rebuilds under the same hover with no teardown.
if fades_on_change(*last, LastHover::Unit(guid)) {
script.world_tooltip_fade();
}
if changed || last_lines.as_ref() != Some(&key) {
script.world_tooltip_unit("mouseover");
*last = LastHover::Unit(guid);
*last_lines = Some(key);
}
return;
@@ -200,13 +192,10 @@ fn drive_mouseover_tooltip(
};
// A bone pile with nothing to take publishes no mouseover: no plate, no brighten.
if !crate::target::corpse_mouseover_eligible(store) {
if !matches!(*last, LastHover::None) {
script.world_tooltip_fade();
*last = LastHover::None;
}
script.publish_world_mouseover(None);
return;
}
if *last == LastHover::Corpse(guid) {
if last == Some(guid) {
return; // already up; a corner plate never re-seats
}
// A corpse carries `CORPSE_FIELD_OWNER`, not a name; the plate waits for the name query.
@@ -224,11 +213,8 @@ fn drive_mouseover_tooltip(
) else {
return;
};
if fades_on_change(*last, LastHover::Corpse(guid)) {
script.world_tooltip_fade();
}
script.publish_world_mouseover(Some(guid));
script.world_tooltip_gameobject(&plate, &[], false);
*last = LastHover::Corpse(guid);
return;
}
if let Some((entity, guid)) = go {
@@ -255,12 +241,9 @@ fn drive_mouseover_tooltip(
})
})
.flatten();
if *last == LastHover::Go(guid) {
if last == Some(guid) {
if crate::target::hover_probe_armed() {
let line = format!(
"held Go({guid:#x}) — plate up {}",
script.world_tooltip_up()
);
let line = format!("held Go({guid:#x})");
if *trace != line {
info!("hover probe/tooltip: {line}");
*trace = line;
@@ -356,18 +339,12 @@ fn drive_mouseover_tooltip(
_ => {}
}
}
if fades_on_change(*last, LastHover::Go(guid)) {
script.world_tooltip_fade();
}
script.publish_world_mouseover(Some(guid));
script.world_tooltip_gameobject(&template.name, &lines, cursor_seated);
*last = LastHover::Go(guid);
return;
}
if fades_on_change(*last, LastHover::None) {
// Hover lost: the same teardown, with no plate after it.
script.world_tooltip_fade();
*last = LastHover::None;
}
// Hover lost: the same teardown, with no plate after it.
script.publish_world_mouseover(None);
}
#[cfg(test)]
@@ -377,4 +354,6 @@ mod deps_tests;
#[cfg(test)]
mod feed_tests;
#[cfg(test)]
mod hover_order_tests;
#[cfg(test)]
mod tests;
+7 -23
View File
@@ -870,13 +870,13 @@ fn the_spell_feed_runs_after_the_trainer_feed() {
));
}
const ME: u64 = 0x77;
const WOLF: u64 = 0xF130_0000_4500_0001;
const BOAR: u64 = 0xF130_0000_4600_0002;
pub(super) const ME: u64 = 0x77;
pub(super) const WOLF: u64 = 0xF130_0000_4500_0001;
pub(super) const BOAR: u64 = 0xF130_0000_4600_0002;
/// The world-hover driver over a bare VM holding `"player"` (us) and `"target"` (the wolf), each
/// with its guid, as the unit feed pushes them; returns the app, the wolf and the boar.
fn mouseover_app() -> (App, Entity, Entity) {
pub(super) fn mouseover_app() -> (App, Entity, Entity) {
use benilla_protocol::ObjectFields;
let mut app = App::new();
@@ -917,13 +917,13 @@ fn mouseover_app() -> (App, Entity, Entity) {
}
/// Set this frame's pick and run the driver.
fn hover(app: &mut App, pick: Hovered, object: HoveredObject) {
pub(super) fn hover(app: &mut App, pick: Hovered, object: HoveredObject) {
*app.world_mut().resource_mut::<Hovered>() = pick;
*app.world_mut().resource_mut::<HoveredObject>() = object;
app.update();
}
fn hover_unit(app: &mut App, entity: Entity, guid: u64) {
pub(super) fn hover_unit(app: &mut App, entity: Entity, guid: u64) {
let pick = Hovered {
target: Some(entity),
guid: Some(guid),
@@ -933,7 +933,7 @@ fn hover_unit(app: &mut App, entity: Entity, guid: u64) {
hover(app, pick, HoveredObject::default());
}
fn eval(app: &mut App, lua: &str) -> Option<i64> {
pub(super) fn eval(app: &mut App, lua: &str) -> Option<i64> {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<Option<i64>>(lua)
@@ -1323,19 +1323,3 @@ fn the_feed_builds_the_pet_views_against_the_pet_and_rebuilds_them_on_its_level(
)
);
}
/// `0x492890` tears down on every change away from a non-null mouseover (`0x4928d5`), loss and
/// replacement alike, and not for a first hover or an unchanged one.
#[test]
fn every_change_away_from_a_hover_requests_the_fade() {
let (a, b) = (LastHover::Go(1), LastHover::Go(2));
assert!(fades_on_change(a, b), "one object replaced by another");
assert!(
fades_on_change(a, LastHover::Unit(2)),
"an object replaced by a unit"
);
assert!(fades_on_change(LastHover::Unit(1), LastHover::Corpse(3)));
assert!(fades_on_change(a, LastHover::None), "hover lost");
assert!(!fades_on_change(LastHover::None, a), "a first hover");
assert!(!fades_on_change(a, a), "unchanged");
}
+15 -7
View File
@@ -66,12 +66,25 @@ impl VPlateMode {
#[derive(Resource, Default)]
pub(crate) struct VPlates(pub(crate) EntityHashSet);
/// The unit whose plate the pointer is inside, from last frame's layout: the plate's OnEnter
/// The unit whose plate holds the hover after this frame's UI walk: the plate's OnEnter
/// publishing the mouseover (`0x7cb850` into `[0xb4e2c8]`), since the world pick stands down
/// over UI.
/// over UI. Read in the frame of the walk, as the plate's leave publishes the loss inside it
/// (`0x7cb8b0`), so the pick never re-publishes a plate the walk has just left.
#[derive(Resource, Default)]
pub(crate) struct PlateHover(pub(crate) Option<Entity>);
/// Read [`PlateHover`] off the walk that just ran, at the end of the UI input pass.
pub(crate) fn seed_plate_hover(
script: Option<NonSend<benilla_ui::script::UiScript>>,
guids: Option<Res<crate::net::GuidIndex>>,
mut hover: ResMut<PlateHover>,
) {
hover.0 = script
.and_then(|s| s.hovered_nameplate())
.zip(guids)
.and_then(|(guid, guids)| guids.0.get(&guid).copied());
}
/// Completed plate clicks, physical or an addon's `plate:Click`, waiting for the targeting chain:
/// the reference's click slot (`0x7cb910`) ends in the same `SetSelection` as a body click.
#[derive(Resource, Default)]
@@ -300,7 +313,6 @@ struct PlateWorld<'w, 's> {
fn drive_vplates(
mode: Res<VPlateMode>,
mut plates: ResMut<VPlates>,
mut plate_hover: ResMut<PlateHover>,
mut plate_clicks: ResMut<PlateClicks>,
rig: Res<crate::player::CameraControl>,
world: PlateWorld,
@@ -321,7 +333,6 @@ fn drive_vplates(
mut mouse_told: Local<crate::ui_script::VmMemo<Option<bool>>>,
) {
plates.0.clear();
plate_hover.0 = None;
bucket.clear();
// Every early return retires the plates first, or the widgets stay on screen; the reference
// hides each live plate and returns it to the pool (`0x608a10`).
@@ -543,9 +554,6 @@ fn drive_vplates(
let mark = group.raid_target_index(guid.0);
let x_units = (plate.min.x + plate.max.x) * 0.5 / seam;
let y_units = (viewport.y - plate.min.y) / seam;
if hovered_key == Some(guid.0) {
plate_hover.0 = Some(entity);
}
for click in &clicked {
if click.key == guid.0 {
match click.button.as_str() {
+1
View File
@@ -96,6 +96,7 @@ pub mod nameplate;
pub use nameplate::{PlateBitWrites, PlateGeometry, PlateState, PLATE_BIT_ENEMY, PLATE_BIT_FRIEND};
mod model;
mod modelframe;
mod mouseover;
mod net_stats;
pub(crate) mod object;
pub use object::frame_kind_from_tag;
+9
View File
@@ -218,6 +218,13 @@ pub(crate) struct Model {
/// reference's `[root+0x1100]` flag (set at `0x764cbb`/`0x764b8d`, pumped at `0x7657a1` into
/// `0x7660d0`).
pub(crate) hover_repick: bool,
/// The world mouseover as last published (`[0xb4e2c8]`, with the WorldFrame's own copy
/// `[wf+0x340]`): the pick's change check and the hover leaves read and write it
/// ([`super::mouseover`]).
pub(crate) world_mouseover: Option<u64>,
/// The blip whose tooltip the Minimap's probe put up, its stand-in for the hit set it keeps
/// (`0xbc81e4`, `0xbdcbac`), which a probe that misses the Minimap drops (`0x4eb12b`).
pub(crate) minimap_blip: Option<String>,
/// Per button held, the frame its press went to, the capture else the hover (`0x7662c0`), for
/// the release; `None` once that frame left the mouse index (hide or mouse disable emptied
/// the capture), so the release has no target but the button still counts as held.
@@ -1129,6 +1136,8 @@ impl Model {
focused_editbox: None,
mouseover: None,
hover_repick: false,
world_mouseover: None,
minimap_blip: None,
mouse_down_on: HashMap::new(),
mouse_capture: None,
last_click: HashMap::new(),
+154
View File
@@ -0,0 +1,154 @@
//! The world mouseover's publisher (`0x492890`) and the engine half of a hover leave. The world's
//! pick publishes through the change check (`0x482090`), and a leave publishes inside the hover
//! walk, which runs the old frame's leave before the new frame's enter (`0x766229`, `0x766239`):
//! the WorldFrame's (`0x483b10`) and a nameplate's (`0x7cb8b0`) publish the empty mouseover, and
//! the Minimap's (`0x4eb880`) hides `GameTooltip`. So the tooltip a window's `OnEnter` takes is
//! never torn down by the hover it replaced.
//!
//! The WorldFrame's enter is the base one (`0x76b6a0`) and publishes nothing: the world gains the
//! mouseover from its own update (`0x481790`), which picks only while the WorldFrame holds the
//! hover (`0x4817ae`), the app's world drive.
use mlua::{Lua, Value};
use crate::widget::{FrameHandle, FrameKind};
use super::{event, Model, UiScript};
/// What a frame's leave notify (`vtbl+0x50`) does beyond its `OnLeave`.
enum LeaveClass {
/// `0x483b10`: the base leave, then the empty mouseover.
World,
/// `0x7cb8b0`: the empty mouseover, then the Button's leave.
Nameplate,
/// `0x4eb880`: `GameTooltip` hidden, then the base leave.
Minimap,
Other,
}
fn leave_class(model: &Model, h: FrameHandle) -> LeaveClass {
match model.arena.frame(h).map(|f| f.kind) {
Some(FrameKind::WorldFrame) => LeaveClass::World,
Some(FrameKind::Minimap) => LeaveClass::Minimap,
Some(_) if model.nameplates.is_plate(h) => LeaveClass::Nameplate,
_ => LeaveClass::Other,
}
}
/// A frame's leave notify, by its class, around its `OnLeave(true)`; `script` is the id whose
/// `OnLeave` fires, `None` where the notify skips it (a disabled Button's `0x7794e0`). The walk's
/// motion leave and the removal tail's leave (`0x764cce`) both come here.
pub(super) fn leave(lua: &Lua, h: FrameHandle, script: Option<u32>) {
let class = leave_class(&model(lua), h);
match class {
// `0x7cb8bf`: `0x492890` with an empty mouseover and an empty "previous", first.
LeaveClass::Nameplate => {
publish(lua, None);
}
// `0x4eb88e`–`0x4eb898`: the shown bit, then the hide slot, whoever holds it.
LeaveClass::Minimap => hide_tooltip(lua),
LeaveClass::World | LeaveClass::Other => {}
}
if let Some(id) = script {
if let Err(e) = event::fire_widget_handler(lua, id, "OnLeave", vec![Value::Boolean(true)]) {
model(lua).record_lua_error(&e);
}
}
// `0x483b1d` runs the base leave first; then, only while the WorldFrame's own last publish is
// not empty (`0x483b30`), the empty one (`0x483b46`).
if let LeaveClass::World = class {
publish(lua, None);
}
}
/// The publisher behind the change check: an unchanged mouseover does nothing (`0x4820b5`). A
/// change away from a published one requests the fade on `GameTooltip` (`0x4928d5`, `0x492909` →
/// `0x530ae0`), whoever holds it, after `"mouseover"` stops naming the old unit (the pair is zeroed
/// at `0x4928e8` first). Returns whether it changed.
pub(super) fn publish(lua: &Lua, guid: Option<u64>) -> bool {
let (prev, tooltip) = {
let mut model = model(lua);
if model.world_mouseover == guid {
return false;
}
let prev = std::mem::replace(&mut model.world_mouseover, guid);
// The token resolver reads the pair (`0x515970`), so `"mouseover"` names a unit only while
// it is the published guid; the app pushes the new unit after the publish.
if model
.pushed_unit("mouseover")
.is_some_and(|u| Some(u.guid) != guid)
{
model.units_by_lower.remove("mouseover");
}
if model.unit_guids.mouseover != 0 && Some(model.unit_guids.mouseover) != guid {
model.unit_guids.mouseover = 0;
}
(prev, model.arena.lookup("GameTooltip"))
};
if let (Some(_), Some(h)) = (prev, tooltip) {
super::tooltip::fade_out(lua, h);
}
true
}
/// The Minimap's direct hide of `GameTooltip`: the shown bit, then the hide slot, with no fade
/// (`0x4eb15e`–`0x4eb166`, `0x4eb55f`–`0x4eb567`, `0x4eb88e`–`0x4eb898`).
fn hide_tooltip(lua: &Lua) {
let tooltip = model(lua).arena.lookup("GameTooltip");
if let Some(h) = tooltip {
super::visibility::set_shown(lua, h, false);
}
}
/// The Minimap's probe (`0x4eb0c0`, its `vtbl+0x3c`) found the cursor outside it: a blip still
/// held is dropped and `GameTooltip` hidden (`0x4eb0ee`, `0x4eb12b`–`0x4eb166`). The walk probes
/// the Minimap while it holds the capture (`0x7661ca`), and in the sweep when no frame ahead of it
/// took the hit.
pub(super) fn minimap_probe_miss(lua: &Lua) {
if model(lua).minimap_blip.take().is_some() {
hide_tooltip(lua);
}
}
fn model(lua: &Lua) -> mlua::AppDataRefMut<'_, Model> {
lua.app_data_mut::<Model>().expect("model app_data")
}
impl UiScript {
/// The world mouseover's guid as last published (`[0xb4e2c8]`), a unit, a GameObject or a
/// corpse; `None` once the pick or a hover leave published the empty one.
pub fn world_mouseover(&self) -> Option<u64> {
self.model_ref().world_mouseover
}
/// Publish the world pick's mouseover ([`publish`]): the caller then pushes a unit's
/// `"mouseover"` and builds its plate. Returns whether it changed.
pub fn publish_world_mouseover(&mut self, guid: Option<u64>) -> bool {
publish(&self.lua, guid)
}
/// Whether the walk probes the Minimap with the cursor on it: it holds the hover, which a hit
/// in the sweep makes it, or the capture, whose probe runs before the sweep (`0x7661ca`). Its
/// probe shows and drops the blip tooltip.
pub fn minimap_probed(&self) -> bool {
let model = self.model_ref();
[model.mouseover, model.mouse_capture]
.into_iter()
.flatten()
.filter_map(|h| model.arena.frame(h))
.any(|f| f.kind == FrameKind::Minimap)
}
/// The blip whose tooltip [`Self::minimap_tooltip`] put up, while the probe holds it.
pub fn minimap_blip(&self) -> Option<String> {
self.model_ref().minimap_blip.clone()
}
/// The probe found no blip under the cursor inside the Minimap: it drops the held one and hides
/// `GameTooltip`, whoever holds it (`0x4eb54c`–`0x4eb567`).
pub fn minimap_blip_lost(&mut self) {
if self.model_mut().minimap_blip.take().is_some() {
hide_tooltip(&self.lua);
}
}
}
+13 -4
View File
@@ -138,6 +138,11 @@ pub(crate) struct NamePlates {
}
impl NamePlates {
/// Whether `frame` is a plate, whose leave publishes the empty mouseover (`0x7cb8b0`).
pub(crate) fn is_plate(&self, frame: FrameHandle) -> bool {
self.by_frame.contains_key(&frame)
}
/// Whether the `0x7cba30` veto refuses `frame`: only a plate, only while the veto stands.
pub(crate) fn vetoes(&self, frame: FrameHandle) -> bool {
self.hit_test_vetoed && self.by_frame.contains_key(&frame)
@@ -272,13 +277,17 @@ impl UiScript {
}
/// The freelook toggle (`0x60f830`): entering camera freelook disables mouse input on every
/// plate and leaving re-enables it (called from `0x483e80` and `0x483e70`).
/// plate and leaving re-enables it (called from `0x483e80` and `0x483e70`). The disable is
/// `EnableMouse(false)`'s (`0x76af50(2)`), whose removal tail (`0x764ba0`, flag 0, `0x76af7f`)
/// ends a hovered plate's hover through its leave (`0x7cb8b0`).
pub fn set_nameplate_mouse(&mut self, enabled: bool) {
let lua = self.lua();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frames: Vec<FrameHandle> = model.nameplates.plates.iter().map(|p| p.frame).collect();
let frames: Vec<FrameHandle> = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.nameplates.plates.iter().map(|p| p.frame).collect()
};
for frame in frames {
model.arena.set_mouse_enabled(frame, enabled);
super::visibility::set_mouse_enabled(lua, frame, enabled);
}
}
+56 -27
View File
@@ -67,6 +67,14 @@ fn is_world_handle(model: &Model, h: FrameHandle) -> bool {
.is_some_and(|f| f.kind == crate::widget::FrameKind::WorldFrame)
}
/// Whether `h` is a `Minimap`, whose probe drops its blip on a miss (`0x4eb0c0`).
fn is_minimap_handle(model: &Model, h: FrameHandle) -> bool {
model
.arena
.frame(h)
.is_some_and(|f| f.kind == crate::widget::FrameKind::Minimap)
}
/// Whether a press or release target is the world: the world frame, the reference's world click
/// target, or no frame where `WorldFrame.xml` is not loaded (most tests).
fn over_world(model: &Model, target: Option<FrameHandle>) -> bool {
@@ -96,21 +104,44 @@ impl UiScript {
/// The handle of the frame that captures `(x, y)`, for a caller that checks it against a
/// handle set taken earlier (an anonymous frame has no name). Needs [`Self::resolve`] first.
pub fn hit_test_frame(&self, x: f32, y: f32) -> Option<FrameHandle> {
self.hover_walk(x, y).0
}
/// The hover walk's probes: the capture frame's first (`[root+0x80]`, `0x7661ba`–`0x7661ca`),
/// unconditionally and its answer discarded, then the sweep (`0x7661cd`–`0x766213`). Returns the
/// first frame whose probe takes the point, and whether a `Minimap` was probed and missed it,
/// which then drops its blip (`0x4eb0c0`). A frame that takes the hit ends the sweep, so a
/// window over the Minimap keeps it from the sweep's probe; a held capture is probed anyway.
fn hover_walk(&self, x: f32, y: f32) -> (Option<FrameHandle>, bool) {
let model = self.model_ref();
let sorted = order::traversal(&model.arena);
let scroll_sources = scroll_clip_sources(&model);
order::hit_test(&sorted, |fh| {
let blip_held = model.minimap_blip.is_some();
let minimap_missed = std::cell::Cell::new(
blip_held
&& model.mouse_capture.is_some_and(|c| {
is_minimap_handle(&model, c)
&& !frame_contains(&model, &scroll_sources, c, x, y)
}),
);
let hit = order::hit_test(&sorted, |fh| {
// Mouse-enabled, or over one of the frame's hyperlink spans. The chat frame takes no
// mouse (`[+0xcc] = 0`); the reference gives each span a mouse-enabled
// `CSimpleHyperlinkButton` child (`0x7a3240`) that routes its scripts to the parent, so
// a chat window hits only over a link and drags by its tab, not its body.
(model.arena.is_mouse_enabled(fh) || link_span_hit(&model, fh, x, y))
// The nameplate's `+0x3c` veto (`0x7cba30`), not its mouse-enabled bit: while a
// ground-targeted spell is armed a plate refuses the hit before its rect is
// tested, so the reticle is placed through it.
let indexed = model.arena.is_mouse_enabled(fh) || link_span_hit(&model, fh, x, y);
// The nameplate's `+0x3c` veto (`0x7cba30`), not its mouse-enabled bit: while a
// ground-targeted spell is armed a plate refuses the hit before its rect is tested,
// so the reticle is placed through it.
let hit = indexed
&& !model.nameplates.vetoes(fh)
&& frame_contains(&model, &scroll_sources, fh, x, y)
})
&& frame_contains(&model, &scroll_sources, fh, x, y);
if blip_held && indexed && !hit && is_minimap_handle(&model, fh) {
minimap_missed.set(true);
}
hit
});
(hit, minimap_missed.get())
}
/// [`Self::hit_test`] over the frames that take the wheel: the same rect and clip rules, gated
@@ -154,10 +185,15 @@ impl UiScript {
pub fn mouse_move(&mut self, x: f32, y: f32) -> Option<u32> {
// A drag-active EditBox extends its selection on every move (`0x77a860`).
editbox::drag_update(&self.lua, x, y);
let new_id = self.hit_test(x, y);
let (new_handle, minimap_missed) = self.hover_walk(x, y);
let new_id = new_handle.and_then(|h| self.model_ref().frame_to_id.get(&h).copied());
// The Minimap's probe runs in the sweep, ahead of the leave and the enter.
if minimap_missed {
super::mouseover::minimap_probe_miss(&self.lua);
}
#[allow(clippy::type_complexity)]
let (leave_id, drag_start, enter_id, slider_change, color_change): (
Option<u32>,
let (leave, drag_start, enter_id, slider_change, color_change): (
Option<(FrameHandle, Option<u32>)>,
Option<(u32, String)>,
Option<u32>,
Option<(u32, f32)>,
@@ -173,19 +209,13 @@ impl UiScript {
super::object::advance_move(&mut model, (x, y));
super::object::advance_size(&mut model, (x, y));
let drag_start = cursor::maybe_start_drag(&mut model, (x, y));
let new_handle = new_id.and_then(|id| model.id_to_frame.get(&id).copied());
let new_handle = new_id.and(new_handle);
if new_handle == model.mouseover {
(None, drag_start, None, slider_change, color_change)
} else {
let old_id = model.mouseover.and_then(|h| {
model
.arena
.frame(h)
.is_some()
.then(|| model.frame_to_id.get(&h).copied())
.flatten()
});
let old_handle = model.mouseover;
// A frame destroyed since it took the hover gets no leave.
let old_handle = model.mouseover.filter(|&h| model.arena.frame(h).is_some());
let old_id = old_handle.and_then(|h| model.frame_to_id.get(&h).copied());
model.mouseover = new_handle;
// Crossing a button's boundary changes no button state: the enter/leave notifies
// (`0x779490`/`0x7794e0`) read `[+0x328]` only as a disabled guard, so a press
@@ -194,7 +224,8 @@ impl UiScript {
// is still the hover: the reference moves `[root+0x7c]` before either notify.
let old_id = old_id.filter(|_| button::hover_notify_runs(&model, old_handle));
let enter_id = new_id.filter(|_| button::hover_notify_runs(&model, new_handle));
(old_id, drag_start, enter_id, slider_change, color_change)
let leave = old_handle.map(|h| (h, old_id));
(leave, drag_start, enter_id, slider_change, color_change)
}
};
// A thumb drag fires `OnValueChanged`, outside the borrow.
@@ -225,12 +256,10 @@ impl UiScript {
self.push_error(e);
}
}
if let Some(oid) = leave_id {
if let Err(e) =
event::fire_widget_handler(&self.lua, oid, "OnLeave", vec![Value::Boolean(true)])
{
self.push_error(e);
}
// The old frame's leave, its class's engine half included ([`super::mouseover::leave`]),
// runs before the new frame's enter (`0x766229`, then `0x766239`).
if let Some((h, script)) = leave {
super::mouseover::leave(&self.lua, h, script);
}
if let Some(nid) = enter_id {
if let Err(e) =
@@ -0,0 +1,168 @@
//! The engine half of a hover leave: what the WorldFrame, a nameplate and the Minimap do to the
//! mouseover and to `GameTooltip` inside the hover walk, before the next frame's `OnEnter`.
use super::common::script;
use crate::script::UiScript;
/// A full-screen `UIParent`, a mouse-enabled `Minimap` at (600, 400)–(740, 540), a `GameTooltip`,
/// and a bag button at (100, 100)–(140, 140) whose `OnEnter` records whether the tooltip was up
/// and then takes it.
fn minimap_and_bag() -> UiScript {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
UIParent = CreateFrame("Frame", "UIParent"); UIParent:SetAllPoints()
local map = CreateFrame("Minimap", "Minimap", UIParent)
map:SetPoint("BOTTOMLEFT", 600, 400); map:SetWidth(140); map:SetHeight(140)
map:EnableMouse(true)
CreateFrame("GameTooltip", "GameTooltip", UIParent):Hide()
Bag = CreateFrame("Button", "Bag", UIParent)
Bag:SetPoint("BOTTOMLEFT", 100, 100); Bag:SetWidth(40); Bag:SetHeight(40)
Bag:EnableMouse(true)
Bag:SetScript("OnEnter", function()
entered_visible = GameTooltip:IsVisible()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText("Bag slot")
end)
"#,
)
.unwrap();
s.resolve();
s
}
/// The Minimap's leave (`0x4eb880`, its `vtbl+0x50`) hides `GameTooltip` (`0x4eb88e`–`0x4eb898`)
/// before its base leave, and the walk runs it before the next frame's enter, so a blip's tooltip
/// is gone when the button takes the tooltip, and the button's stays.
#[test]
fn leaving_the_minimap_hides_the_tooltip_before_the_next_enter() {
let mut s = minimap_and_bag();
s.mouse_move(670.0, 470.0);
assert!(s.minimap_tooltip("Stormwind", false));
s.mouse_move(120.0, 120.0);
assert!(
s.eval::<bool>("return entered_visible == nil").unwrap(),
"the blip's tooltip was hidden before the bag's OnEnter"
);
assert!(s
.eval::<bool>(r#"return GameTooltip:IsShown() == 1 and GameTooltip:IsOwned(Bag) and true"#)
.unwrap());
assert!(s.take_errors().is_empty());
}
/// The WorldFrame's leave (`0x483b10`) runs the base leave first (`0x483b1d`), so its `OnLeave`
/// still finds the mouseover, and then publishes the empty one (`0x483b46`): `"mouseover"` names
/// nobody and the published guid is gone. Its enter is the base one and publishes nothing.
#[test]
fn the_world_frames_leave_runs_its_onleave_then_publishes_the_loss() {
let mut s = minimap_and_bag();
s.run(
r#"
WorldFrame = CreateFrame("WorldFrame", "WorldFrame"); WorldFrame:SetAllPoints()
WorldFrame:SetScript("OnLeave", function() left_mouseover = UnitExists("mouseover") end)
"#,
)
.unwrap();
s.resolve();
const WOLF: u64 = 0xF130_0000_4500_0001;
s.mouse_move(400.0, 300.0);
assert_eq!(
s.world_mouseover(),
None,
"the WorldFrame's enter publishes nothing"
);
assert!(s.publish_world_mouseover(Some(WOLF)));
s.set_unit(
"mouseover",
Some(crate::script::UnitState {
exists: true,
guid: WOLF,
..Default::default()
}),
);
s.mouse_move(120.0, 120.0);
assert!(s
.eval::<bool>("return left_mouseover == 1 and UnitExists('mouseover') == nil")
.unwrap());
assert_eq!(s.world_mouseover(), None);
assert!(s.take_errors().is_empty());
}
/// With no capture held, the walk probes the Minimap only in its sweep, and the sweep only when no
/// frame ahead of it took the hit (`0x76620c`): off it, the probe drops the blip and hides the
/// tooltip (`0x4eb12b`–`0x4eb166`), so the same blip shows afresh later; onto a window over it,
/// the probe never runs and the blip stays held, though the Minimap's leave still hides the
/// tooltip. A held capture is probed before the sweep (the next test).
#[test]
fn the_minimap_probe_drops_its_blip_only_when_the_walk_probes_it() {
let mut s = minimap_and_bag();
s.run(
r#"
Cover = CreateFrame("Frame", "Cover", UIParent)
Cover:SetPoint("BOTTOMLEFT", 700, 500); Cover:SetWidth(40); Cover:SetHeight(40)
Cover:SetFrameStrata("DIALOG"); Cover:EnableMouse(true)
"#,
)
.unwrap();
s.resolve();
s.mouse_move(670.0, 470.0);
assert!(s.minimap_tooltip("Stormwind", false));
s.mouse_move(720.0, 520.0);
assert_eq!(s.minimap_blip().as_deref(), Some("Stormwind"), "not probed");
assert!(!s
.eval::<bool>("return GameTooltip:IsVisible() == 1")
.unwrap());
s.mouse_move(670.0, 470.0);
assert!(s.minimap_tooltip("Stormwind", false));
s.mouse_move(300.0, 300.0);
assert_eq!(s.minimap_blip(), None, "probed and missed");
assert!(!s
.eval::<bool>("return GameTooltip:IsVisible() == 1")
.unwrap());
assert!(s.take_errors().is_empty());
}
/// A window shown under a still cursor takes the hover at the pump's re-pick (`0x7657be` →
/// `0x7660d0`), after the update walk and its drain, and that walk runs the WorldFrame's leave
/// like a moving one: the loss is published before the window's `OnEnter`, in the same tick.
#[test]
fn the_re_pick_onto_a_shown_window_publishes_the_worlds_loss_first() {
let mut s = minimap_and_bag();
s.run(
r#"
WorldFrame = CreateFrame("WorldFrame", "WorldFrame"); WorldFrame:SetAllPoints()
Bag:Hide()
"#,
)
.unwrap();
s.resolve();
s.mouse_move(120.0, 120.0);
assert!(s.publish_world_mouseover(Some(7)));
s.run(r#"GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR"); GameTooltip:SetText("Wolf")"#)
.unwrap();
s.run("Bag:Show()").unwrap();
s.tick(0.016);
assert_eq!(s.world_mouseover(), None);
assert!(s
.eval::<bool>(r#"return entered_visible == nil and GameTooltip:IsOwned(Bag) and true"#)
.unwrap());
assert!(s.take_errors().is_empty());
}
/// Before its sweep the walk probes the capture frame (`[root+0x80]`, `0x7661ba`–`0x7661ca`),
/// unconditionally and for nothing but the probe's own work: a Minimap pressed and dragged off onto
/// a frame that wins the sweep is still probed, misses, and drops its blip (`0x4eb12b`).
#[test]
fn a_captured_minimap_is_probed_though_the_sweep_never_reaches_it() {
let mut s = minimap_and_bag();
s.run(r#"Bag:SetFrameStrata("DIALOG")"#).unwrap();
s.resolve();
s.mouse_move(670.0, 470.0);
assert!(s.minimap_tooltip("Stormwind", false));
s.mouse_button(670.0, 470.0, "LeftButton", true);
s.mouse_move(120.0, 120.0);
assert_eq!(s.minimap_blip(), None, "the capture's probe missed");
s.mouse_button(120.0, 120.0, "LeftButton", false);
assert!(s.take_errors().is_empty());
}
@@ -26,6 +26,7 @@ mod generic_for;
mod gm_ticket;
mod handler_prof;
mod host_state;
mod hover_publish;
mod input;
mod keep_rect;
mod keyboard;
@@ -735,3 +735,72 @@ fn a_ground_target_veto_refuses_the_hit_without_disabling_the_mouse() {
"and the plate takes it back when the cast is gone"
);
}
/// A plate's leave (`0x7cb8b0`) publishes the empty mouseover first (`0x7cb8bf`), whose change
/// requests the fade (`0x492909`), so a window entered from a plate finds the plate's cursor-seated
/// tooltip gone; a hovered plate retired by its unit leaving takes the same leave (`0x764cce`).
#[test]
fn leaving_a_plate_publishes_the_empty_mouseover_before_the_next_enter() {
let mut s = vm();
let wolf = plate("Wolf", 30.0, 40.0);
let key = wolf.key;
s.run(
r#"
CreateFrame("GameTooltip", "GameTooltip"):Hide()
Bag = CreateFrame("Button", "Bag")
Bag:SetPoint("BOTTOMLEFT", 40, 40); Bag:SetWidth(40); Bag:SetHeight(40)
Bag:EnableMouse(true)
Bag:SetScript("OnEnter", function() entered_visible = GameTooltip:IsVisible() end)
"#,
)
.unwrap();
drive(&mut s, std::slice::from_ref(&wolf));
let hover_plate = |s: &mut UiScript| {
s.mouse_move(500.0, 384.0);
assert!(s.publish_world_mouseover(Some(key)), "the plate's enter");
s.run(r#"GameTooltip:SetOwner(WorldFrame, "ANCHOR_CURSOR"); GameTooltip:SetText("Wolf")"#)
.unwrap();
};
hover_plate(&mut s);
s.mouse_move(60.0, 60.0);
assert!(s.eval::<bool>("return entered_visible == nil").unwrap());
assert_eq!(s.world_mouseover(), None);
hover_plate(&mut s);
drive(&mut s, &[]);
assert_eq!(s.world_mouseover(), None, "the retired plate's leave");
assert!(!s
.eval::<bool>("return GameTooltip:IsVisible() == 1")
.unwrap());
assert!(s.take_errors().is_empty());
}
/// Freelook's entry disables every plate's mouse through `0x76af50(2)` (`0x60f830`), whose removal
/// tail (`0x764ba0`, flag 0, `0x76af7f`) ends the hovered plate's hover and runs its leave
/// (`0x7cb8b0`): `OnLeave` fires, the mouseover is published empty, and the hover re-picks.
#[test]
fn freelook_ends_a_hovered_plates_hover_through_its_leave() {
let mut s = vm();
let wolf = plate("Wolf", 30.0, 40.0);
let key = wolf.key;
drive(&mut s, std::slice::from_ref(&wolf));
s.run(
r#"
left = 0
local plate = WorldFrame:GetChildren()
plate:SetScript("OnLeave", function() left = left + 1 end)
"#,
)
.unwrap();
s.mouse_move(500.0, 384.0);
assert!(s.publish_world_mouseover(Some(key)), "the plate's enter");
s.set_nameplate_mouse(false);
assert_eq!(
s.eval::<i64>("return left").unwrap(),
1,
"the plate's OnLeave"
);
assert_eq!(s.world_mouseover(), None, "published empty");
assert_eq!(s.hovered_nameplate(), None);
assert!(s.take_errors().is_empty());
}
@@ -579,6 +579,10 @@ fn world_hover_drive_and_health_watcher() {
"#,
)
.unwrap();
assert!(
s.publish_world_mouseover(Some(wolf().guid)),
"a first hover"
);
assert!(s.world_tooltip_unit("mouseover"), "the hover shows");
s.run(
r#"
@@ -614,8 +618,7 @@ fn world_hover_drive_and_health_watcher() {
.unwrap();
// Hover lost: the fade arms and `"mouseover"` names nobody (`0x492890` zeroes the pair), yet
// the fading plate keeps its bar, whose watcher follows the unit's guid, not the token.
s.world_tooltip_fade();
s.set_unit("mouseover", None);
assert!(s.publish_world_mouseover(None));
s.tick(0.1);
s.run(
r#"
@@ -655,7 +658,8 @@ fn minimap_blip_tooltip_shows_follows_and_hides() {
r#"
local up = CreateFrame("Frame", "UIParent")
up:SetPoint("BOTTOMLEFT", 0, 0); up:SetWidth(800); up:SetHeight(600)
CreateFrame("Minimap", "Minimap", up)
-- The cursor stays on the Minimap, whose probe drops the blip on a miss.
CreateFrame("Minimap", "Minimap", up):SetAllPoints(up)
"#,
)
.unwrap();
@@ -690,7 +694,7 @@ fn minimap_blip_tooltip_shows_follows_and_hides() {
)
.unwrap();
// Hover loss hides at once.
s.world_tooltip_hide();
s.minimap_blip_lost();
s.run(r#"assert(not GameTooltip:IsShown(), "hidden with no fade")"#)
.unwrap();
assert!(s.take_errors().is_empty());
@@ -707,7 +711,8 @@ fn a_cursor_seated_plate_lands_on_the_cursor_under_the_ui_scale() {
r#"
local up = CreateFrame("Frame", "UIParent")
up:SetPoint("BOTTOMLEFT", 0, 0); up:SetPoint("TOPRIGHT", 0, 0)
CreateFrame("Minimap", "Minimap", up)
-- The cursor stays on the Minimap, whose probe drops the blip on a miss.
CreateFrame("Minimap", "Minimap", up):SetAllPoints(up)
CreateFrame("GameTooltip", "GameTooltip", UIParent)
"#,
)
@@ -807,10 +812,11 @@ fn a_cursor_seated_world_plate_hides_at_once_on_hover_loss() {
"#,
)
.unwrap();
s.publish_world_mouseover(Some(1));
assert!(s.world_tooltip_gameobject("Brill", &[], true));
s.run(r#"assert(GameTooltip:GetAnchorType() == "ANCHOR_CURSOR", "seated in mode 6")"#)
.unwrap();
s.world_tooltip_fade();
s.publish_world_mouseover(None);
s.run(r#"assert(not GameTooltip:IsVisible(), "gone at once")"#)
.unwrap();
assert!(s.take_errors().is_empty());
@@ -840,7 +846,7 @@ fn a_minimap_blip_plate_hides_at_once_on_hover_loss() {
"#,
)
.unwrap();
s.world_tooltip_hide();
s.minimap_blip_lost();
s.run(r#"assert(not GameTooltip:IsShown(), "gone at once")"#)
.unwrap();
assert!(s.take_errors().is_empty());
@@ -865,11 +871,12 @@ fn a_cursor_plate_replaced_by_another_hides_then_shows_and_clears_twice_per_seat
"#,
)
.unwrap();
s.publish_world_mouseover(Some(1));
assert!(s.world_tooltip_gameobject("Brill", &[], true));
s.run(r#"assert(table.concat(log, ",") == "cleared,cleared,show", table.concat(log, ",")) log = {}"#)
.unwrap();
// The driver's change: the teardown, then the next seat.
s.world_tooltip_fade();
s.publish_world_mouseover(Some(2));
assert!(s.world_tooltip_gameobject("Tirisfal Glades", &[], true));
s.run(
r#"
@@ -881,48 +888,47 @@ fn a_cursor_plate_replaced_by_another_hides_then_shows_and_clears_twice_per_seat
assert!(s.take_errors().is_empty());
}
/// A Lua content re-set of the world plate (an `UPDATE_MOUSEOVER_UNIT` handler's `SetUnit`) keeps
/// it the world plate, so hover loss still fades it; a re-own by a window's `SetOwner` does not.
/// The publisher tears down on every change away from a published mouseover (`0x4928d5`), loss
/// and replacement alike, and not for a first one or an unchanged one (`0x4820b5`); its fade
/// request is on `GameTooltip` whoever holds it (`0x492909`), so a tooltip a window took since
/// fades too.
#[test]
fn hover_loss_fades_a_re_set_world_plate_but_not_a_re_owned_one() {
fn a_mouseover_change_fades_the_tooltip_whoever_holds_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_unit("mouseover", Some(wolf()));
s.run(
r#"
UIParent = CreateFrame("Frame", "UIParent"); UIParent:SetAllPoints()
local tt = CreateFrame("GameTooltip", "GameTooltip"); tt:Hide()
tt:SetScript("OnTooltipSetDefaultAnchor", function()
GameTooltip:SetOwner(UIParent, "ANCHOR_NONE")
end)
tt:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
tt:SetScript("OnEvent", function() GameTooltip:SetUnit("mouseover") end)
CreateFrame("GameTooltip", "GameTooltip"):Hide()
Bag = CreateFrame("Button", "Bag")
function bag_tooltip()
GameTooltip:SetOwner(Bag, "ANCHOR_RIGHT"); GameTooltip:SetText("Bag slot")
end
"#,
)
.unwrap();
assert!(s.world_tooltip_unit("mouseover"));
assert!(
s.world_tooltip_up(),
"the re-set plate is still the world's"
);
s.world_tooltip_fade();
s.tick(2.0);
s.run(r#"assert(not GameTooltip:IsShown(), "faded out")"#)
.unwrap();
assert!(s.world_tooltip_unit("mouseover"));
s.run(
r#"
GameTooltip:SetOwner(CreateFrame("Button", "Bag1"), "ANCHOR_RIGHT")
GameTooltip:SetText("Bag slot")
"#,
)
.unwrap();
assert!(!s.world_tooltip_up());
s.world_tooltip_fade();
s.tick(2.0);
s.run(r#"assert(GameTooltip:IsShown() and GameTooltip:GetAlpha() == 1, "the window's tooltip stays")"#)
.unwrap();
let up = |s: &mut crate::script::UiScript| {
s.tick(2.5);
s.eval::<bool>("return GameTooltip:IsShown() == 1").unwrap()
};
// A first publish has nothing to tear down; an unchanged one does nothing.
s.run("bag_tooltip()").unwrap();
assert!(s.publish_world_mouseover(Some(1)));
assert!(!s.publish_world_mouseover(Some(1)));
assert!(up(&mut s), "no fade for a first or an unchanged mouseover");
// A replacement and a loss each request the fade.
for next in [Some(2), None] {
s.run("bag_tooltip()").unwrap();
assert!(s.publish_world_mouseover(next));
assert!(
!up(&mut s),
"the bag's tooltip faded on the change to {next:?}"
);
}
// From the empty mouseover, a new one tears nothing down.
s.run("bag_tooltip()").unwrap();
assert!(s.publish_world_mouseover(Some(3)));
assert!(up(&mut s));
assert!(s.take_errors().is_empty());
}
@@ -424,8 +424,6 @@ pub(in crate::script) fn set_owner_core(
};
t.owner = owner;
t.anchor = TooltipAnchor::None;
// A re-own, or the hide's un-own, ends the world plate; a content re-set alone does not.
t.world_owned = false;
if let Some(owner) = owner {
t.anchor = anchor;
apply_anchor(&mut model, h, owner, anchor, x, y);
+5 -60
View File
@@ -161,16 +161,6 @@ fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result<bool> {
Ok(true)
}
/// Mark the tooltip as world-hover-owned (the fade-on-loss gate).
fn set_world_owned(lua: &Lua, h: FrameHandle) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
t.world_owned = true;
}
}
}
/// Remember (or drop) the guid of the unit the tooltip shows, the health watcher's key.
fn set_subject(lua: &Lua, h: FrameHandle, guid: Option<u64>) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -264,13 +254,12 @@ impl super::UiScript {
/// fire `UPDATE_MOUSEOVER_UNIT`. Call on a hover-target change; the render's show returns a
/// fading plate to full alpha. `false` when there is no tooltip frame or no such unit.
pub fn world_tooltip_unit(&mut self, token: &str) -> bool {
let (h, id) = {
let id = {
let mut model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return false;
};
let id = model.frame_id(h);
(h, id)
model.frame_id(h)
};
if let Err(e) =
super::event::fire_widget_handler(&self.lua, id, "OnTooltipSetDefaultAnchor", vec![])
@@ -292,7 +281,6 @@ impl super::UiScript {
}
};
if shown {
set_world_owned(&self.lua, h);
self.fire_event("UPDATE_MOUSEOVER_UNIT", vec![]);
}
shown
@@ -351,7 +339,6 @@ impl super::UiScript {
self.push_error(e);
return false;
}
set_world_owned(&self.lua, h);
show_or_hide_empty(&self.lua, h);
true
}
@@ -360,7 +347,8 @@ impl super::UiScript {
/// probe `0x4eb0c0`: owned by `Minimap` itself (`0x4eb19c` keeps `this + 0x24`, pushed at
/// `0x4eb57d`) in mode 6, `ANCHOR_CURSOR` (`0x4eb587`), so the tooltip's own update keeps it on
/// the cursor; one gold line, faint gold for a cross-interior dot; then the show
/// (`0x4eb85f`/`0x4eb869`). Hover loss is [`Self::world_tooltip_hide`].
/// (`0x4eb85f`/`0x4eb869`). The blip is held until the probe finds none
/// ([`Self::minimap_blip_lost`]) or misses the Minimap.
pub fn minimap_tooltip(&mut self, text: &str, grey: bool) -> bool {
let (h, id, root) = {
let mut model = self.model_mut();
@@ -391,53 +379,10 @@ impl super::UiScript {
self.push_error(e);
return false;
}
set_world_owned(&self.lua, h);
self.model_mut().minimap_blip = Some(text.to_string());
show_or_hide_empty(&self.lua, h);
true
}
/// Whether `GameTooltip` is still the shown plate a world hover put up, with no `SetOwner` or
/// hide since; a Lua content re-set (an `UPDATE_MOUSEOVER_UNIT` handler's `SetUnit`) keeps it.
pub fn world_tooltip_up(&mut self) -> bool {
self.world_plate().is_some()
}
/// The world plate, when [`Self::world_tooltip_up`].
fn world_plate(&mut self) -> Option<FrameHandle> {
let model = self.model_mut();
let h = model.arena.lookup("GameTooltip")?;
model
.arena
.frame(h)
.is_some_and(|f| {
matches!(&f.kind_state, KindState::Tooltip(t) if t.world_owned) && f.shown
})
.then_some(h)
}
/// On world hover loss, the fade request (`0x492909` → `0x530ae0`): a cursor-seated plate hides
/// at once, any other fades. The reference requests it whenever the previous mouseover goes,
/// whoever holds the tooltip. Moving onto a window, its hover walk runs the WorldFrame's leave,
/// which publishes the empty mouseover and fades (`0x483b10` → `0x483b46`), before the window's
/// enter takes the tooltip (`0x766229` before `0x766239`). The host's world drive runs in the
/// feed phase, before the UI input pass, so it learns of the loss only after the window's enter
/// has run. So this acts only while the world plate is up ([`Self::world_tooltip_up`]): a
/// tooltip re-owned since, by a window or by an addon while the pointer stays on the world, is
/// left alone where the reference would fade it, and a hidden one is not touched.
pub fn world_tooltip_fade(&mut self) {
if let Some(h) = self.world_plate() {
super::tooltip::fade_out(&self.lua, h);
}
}
/// On minimap blip hover loss, the direct hide (`0x4eb0c0`: shown bit, then the hide slot,
/// `0x4eb15e`–`0x4eb166`), with no fade. The reference hides whoever holds the tooltip; as in
/// [`Self::world_tooltip_fade`], this acts only while the world plate is still up.
pub fn world_tooltip_hide(&mut self) {
if let Some(h) = self.world_plate() {
super::visibility::set_shown(&self.lua, h, false);
}
}
}
/// Register the unit content channel into the GameTooltip kind method table.
+13 -14
View File
@@ -8,7 +8,7 @@
//! (`0x76abfd`, `0x76ab41`). [`show`] and [`hide`] are that dispatch, so a kind's override runs
//! the same however the transition comes.
use mlua::{Lua, Table, Value};
use mlua::{Lua, Table};
use super::{event, Model};
use crate::widget::{ButtonState, ChildWalk, FrameHandle, FrameKind};
@@ -95,9 +95,8 @@ pub(super) fn base_hide(lua: &Lua, h: FrameHandle) {
}
leave_mouse_index(&mut model, h)
};
if let Some(id) = left {
let fired = event::fire_widget_handler(lua, id, "OnLeave", vec![Value::Boolean(true)]);
record(lua, fired);
if let Some(script) = left {
super::mouseover::leave(lua, h, script);
}
walk_children(lua, h, hide);
// `CSimpleButton`'s hide notify (`+0x34`, `0x7791e0`) un-presses it, unless disabled or
@@ -147,17 +146,16 @@ pub(super) fn set_mouse_enabled(lua: &Lua, h: FrameHandle, enabled: bool) {
}
leave_mouse_index(&mut model, h)
};
if let Some(id) = left {
let fired = event::fire_widget_handler(lua, id, "OnLeave", vec![Value::Boolean(true)]);
record(lua, fired);
if let Some(script) = left {
super::mouseover::leave(lua, h, script);
}
model_mut(lua).arena.set_mouse_enabled(h, false);
}
/// The mouse kind's removal tail (`0x764ba0`, kind 2, flag 0), shared by the hide and a mouse
/// disable: the capture and the press it took drop, then the hover ends. The id whose `OnLeave`
/// the caller fires.
fn leave_mouse_index(model: &mut Model, h: FrameHandle) -> Option<u32> {
/// disable: the capture and the press it took drop, then the hover ends. `Some` when the frame
/// held the hover, holding the id whose `OnLeave` its leave notify fires.
fn leave_mouse_index(model: &mut Model, h: FrameHandle) -> Option<Option<u32>> {
drop_capture(model, h);
leave_if_hovered(model, h)
}
@@ -188,9 +186,10 @@ fn drop_capture(model: &mut Model, h: FrameHandle) {
/// and calls the leave notify with 1 (`0x764cce`), whose base `0x76b6f0` clears the drag-arm
/// (`+0x100`/`+0x104`), so the hovered frame's OnLeave runs before its own OnHide and its
/// ancestors'.
/// The leave is a virtual call: a disabled Button's own `0x7794e0` skips its OnLeave. This leave is
/// how a tooltip closes with its window; the reference has no other. The id whose OnLeave fires.
fn leave_if_hovered(model: &mut Model, h: FrameHandle) -> Option<u32> {
/// The leave is a virtual call ([`super::mouseover::leave`]): a disabled Button's own `0x7794e0`
/// skips its OnLeave. This leave is how a tooltip closes with its window; the reference has no
/// other. `Some` when the frame held the hover, holding the id whose OnLeave fires.
fn leave_if_hovered(model: &mut Model, h: FrameHandle) -> Option<Option<u32>> {
if model.mouseover != Some(h) {
return None;
}
@@ -200,7 +199,7 @@ fn leave_if_hovered(model: &mut Model, h: FrameHandle) -> Option<u32> {
model.drag = None;
}
model.hover_repick = true;
notified.then(|| model.frame_id(h))
Some(notified.then(|| model.frame_id(h)))
}
/// The notify slot (`+0x30` OnShow, `+0x34` OnHide): the base fires the script unless the frame is
@@ -265,10 +265,6 @@ pub struct TooltipState {
/// health watcher (`0x467e70`, registered at `0x52a0a8`) drives the bar; a `set_unit` push of
/// that unit, under any token, re-drives it.
pub unit_guid: Option<u64>,
/// The plate a world hover put up, set after its build and cleared by the next SetOwner core
/// (a Lua `SetOwner`, an engine seat, or any hide's un-own), not by a content re-set. Hover loss
/// fades only such a plate.
pub world_owned: bool,
/// Armed for a compare render: the next `SetInventoryItem` prepends "Currently Equipped"
/// (`[arg+0x18]`, not the compact `[arg+0x14]`), surviving the stock caller's `SetOwner` in
/// between (`MerchantFrame.xml:67-72`); the reference's route to `0x52b650` is untraced.