From ec5083f4715cb2bb43f4c927b8593382a61d1116 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:10:24 +0700 Subject: [PATCH] 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) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/benilla-app/src/spell/bind_gates.rs | 3 +- crates/benilla-app/src/spell/cast_target.rs | 51 +- crates/benilla-app/src/spell/mod.rs | 2 + crates/benilla-app/src/spell/targeting/mod.rs | 4 + .../benilla-app/src/spell/targeting/pick.rs | 616 ++++++++++++++++++ .../benilla-app/src/spell/targeting/world.rs | 4 +- crates/benilla-app/src/target/hover.rs | 249 ++++++- 7 files changed, 900 insertions(+), 29 deletions(-) create mode 100644 crates/benilla-app/src/spell/targeting/pick.rs diff --git a/crates/benilla-app/src/spell/bind_gates.rs b/crates/benilla-app/src/spell/bind_gates.rs index 0b138f03..8686bc4d 100644 --- a/crates/benilla-app/src/spell/bind_gates.rs +++ b/crates/benilla-app/src/spell/bind_gates.rs @@ -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 diff --git a/crates/benilla-app/src/spell/cast_target.rs b/crates/benilla-app/src/spell/cast_target.rs index e66c52d9..b84f1682 100644 --- a/crates/benilla-app/src/spell/cast_target.rs +++ b/crates/benilla-app/src/spell/cast_target.rs @@ -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, @@ -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`, diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index c076a901..63577058 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -69,6 +69,7 @@ impl Plugin for SpellPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .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 diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index 16a61fba..90b33a9b 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -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::*; diff --git a/crates/benilla-app/src/spell/targeting/pick.rs b/crates/benilla-app/src/spell/targeting/pick.rs new file mode 100644 index 00000000..f74c48f7 --- /dev/null +++ b/crates/benilla-app/src/spell/targeting/pick.rs @@ -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, + /// The guids of [`crate::ui_party::GroupState::party_slots`], empty outside targeting. + party: Vec, +} + +/// 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, + group: Option>, + mut pick: ResMut, +) { + let flags = targeting + .0 + .as_ref() + .map(|targeting| PickFlags::of(targeting.word)); + let party: Vec = 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>, + self_q: Query<'w, 's, (Entity, Option<&'static ObjectStore>), With>, + factions: Option>, + 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 { + 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::(); + world.init_resource::(); + world.insert_resource(fx::factions()); + world.insert_resource(Reputations(Vec::new())); + world.init_resource::(); + world.init_resource::(); + 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::().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::().0.insert(guid, entity); + entity + } + + fn seat(world: &mut World, guid: u64) { + world + .resource_mut::() + .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::().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::(); + world + .resource_mut::() + .members + .clear(); + assert!(!asked(&mut world, PARTY_WORD, stranger)); + assert!( + asked(&mut world, PARTY_WORD, me), + "but we are still ourselves" + ); + } +} diff --git a/crates/benilla-app/src/spell/targeting/world.rs b/crates/benilla-app/src/spell/targeting/world.rs index 3626ab85..18df9ef7 100644 --- a/crates/benilla-app/src/spell/targeting/world.rs +++ b/crates/benilla-app/src/spell/targeting/world.rs @@ -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( diff --git a/crates/benilla-app/src/target/hover.rs b/crates/benilla-app/src/target/hover.rs index d5d2dd94..336358e2 100644 --- a/crates/benilla-app/src/target/hover.rs +++ b/crates/benilla-app/src/target/hover.rs @@ -96,16 +96,20 @@ pub(super) struct TargetHoverState<'w, 's> { /// The corpse reaction gate's inputs (`0x6067d0`). factions: Option>, self_store: Query<'w, 's, &'static ObjectStore, With>, + /// 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::(); world.init_resource::(); + world.init_resource::(); + 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) -> Hovered { + let mut targeting = world.resource_mut::(); + 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::() + } + + /// 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::(); + 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::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + + pick_under(&mut world, Some(0x0100)); + world + .resource_mut::>() + .press(MouseButton::Left); + world + .run_system_cached(latch_press_pick) + .expect("the press latches"); + assert_eq!(world.resource::().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]