The mouseover unit carries the hovered guid, so UnitIsUnit("mouseover", "target") answers 1 over the target: unit snapshots take their guid (#502)

Hovering a unit now makes `UnitIsUnit("mouseover", "target")` (or any other token naming the same unit) answer 1, as in 1.12.1, where `UnitIsUnit` (`0x516070`) resolves both tokens through `0x515970` and compares their guids, and `mouseover` reads the hovered unit's guid (`0xb4e2c8`/`0xb4e2cc`, written by `0x492890`). The `mouseover` unit state was pushed with guid 0, so every such comparison answered nil.

`ui_unit::snapshot` now takes the unit's guid and sets it, so every unit state built from a snapshot carries it; the callers that set it by hand afterwards no longer do.

Fixes #496

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-28 14:04:09 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bacbb791db
commit 1a37909c9c
5 changed files with 104 additions and 18 deletions
+3 -4
View File
@@ -318,9 +318,8 @@ pub(super) fn feed_party(
if Some(*guid) == self_guid {
let (_, _, store) = self_pair?;
let name = names.peek(*guid).map(str::to_string);
let mut s = crate::ui_unit::snapshot(store, name, 0, chr);
let mut s = crate::ui_unit::snapshot(store, *guid, name, 0, chr);
s.is_player = true;
s.guid = *guid;
s.raid_target = group.raid_target_index(*guid);
s.faction_group = own_group.clone();
// A same-faction friendly player, as in `member_unit_state`.
@@ -644,13 +643,14 @@ fn member_unit_state(
classes: Option<&benilla_formats::ChrClasses>,
) -> UnitState {
let mut s = match store {
Some(store) => crate::ui_unit::snapshot(store, Some(m.name.clone()), 0, classes),
Some(store) => crate::ui_unit::snapshot(store, m.guid, Some(m.name.clone()), 0, classes),
// Unstreamed: the roster record, snapshotted from the descriptor at despawn (`0x5f0880`),
// seated at 1/1 for a member never seen (`0x4e82d0`) and patched by the wire. The
// reference's getters read the descriptor, then the party record (`0x496400`), then the
// pet record (`0x496420`); a `partyN` token is never a pet, so the pet leg cannot arise.
None => UnitState {
exists: true,
guid: m.guid,
name: Some(m.name.clone()),
health: stats.and_then(|s| s.cur_hp).map_or(0, u32::from),
max_health: stats.and_then(|s| s.max_hp).map_or(0, u32::from),
@@ -672,7 +672,6 @@ fn member_unit_state(
s.is_player = true;
// A party member is always a same-faction friendly player: the popup's `UnitCanCooperate`
// gate reads the reaction, which neither leg resolves for a party token.
s.guid = m.guid;
s.raid_target = group.raid_target_index(m.guid);
s.reaction = 5;
s.faction_group = own_group;
+1 -3
View File
@@ -81,9 +81,7 @@ pub(super) fn feed_pet_unit(
.resolve_unit(pet_guid, Some(store), &commands)
.map(str::to_string);
// No `ChrClasses.dbc`: the reference reads a class only for TYPEMASK_PLAYER.
let mut s = snapshot(store, name, 0, None);
s.guid = pet_guid;
s
snapshot(store, pet_guid, name, 0, None)
});
let dirty = match (&fresh, &memory.pushed) {
+3 -1
View File
@@ -628,7 +628,9 @@ fn drive_mouseover_tooltip(
.resolve_unit(guid, Some(store), &commands)
.map(str::to_string);
let reaction = unit_reaction(rx.factions.as_deref(), &rx.reputations, store, self_store);
let mut s = snapshot(store, name, reaction, chr);
// The hovered guid, the pair `0x492890` writes to `0xb4e2c8`/`0xb4e2cc` and the token
// resolver `0x515970` reads for `"mouseover"`, so `UnitIsUnit` can match it.
let mut s = snapshot(store, guid, name, reaction, chr);
enrich_unit(
&mut s,
guid,
@@ -517,3 +517,87 @@ fn the_spell_feed_runs_after_the_trainer_feed() {
feed_spell_tooltips
));
}
/// 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;
let mut app = App::new();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx))
.insert_resource(crate::ui_script::UiScaleCvar(1.0))
.init_resource::<Hovered>()
.init_resource::<HoveredObject>()
.init_resource::<NameCache>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::go_templates::GameObjectTemplates>()
.init_resource::<Items>()
.init_resource::<PlayerActions>();
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();
// `"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(
token,
Some(UnitState {
exists: true,
has_object: true,
guid,
..Default::default()
}),
);
}
app.insert_non_send_resource(script);
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();
};
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(&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);
assert_eq!(
is_unit(&mut app, "target"),
None,
"another unit is not the target"
);
assert_eq!(
is_unit(&mut app, "mouseover"),
Some(1),
"the token is itself"
);
}
+13 -10
View File
@@ -770,10 +770,12 @@ pub(crate) fn unit_reaction(
ring_reaction(factions, reputations, Some(store), self_store) + 1
}
/// Build a unit snapshot from a streamed descriptor, its cached name and its `UnitReaction`
/// (`1..=7`, or `0` where none is resolved, as for `"player"`); `classes` feeds the relic column.
/// Build a unit snapshot from a streamed descriptor, its guid, its cached name and its
/// `UnitReaction` (`1..=7`, or `0` where none is resolved, as for `"player"`); `classes` feeds the
/// relic column.
pub(crate) fn snapshot(
store: &ObjectStore,
guid: u64,
name: Option<String>,
reaction: u8,
classes: Option<&ChrClasses>,
@@ -786,6 +788,8 @@ pub(crate) fn snapshot(
exists: true,
// A live descriptor is `0x468460` having succeeded, all of `UnitIsVisible` (`0x516030`).
has_object: true,
// What the token resolver `0x515970` yields, and `UnitIsUnit` (`0x516070`) compares.
guid,
name,
// The UI getters: `UNIT_DYNFLAG_DEAD` (feign death) zeroes `UnitHealth` (`0x5174d0`) and
// `UnitMana` (`0x517670`) but not the maxima, so its edge fires the reference's pair
@@ -1187,12 +1191,11 @@ fn feed_units(
let name = names
.resolve_unit(guid.0, Some(store), &commands)
.map(str::to_string);
let mut s = snapshot(store, name, 0, chr);
let mut s = snapshot(store, guid.0, name, 0, chr);
s.is_player = true;
// Every token pushed here is streamed, so connected; real link-death rides only the group
// roster's status byte, which the party feed reads for its own tokens.
s.is_connected = true;
s.guid = guid.0;
s.raid_target = group.raid_target_index(guid.0);
s.faction_group = faction_group(store, factions.as_deref());
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
@@ -1226,8 +1229,7 @@ fn feed_units(
store,
self_pair.map(|(s, _)| s),
);
let mut s = snapshot(store, name, reaction, chr);
s.guid = guid;
let mut s = snapshot(store, guid, name, reaction, chr);
s.is_connected = true;
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
@@ -1270,8 +1272,7 @@ fn feed_units(
store,
self_pair.map(|(s, _)| s),
);
let mut s = snapshot(store, name, reaction, chr);
s.guid = guid;
let mut s = snapshot(store, guid, name, reaction, chr);
s.is_connected = true;
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
@@ -1338,8 +1339,7 @@ fn feed_units(
store,
self_pair.map(|(s, _)| s),
);
let mut s = snapshot(store, name, reaction, chr);
s.guid = guid;
let mut s = snapshot(store, guid, name, reaction, chr);
s.is_connected = true;
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
@@ -1642,6 +1642,7 @@ mod tests {
let team = |fields: &[(u16, u32)]| {
snapshot(
&ObjectStore(ObjectFields::from_pairs(fields)),
0,
None,
0,
None,
@@ -2307,6 +2308,7 @@ mod tests {
];
let alive = snapshot(
&ObjectStore(ObjectFields::from_pairs(&vitals)),
0,
Some("Hunter".into()),
0,
None,
@@ -2319,6 +2321,7 @@ mod tests {
&ObjectStore(ObjectFields::from_pairs(
&[vitals.as_slice(), &[(DYNFLAGS, 0x20)]].concat(),
)),
0,
Some("Hunter".into()),
0,
None,