world pick: while a spell targets, a unit or player the word cannot take is no candidate, so the pick reaches the one behind it (0x481050, 0x480610) (#652)

While a spell was targeting, the world pick admitted every unit and player. A unit the spell could not take, standing in front of one it could, took the hover and the click:
- a hostile or dead unit in front of a friend under a heal;
- a living player standing on a corpse under Resurrection;
- any unit in front of a chest under a lock word.

In the reference, both world picks build their flags through `0x481050`. While targeting (`4810f1`-`481187`):
- a word with unit bits (`0x6e6180`, `& 0x878e`) adds units and players (`0x18`);
- three relation flags follow: party `0x10000` (`0x6e61e0`, `& 0x40c`), assist `0x20000` (`0x6e6200`, `& 0x500`) and attack `0x40000` (`0x6e6210`, `& 0x480`);
- a dead-target word (`& 0x8600`) adds `0x200040`, and any other adds `0x100000`;
- `0x6e6240` (`0x8000`) adds assist, and `0x6e6250` (`0x200`) adds attack.

`0x480610` then drops a unit or player candidate when:
- none of the relations the flags ask for holds (`480686`): `0x606c20`, `CanAssist 0x6066f0` or `CanAttack 0x606980`, toward the active player;
- its liveness is not the one asked for (`4806fd`, signed `UNIT_FIELD_HEALTH` above 0);
- it has `UNIT_FIELD_FLAGS & 0x100` (`480737`).

A word with no unit bits puts no unit in the pick at all (`0x480780`'s unit and player arms need `0x8` and `0x10`).

- `spell/targeting/pick.rs`: `PickFlags::of(word)` builds the flags as `0x481050` does, and `PickFlags::admits` applies the three legs.
- `0x606c20` is built here, from the published party slots.
- `TargetHoverState::is_candidate` routes units and players through it on both the posed pass and the box fallback. The press latches the same pick.
- `CanAssist` and `CanAttack` toward a candidate become `TargetRelations::assistable` and `attackable`, shared with the binder's arms. The binder's behaviour is unchanged.
- The alive test is `bind_gates::unit_alive`.

Tests:
- `pick.rs` covers the flag table, each relation, Resurrection, the enemy corpse word, `0x100`, the caster, and `0x606c20` for self, a party member, pets and a stranger.
- `hover.rs` stands two units on the cursor's ray. The rear unit wins when the front one is refused. A lone refused unit gives no pick, and a word with no unit bits picks no unit. The press latches the rear unit.
- Outside targeting the front unit still wins.

Fixes #464

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-30 02:10:24 +07:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent 4573596e27
commit ec5083f471
7 changed files with 900 additions and 29 deletions
+2 -1
View File
@@ -14,7 +14,8 @@ use super::cast_target::{TargetRelations, CORPSE_WORD_BITS, TF_EXPLICIT_GATE};
/// at `6e5c16`-`6e5c1d`.
const UNIT_FLAG_NON_ATTACKABLE_2: u32 = 0x0001_0000;
/// The bind's alive test (`6e5c5f`, `6e5e55`): signed `UNIT_FIELD_HEALTH` above 0. An absent field
/// The bind's alive test (`6e5c5f`, `6e5e55`) and the world pick's (`4806fd`, `480713 setg`,
/// [`super::targeting::PickFlags::admits`]): signed `UNIT_FIELD_HEALTH` above 0. An absent field
/// reads 0, as the reference's descriptor does, so a dead unit whose create sent no health is dead.
pub(super) fn unit_alive(fields: &ObjectFields) -> bool {
fields.unit_health().unwrap_or(0) as i32 > 0
+32 -19
View File
@@ -124,6 +124,33 @@ pub(crate) struct TargetRelations<'a> {
pub(crate) types: CreatureTypeSources<'a>,
}
impl TargetRelations<'_> {
/// `CanAssist 0x6066f0` toward the candidate, which the caster always is for himself. The
/// binder's assist arm and the world pick's relation leg both ask it.
pub(super) fn assistable(&self, is_self: bool) -> bool {
is_self
|| can_assist(
self.target_store,
self.factions,
self.reputations,
self.self_store,
|_| self.target_owner_store.cloned(),
)
}
/// `CanAttack 0x606980` toward the candidate, never the caster. The binder's enemy arm and the
/// world pick's relation leg both ask it.
pub(super) fn attackable(&self, is_self: bool) -> bool {
!is_self
&& can_attack(
self.target_store,
self.factions,
self.reputations,
self.self_store,
)
}
}
/// The targeting inputs [`super::send_spell_cast`] resolves with, built by both cast callers.
pub(crate) struct CastContext<'a> {
pub(crate) selection_guid: Option<u64>,
@@ -317,18 +344,12 @@ pub(crate) fn cast_target_mask(def: &SpellDisplay) -> u16 {
///
/// The assist bit asks `CanAssist 0x6066f0`: selectable, friendly or better, and an NPC's owner
/// (or the NPC itself) PvP-enabled. This keeps friendly ambient NPCs and critters unbindable.
/// The enemy bit asks `CanAttack` (`0x606980`). Party and raid (`0x606c20`, `0x606d20`) accept
/// only the player; the corpse check (`0x6067d0`) is assistable with health 0 here.
/// The enemy bit asks `CanAttack` (`0x606980`). Party and raid (`0x606c20`, `0x606d20`, the player
/// or a member of the group) clear for the caster alone; the corpse check (`0x6067d0`) is
/// assistable with health 0 here.
fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16 {
let mut word = word;
let assist = is_self
|| can_assist(
rel.target_store,
rel.factions,
rel.reputations,
rel.self_store,
|_| rel.target_owner_store.cloned(),
);
let assist = rel.assistable(is_self);
let dead = rel.target_store.is_some_and(|s| !unit_alive(&s.0));
if word & TF_UNIT_PARTY != 0 && is_self {
word &= !TF_UNIT_PARTY;
@@ -339,15 +360,7 @@ fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16
if word & TF_UNIT_ASSIST != 0 && assist {
word &= !TF_UNIT_ASSIST;
}
if word & TF_UNIT_ENEMY != 0
&& !is_self
&& can_attack(
rel.target_store,
rel.factions,
rel.reputations,
rel.self_store,
)
{
if word & TF_UNIT_ENEMY != 0 && rel.attackable(is_self) {
word &= !TF_UNIT_ENEMY;
}
// Bit 1 has no relation check: any unit binds. A dead unit's bind clears bit 10 (`6e5d89`,
+2
View File
@@ -69,6 +69,7 @@ impl Plugin for SpellPlugin {
.init_resource::<targeting::EnchantConfirmItem>()
.init_resource::<targeting::PicksSelf>()
.init_resource::<targeting::CorpsePick>()
.init_resource::<targeting::UnitPick>()
.add_observer(cast_target::on_cvar)
.add_systems(
Update,
@@ -89,6 +90,7 @@ impl Plugin for SpellPlugin {
(
targeting::publish_picks_self,
targeting::publish_corpse_pick,
targeting::publish_unit_pick,
)
.in_set(UiFeed),
// The item-target commit (`0x495d60`): after the input pass so a bag click
@@ -10,6 +10,8 @@
//! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`),
//! whose unit arm [`bind_target_unit`] shares with `SpellTargetUnit`
//! ([`ScriptCursor::spell_target_unit`]), and whose corpse arm is [`corpse`]'s.
//! - [`pick`]: the world pick's unit and player filter, `0x481050`'s flags from the word and each
//! candidate's `0x480610`, so a unit the word cannot take is out of the hover and the click.
//! - [`item`]: the bag click (`PickupContainerItem 0x4f9b30`) and the paper-doll click
//! (`0x4c7300`), both `IsTargeting`, `TargetingWantsItem 0x6e6330`, then `0x495d60`, whose
//! confirm popups park the clicked guid (`0xb4e3c0`) with the word still standing.
@@ -29,6 +31,7 @@
mod corpse;
mod cursor;
mod item;
mod pick;
mod world;
#[cfg(test)]
@@ -36,6 +39,7 @@ pub(crate) use corpse::fixture as corpse_fixture;
pub(crate) use corpse::{corpse_pick_admits, publish_corpse_pick, CorpsePick};
pub(crate) use cursor::{drive_targeting_cursor, ground_cast_radius};
pub(crate) use item::{commit_item_cast_on_pick, EnchantConfirmItem};
pub(crate) use pick::{publish_unit_pick, PickChecks, UnitPick};
pub(crate) use world::{commit_ground_cast_on_click, commit_object_cast_on_click};
use bevy::prelude::*;
@@ -0,0 +1,616 @@
//! The world pick's unit and player filter while a word stands. The pick flags come from the
//! word alone (`0x481050`'s targeting arm, `4810f1`-`481187`), and each unit and player the trace
//! finds meets them in `0x480610`, where `0x480780`'s unit and player arms send it. A unit the
//! spell cannot take is no candidate, so the cursor and the click reach the one behind it.
//!
//! - [`PickFlags`]: the unit-shaped bits of the flags, [`PickFlags::of`], and the legs of
//! `0x480610` they switch on, [`PickFlags::admits`].
//! - [`UnitPick`]: the flags and the party slots as the frame began, [`publish_unit_pick`],
//! beside [`super::PicksSelf`] (flag `0x20`) and [`super::CorpsePick`] (flag `0x40`), which own
//! the other two bits the flags carry for these arms.
//! - [`PickChecks`]: what the hover reads per candidate, the relation checks the bind runs
//! ([`crate::spell::cast_target::TargetRelations`]) and the party leg `0x606c20`.
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_protocol::messages::{ObjectType, OwnerFallback};
use super::SpellTargeting;
use crate::net::{Guid, GuidIndex, ObjectStore, Reputations, SelfGuid, SelfPlayer};
use crate::spell::bind_gates::unit_alive;
use crate::spell::cast_target::TargetRelations;
use crate::target::Factions;
/// Flags `0x8` and `0x10`, which `0x480780`'s unit and player arms test (`4807e1`, `4807cb`) and
/// `0x481050` sets together (`481116 or esi,0x18`) for a word with unit bits.
const UNITS: u32 = 0x18;
/// The relation flags: `0x606c20` (`0x6e61e0`), `CanAssist 0x6066f0` (`0x6e6200`) and `CanAttack
/// 0x606980` (`0x6e6210`), of which `0x480610` needs one (`480686`).
const PARTY: u32 = 0x1_0000;
const ASSIST: u32 = 0x2_0000;
const ATTACK: u32 = 0x4_0000;
const RELATIONS: u32 = PARTY | ASSIST | ATTACK;
/// The liveness flags: the word takes a living candidate, or a dead one (`4806fd`).
const ALIVE: u32 = 0x10_0000;
const DEAD: u32 = 0x20_0000;
const LIVENESS: u32 = ALIVE | DEAD;
/// `UNIT_FLAG_PLAYER_CONTROLLED` (vmangos `UnitDefines.h:548`), tested for both units at
/// `606c5e` and `606c76`.
const UNIT_FLAG_PLAYER_CONTROLLED: u32 = 0x8;
/// `UNIT_FLAG_IMMUNE_TO_PLAYER` (vmangos `UnitDefines.h:553`): the `[desc+0xa0]` bit 8 that
/// `0x480737` refuses while targeting.
const UNIT_FLAG_IMMUNE_TO_PLAYER: u32 = 0x100;
/// The unit-shaped bits of the pick flags `0x481050` builds from the standing word. The terrain
/// (`0x3`), GameObject (`0x4`), self (`0x20`) and corpse (`0x40`) bits belong to their own arms.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct PickFlags(u32);
impl PickFlags {
/// `0x481050`'s targeting arm over the word `0xcecac0`:
///
/// - units and players (`0x18`) for a word in `0x878e` (`0x6e6180`), and inside that arm the
/// relation flags for a word in `0x40c` (`0x6e61e0`, party), `0x500` (`0x6e6200`, assist)
/// and `0x480` (`0x6e6210`, enemy), then the liveness flag: dead for a word in `0x8600`
/// (`0x6e6230`, with the corpse flag, `0x200040`), else alive (`0x100000`);
/// - after it, the ally corpse bit `0x8000` (`0x6e6240`) adds the assist flag and the enemy
/// corpse bit `0x200` (`0x6e6250`) the attack flag (`0x20040`, `0x40040`).
pub(crate) fn of(word: u16) -> Self {
let word = u32::from(word);
let mut flags = 0;
if word & 0x878e != 0 {
flags |= UNITS;
if word & 0x040c != 0 {
flags |= PARTY;
}
if word & 0x0500 != 0 {
flags |= ASSIST;
}
if word & 0x0480 != 0 {
flags |= ATTACK;
}
flags |= if word & 0x8600 != 0 { DEAD } else { ALIVE };
}
if word & 0x8000 != 0 {
flags |= ASSIST;
}
if word & 0x0200 != 0 {
flags |= ATTACK;
}
Self(flags)
}
/// Whether a unit or player is a candidate of the pick: `0x480780`'s arms need the unit
/// flags, then `0x480610`'s three legs:
///
/// - `480686`: with a relation flag set, one of the set relations must hold. The party leg
/// `in_party` is `0x606c20`, run only when asked.
/// - `4806fd`: with a liveness flag set, a living candidate needs `0x100000` and a dead one
/// `0x200000`. Alive is the signed `UNIT_FIELD_HEALTH` above 0 (`480713 setg`), the bind's
/// own test ([`unit_alive`]), and a candidate with no descriptor reads dead.
/// - `480737`: while targeting, `UNIT_FIELD_FLAGS & 0x100` refuses.
///
/// The self exclusions that come first (`48062c`-`480683`) are the caller's.
pub(crate) fn admits(
self,
is_self: bool,
rel: &TargetRelations,
in_party: impl FnOnce() -> bool,
) -> bool {
let flags = self.0;
if flags & UNITS == 0 {
return false;
}
let related = flags & RELATIONS == 0
|| flags & PARTY != 0 && in_party()
|| flags & ASSIST != 0 && rel.assistable(is_self)
|| flags & ATTACK != 0 && rel.attackable(is_self);
if !related {
return false;
}
if flags & LIVENESS != 0 {
let alive = rel.target_store.is_some_and(|s| unit_alive(&s.0));
let needed = if alive { ALIVE } else { DEAD };
if flags & needed == 0 {
return false;
}
}
rel.target_store
.is_none_or(|s| s.0.unit_flags() & UNIT_FLAG_IMMUNE_TO_PLAYER == 0)
}
}
/// The unit pick's inputs as the frame began, which [`crate::target`]'s hover reads: the flags,
/// `None` outside targeting, where they carry `0x5c` and the filter's three legs never run; and
/// while targeting the party slots, the table `0x4e7f70` reads. The press latches the hover's
/// pick, so both read this.
#[derive(Resource, Default)]
pub(crate) struct UnitPick {
pub(crate) flags: Option<PickFlags>,
/// The guids of [`crate::ui_party::GroupState::party_slots`], empty outside targeting.
party: Vec<u64>,
}
/// Publish [`UnitPick`] before the input pass, beside [`super::PicksSelf`]. The party slots are
/// read here and not by the hover, so a party command in the input pass never orders against it.
pub(crate) fn publish_unit_pick(
targeting: Res<SpellTargeting>,
group: Option<Res<crate::ui_party::GroupState>>,
mut pick: ResMut<UnitPick>,
) {
let flags = targeting
.0
.as_ref()
.map(|targeting| PickFlags::of(targeting.word));
let party: Vec<u64> = match (flags, group.as_deref()) {
(Some(_), Some(group)) => group.party_slots().map(|m| m.guid).collect(),
_ => Vec::new(),
};
if pick.flags != flags || pick.party != party {
*pick = UnitPick { flags, party };
}
}
/// What the hover asks of a unit or player candidate while a word stands: the bind's relation
/// checks over the same inputs, and the party leg.
#[derive(SystemParam)]
pub(crate) struct PickChecks<'w, 's> {
pick: Res<'w, UnitPick>,
stores: Query<'w, 's, &'static ObjectStore>,
index: Option<Res<'w, GuidIndex>>,
self_q: Query<'w, 's, (Entity, Option<&'static ObjectStore>), With<SelfPlayer>>,
factions: Option<Res<'w, Factions>>,
reputations: Res<'w, Reputations>,
self_guid: Res<'w, SelfGuid>,
guids: Query<'w, 's, &'static Guid>,
}
impl PickChecks<'_, '_> {
/// Whether this unit or player is a candidate of the pick: always outside targeting,
/// otherwise [`PickFlags::admits`]. `store` is its descriptor, if streamed.
pub(crate) fn admits_unit(&self, entity: Entity, store: Option<&ObjectStore>) -> bool {
let Some(flags) = self.pick.flags else {
return true;
};
let is_self = self
.self_q
.iter()
.next()
.is_some_and(|(me, _)| me == entity);
let rel = TargetRelations {
target_store: store,
// `CanAssist`'s `IsPvP` owner chase: charmer, else creator.
target_owner_store: store
.and_then(|s| s.0.unit_owner(OwnerFallback::CreatedBy))
.and_then(|owner| self.store_of(owner)),
self_store: self.self_q.iter().next().and_then(|(_, store)| store),
factions: self.factions.as_deref(),
reputations: &self.reputations,
// The pick reads no creature type.
types: Default::default(),
};
flags.admits(is_self, &rel, || self.in_my_party(&rel, entity, is_self))
}
/// A guid's descriptor, whatever kind of object holds it (`0x468460`).
fn store_of(&self, guid: u64) -> Option<&ObjectStore> {
let entity = *self.index.as_ref()?.0.get(&guid)?;
self.stores.get(entity).ok()
}
/// `0x606c20(active player, candidate)`: the candidate is the active player (`606c3d`), or
/// both are player-controlled (`606c4f`-`606c79`) and one side's controlling player
/// ([`Self::controller`], `0x606170`) is the active player and the other's is in his party
/// (`606ca3`-`606d02`, [`Self::in_party`]). A group member's pet is such a unit.
fn in_my_party(&self, rel: &TargetRelations, entity: Entity, is_self: bool) -> bool {
if is_self {
return true;
}
let (Some(me), Some(me_store), Some(store), Ok(&Guid(guid))) = (
self.self_guid.0,
rel.self_store,
rel.target_store,
self.guids.get(entity),
) else {
return false;
};
let both = me_store.0.unit_flags() & store.0.unit_flags();
if both & UNIT_FLAG_PLAYER_CONTROLLED == 0 {
return false;
}
let (Some(mine), Some(theirs)) =
(self.controller(me_store, me), self.controller(store, guid))
else {
return false;
};
mine == me && self.in_party(theirs) || theirs == me && self.in_party(mine)
}
/// `0x606170`: the player a unit answers to, its charmer else its creator (`606170`-`60619f`)
/// or the unit itself when neither is set, and only when that object is a player (`6061c6`-
/// `6061cf`, `TYPEMASK_PLAYER`); a controller that has not streamed answers none.
fn controller(&self, store: &ObjectStore, guid: u64) -> Option<u64> {
let owner = store.0.unit_owner(OwnerFallback::CreatedBy).unwrap_or(guid);
let owner_store = if owner == guid {
Some(store)
} else {
self.store_of(owner)
};
owner_store
.is_some_and(|s| s.0.object_type() == Some(ObjectType::Player))
.then_some(owner)
}
/// `0x4e7f70`: a nonzero guid that is the active player's (`4e7f84`) or one of the four
/// party slots' (`4e7f96`-`4e7fb0`, [`UnitPick::party`]).
fn in_party(&self, guid: u64) -> bool {
guid != 0 && (self.self_guid.0 == Some(guid) || self.pick.party.contains(&guid))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::{GuidIndex, Reputations};
use crate::spell::targeting::corpse_fixture as fx;
use benilla_protocol::messages::GroupMemberEntry;
use benilla_protocol::ObjectFields;
use bevy::ecs::system::RunSystemOnce;
/// Absolute descriptor indices: `OBJECT_FIELD_TYPE`, `UNIT_FIELD_HEALTH`,
/// `UNIT_FIELD_FLAGS`, `UNIT_FIELD_CHARMEDBY` and `UNIT_FIELD_CREATEDBY`.
const TYPE: u16 = 2;
const HEALTH: u16 = 22;
const FLAGS: u16 = 46;
const CHARMED_BY: u16 = 10;
const CREATED_BY: u16 = 14;
/// `UNIT_FIELD_FACTIONTEMPLATE`, on [`fx::factions`]' rows 1 (Human) and 2 (Orc).
const TEMPLATE: u16 = 35;
const PLAYER_TYPE: u32 = 0x19;
// The words: `TARGET_FLAG_*`.
const UNIT: u16 = 0x0002;
const RAID: u16 = 0x0004;
const PARTY_WORD: u16 = 0x0008;
const ENEMY: u16 = 0x0080;
const ASSIST_WORD: u16 = 0x0100;
const CORPSE_ENEMY: u16 = 0x0200;
const DEAD_ONLY: u16 = 0x0400;
const CORPSE_ALLY: u16 = 0x8000;
fn unit(pairs: &[(u16, u32)]) -> ObjectStore {
let mut pairs = pairs.to_vec();
if !pairs.iter().any(|&(index, _)| index == HEALTH) {
pairs.push((HEALTH, 100));
}
ObjectStore(ObjectFields::from_pairs(&pairs))
}
/// A player of the caster's faction, which `CanAssist` passes on its player-controlled arm.
fn friend(health: u32) -> ObjectStore {
friend_with(health, 0)
}
fn friend_with(health: u32, flags: u32) -> ObjectStore {
unit(&[(TEMPLATE, 1), (FLAGS, 0x8 | flags), (HEALTH, health)])
}
/// An Orc creature: hostile to the human caster and not player-controlled.
fn foe(health: u32) -> ObjectStore {
foe_with(health, 0)
}
fn foe_with(health: u32, flags: u32) -> ObjectStore {
unit(&[(TEMPLATE, 2), (FLAGS, flags), (HEALTH, health)])
}
fn admits(word: u16, candidate: &ObjectStore) -> bool {
admits_as(word, false, candidate, || false)
}
fn admits_as(
word: u16,
is_self: bool,
candidate: &ObjectStore,
in_party: impl FnOnce() -> bool,
) -> bool {
let factions = fx::factions();
let me = fx::caster(1.5);
let rel = TargetRelations {
target_store: Some(candidate),
target_owner_store: None,
self_store: Some(&me),
factions: Some(&factions),
reputations: &Reputations(Vec::new()),
types: Default::default(),
};
PickFlags::of(word).admits(is_self, &rel, in_party)
}
/// `0x481050`'s targeting arm, bit by bit: which unit-shaped flags each word sets.
#[test]
fn the_word_builds_the_flags_as_the_chooser_does() {
let flags = |word| PickFlags::of(word).0;
assert_eq!(flags(0), 0, "no word, no unit flag");
for word in [0x0010, 0x0020, 0x0040, 0x4000, 0x0800, 0x4800] {
assert_eq!(
flags(word),
0,
"{word:#06x} has no unit bit: no unit is picked"
);
}
assert_eq!(flags(UNIT), UNITS | ALIVE);
assert_eq!(flags(ASSIST_WORD), UNITS | ASSIST | ALIVE);
assert_eq!(flags(ENEMY), UNITS | ATTACK | ALIVE);
assert_eq!(flags(PARTY_WORD), UNITS | PARTY | ALIVE);
assert_eq!(
flags(RAID),
UNITS | PARTY | ALIVE,
"raid asks `0x606c20` too"
);
// Resurrection: the ally corpse bit takes the assist flag through `0x6e6240`, and a word
// in `0x8600` takes the dead flag instead of the alive one.
assert_eq!(flags(CORPSE_ALLY), UNITS | ASSIST | DEAD);
assert_eq!(flags(CORPSE_ENEMY), UNITS | ATTACK | DEAD);
// Skinning's word: all three relations, and dead.
assert_eq!(
flags(UNIT | DEAD_ONLY),
UNITS | PARTY | ASSIST | ATTACK | DEAD
);
assert_eq!(flags(ASSIST_WORD | ENEMY), UNITS | ASSIST | ATTACK | ALIVE);
}
/// A heal (the assist word) takes a friend and drops a foe.
#[test]
fn an_assist_word_drops_the_hostile_unit_and_keeps_the_friendly_one() {
assert!(admits(ASSIST_WORD, &friend(100)));
assert!(!admits(ASSIST_WORD, &foe(100)));
// A plain unit word has no relation flag: a foe is a candidate.
assert!(admits(UNIT, &foe(100)));
assert!(admits(UNIT, &friend(100)));
}
/// An attack word (the enemy bit) takes a foe and drops a friend.
#[test]
fn an_enemy_word_drops_the_friendly_unit_and_keeps_the_hostile_one() {
assert!(admits(ENEMY, &foe(100)));
assert!(!admits(ENEMY, &friend(100)));
// Neither is the caster's own body: never attackable, so never a candidate.
assert!(!admits_as(ENEMY, true, &friend(100), || false));
}
/// `4806fd`: a living candidate needs the alive flag, a dead one the dead flag.
#[test]
fn a_resurrection_word_drops_the_living_and_keeps_the_dead() {
assert!(admits(CORPSE_ALLY, &friend(0)), "a dead friend");
assert!(!admits(CORPSE_ALLY, &friend(100)), "a living friend");
// The assist flag `0x6e6240` adds still applies: a dead foe is no ally's corpse.
assert!(!admits(CORPSE_ALLY, &foe(0)), "a dead foe");
// The dead-only word: a dead unit of any relation, never a living one.
assert!(admits(UNIT | DEAD_ONLY, &foe(0)));
assert!(admits(UNIT | DEAD_ONLY, &friend(0)));
assert!(!admits(UNIT | DEAD_ONLY, &foe(100)));
// Every other unit word takes the living only.
assert!(
!admits(ASSIST_WORD, &friend(0)),
"a heal never takes a corpse"
);
assert!(!admits(UNIT, &foe(0)));
// A candidate with no health streamed is dead, as in the bind.
let bare = unit(&[(TEMPLATE, 1), (FLAGS, 0x8), (HEALTH, 0)]);
assert!(admits(CORPSE_ALLY, &bare));
}
/// The enemy corpse bit: `0x6e6250` adds the attack flag, so only a dead unit that can be
/// attacked is a candidate; a dead friend and a living foe are not.
#[test]
fn the_enemy_corpse_word_takes_the_dead_that_can_be_attacked() {
assert!(admits(CORPSE_ENEMY, &foe(0)), "a dead foe");
assert!(!admits(CORPSE_ENEMY, &friend(0)), "a dead friend");
assert!(!admits(CORPSE_ENEMY, &foe(100)), "a living foe");
}
/// The health field is signed and an absent descriptor is dead, as the bind reads it.
#[test]
fn the_alive_test_is_the_binds() {
let negative = friend(0x8000_0000);
assert!(admits(CORPSE_ALLY, &negative), "signed health below 0");
assert!(!admits(ASSIST_WORD, &negative));
let empty = ObjectStore(ObjectFields::default());
let factions = fx::factions();
let me = fx::caster(1.5);
let rel = TargetRelations {
target_store: Some(&empty),
target_owner_store: None,
self_store: Some(&me),
factions: Some(&factions),
reputations: &Reputations(Vec::new()),
types: Default::default(),
};
assert!(!PickFlags::of(UNIT).admits(false, &rel, || false));
assert!(PickFlags::of(UNIT | DEAD_ONLY).admits(false, &rel, || true));
}
/// `480737`: while targeting, `UNIT_FIELD_FLAGS & 0x100` is refused, whatever the word.
#[test]
fn a_unit_immune_to_players_is_never_a_candidate() {
// The word, and the subject it takes: (friend or foe, health).
type Make = fn(u32, u32) -> ObjectStore;
let cases: [(u16, Make, u32); 5] = [
(UNIT, friend_with, 100),
(ASSIST_WORD, friend_with, 100),
(ENEMY, foe_with, 100),
(CORPSE_ALLY, friend_with, 0),
(UNIT | DEAD_ONLY, foe_with, 0),
];
for (word, make, health) in cases {
assert!(admits(word, &make(health, 0)), "{word:#06x}: the control");
assert!(
!admits(word, &make(health, UNIT_FLAG_IMMUNE_TO_PLAYER)),
"{word:#06x}: flagged"
);
}
// Only bit 8: another bit of the same nibble does not.
let other = unit(&[(TEMPLATE, 1), (FLAGS, 0x0f8)]);
assert!(admits(UNIT, &other));
}
/// The party leg is asked only when its flag is set, and one relation suffices.
#[test]
fn one_relation_of_those_asked_is_enough() {
assert!(admits_as(PARTY_WORD, false, &foe(100), || true));
assert!(!admits_as(PARTY_WORD, false, &foe(100), || false));
// The assist relation does not stand in for the party flag alone.
assert!(!admits_as(PARTY_WORD, false, &friend(100), || false));
// A word with both flags: either passes.
assert!(admits_as(
PARTY_WORD | ASSIST_WORD,
false,
&friend(100),
|| false
));
assert!(admits_as(
PARTY_WORD | ASSIST_WORD,
false,
&foe(100),
|| true
));
assert!(!admits_as(
PARTY_WORD | ASSIST_WORD,
false,
&foe(100),
|| false
));
// No party flag, no question asked.
assert!(admits_as(
ASSIST_WORD,
false,
&friend(100),
|| unreachable!()
));
}
/// Ourselves: the caster assists himself and never attacks himself, so a heal word takes him
/// and an enemy word does not.
#[test]
fn the_caster_is_his_own_assist_and_never_his_own_attack() {
assert!(admits_as(ASSIST_WORD, true, &friend(100), || false));
assert!(!admits_as(ENEMY, true, &friend(100), || false));
}
/// The party leg's inputs, standing in a world: us (guid 1) and whoever a case spawns.
fn party_world() -> (World, Entity) {
let mut world = World::new();
world.init_resource::<UnitPick>();
world.init_resource::<SpellTargeting>();
world.insert_resource(fx::factions());
world.insert_resource(Reputations(Vec::new()));
world.init_resource::<GuidIndex>();
world.init_resource::<crate::ui_party::GroupState>();
world.insert_resource(SelfGuid(Some(1)));
let me = world
.spawn((
crate::net::SelfPlayer,
Guid(1),
GlobalTransform::default(),
unit(&[(TYPE, PLAYER_TYPE), (TEMPLATE, 1), (FLAGS, 0x8)]),
))
.id();
world.resource_mut::<GuidIndex>().0.insert(1, me);
(world, me)
}
fn spawn(world: &mut World, guid: u64, store: ObjectStore) -> Entity {
let entity = world
.spawn((Guid(guid), GlobalTransform::default(), store))
.id();
world.resource_mut::<GuidIndex>().0.insert(guid, entity);
entity
}
fn seat(world: &mut World, guid: u64) {
world
.resource_mut::<crate::ui_party::GroupState>()
.members
.push(GroupMemberEntry {
name: String::new(),
guid,
status: 0,
flags: 0,
});
}
/// The pick under `word` and the roster as it stands, published as a frame does.
fn asked(world: &mut World, word: u16, entity: Entity) -> bool {
world.resource_mut::<SpellTargeting>().enter(
fx::RESURRECTION,
crate::spell::CastCommit::Spell,
word,
);
world
.run_system_once(publish_unit_pick)
.expect("the word publishes");
world
.run_system_once(move |checks: PickChecks, stores: Query<&ObjectStore>| {
checks.admits_unit(entity, stores.get(entity).ok())
})
.expect("the checks run")
}
fn player(extra: &[(u16, u32)]) -> ObjectStore {
let mut pairs = vec![(TYPE, PLAYER_TYPE), (TEMPLATE, 1), (FLAGS, 0x8)];
pairs.extend_from_slice(extra);
unit(&pairs)
}
/// `0x606c20`, through the party word: us, a member of our party, our own pet and a member's
/// pet pass; a stranger, a hostile creature, and a member who is not player-controlled do not.
#[test]
fn the_party_word_takes_us_and_the_people_of_our_party() {
let (mut world, me) = party_world();
let member = spawn(&mut world, 2, player(&[]));
let stranger = spawn(&mut world, 3, player(&[]));
let pet = spawn(&mut world, 4, unit(&[(FLAGS, 0x8), (CREATED_BY, 1)]));
let member_pet = spawn(&mut world, 5, unit(&[(FLAGS, 0x8), (CHARMED_BY, 2)]));
let stranger_pet = spawn(&mut world, 6, unit(&[(FLAGS, 0x8), (CREATED_BY, 3)]));
let npc = spawn(&mut world, 7, foe(100));
seat(&mut world, 2);
assert!(asked(&mut world, PARTY_WORD, me), "ourselves (606c3d)");
assert!(asked(&mut world, PARTY_WORD, member), "a party member");
assert!(asked(&mut world, PARTY_WORD, pet), "our own pet");
assert!(asked(&mut world, PARTY_WORD, member_pet), "a member's pet");
assert!(
!asked(&mut world, PARTY_WORD, stranger),
"a player outside the party"
);
assert!(!asked(&mut world, PARTY_WORD, stranger_pet), "his pet");
assert!(!asked(&mut world, PARTY_WORD, npc), "a creature");
// Not player-controlled on either side: no party relation (606c5e, 606c76).
world.entity_mut(member).insert(player(&[(FLAGS, 0)]));
assert!(
!asked(&mut world, PARTY_WORD, member),
"a member without flag 0x8"
);
// A pet whose owner has not streamed answers no controller (606c92).
let orphan = spawn(&mut world, 8, unit(&[(FLAGS, 0x8), (CREATED_BY, 99)]));
assert!(!asked(&mut world, PARTY_WORD, orphan));
// The raid word asks the same predicate.
seat(&mut world, 3);
assert!(asked(&mut world, RAID, stranger), "seated now");
// Nobody is anybody's party once the roster is empty.
world.init_resource::<crate::ui_party::GroupState>();
world
.resource_mut::<crate::ui_party::GroupState>()
.members
.clear();
assert!(!asked(&mut world, PARTY_WORD, stranger));
assert!(
asked(&mut world, PARTY_WORD, me),
"but we are still ourselves"
);
}
}
@@ -77,8 +77,8 @@ pub(crate) fn commit_ground_cast_on_click(
/// not range nor the lock: the refusal is the server's `SMSG_CAST_RESULT`. The right-click path
/// ([`crate::target::click`], `0x5f33e0`) does resolve the lock and can refuse locally. Otherwise
/// the picked unit goes to the unit arm ([`super::bind_target_unit`]) and the picked corpse to the
/// corpse arm ([`super::corpse::bind_target_corpse`]); a unit click under a lock word binds nothing
/// there, since every unit arm tests a bit the word lacks.
/// corpse arm ([`super::corpse::bind_target_corpse`]). A lock word has no unit bit, so it puts no
/// unit in the pick and the click reaches what stands behind one ([`super::pick`]).
///
/// Runs after `select_on_click`, as the terrain commit does.
pub(crate) fn commit_object_cast_on_click(
+242 -7
View File
@@ -96,16 +96,20 @@ pub(super) struct TargetHoverState<'w, 's> {
/// The corpse reaction gate's inputs (`0x6067d0`).
factions: Option<Res<'w, super::Factions>>,
self_store: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
/// The unit and player filter while a word stands (`0x480610`).
units: crate::spell::targeting::PickChecks<'w, 's>,
}
impl TargetHoverState<'_, '_> {
/// Whether an object of this kind is a candidate of this pick, the posed pass and the box
/// fallback alike: every unit and player, and 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.
fn is_candidate(&self, kind: EntityKind, store: Option<&ObjectStore>) -> bool {
/// 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 {
match kind {
EntityKind::Unit | EntityKind::Player => true,
EntityKind::Unit | EntityKind::Player => self.units.admits_unit(entity, store),
EntityKind::Corpse => {
let Some(word) = self.corpse_pick.0 else {
return true;
@@ -253,7 +257,7 @@ pub(super) fn update_hover(
}
// 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(net.kind, store) {
if !target_state.is_candidate(entity, net.kind, store) {
continue;
}
// Not drawn, not clickable. Checked before `faithful.insert`, so an undrawn unit is out by
@@ -358,7 +362,7 @@ pub(super) fn update_hover(
continue;
};
let store = roots.get(parent).ok().and_then(|root| root.6);
if !target_state.is_candidate(parent_net.kind, store) {
if !target_state.is_candidate(parent, parent_net.kind, store) {
continue;
}
if let Some(t) = ray_mesh_bounds(origin, dir, aabb, gt) {
@@ -770,6 +774,7 @@ pub(super) fn update_hovered_object(
#[cfg(test)]
mod tests {
use super::*;
use crate::spell::targeting::corpse_fixture as fx;
use bevy::ecs::system::RunSystemOnce;
fn card(bone: u16, owner: Entity) -> BillboardCard {
@@ -900,6 +905,8 @@ mod tests {
world.insert_resource(crate::net::SelfGuid(Some(1)));
world.init_resource::<crate::spell::PicksSelf>();
world.init_resource::<crate::spell::CorpsePick>();
world.init_resource::<crate::spell::targeting::UnitPick>();
world.insert_resource(crate::net::Reputations(Vec::new()));
let mut camera = Camera::default();
camera.computed.clip_from_view = Mat4::perspective_infinite_reverse_rh(1.0, 1.0, 0.1);
camera.computed.target_info = Some(RenderTargetInfo {
@@ -1034,6 +1041,234 @@ mod tests {
assert!(!hover(friend(), Some(0x0040)), "a ground word");
}
/// A unit or player at depth `z` on the cursor's ray with one drawn pick box, of this store.
fn unit_at(world: &mut World, guid: u64, z: f32, store: ObjectStore) -> Entity {
let at = GlobalTransform::from_translation(Vec3::new(0.0, 0.0, z));
let body = world
.spawn((
Guid(guid),
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
at,
InheritedVisibility::VISIBLE,
store,
))
.id();
world.spawn((
ChildOf(body),
CreaturePickPart,
Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0)),
at,
InheritedVisibility::VISIBLE,
));
body
}
/// A store of `UNIT_FIELD_FACTIONTEMPLATE` (35), `UNIT_FIELD_FLAGS` (46) and
/// `UNIT_FIELD_HEALTH` (22), on [`fx::factions`]' rows: template 1 the caster's Human, 2 an Orc.
fn unit_store(template: u32, flags: u32, health: u32) -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(35, template),
(46, flags),
(22, health),
]))
}
/// A living player of the caster's faction: assistable, not attackable.
fn friend() -> ObjectStore {
unit_store(1, 0x8, 100)
}
/// A living Orc creature: attackable, not assistable.
fn foe() -> ObjectStore {
unit_store(2, 0, 100)
}
/// The pick over `word`, the way a frame runs it: the word published, then the pick.
fn pick_under(world: &mut World, word: Option<u16>) -> Hovered {
let mut targeting = world.resource_mut::<crate::spell::SpellTargeting>();
match word {
Some(word) => targeting.enter(fx::RESURRECTION, crate::spell::CastCommit::Spell, word),
None => targeting.clear(),
}
world
.run_system_cached(crate::spell::targeting::publish_unit_pick)
.expect("the word publishes");
world
.run_system_cached(update_hover)
.expect("the pick runs");
*world.resource::<Hovered>()
}
/// Us, a human away from the ray, the faction tables, and the units a case puts on it.
fn unit_pick_world() -> World {
let mut world = pick_world();
world.insert_resource(fx::factions());
world.init_resource::<crate::spell::SpellTargeting>();
world.spawn((
SelfPlayer,
Guid(1),
GlobalTransform::from_translation(Vec3::new(50.0, 0.0, 0.0)),
fx::caster(1.5),
));
world
}
/// `0x480610` while a word stands: a unit the word cannot take is no candidate, so the one
/// behind it takes the pick, where outside targeting the nearer wins whatever it is. The
/// front unit is 5 yards nearer the camera than the rear one.
#[test]
fn a_unit_the_word_cannot_take_lets_the_pick_through_to_the_one_behind() {
const HEAL: u16 = 0x0100;
const ENEMY: u16 = 0x0080;
const RESURRECTION: u16 = 0x8000;
const SKINNING: u16 = 0x0402;
// (word, the front unit it refuses, the rear unit it takes, what stands behind it)
let cases: [(&str, u16, ObjectStore, ObjectStore); 6] = [
("a foe before a friend, under a heal", HEAL, foe(), friend()),
(
"a corpse before a friend, under a heal",
HEAL,
unit_store(1, 0x8, 0),
friend(),
),
(
"a friend before a foe, under an enemy word",
ENEMY,
friend(),
foe(),
),
(
"the living before a corpse, under Resurrection",
RESURRECTION,
friend(),
unit_store(1, 0x8, 0),
),
(
"the living before a corpse, under Skinning",
SKINNING,
foe(),
unit_store(2, 0, 0),
),
(
"a unit immune to players before a friend, under a heal",
HEAL,
unit_store(1, 0x108, 100),
friend(),
),
];
for (what, word, front_store, rear_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, rear_store);
// Outside targeting the nearer unit wins, whoever it is.
assert_eq!(
pick_under(&mut world, None).target,
Some(front),
"{what}: outside targeting"
);
let hovered = pick_under(&mut world, Some(word));
assert_eq!(
hovered.target,
Some(rear),
"{what}: the word takes the rear"
);
assert_eq!(hovered.guid, Some(11), "{what}");
assert_eq!(
pick_under(&mut world, None).target,
Some(front),
"{what}: and back outside targeting"
);
}
}
/// The control of the table above: the same front unit is the pick when the word takes it.
#[test]
fn a_unit_the_word_takes_keeps_the_pick_in_front_of_the_one_behind() {
let cases: [(&str, u16, ObjectStore); 4] = [
("a friend under a heal", 0x0100, friend()),
("a foe under an enemy word", 0x0080, foe()),
("a corpse under Resurrection", 0x8000, unit_store(1, 0x8, 0)),
("a foe under a plain unit word", 0x0002, foe()),
];
for (what, word, front_store) in cases {
let mut world = unit_pick_world();
let front = unit_at(&mut world, 10, 5.0, front_store);
let rear_store = if word == 0x8000 {
unit_store(1, 0x8, 0)
} else {
friend()
};
unit_at(&mut world, 11, 0.0, rear_store);
assert_eq!(
pick_under(&mut world, Some(word)).target,
Some(front),
"{what}"
);
}
}
/// With nothing behind it, a unit the word cannot take leaves the pick empty, which is no
/// refusal: `IsSelectable` grades the winner only, and there is none.
#[test]
fn a_lone_unit_the_word_cannot_take_is_no_pick_at_all() {
let mut world = unit_pick_world();
unit_at(&mut world, 10, 0.0, foe());
let hovered = pick_under(&mut world, Some(0x0100));
assert_eq!((hovered.target, hovered.guid), (None, None));
assert!(!hovered.refused);
assert_eq!(hovered.distance, f32::MAX);
}
/// `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.
#[test]
fn only_a_word_with_unit_bits_picks_units() {
let mut world = unit_pick_world();
let front = unit_at(&mut world, 10, 5.0, friend());
for word in [0x0040, 0x0020, 0x4800, 0x0800, 0x0010] {
let hovered = pick_under(&mut world, Some(word));
assert_eq!(hovered.target, None, "{word:#06x}");
assert!(!hovered.refused, "{word:#06x}");
}
for word in [0x0002, 0x0100] {
assert_eq!(
pick_under(&mut world, Some(word)).target,
Some(front),
"{word:#06x}"
);
}
}
/// The press latches the hover's pick, so the click reaches what the cursor showed: the unit
/// behind, not the one in front the word refuses.
#[test]
fn the_press_latches_the_pick_the_word_left() {
use crate::target::{latch_press_pick, PressPick};
let mut world = unit_pick_world();
unit_at(&mut world, 10, 5.0, foe());
let rear = unit_at(&mut world, 11, 0.0, friend());
world.init_resource::<HoveredObject>();
world.init_resource::<PressPick>();
world.init_resource::<crate::target::WorldCursor>();
world.init_resource::<crate::target::cursor_mode::AttackFork>();
world.init_resource::<ButtonInput<MouseButton>>();
pick_under(&mut world, Some(0x0100));
world
.resource_mut::<ButtonInput<MouseButton>>()
.press(MouseButton::Left);
world
.run_system_cached(latch_press_pick)
.expect("the press latches");
assert_eq!(world.resource::<PressPick>().hovered.target, Some(rear));
}
/// Naxxramas's "Unholy Axe" is an `InvisibleStalker` body with no vertices holding a real axe,
/// which is its whole pick surface.
#[test]