world pick: while no word carries flag 0x20, a unit the active player possesses is no candidate, beside the active player, so the cursor and click reach the unit behind it (0x480610, 0x48064f-0x480674) (#659)

The world pick dropped only the active player. A unit you possess stayed a candidate, so under a Mind Control or an Eye of Kilrogg it highlighted under the cursor and a click selected it.

`0x480610`, the pick's unit and player filter, applies two exclusions whenever the pick flags lack `0x20`:
- the active player (`0x480638` → `0x480676`);
- a unit with `UNIT_FIELD_FLAGS & 0x01000000` (`UNIT_FLAG_POSSESSED`, `0x48064f`) whose `UNIT_FIELD_CHARMEDBY`, else `UNIT_FIELD_CREATEDBY`, is the active player (`0x480658`-`0x480674`).

Outside targeting the flags always lack `0x20`. `0x481050` ORs it only for a word that can bind yourself.

Changes:
- `TargetHoverState::excludes` holds both exclusions under the `PicksSelf` gate. `is_candidate` asks it on the posed pass and on the box path, so the two separate self skips are gone.
- The possession test moves to `target::relations::is_possessed_by`. The pet bar's `possessing` now wraps it.

Tests:
- `a_unit_you_possess_lets_the_pick_through_to_the_one_behind`: Mind Control, an Eye of Kilrogg, and a unit charmed by you but created by another, outside targeting and under an enemy word. The rear unit wins, and under a `0x20` word the possessed unit is the pick again.
- `a_lone_unit_you_possess_is_no_pick_at_all`.
- `a_unit_you_do_not_possess_keeps_the_pick`, with five controls.
- `possession_is_the_flag_and_the_charmer_else_the_creator`.

Fixes #649

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-30 04:49:08 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3a0728cde2
commit 6a36d8c351
7 changed files with 213 additions and 34 deletions
+148 -19
View File
@@ -87,7 +87,7 @@ pub(super) struct PickPose<'w, 's> {
/// the picker's param limit.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct TargetHoverState<'w, 's> {
/// For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause.
/// For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause and the pick filter's possession clause.
self_guid: Res<'w, crate::net::SelfGuid>,
/// `0x6e61a0`'s pick flag `0x20`, as the frame began.
picks_self: Res<'w, crate::spell::PicksSelf>,
@@ -101,15 +101,34 @@ pub(super) struct TargetHoverState<'w, 's> {
}
impl TargetHoverState<'_, '_> {
/// `0x480610`'s two exclusions, which come first and which a pick whose flags carry `0x20`
/// skips (`48062c`, [`crate::spell::PicksSelf`]; `0x481050` ORs it in only for a word
/// `0x6e61a0` passes, so outside targeting they always run): the active player (`480638`),
/// and a unit they possess, an Eye of Kilrogg or a Mind Controlled target
/// ([`super::is_possessed_by`], `48064f`-`480674`), so the cursor and the click pass through
/// it to what lies behind.
fn excludes(&self, is_self: bool, store: Option<&ObjectStore>) -> bool {
!self.picks_self.0 && (is_self || super::is_possessed_by(store, self.self_guid.0))
}
/// Whether an object of this kind is a candidate of this pick, the posed pass and the box
/// fallback alike: every unit and player outside targeting, and while targeting the ones
/// `0x480610` passes for the word ([`crate::spell::targeting::PickChecks::admits_unit`]);
/// a corpse (`0x480816`) always outside targeting, whose pick flags carry `0x40`, and while
/// targeting only one `0x6e6260` passes. A corpse whose store has not streamed is refused
/// then. The hover and the press, which latches this pick, share it.
fn is_candidate(&self, entity: Entity, kind: EntityKind, store: Option<&ObjectStore>) -> bool {
/// fallback alike: every unit and player outside targeting but the two [`Self::excludes`]
/// drops, and while targeting the ones `0x480610` passes for the word
/// ([`crate::spell::targeting::PickChecks::admits_unit`]); a corpse (`0x480816`) always
/// outside targeting, whose pick flags carry `0x40`, and while targeting only one `0x6e6260`
/// passes. A corpse whose store has not streamed is refused then. `is_self` is the active
/// player's own body. The hover and the press, which latches this pick, share it.
fn is_candidate(
&self,
entity: Entity,
kind: EntityKind,
is_self: bool,
store: Option<&ObjectStore>,
) -> bool {
match kind {
EntityKind::Unit | EntityKind::Player => self.units.admits_unit(entity, store),
EntityKind::Unit | EntityKind::Player => {
!self.excludes(is_self, store) && self.units.admits_unit(entity, store)
}
EntityKind::Corpse => {
let Some(word) = self.corpse_pick.0 else {
return true;
@@ -249,15 +268,11 @@ pub(super) fn update_hover(
let mut candidates: Vec<(Entity, u8, Vec<AssetId<Mesh>>, Vec<Mat4>)> = Vec::new();
// Units with skinned parts: out of the box fallback even when the broad phase rejects them.
let mut faithful: HashSet<Entity> = HashSet::new();
let picks_self = target_state.picks_self.0;
for (entity, is_self, gt, net, anims, drv, store, children, mount_child, drawn, held) in &roots
{
if is_self && !picks_self {
continue;
}
// Units, players and corpses: the reference picks every CGObject in one trace and
// switches on type at the end, below.
if !target_state.is_candidate(entity, net.kind, store) {
if !target_state.is_candidate(entity, net.kind, is_self, store) {
continue;
}
// Not drawn, not clickable. Checked before `faithful.insert`, so an undrawn unit is out by
@@ -353,16 +368,15 @@ pub(super) fn update_hover(
if faithful.contains(&parent) {
continue; // posed-mesh-tested above
}
if !picks_self && roots.get(parent).is_ok_and(|root| root.1) {
continue;
}
// The same candidates as the posed pick. A bone pile lands here: its corpse model has no
// skeleton, so only this box test can pick it.
let Ok((_, parent_net)) = units.get(parent) else {
continue;
};
let store = roots.get(parent).ok().and_then(|root| root.6);
if !target_state.is_candidate(parent, parent_net.kind, store) {
let root = roots.get(parent).ok();
let is_self = root.as_ref().is_some_and(|root| root.1);
let store = root.and_then(|root| root.6);
if !target_state.is_candidate(parent, parent_net.kind, is_self, store) {
continue;
}
if let Some(t) = ray_mesh_bounds(origin, dir, aabb, gt) {
@@ -1087,7 +1101,8 @@ mod tests {
unit_store(2, 0, 100)
}
/// The pick over `word`, the way a frame runs it: the word published, then the pick.
/// The pick over `word`, the way a frame runs it: the word published, its flags `0x20` and the
/// unit legs both, then the pick.
fn pick_under(world: &mut World, word: Option<u16>) -> Hovered {
let mut targeting = world.resource_mut::<crate::spell::SpellTargeting>();
match word {
@@ -1097,6 +1112,9 @@ mod tests {
world
.run_system_cached(crate::spell::targeting::publish_unit_pick)
.expect("the word publishes");
world
.run_system_cached(crate::spell::targeting::publish_picks_self)
.expect("the self bit publishes");
world
.run_system_cached(update_hover)
.expect("the pick runs");
@@ -1223,6 +1241,117 @@ mod tests {
assert_eq!(hovered.distance, f32::MAX);
}
/// A living Orc creature whose `UNIT_FIELD_FLAGS` are `flags`, with its `UNIT_FIELD_CHARMEDBY`
/// (10) and `UNIT_FIELD_CREATEDBY` (14) the guids `charmed_by` and `created_by`, 0 for none.
fn owned(flags: u32, charmed_by: u32, created_by: u32) -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(35, 2),
(46, flags),
(22, 100),
(10, charmed_by),
(11, 0),
(14, created_by),
(15, 0),
]))
}
/// The word's plain unit bit (`0x0002`), which `0x6e61a0` takes the player for (`0x850e`), and
/// the enemy bit (`0x0080`), which it does not.
const SELF_WORD: u16 = 0x0002;
const ENEMY_WORD: u16 = 0x0080;
/// `0x480610`, `48064f`-`480676`: a unit flagged `UNIT_FLAG_POSSESSED` whose charmer, else
/// creator, is the active player is no candidate outside targeting, or while a word without
/// `0x20` stands, so the pick passes through it to the unit behind. Us is guid 1.
#[test]
fn a_unit_you_possess_lets_the_pick_through_to_the_one_behind() {
let possessed = crate::target::UNIT_FLAG_POSSESSED;
let cases: [(&str, ObjectStore); 3] = [
(
"a Mind Controlled unit, charmed by us",
owned(possessed, 1, 0),
),
("an Eye of Kilrogg, created by us", owned(possessed, 0, 1)),
(
"charmed by us, created by another: the charmer comes first",
owned(possessed, 1, 7),
),
];
for (what, front_store) in cases {
let mut world = unit_pick_world();
let front = unit_at(&mut world, 10, 5.0, front_store);
let rear = unit_at(&mut world, 11, 0.0, foe());
for word in [None, Some(ENEMY_WORD)] {
let hovered = pick_under(&mut world, word);
assert_eq!(
hovered.target,
Some(rear),
"{what}: the rear, word {word:?}"
);
assert_eq!(hovered.guid, Some(11), "{what}: word {word:?}");
}
// The word that takes us takes it too: the exclusions are skipped (`48062c`).
assert_eq!(
pick_under(&mut world, Some(SELF_WORD)).target,
Some(front),
"{what}: while flag 0x20 stands"
);
assert_eq!(
pick_under(&mut world, None).target,
Some(rear),
"{what}: and excluded again once the word ends"
);
}
}
/// With nothing behind it, the unit you possess leaves the pick empty, which is no refusal.
#[test]
fn a_lone_unit_you_possess_is_no_pick_at_all() {
let mut world = unit_pick_world();
unit_at(
&mut world,
10,
0.0,
owned(crate::target::UNIT_FLAG_POSSESSED, 1, 0),
);
let hovered = pick_under(&mut world, None);
assert_eq!((hovered.target, hovered.guid), (None, None));
assert!(!hovered.refused);
}
/// The exclusion asks for the flag, then for us: an owned unit that is not possessed, a unit
/// someone else possesses, and one whose charmer is another even where its creator is us, all
/// stay candidates.
#[test]
fn a_unit_you_do_not_possess_keeps_the_pick() {
let possessed = crate::target::UNIT_FLAG_POSSESSED;
let cases: [(&str, ObjectStore); 5] = [
("possessed, charmed by another", owned(possessed, 2, 0)),
("possessed, created by another", owned(possessed, 0, 2)),
(
"possessed, charmed by another, created by us: the charmer comes first",
owned(possessed, 2, 1),
),
(
"our own pet, created by us but not possessed",
owned(0, 0, 1),
),
("possessed by no one", owned(possessed, 0, 0)),
];
for (what, front_store) in cases {
let mut world = unit_pick_world();
let front = unit_at(&mut world, 10, 5.0, front_store);
unit_at(&mut world, 11, 0.0, foe());
for word in [None, Some(ENEMY_WORD), Some(SELF_WORD)] {
assert_eq!(
pick_under(&mut world, word).target,
Some(front),
"{what}: word {word:?}"
);
}
}
}
/// `0x481116`: only a word with unit bits (`0x878e`) puts units and players in the pick. A
/// ground word, a lock word and a bag-item word pick none, so a unit in front of the ground
/// or the chest is no obstacle, and a unit word does pick them.
+3 -1
View File
@@ -62,7 +62,9 @@ pub(crate) use flash::CombatFlash;
pub(crate) use price_discount::vendor_price_discount;
#[cfg(test)]
pub(crate) use price_discount::{stormwind_fixture, HUMAN_WARRIOR};
pub(crate) use relations::{can_assist, can_attack, can_interact, corpse_friendly};
pub(crate) use relations::{
can_assist, can_attack, can_interact, corpse_friendly, is_possessed_by, UNIT_FLAG_POSSESSED,
};
// The attack validator's target half, run in the call that asks, for the player's Attack and the
// pet bar's; the melee probe's press with nothing selected.
pub(crate) use scan::{AttackNearestRequest, AttackPick};
@@ -86,6 +86,23 @@ pub(crate) fn is_selectable(store: Option<&ObjectStore>, self_guid: Option<u64>)
|| (self_guid.is_some() && store.0.unit_created_by() == self_guid)
}
/// `UNIT_FLAG_POSSESSED`, `UNIT_FIELD_FLAGS` bit 24 (vmangos `UnitDefines.h:569`), which the
/// reference reads as `[[unit+0x110]+0xA3] & 1`.
pub(crate) const UNIT_FLAG_POSSESSED: u32 = 0x0100_0000;
/// Whether the active player possesses this unit (Mind Control, an Eye of Kilrogg, Eyes of the
/// Beast): `UNIT_FLAG_POSSESSED` set and `UNIT_FIELD_CHARMEDBY`, else `UNIT_FIELD_CREATEDBY`, the
/// active player's guid. The own-pet resolver `0x5ee5a0` (`0x5ee626`, `0x5ee62f`) and the world
/// pick's filter `0x480610` (`0x48064f`-`0x480674`) read this same clause. No store, no owner or
/// no active player is no possession.
pub(crate) fn is_possessed_by(store: Option<&ObjectStore>, self_guid: Option<u64>) -> bool {
let (Some(store), Some(me)) = (store, self_guid) else {
return false;
};
store.0.unit_flags() & UNIT_FLAG_POSSESSED != 0
&& store.0.unit_owner(OwnerFallback::CreatedBy) == Some(me)
}
/// `0x6067d0(player, corpse)`, the corpse's reaction gate: `0x6064c0` compares the player's
/// template (`UNIT_FIELD_FACTIONTEMPLATE`) toward the dead player's race's (`0x5d7120`: the race
/// byte of `CORPSE_FIELD_BYTES_1`, then `ChrRaces.dbc` column 2) with the template comparator
@@ -201,4 +218,40 @@ mod tests {
}
assert!(!gate(0) && !gate(10), "a race with no row");
}
/// `0x480658`-`0x480667`: the flag, then the charmer if there is one, else the creator; an
/// unset owner or an unknown active player is no possession.
#[test]
fn possession_is_the_flag_and_the_charmer_else_the_creator() {
let store = |flags: u32, charmed_by: u32, created_by: u32| {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(46, flags),
(10, charmed_by),
(11, 0),
(14, created_by),
(15, 0),
]))
};
let me = Some(0x77);
let flag = UNIT_FLAG_POSSESSED;
assert!(
is_possessed_by(Some(&store(flag, 0x77, 0)), me),
"charmed by us"
);
assert!(
is_possessed_by(Some(&store(flag, 0, 0x77)), me),
"created by us"
);
assert!(
!is_possessed_by(Some(&store(flag, 0x99, 0x77)), me),
"a charmer other than us comes before our creation"
);
assert!(!is_possessed_by(Some(&store(0, 0x77, 0x77)), me), "no flag");
assert!(!is_possessed_by(Some(&store(flag, 0, 0)), me), "no owner");
assert!(
!is_possessed_by(Some(&store(flag, 0, 0)), None),
"no active player"
);
assert!(!is_possessed_by(None, me), "no descriptor");
}
}
+1 -1
View File
@@ -12,9 +12,9 @@ use benilla_protocol::messages::{
use benilla_ui::script::{PetActionView, UiScript};
use crate::net::{GuidIndex, ObjectStore};
use crate::target::UNIT_FLAG_POSSESSED;
use crate::ui_action::Spells;
use super::drain::UNIT_FLAG_POSSESSED;
use super::PetBar;
/// `GetPetActionsUsable()` (`0x4bcf70`): false while the state's bit 27 is set or the pet is
+6 -12
View File
@@ -13,16 +13,12 @@ use benilla_protocol::messages::{
use benilla_ui::script::UiScript;
use crate::net::{ClientCommand, NetCommands, ObjectStore};
use crate::target::Selection;
use crate::target::{is_possessed_by, Selection};
use crate::ui_action::Spells;
use super::bar::active_aura_press;
use super::{PetBar, PetUnit};
/// `UNIT_FLAG_POSSESSED`, `UNIT_FIELD_FLAGS` bit 24 (vmangos `UnitDefines.h:569`), which the
/// reference reads as `[[pet+0x110]+0xA3] & 1`.
pub(super) const UNIT_FLAG_POSSESSED: u32 = 0x0100_0000;
/// What a pet bar press reads and writes: `CastPetAction`, `PetAttack` and the other orders, and
/// the pet book's `CastSpell(id, "pet")`.
#[derive(bevy::ecs::system::SystemParam)]
@@ -287,14 +283,12 @@ pub(crate) fn pet_stop_on_old_target_clear(
/// `0x5ee5a0`: the unit the player possesses, which must be the bar's unit for an Attack press to
/// latch (`0x4bd420`, `0x4bd42e`). It needs `UNIT_FLAG_POSSESSED` (`0x5ee626`) and charmed-by,
/// else created-by, us (`0x5ee62f`); vmangos sets that flag only with possession (Mind Control,
/// Eyes of the Beast), never for an ordinary pet. The reference also tests the active mover
/// (`0x5ee5bc`, `0x5ee5e9`), not modelled here: vmangos sets the mover with the flag.
/// else created-by, us (`0x5ee62f`), [`is_possessed_by`]; vmangos sets that flag only with
/// possession (Mind Control, Eyes of the Beast), never for an ordinary pet. The reference also
/// tests the active mover (`0x5ee5bc`, `0x5ee5e9`), not modelled here: vmangos sets the mover
/// with the flag.
pub(super) fn possessing(store: Option<&ObjectStore>, self_guid: Option<u64>) -> bool {
store.is_some_and(|s| {
s.0.unit_flags() & UNIT_FLAG_POSSESSED != 0
&& s.0.unit_owner(benilla_protocol::OwnerFallback::CreatedBy) == self_guid
})
is_possessed_by(store, self_guid)
}
/// The Attack order, the one press validated before it sends: the type-7 arm branches only on
+1 -1
View File
@@ -297,7 +297,7 @@ fn a_possessed_units_press_starts_no_pet_gcd() {
let charmed = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(CHARMEDBY, ME as u32),
(CHARMEDBY + 1, 0),
(FLAGS, super::drain::UNIT_FLAG_POSSESSED),
(FLAGS, crate::target::UNIT_FLAG_POSSESSED),
]));
let mut rig = rig(vec![(CLAW, claw())], &[CLAW], Some(charmed));
+1
View File
@@ -9,6 +9,7 @@ use benilla_protocol::messages::{
use benilla_ui::script::PetActionView;
use crate::net::{ClientCommand, NetCommands, ObjectStore};
use crate::target::UNIT_FLAG_POSSESSED;
use super::bar::*;
use super::drain::*;