"mouseover" names nobody once no unit wins the hover: empty ground, a corpse or a nearer GameObject clear it, and the fading tooltip's health bar follows the unit's guid, not the token (#518)

`"mouseover"` now names nobody once the mouse leaves a unit, as in 1.12.1, where the publisher `0x492890` zeroes the mouseover guid pair on teardown (`0x4928e8`, `0x4928f2`) and a corpse or GameObject hover leaves it naming no unit. Before, `UnitExists("mouseover")`, `UnitName("mouseover")` and `UnitIsUnit("mouseover", …)` kept answering for the last unit hovered.

- The world tooltip's health bar follows its subject's guid, as the builder `0x529fe0` registers its health watcher on the guid (`0x52a0a8`), so clearing the token leaves the fading plate and its bar as they were; the clear fires no `UPDATE_MOUSEOVER_UNIT`, whose one fire site is the unit arm (`0x4929b3`).
- One definition of the hovered unit (none when a nearer GameObject wins the pick) serves the tooltip, the unit tokens and the aura feed, so they clear together.

Fixes #500

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-28 15:28:30 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 829194ac1a
commit 0e39273f13
11 changed files with 252 additions and 67 deletions
+10
View File
@@ -113,6 +113,16 @@ impl Hovered {
pub(crate) fn any(&self) -> Option<Entity> {
self.target.or(self.corpse)
}
/// The unit `"mouseover"` names: the hovered unit, unless a nearer GameObject won the pick.
/// The publisher `0x492890` writes whatever won to `0xb4e2c8`/`0xb4e2cc` (`0x492927`,
/// `0x492938`), and the token resolver re-resolves it as a unit (`0x515bca mov ecx,8`), so a
/// GameObject, a corpse or nothing names nobody (`0x515bd9 je`).
pub(crate) fn mouseover(&self, go: &HoveredObject) -> Option<(Entity, u64)> {
self.target
.zip(self.guid)
.filter(|_| !go_is_nearest(self, go))
}
}
/// The GameObject under the cursor this frame, by [`hover::update_hovered_object`]: usable, never
+5 -2
View File
@@ -393,6 +393,7 @@ struct AuraUnits<'w> {
// anyone.
group: Option<Res<'w, crate::ui_party::GroupState>>,
hovered: Option<Res<'w, crate::target::Hovered>>,
hovered_go: Option<Res<'w, crate::target::HoveredObject>>,
interact: Option<Res<'w, crate::ui_session::InteractNpc>>,
}
@@ -548,8 +549,10 @@ fn feed_auras(
mouseover: units
.hovered
.as_deref()
.and_then(|h| h.target.and(h.guid))
.unwrap_or(0),
.and_then(|h| {
h.mouseover(&units.hovered_go.as_deref().copied().unwrap_or_default())
})
.map_or(0, |(_, guid)| guid),
npc: units.interact.as_deref().and_then(|i| i.1).unwrap_or(0),
held,
buffs_visible,
+20
View File
@@ -963,6 +963,26 @@ mod tests {
assert_eq!(listed(&mut app, "party1target", true), [770, 589]);
}
/// A nearer GameObject wins the pick, and the publisher writes its guid, which the resolver
/// rejects as a unit (`0x515bd9 je`): the unit behind it is no longer the mouseover.
#[test]
fn a_nearer_gameobject_takes_the_mouseover_from_the_unit_behind_it() {
let mut app = chain_app();
assert_eq!(listed(&mut app, "mouseover", true), [770]);
app.world_mut()
.resource_mut::<crate::target::Hovered>()
.distance = 10.0;
let chest = app.world_mut().spawn_empty().id();
app.insert_resource(crate::target::HoveredObject {
target: Some(chest),
guid: Some(0xF110_0000_0000_0004),
distance: 5.0,
});
app.update();
assert!(listed(&mut app, "mouseover", true).is_empty());
assert!(listed(&mut app, "mouseovertarget", true).is_empty());
}
/// A raid member's target that is out of view but on the roster reads its record, as
/// `UnitDebuff`'s roster walk finds it (`0x519b25`); a hop off a unit not held is nobody.
#[test]
+15 -3
View File
@@ -565,6 +565,8 @@ pub(crate) struct HoverMemo<'s> {
last_lines: Local<'s, crate::ui_script::VmMemo<Option<UnitState>>>,
/// The hover probe's last trace line, so a stationary probe logs it once.
trace: Local<'s, String>,
/// Whether the VM holds a `"mouseover"` unit, so the clear is pushed once.
published: Local<'s, crate::ui_script::VmMemo<bool>>,
}
fn lines_view(s: &UnitState) -> UnitState {
@@ -619,10 +621,11 @@ fn drive_mouseover_tooltip(
let (last, last_lines, trace) = (&mut memo.last, &mut memo.last_lines, &mut *memo.trace);
let last = last.get(&script);
let last_lines = last_lines.get(&script);
let published = memo.published.get(&script);
let self_store = self_q.iter().next();
let chr = classes.as_deref().map(|t| &t.0);
let unit = hovered.target.zip(hovered.guid).and_then(|(entity, guid)| {
let unit = hovered.mouseover(&hovered_go).and_then(|(entity, guid)| {
let store = stores.get(entity).ok()?;
let name = names
.resolve_unit(guid, Some(store), &commands)
@@ -648,11 +651,12 @@ fn drive_mouseover_tooltip(
(unit.is_none() && hovered.corpse.is_none()) || go_is_nearest(&hovered, &hovered_go)
});
if let Some((guid, state)) = unit.filter(|_| go.is_none()) {
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.
let key = lines_view(&state);
script.set_unit("mouseover", Some(state));
*published = true;
if *last != LastHover::Unit(guid) || last_lines.as_ref() != Some(&key) {
script.world_tooltip_unit("mouseover");
*last = LastHover::Unit(guid);
@@ -660,6 +664,14 @@ fn drive_mouseover_tooltip(
}
return;
}
// No unit won the pick: the publisher's teardown zeroes the mouseover pair (`0x4928e8`,
// `0x4928f2`) and writes a null, corpse or GameObject guid, none of which resolves as a unit,
// so `"mouseover"` names nobody from this frame. A fading plate keeps its lines and its bar,
// which follows the unit's guid, not the token; the null publish fires no event (the sole
// `UPDATE_MOUSEOVER_UNIT` is the unit arm's, `0x4929b3`).
if std::mem::take(published) {
script.set_unit("mouseover", None);
}
// The corpse plate, "Corpse of <owner>" (`0x52aef0`): a name and nothing else, corner-seated.
// The publisher fires no event for a corpse, so `UnitName("mouseover")` on one stays nil.
if let Some((entity, guid)) = hovered
@@ -830,7 +842,7 @@ fn drive_mouseover_tooltip(
return;
}
if !matches!(*last, LastHover::None) {
// Hover lost: arm the fade. The `mouseover` state stays, so the fading plate keeps it.
// Hover lost: arm the fade (`0x492909` → `0x530ae0`).
script.world_tooltip_fade();
*last = LastHover::None;
}
+110 -36
View File
@@ -518,17 +518,14 @@ fn the_spell_feed_runs_after_the_trainer_feed() {
));
}
/// The hover pushes `"mouseover"` with the hovered guid, the pair `0x492890` writes to
/// `0xb4e2c8`/`0xb4e2cc`: `UnitIsUnit` (`0x516070`) resolves both tokens through `0x515970` and
/// compares guids, so hovering the target answers 1 and hovering anyone else nil.
#[test]
fn the_mouseover_token_carries_the_hovered_guid() {
use benilla_protocol::ObjectFields;
use bevy::ecs::system::RunSystemOnce;
const ME: u64 = 0x77;
const WOLF: u64 = 0xF130_0000_4500_0001;
const BOAR: u64 = 0xF130_0000_4600_0002;
const ME: u64 = 0x77;
const WOLF: u64 = 0xF130_0000_4500_0001;
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) {
use benilla_protocol::ObjectFields;
let mut app = App::new();
let (tx, _rx) = crossbeam_channel::unbounded();
@@ -541,19 +538,17 @@ fn the_mouseover_token_carries_the_hovered_guid() {
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::go_templates::GameObjectTemplates>()
.init_resource::<Items>()
.init_resource::<PlayerActions>();
.init_resource::<PlayerActions>()
.add_systems(Update, drive_mouseover_tooltip);
app.world_mut()
.spawn((SelfPlayer, ObjectStore(ObjectFields::default())));
let wolf = app
.world_mut()
.spawn(ObjectStore(ObjectFields::default()))
.id();
let boar = app
.world_mut()
.spawn(ObjectStore(ObjectFields::default()))
.id();
let mut unit = || {
app.world_mut()
.spawn(ObjectStore(ObjectFields::default()))
.id()
};
let (wolf, boar) = (unit(), unit());
// `"player"` and `"target"` as the unit feed pushes them, each with its guid.
let mut script = UiScript::new().unwrap();
for (token, guid) in [("player", ME), ("target", WOLF)] {
script.set_unit(
@@ -567,29 +562,51 @@ fn the_mouseover_token_carries_the_hovered_guid() {
);
}
app.insert_non_send_resource(script);
(app, wolf, boar)
}
let hover = |app: &mut App, entity: Entity, guid: u64| {
*app.world_mut().resource_mut::<Hovered>() = Hovered {
target: Some(entity),
guid: Some(guid),
..Default::default()
};
app.world_mut()
.run_system_once(drive_mouseover_tooltip)
.unwrap();
/// Set this frame's pick and run the driver.
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) {
let pick = Hovered {
target: Some(entity),
guid: Some(guid),
distance: 10.0,
..Default::default()
};
let is_unit = |app: &mut App, other: &str| -> Option<i64> {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<Option<i64>>(&format!(r#"return UnitIsUnit("mouseover", "{other}")"#))
.unwrap()
hover(app, pick, HoveredObject::default());
}
fn eval(app: &mut App, lua: &str) -> Option<i64> {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<Option<i64>>(lua)
.unwrap()
}
/// The hover pushes `"mouseover"` with the hovered guid, the pair `0x492890` writes to
/// `0xb4e2c8`/`0xb4e2cc`: `UnitIsUnit` (`0x516070`) resolves both tokens through `0x515970` and
/// compares guids, so hovering the target answers 1 and hovering anyone else nil.
#[test]
fn the_mouseover_token_carries_the_hovered_guid() {
let (mut app, wolf, boar) = mouseover_app();
let is_unit = |app: &mut App, other: &str| {
eval(
app,
&format!(r#"return UnitIsUnit("mouseover", "{other}")"#),
)
};
hover(&mut app, wolf, WOLF);
hover_unit(&mut app, wolf, WOLF);
assert_eq!(is_unit(&mut app, "target"), Some(1), "hovering the target");
assert_eq!(is_unit(&mut app, "player"), None, "the target is not us");
hover(&mut app, boar, BOAR);
hover_unit(&mut app, boar, BOAR);
assert_eq!(
is_unit(&mut app, "target"),
None,
@@ -601,3 +618,60 @@ fn the_mouseover_token_carries_the_hovered_guid() {
"the token is itself"
);
}
/// Once no unit wins the pick, `"mouseover"` names nobody: the publisher `0x492890` zeroes the pair
/// (`0x4928e8`, `0x4928f2`) and writes a null, corpse or GameObject guid, which the resolver
/// rejects as a unit (`0x515bca mov ecx,8`, `0x515bd9 je`). Empty ground, a corpse and a nearer
/// GameObject each clear it, in the frame the hover leaves the unit.
#[test]
fn the_mouseover_token_clears_when_no_unit_is_hovered() {
use benilla_protocol::ObjectFields;
let (mut app, wolf, _) = mouseover_app();
let corpse = app
.world_mut()
.spawn(ObjectStore(ObjectFields::default()))
.id();
let chest = app
.world_mut()
.spawn(ObjectStore(ObjectFields::default()))
.id();
let named = |app: &mut App| {
(
eval(app, r#"return UnitExists("mouseover")"#),
eval(app, r#"return UnitIsUnit("mouseover", "target")"#),
)
};
let empty = Hovered::default();
let on_corpse = Hovered {
corpse: Some(corpse),
corpse_guid: Some(0xF100_0000_0000_0003),
distance: 10.0,
..Default::default()
};
let under_chest = Hovered {
target: Some(wolf),
guid: Some(WOLF),
distance: 10.0,
..Default::default()
};
let chest_nearer = HoveredObject {
target: Some(chest),
guid: Some(0xF110_0000_0000_0004),
distance: 5.0,
};
for (leave, object, what) in [
(empty, HoveredObject::default(), "empty ground"),
(on_corpse, HoveredObject::default(), "a corpse"),
(under_chest, chest_nearer, "a nearer GameObject"),
] {
hover_unit(&mut app, wolf, WOLF);
assert_eq!(
named(&mut app),
(Some(1), Some(1)),
"over the wolf, before {what}"
);
hover(&mut app, leave, object);
assert_eq!(named(&mut app), (None, None), "after {what}");
}
}
+41 -5
View File
@@ -582,6 +582,7 @@ pub(crate) struct UnitTokens<'w, 's> {
index: Option<Res<'w, crate::net::GuidIndex>>,
pet: Option<Res<'w, crate::ui_pet::PetBar>>,
hovered: Option<Res<'w, crate::target::Hovered>>,
hovered_go: Option<Res<'w, crate::target::HoveredObject>>,
group: Res<'w, crate::ui_party::GroupState>,
pub(crate) stores: Query<'w, 's, &'static ObjectStore>,
me: Query<'w, 's, (Entity, &'static Guid), With<SelfPlayer>>,
@@ -610,11 +611,11 @@ impl UnitTokens<'_, '_> {
.and_then(|s| s.0.unit_target())
.filter(|g| *g != 0)
.and_then(|g| self.held(g)),
// The same `Hovered` pick `ui_tooltip` pushes `"mouseover"` from.
"mouseover" => {
let h = self.hovered.as_ref()?;
h.target.zip(h.guid)
}
// The same pick `ui_tooltip` pushes `"mouseover"` from.
"mouseover" => self
.hovered
.as_ref()?
.mouseover(&self.hovered_go.as_deref().copied().unwrap_or_default()),
// Off the pet bar's cached guid, as the `"pet"` snapshot reads it.
"pet" => {
let guid = self.pet.as_ref()?.spells.pet_guid;
@@ -1629,6 +1630,41 @@ fn combo_edge(last: Option<(u8, u64)>, now: (u8, u64)) -> Option<bool> {
mod tests {
use super::*;
/// `"mouseover"` resolves through the pick the tooltip publishes: the hovered unit, until a
/// nearer GameObject wins it and the resolver rejects the GameObject's guid (`0x515bd9 je`).
#[test]
fn the_mouseover_token_resolves_nobody_behind_a_nearer_gameobject() {
use crate::target::{Hovered, HoveredObject};
use bevy::ecs::system::RunSystemOnce;
const WOLF: u64 = 0xF130_0000_4500_0001;
let mut app = App::new();
app.init_resource::<crate::ui_party::GroupState>()
.init_resource::<HoveredObject>();
let wolf = app.world_mut().spawn_empty().id();
app.insert_resource(Hovered {
target: Some(wolf),
guid: Some(WOLF),
distance: 10.0,
..Default::default()
});
let resolve = |app: &mut App| {
app.world_mut()
.run_system_once(|tokens: UnitTokens| {
tokens.resolve("mouseover", &Selection::default())
})
.unwrap()
};
assert_eq!(resolve(&mut app), Some((wolf, WOLF)), "the hovered unit");
let chest = app.world_mut().spawn_empty().id();
app.insert_resource(HoveredObject {
target: Some(chest),
guid: Some(0xF110_0000_0000_0004),
distance: 5.0,
});
assert_eq!(resolve(&mut app), None, "a nearer GameObject names nobody");
}
/// The team digit comes off the race byte, whatever template sits beside it (a GM's 35).
#[test]
fn the_team_digit_comes_off_the_race_byte_not_the_faction_template() {
@@ -25,6 +25,7 @@ fn seed_level_strings(s: &mut UiScript) {
fn wolf() -> UnitState {
UnitState {
exists: true,
guid: 0xF130_0000_4500_0001,
name: Some("Timber Wolf".into()),
health: 30,
max_health: 50,
@@ -454,8 +455,28 @@ fn world_hover_drive_and_health_watcher() {
r#"assert(GameTooltipStatusBar:GetValue() == 12, "the health watcher tracked the push")"#,
)
.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.tick(0.6);
s.set_unit("mouseover", None);
s.tick(0.1);
s.run(
r#"
assert(UnitExists("mouseover") == nil, "the token is cleared")
assert(GameTooltip:IsShown(), "still fading")
assert(GameTooltipStatusBar:IsShown(), "the fading plate keeps its bar")
assert(GameTooltipStatusBar:GetValue() == 12, "and its value")
assert(recolored == 1, "the clear fires no UPDATE_MOUSEOVER_UNIT")
"#,
)
.unwrap();
// The same unit pushed under another token still drives the bar.
let mut as_target = wolf();
as_target.health = 8;
s.set_unit("target", Some(as_target));
s.run(r#"assert(GameTooltipStatusBar:GetValue() == 8, "the watcher follows the guid")"#)
.unwrap();
s.tick(0.5);
s.run(r#"assert(not GameTooltip:IsShown(), "faded out after the ramp")"#)
.unwrap();
assert!(s.take_errors().is_empty());
+2 -1
View File
@@ -271,7 +271,8 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) {
if let Ok(t) = tip_mut(model, h) {
t.num_lines = 0;
t.min_width = 0.0;
t.unit_token = None;
// Clear drops the subject and unregisters its health watcher (`0x5300ad`, `0x53007c`).
t.unit_guid = None;
t.world_owned = false;
}
// The `<name>StatusBar` health bar is unit content: it hides with the lines, and the next
+22 -16
View File
@@ -113,7 +113,7 @@ fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result<bool> {
}
fire_cleared(lua, h);
let Some(u) = unit else {
set_live_token(lua, h, None);
set_subject(lua, h, None);
show_or_hide_empty(lua, h);
return Ok(false);
};
@@ -149,7 +149,8 @@ fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result<bool> {
if u.racial_leader && u.pvp {
append_line(lua, this, ("Leader".into(), WHITE), None, false)?;
}
set_live_token(lua, h, Some(token.to_string()));
// An unresolved guid (0) is no subject: nothing can push for it.
set_subject(lua, h, Some(u.guid).filter(|&g| g != 0));
update_bar(lua, h, Some(&u));
show_or_hide_empty(lua, h);
Ok(true)
@@ -165,12 +166,12 @@ fn set_world_owned(lua: &Lua, h: FrameHandle) {
}
}
/// Remember (or drop) the tooltip's live unit token, the health watcher's key.
fn set_live_token(lua: &Lua, h: FrameHandle, token: Option<String>) {
/// 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");
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
t.unit_token = token;
t.unit_guid = guid;
}
}
}
@@ -227,13 +228,18 @@ fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) {
}
}
/// A `set_unit` push for a tooltip's live token updates its health bar without rebuilding the
/// lines, as the reference's HEALTH field watcher does.
/// A `set_unit` push of the unit a tooltip shows updates its health bar without rebuilding the
/// lines, as the reference's HEALTH field watcher does: it is keyed on the unit's guid (`0x52a0a8`
/// registers `0x529560` on `+0x368`/`+0x36c`), so a token that stops naming the unit, as
/// `"mouseover"` does when the hover ends, leaves a fading plate's bar standing.
pub(super) fn on_unit_push(lua: &Lua, token: &str) {
let hits: Vec<FrameHandle> = {
let (unit, hits): (UnitState, Vec<FrameHandle>) = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(unit) = model.unit(token).filter(|u| u.exists && u.guid != 0) else {
return;
};
// Registered frames only, as `update_bar` writes layout.
model
let hits: Vec<FrameHandle> = model
.arena
.tooltip_kinds()
.iter()
@@ -242,17 +248,17 @@ pub(super) fn on_unit_push(lua: &Lua, token: &str) {
model.frame_to_id.contains_key(&h)
&& matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::Tooltip(t)) if t.unit_token.as_deref() == Some(token)
Some(KindState::Tooltip(t)) if t.unit_guid == Some(unit.guid)
)
})
.collect()
.collect();
if hits.is_empty() {
return;
}
(unit.clone(), hits)
};
for h in hits {
let unit = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
model.unit(token).cloned().filter(|u| u.exists)
};
update_bar(lua, h, unit.as_ref());
update_bar(lua, h, Some(&unit));
}
}
+1 -1
View File
@@ -308,7 +308,7 @@ impl super::UiScript {
}
}
}
// A push for a tooltip's live unit token re-drives its health bar, without a line rebuild.
// A push of the unit a tooltip shows re-drives its health bar, without a line rebuild.
super::tooltip_unit::on_unit_push(&self.lua, token);
}
+4 -2
View File
@@ -189,8 +189,10 @@ pub struct TooltipState {
pub min_width: f32,
/// `FadeOut()`'s start on the `GetTime` clock; any fresh content or `Show` cancels the fade.
pub fade_start: Option<f64>,
/// The unit this tooltip shows; a `set_unit` push for it re-drives the health bar.
pub unit_token: Option<String>,
/// The guid of the unit this tooltip shows (`+0x368`/`+0x36c`, stored at `0x52a070`), whose
/// 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>,
/// Shows world-hover content, which fades when the hover is lost; a window hover never fades.
pub world_owned: bool,
/// Armed for a compare render: the next `SetInventoryItem` prepends "Currently Equipped"