diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index f44acc4a..c26981e3 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -234,14 +234,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // `autoSelfCast` (register site `0x6e731d`, record `[0xceac34]`, read at `0x6e53d7`; `0x870dc0` // is its name string): a friendly cast that binds nothing falls back to the caster. // `TOGGLEAUTOSELFCAST` toggles it. - deviates( - "autoSelfCast", - "1", - "0", - "with it off, an unbindable friendly cast falls into the reference's \ - targeting-cursor machine, which is unmodeled — leaving no path at all. Flip when that \ - machine lands", - ), + same("autoSelfCast", "0"), // The five saved camera views and the live index, at the reference's names and default strings; // owned by [`crate::player::camera_view`]. Registered so a `SaveView` persists. same( @@ -1524,7 +1517,6 @@ mod tests { names, vec![ "SoundReverb", - "autoSelfCast", "frillDensity", "gxApi", "gxColorBits", diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 1fb86f24..c4fd6f11 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -662,12 +662,17 @@ pub(crate) mod schedule_tests { /// `Player`: it reads `control_lost`, and they write only `settling` and `world_stale`; /// - `ui_quest::lines::feed_quest_lines` against `ui_items::feed_item_stats` and /// `ui_tooltip::feed_spell_tooltips` over `Items` and the VM: it only reads templates, which - /// those two do not write, and a chat line commutes with their pushes. + /// those two do not write, and a chat line commutes with their pushes; + /// - `spell::targeting::drain_spell_target_unit` against the lone `.after(UiInput)` cast + /// drains over the `CastLadder`, and the party, pet-book and chat drains over the unit-token + /// resolver: the class `drop_item_on_unit` and the world click's legs already carry, as none + /// of those drains orders against the target chain. A cast press and a unit-frame bind in one + /// frame take either order until the cast drains share a set that does. /// /// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the /// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource /// that commutes by construction belongs in [`Classes`]. - const UPDATE_ACTIONABLE_CEILING: usize = 5_463; + const UPDATE_ACTIONABLE_CEILING: usize = 5_481; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index 1b1c8c5f..ef8068c4 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -101,6 +101,8 @@ pub(crate) enum TargetedBind { Item(u64), /// The world click's GameObject guid: a chest, door, vein or herb. Object(u64), + /// The world click's unit guid. + Unit(u64), } impl CastLadder<'_, '_> { @@ -126,6 +128,10 @@ impl CastLadder<'_, '_> { TargetedBind::Object(go_guid) => { ClientCommand::CastSpellGameObject { spell_id, go_guid } } + TargetedBind::Unit(unit_guid) => ClientCommand::CastSpell { + spell_id, + target: Some(unit_guid), + }, }, CastCommit::Item { bag_index, @@ -141,6 +147,7 @@ impl CastLadder<'_, '_> { TargetedBind::Source(src) => UseItemTarget::Source(src), TargetedBind::Item(guid) => UseItemTarget::Item(guid), TargetedBind::Object(guid) => UseItemTarget::Object(guid), + TargetedBind::Unit(guid) => UseItemTarget::Unit(guid), }, }, }; @@ -404,18 +411,7 @@ fn send_spell_cast( if let Some(d) = def { if target.is_some() && target == ctx.selection_guid && target != ctx.self_guid { let row = spells.and_then(|s| s.ranges.get(d.range_index)); - let dist_sq = ctx - .range - .self_pos - .zip(ctx.range.target_pos) - .map(|(a, b)| a.distance_squared(b)); - if let Some(reason) = validator::cast_range_refusal( - d, - row, - ctx.range.self_reach, - ctx.range.target_reach, - dist_sq, - ) { + if let Some(reason) = ctx.range.refusal(d, row) { debug!("ui_action: cast {spell_id} refused locally — range ({reason:#x})"); cast_errors.push_local(spell_id, reason); return; @@ -723,6 +719,7 @@ mod tests { auto_self_cast: false, rel: cast_target::TargetRelations { target_store: None, + target_owner_store: None, self_store: None, factions: None, reputations: &EMPTY_REPUTATIONS, diff --git a/crates/benilla-app/src/spell/cast_target.rs b/crates/benilla-app/src/spell/cast_target.rs index 52614d9b..fd842c0a 100644 --- a/crates/benilla-app/src/spell/cast_target.rs +++ b/crates/benilla-app/src/spell/cast_target.rs @@ -9,18 +9,20 @@ //! - otherwise each bit is cleared against the selection by its relation check, then against the //! player behind `autoSelfCast` (`0x6e53d7`); only a fully cleared word commits, as a unit guid. //! - a word with item, lock, GameObject or location bits enters the targeting cursor carrying -//! the whole word: its three predicates (`0x6e6320` `& 0x60`, `0x6e6330` `& 0x4010`, `0x6e62d0` -//! `& 0x4800`) can hold at once, and the click picks the leg. +//! the whole word: its location (`0x6e6320`, `& 0x60`), item (`0x6e6330`, `& 0x4010`), +//! GameObject (`0x6e62d0`, `& 0x4800`) and unit (`0x6e6460`) predicates can hold at once, and +//! the click picks the leg. //! -//! Not built: the unit hand cursor the reference enters for a residual unit word, and the STRING -//! bit 13; both refuse locally with the client's "You have no target." or "Invalid target". +//! The unit hand cursor receives a residual unit word without the enemy bit. A word carrying +//! `0x80` instead raises the local no-target/invalid-target refusal; STRING bit 13 remains +//! unmodeled and refuses locally with "Invalid target". use benilla_formats::SpellDisplay; use bevy::ecs::system::SystemParam; use bevy::prelude::*; -use crate::net::{ObjectStore, Reputations, SelfGuid, SelfPlayer}; -use crate::target::{can_attack, ring_reaction, Factions, Selection}; +use crate::net::{GuidIndex, ObjectStore, Reputations, SelfGuid, SelfPlayer}; +use crate::target::{can_assist, can_attack, Factions, Selection}; /// `TARGET_FLAG_*` bits of the client's targeting flag word (`0xcecac0`). const TF_UNIT: u16 = 0x0002; @@ -41,8 +43,7 @@ const UNIT_BITS: u16 = TF_UNIT | TF_EXPLICIT_GATE | TF_CORPSE_ALLY; -/// Client cast-failed reasons "You have no target." and "Invalid target", raised locally where -/// the reference would enter its unit targeting cursor. +/// Client cast-failed reasons used when an enemy word cannot bind. pub(crate) const ERR_NO_TARGET: u8 = 0x09; pub(crate) const ERR_INVALID_TARGET: u8 = 0x0A; @@ -82,8 +83,7 @@ pub(crate) enum CastWireTarget { /// `IsTargeting 0x6e48a0`). Each click seam tests the word with its own mask, so one word can /// serve several: Opening and Pick Lock take a bag item or a world GameObject. Targeting(u16), - /// Do not send; show this client error. The reference raises nothing here: it enters its unit - /// targeting cursor, which is not built. + /// Do not send; show this client error. Refused(u8), /// A refusal the reference raises in its cast tail after `ArmCast` returns false (`0x6e5045`). /// That tail runs after the requirement validator `0x6094f0`, so it fires at the cursor-entry @@ -108,6 +108,8 @@ pub(crate) struct CastCandidates { #[derive(Clone, Copy)] pub(crate) struct TargetRelations<'a> { pub(crate) target_store: Option<&'a ObjectStore>, + /// The target's charmer, creator or summoner store, for `CanAssist`'s `IsPvP` owner chase. + pub(crate) target_owner_store: Option<&'a ObjectStore>, pub(crate) self_store: Option<&'a ObjectStore>, pub(crate) factions: Option<&'a Factions>, pub(crate) reputations: &'a Reputations, @@ -140,6 +142,24 @@ pub(crate) struct RangeInputs { pub(crate) target_reach: Option, } +impl RangeInputs { + /// The one range compare: the squared 3D distance against `GetMinMaxRange 0x6e3480`'s bounds, + /// "Out of range." (0x59) past max², "Target too close" (0x76) inside a nonzero min². The + /// pre-send gate (`IsTargetInRange 0x6e47b0`) and the targeting cursor's unit leg (`0x6e6460`, + /// `6e677c`–`6e6802`) both ask it. No row or no position passes; the server judges. + pub(crate) fn refusal( + &self, + def: &SpellDisplay, + row: Option<&benilla_formats::SpellRange>, + ) -> Option { + let dist_sq = self + .self_pos + .zip(self.target_pos) + .map(|(a, b)| a.distance_squared(b)); + super::validator::cast_range_refusal(def, row, self.self_reach, self.target_reach, dist_sq) + } +} + impl Default for RangeInputs { fn default() -> Self { Self { @@ -158,6 +178,7 @@ pub(crate) struct CastTargeting<'w, 's> { pub(crate) selection: Res<'w, Selection>, pub(crate) self_store: Query<'w, 's, &'static ObjectStore, With>, stores: Query<'w, 's, &'static ObjectStore>, + index: Option>, self_guid: Res<'w, SelfGuid>, auto_self_cast: Res<'w, AutoSelfCast>, factions: Option>, @@ -173,12 +194,21 @@ impl CastTargeting<'_, '_> { /// This frame's [`CastContext`]. pub(crate) fn context(&self) -> CastContext<'_> { let target_store = self.selection.target.and_then(|e| self.stores.get(e).ok()); + let target_owner_store = target_store + .and_then(|store| { + store + .0 + .unit_owner(benilla_protocol::messages::OwnerFallback::CreatedBy) + }) + .and_then(|guid| self.index.as_ref()?.0.get(&guid).copied()) + .and_then(|entity| self.stores.get(entity).ok()); CastContext { selection_guid: self.selection.guid, self_guid: self.self_guid.0, auto_self_cast: self.auto_self_cast.0, rel: TargetRelations { target_store, + target_owner_store, self_store: self.self_store.iter().next(), factions: self.factions.as_deref(), reputations: &self.reputations, @@ -212,9 +242,7 @@ impl CastTargeting<'_, '_> { const EQUIPMENT_SLOT_MAINHAND: u8 = 15; /// The `autoSelfCast` CVar (name `0x870dc0`, read at `0x6e53d7`; reference default `"0"`). -/// Deviation: defaults on, because with it off an unbindable friendly cast needs the unit -/// targeting cursor, which is not built. -#[derive(bevy::prelude::Resource)] +#[derive(bevy::prelude::Resource, Default)] pub(crate) struct AutoSelfCast(pub(crate) bool); /// `autoSelfCast`'s change callback: a flag. @@ -224,12 +252,6 @@ pub(crate) fn on_cvar(ev: On, mut auto: ResMut Self { - Self(true) - } -} - /// The flag word: `Targets`, then one arm keyed on `EffectImplicitTargetA[0]` (`0x6e525a`- /// `0x6e52ef`); every implicit target not matched below leaves the word as it is. pub(crate) fn cast_target_mask(def: &SpellDisplay) -> u16 { @@ -253,18 +275,20 @@ pub(crate) fn cast_target_mask(def: &SpellDisplay) -> u16 { /// `BindTarget 0x6e5b40`'s unit branch: clear every flag-word bit the candidate satisfies. /// -/// The reference asks `CanAssist 0x6066f0` for the assist bit; this uses reaction alone (>= 4). +/// 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. fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16 { let mut word = word; - let reaction = ring_reaction( - rel.factions, - rel.reputations, - rel.target_store, - rel.self_store, - ); - let assist = is_self || reaction >= 4; + let assist = is_self + || can_assist( + rel.target_store, + rel.factions, + rel.reputations, + rel.self_store, + |_| rel.target_owner_store.cloned(), + ); let dead = rel .target_store .is_some_and(|s| s.0.unit_health() == Some(0)); @@ -304,6 +328,12 @@ fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16 word } +/// Whether `BindTarget 0x6e5b40`'s unit arm clears the whole standing word for this unit. +/// The initial selection, a world click and `SpellTargetUnit` all use this one predicate. +pub(super) fn unit_word_binds(word: u16, is_self: bool, rel: &TargetRelations) -> bool { + word & UNIT_BITS != 0 && clear_satisfied_bits(word, is_self, rel) == 0 +} + /// The wire target for casting `def`. An unknown spell sends the selection as is, or no target /// without one, and the server validates. pub(crate) fn resolve_cast_target( @@ -361,7 +391,7 @@ pub(crate) fn resolve_cast_target( // The selection (`0xb4e2d8`, `0x6e539f`). if let Some(guid) = cand.selection { let is_self = cand.caster == Some(guid); - if clear_satisfied_bits(word, is_self, rel) == 0 { + if unit_word_binds(word, is_self, rel) { return CastWireTarget::Unit(guid); } } @@ -370,19 +400,25 @@ pub(crate) fn resolve_cast_target( if let Some(guid) = cand.caster { let self_rel = TargetRelations { target_store: rel.self_store, + target_owner_store: None, ..*rel }; - if clear_satisfied_bits(word, true, &self_rel) == 0 { + if unit_word_binds(word, true, &self_rel) { return CastWireTarget::Unit(guid); } } } - // The reference enters its unit targeting cursor here; not built, so refuse. - CastWireTarget::Refused(if cand.selection.is_some() { - ERR_INVALID_TARGET + // A standing enemy word refuses locally. Every other residual unit word proceeds to + // `BindTarget`'s hand cursor. + if word & TF_UNIT_ENEMY != 0 { + CastWireTarget::Refused(if cand.selection.is_some() { + ERR_INVALID_TARGET + } else { + ERR_NO_TARGET + }) } else { - ERR_NO_TARGET - }) + CastWireTarget::Targeting(word) + } } #[cfg(test)] @@ -406,6 +442,14 @@ mod tests { } } + #[test] + fn auto_self_cast_boots_at_the_reference_default() { + assert!( + !AutoSelfCast::default().0, + "autoSelfCast defaults to the reference's disabled state" + ); + } + #[test] fn mask_follows_the_arm_map() { assert_eq!(cast_target_mask(&spell(0, 1)), 0, "Ice Armor: self"); @@ -442,6 +486,7 @@ mod tests { let it = crate::net::ObjectStore(ObjectFields::from_pairs(&[(35, 0)])); let rel = TargetRelations { target_store: Some(&it), + target_owner_store: None, self_store: Some(&me), factions: None, reputations: &Reputations(Vec::new()), @@ -455,7 +500,8 @@ mod tests { let fireball = spell(0, 6); assert_eq!( resolve_cast_target(Some(&fireball), &cands(None, Some(1)), true, &rel), - CastWireTarget::Refused(ERR_NO_TARGET) + CastWireTarget::Refused(ERR_NO_TARGET), + "enemy words retain the no-target refusal" ); // Neutral (3) is attackable by the mixed arm's `< 4` and not assistable by `>= 4`. assert_eq!( @@ -463,6 +509,10 @@ mod tests { CastWireTarget::Unit(42) ); let intellect = spell(0, 21); + assert!( + !unit_word_binds(TF_UNIT_ASSIST, false, &rel), + "the cursor's unit binder must reject the same neutral unit as the initial cast arm" + ); assert_eq!( resolve_cast_target(Some(&intellect), &cands(Some(42), Some(1)), true, &rel), CastWireTarget::Unit(1), @@ -470,8 +520,8 @@ mod tests { ); assert_eq!( resolve_cast_target(Some(&intellect), &cands(Some(42), Some(1)), false, &rel), - CastWireTarget::Refused(ERR_INVALID_TARGET), - "autoSelfCast off: the fallback is gated" + CastWireTarget::Targeting(TF_UNIT_ASSIST), + "autoSelfCast off leaves the friendly word for the unit cursor" ); assert_eq!( resolve_cast_target(Some(&intellect), &cands(None, Some(1)), true, &rel), @@ -490,6 +540,38 @@ mod tests { ); } + #[test] + fn assist_word_requires_the_npc_pvp_flag_not_only_a_friendly_reaction() { + use benilla_protocol::ObjectFields; + + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let factions = Factions::from_catalog( + benilla_formats::load_faction_catalog(&mut chain).expect("FactionTemplate.dbc"), + ); + // Faction templates 35 and 1 are a real friendly pair. Field 46 is UNIT_FIELD_FLAGS. + let me = ObjectStore(ObjectFields::from_pairs(&[(35, 1), (46, 0x8)])); + let quiet = ObjectStore(ObjectFields::from_pairs(&[(35, 35), (46, 0)])); + let pvp = ObjectStore(ObjectFields::from_pairs(&[(35, 35), (46, 0x1000)])); + let reputations = Reputations(Vec::new()); + let rel = |target| TargetRelations { + target_store: Some(target), + target_owner_store: None, + self_store: Some(&me), + factions: Some(&factions), + reputations: &reputations, + }; + + assert!( + !unit_word_binds(TF_UNIT_ASSIST, false, &rel(&quiet)), + "a friendly ambient NPC is not assistable" + ); + assert!( + unit_word_binds(TF_UNIT_ASSIST, false, &rel(&pvp)), + "the same friendly NPC becomes assistable with UNIT_FLAG_PVP" + ); + } + /// Flamestrike (implicit 16) defers even with a selection, Blizzard (implicit 28) by its /// bare DEST word; a word of 0 still commits first (`0x6e5338`). #[test] @@ -660,6 +742,7 @@ mod tests { static EMPTY: Reputations = Reputations(Vec::new()); TargetRelations { target_store: None, + target_owner_store: None, self_store: None, factions: None, reputations: &EMPTY, diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index f3bd10fc..eefb9fec 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -7,7 +7,7 @@ use bevy::prelude::*; use crate::char_select::ClientState; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_unit::UnitFeed; use benilla_world::schedule::WorldStage; @@ -32,7 +32,7 @@ pub(crate) use inflight::{ }; pub(crate) use mods::{SpellModifiers, OP_COST}; // `TargetingWants` is exported for the ground reticle, which draws for the location word alone. -pub(crate) use targeting::{ground_cast_radius, SpellTargeting, TargetingWants}; +pub(crate) use targeting::{ground_cast_radius, PicksSelf, SpellTargeting, TargetingWants}; /// The local self-cancel's set: a reader of the in-flight state orders `.after(LocalCancel)` so a /// cast ended by a move, jump or Esc drops its cast bar the same frame. @@ -53,6 +53,7 @@ impl Plugin for SpellPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_observer(cast_target::on_cvar) .add_systems( Update, @@ -65,7 +66,12 @@ impl Plugin for SpellPlugin { .before(UnitFeed), // The state push runs before the input pass's `ToggleGameMenu` and the drain // after it, so an Esc cancel lands before next frame's cursor reads the mode. - targeting::feed_targeting_to_vm.in_set(UnitFeed), + // After the old-target clear, the pet bar's writer in the feed: `"pet"` + // resolves off the bar. + targeting::feed_targeting_to_vm + .in_set(UnitFeed) + .after(crate::ui_pet::pet_stop_on_old_target_clear), + targeting::publish_picks_self.in_set(UiFeed), targeting::drain_stop_targeting.after(UiInput), // The item-target commit (`0x495d60`): after the input pass so a bag click // binds the same frame; outside the target chain, as its clicks never reach diff --git a/crates/benilla-app/src/spell/targeting/cursor.rs b/crates/benilla-app/src/spell/targeting/cursor.rs index 6e575408..fbd3cfc9 100644 --- a/crates/benilla-app/src/spell/targeting/cursor.rs +++ b/crates/benilla-app/src/spell/targeting/cursor.rs @@ -75,12 +75,13 @@ pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level: /// its ray (`0x4812c8`) and the world cursor stays grey. /// /// The object arm is `0x6e6460`'s GameObject leg: `word & 0x4800`, the lock predicate `0x5f8260`, -/// then the same min/max range test through `GetMinMaxRange 0x6e3480`. Its unit, world-item and -/// corpse legs are unreachable here: a unit-target spell never enters targeting mode. +/// then the same min/max range test through `GetMinMaxRange 0x6e3480`. Its unit leg is +/// [`SpellTargeting::can_target_unit`]. The world-item and corpse legs are not built. /// /// Every seam shows the `Cast` kind, so this reads the whole-word [`SpellTargeting::spell`]. pub(crate) fn drive_targeting_cursor( targeting: Res, + unit_checks: super::UnitBindChecks, occlusion: Res, hovered: Res, hovered_object: Res, @@ -102,8 +103,8 @@ pub(crate) fn drive_targeting_cursor( }; let row = range_row(spells.as_deref(), spell_id); let me = self_tf.single().ok().map(|tf| tf.translation); - // "A GameObject is the nearest pick" is the same test the click uses - // ([`super::world::commit_object_cast_on_click`]). + // The arm the click would take ([`super::world::commit_object_cast_on_click`]): a GameObject + // word's nearest GameObject, else the picked unit, else the terrain. let able = if targeting.wants(TargetingWants::GameObject) && crate::target::go_is_nearest(&hovered, &hovered_object) { @@ -116,6 +117,11 @@ pub(crate) fn drive_targeting_cursor( row, me, ) + } else if let Some(unit) = hovered + .target + .filter(|_| targeting.wants(TargetingWants::Unit)) + { + targeting.can_target_unit(unit, &unit_checks) } else if targeting.wants(TargetingWants::Location) { // `0x4820f0`. No ground hit (sky, mouselook) is state 0, UnableCast. match (occlusion.point, me) { @@ -289,6 +295,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); world.insert_resource(PickOcclusion { distance: 10.0, point, @@ -341,4 +348,89 @@ mod tests { // A lock word that also carries DEST (`0x4840`) keeps its terrain handler off a GameObject. assert!(verdict(0x4840, Some(Vec3::ZERO), None)); } + + /// `0x6e6460`'s unit leg over a hovered unit: the relation checks, then min² ≤ d² ≤ max² + /// (`6e677c`–`6e6802`). Row 5 is 0 to 30 yd and row 114 is 8 to 35 yd, both padded by the two + /// 1.5 combat reaches: 0 to 33 and 11 to 38. + #[test] + fn the_unit_leg_is_grey_out_of_range_or_off_relation() { + use bevy::ecs::system::RunSystemOnce; + use std::collections::HashMap; + + const HEAL: u32 = 2050; + const SHOT: u32 = 75; + let verdict_for = |spell: u32, word: u16, distance: f32| { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); + world.init_resource::(); + let display = |range_index| benilla_formats::SpellDisplay { + range_index, + ..Default::default() + }; + let row = |min, max| SpellRange { min, max, flags: 0 }; + let mut spells = Spells::empty_for_tests(); + spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([ + (HEAL, display(5)), + (SHOT, display(114)), + ])); + spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([ + (5, row(0.0, 30.0)), + (114, row(8.0, 35.0)), + ])); + world.insert_resource(spells); + let empty = || crate::net::ObjectStore(benilla_protocol::ObjectFields::default()); + world.spawn(( + SelfPlayer, + Transform::default(), + GlobalTransform::default(), + empty(), + )); + let unit = world + .spawn(( + GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)), + empty(), + )) + .id(); + world.insert_resource(crate::target::Hovered { + target: Some(unit), + guid: Some(0xF130_0000_0000_0001), + distance: 5.0, + ..Default::default() + }); + world.resource_mut::().enter( + spell, + crate::spell::CastCommit::Spell, + word, + ); + world + .run_system_once(drive_targeting_cursor) + .expect("the targeting cursor drives"); + !world.resource::().unable + }; + let verdict = |word, distance| verdict_for(HEAL, word, distance); + + // `TARGET_FLAG_UNIT` (0x2) binds any unit, so only range decides. + assert!(verdict(0x0002, 10.0), "a valid unit in range → Cast"); + assert!( + !verdict(0x0002, 40.0), + "the same unit out of range → UnableCast" + ); + // An assist word over a unit with no faction catalog: neutral, not assistable. + assert!( + !verdict(0x0100, 10.0), + "a unit that fails the relation → UnableCast" + ); + // Inside the minimum is out of range too. + assert!(verdict_for(SHOT, 0x0002, 20.0), "inside the band → Cast"); + assert!( + !verdict_for(SHOT, 0x0002, 5.0), + "inside the minimum → UnableCast" + ); + } } diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index 3481b208..cfb5dafc 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -3,18 +3,21 @@ //! word and each click seam asks it its own mask test ([`TargetingWants`]). //! //! - [`cursor`]: the hover verdict per seam. Terrain is `0x4820f0`'s `CheckGroundPointInRange -//! 0x6e6810`, a GameObject is `0x4828d0`'s `0x6e6460` (the spell-vs-lock predicate `0x5f8260`, -//! then range), and a word no seam handles is UnableCast. +//! 0x6e6810`, a GameObject or a unit is `0x4828d0`'s `0x6e6460` (the spell-vs-lock predicate +//! `0x5f8260` or [`SpellTargeting::can_target_unit`], then range), and a word no seam handles is +//! UnableCast. //! - [`world`]: the world-click dispatcher `0x492ce0`, its terrain leg (`0x492580` → `BindLocation -//! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`). +//! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), +//! whose unit arm [`bind_target_unit`] shares with `SpellTargetUnit` ([`drain_spell_target_unit`]). //! - [`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. //! //! Every seam commits through [`crate::spell::CastLadder::commit_targeted`] (`SendCast 0x6e54f0`). -//! Neither world leg has a range gate: the click sends and the server judges range, and -//! `CheckGroundPointInRange` only colours the cursor. While targeting, the pick flags come from -//! the word alone, so a click over a unit with a dest-only word commits on the ground behind it. +//! Only the unit arm has a range gate: the terrain and GameObject clicks send and the server +//! judges range, and `CheckGroundPointInRange` only colours the cursor. While targeting, the pick +//! flags come from the word alone, so a click over a unit with a dest-only word commits on the +//! ground behind it. //! //! Cancels: ESC through `UIParent.lua:1490` ([`feed_targeting_to_vm`], [`drain_stop_targeting`]), //! the right-button down edge ([`cancel_targeting_on_right_press`]), a new spell's press, which @@ -34,11 +37,104 @@ use bevy::prelude::*; use benilla_world::interact::WorldRightPress; +/// The inputs of the cursor's two unit functions, [`SpellTargeting::can_target_unit`] and +/// [`bind_target_unit`]: the relation checks the cast arm's selection bind runs +/// ([`super::cast_target::unit_word_binds`]) and the pre-send range gate's inputs +/// ([`super::cast_target::RangeInputs`]), read as [`super::cast_target::CastTargeting`] reads them. +#[derive(bevy::ecs::system::SystemParam)] +pub(crate) struct UnitBindChecks<'w, 's> { + stores: Query<'w, 's, &'static crate::net::ObjectStore>, + index: Option>, + self_q: Query< + 'w, + 's, + (Entity, Option<&'static crate::net::ObjectStore>), + With, + >, + factions: Option>, + reputations: Res<'w, crate::net::Reputations>, + /// The range leg's positions: the pose the hover picks against, as last frame propagated it, + /// so the VM feed, the cursor and both binds read one pose. Never the camera's, which + /// `publish_camera_pose` rewrites mid-frame. + poses: Query<'w, 's, &'static GlobalTransform, Without>, + spells: Option>, +} + +impl UnitBindChecks<'_, '_> { + fn is_self(&self, entity: Entity) -> bool { + self.self_q + .iter() + .next() + .is_some_and(|(me, _)| me == entity) + } + + /// `BindTarget 0x6e5b40`'s relation checks: whether this unit clears the whole word. + fn relations_clear(&self, word: u16, entity: Entity) -> bool { + let target_store = self.stores.get(entity).ok(); + let target_owner_store = target_store + .and_then(|store| { + store + .0 + .unit_owner(benilla_protocol::messages::OwnerFallback::CreatedBy) + }) + .and_then(|guid| self.index.as_ref()?.0.get(&guid).copied()) + .and_then(|owner| self.stores.get(owner).ok()); + let rel = super::cast_target::TargetRelations { + target_store, + target_owner_store, + self_store: self.self_q.iter().next().and_then(|(_, store)| store), + factions: self.factions.as_deref(), + reputations: &self.reputations, + }; + super::cast_target::unit_word_binds(word, self.is_self(entity), &rel) + } + + /// The spell's min/max range against this unit, through the one compare the pre-send gate + /// asks ([`super::cast_target::RangeInputs::refusal`]); our own body is distance 0. An + /// unknown spell or a missing row passes. + fn range_refusal(&self, spell_id: u32, entity: Entity) -> Option { + let spells = self.spells.as_deref()?; + let def = spells.catalog.get(spell_id)?; + let me = self.self_q.iter().next(); + let mut range = super::cast_target::RangeInputs { + self_pos: me + .and_then(|(e, _)| self.poses.get(e).ok()) + .map(GlobalTransform::translation), + target_pos: self + .poses + .get(entity) + .ok() + .map(GlobalTransform::translation), + target_reach: self + .stores + .get(entity) + .ok() + .map(|s| s.0.unit_combat_reach()), + ..Default::default() + }; + if let Some((_, Some(store))) = me { + range.self_reach = store.0.unit_combat_reach(); + } + range.refusal(def, spells.ranges.get(def.range_index)) + } + + /// The caster under a spell with `AttributesEx & 0x80000` (`6e6507`). + fn excluded_caster(&self, spell_id: u32, entity: Entity) -> bool { + self.is_self(entity) + && self + .spells + .as_deref() + .and_then(|s| s.catalog.get(spell_id)) + .is_some_and(|d| d.excludes_caster()) + } +} + /// A click seam's mask test on the flag_word `0xcecac0`, one per reference predicate: /// /// - `Location`: `TargetingWantsLocation 0x6e6320`, `word & 0x60`, the terrain click. /// - `Item`: `TargetingWantsItem 0x6e6330`, `word & 0x4010`, the bag and paper-doll clicks. /// - `GameObject`: `TargetingWantsGameObject 0x6e62d0`, `word & 0x4800`, the world object click. +/// - `Unit`: `SpellCanTargetUnit 0x6e6460`, the unit arm of the world click. /// /// The masks overlap on `TARGET_FLAG_LOCKED`, so a lock spell answers both the item and the /// GameObject seam; the reference settles it only at the click, where `BindTarget 0x6e5b40` picks @@ -48,10 +144,11 @@ pub(crate) enum TargetingWants { Location, Item, GameObject, + Unit, } -/// The unit-shaped bits of the flag_word, what `SpellCanTargetUnit` tests. The resolver binds or -/// refuses a unit word before it reaches the cursor, so none is set today. +/// The unit-shaped bits of the flag_word, the first gate `SpellCanTargetUnit` tests before it runs +/// the candidate through the unit arm's relation and liveness checks. const UNIT_WORD_BITS: u16 = 0x0002 | 0x0004 | 0x0008 | 0x0080 | 0x0100 | 0x0200 | 0x0400 | 0x8000; impl TargetingWants { @@ -60,11 +157,16 @@ impl TargetingWants { Self::Location => 0x0060, Self::Item => 0x4010, Self::GameObject => 0x4800, + Self::Unit => UNIT_WORD_BITS, }; word & mask != 0 } } +/// The word bits `0x6e61a0` lets the world pick take the local player for: unit, raid, party, +/// assist, the explicit gate and the ally corpse, never enemy (`0x80`) or enemy corpse (`0x200`). +const SELF_PICK_BITS: u16 = 0x850e; + /// The targeting mode, `Some` while the flag_word is nonzero. Entered by the cast-send path /// ([`super::cast_target::CastWireTarget::Targeting`]), cleared by a commit or a cancel. #[derive(Resource, Default)] @@ -96,6 +198,36 @@ impl SpellTargeting { self.0.as_ref().is_some_and(|t| wants.matches(t.word)) } + /// `SpellCanTargetUnit`'s predicate `0x6e6460`, its unit leg, with the range flag both callers + /// pass (the world hover `0x4828d0` at `48290b`, the Lua `SpellCanTargetUnit 0x6e6d00`): + /// `BindTarget`'s relation checks, the caster refused under `AttributesEx & 0x80000` + /// (`6e6507`), then min² ≤ d² ≤ max² (`6e677c`–`6e6802`), so a unit inside the minimum is out + /// too. There is no line-of-sight test. + pub(crate) fn can_target_unit(&self, entity: Entity, checks: &UnitBindChecks) -> bool { + self.0.as_ref().is_some_and(|t| { + checks.relations_clear(t.word, entity) + && !checks.excluded_caster(t.spell_id, entity) + && checks.range_refusal(t.spell_id, entity).is_none() + }) + } + + /// `0x6e61a0`: whether the world pick takes the local player, the pick flag `0x20` that + /// `0x480610` tests at `48062c`. Never outside targeting (`480638`); while targeting, a word in + /// `0x850e` whose spell lacks `AttributesEx & 0x80000` (`6e61cf`). An unknown spell passes. + pub(crate) fn picks_self(&self, spells: Option<&crate::ui_action::Spells>) -> bool { + self.0.as_ref().is_some_and(|t| { + t.word & SELF_PICK_BITS != 0 + && !spells + .and_then(|s| s.catalog.get(t.spell_id)) + .is_some_and(|d| d.excludes_caster()) + }) + } + + /// The pending cast and its standing word, whatever seam it wants. + fn pending(&self) -> Option<(u32, super::cast_send::CastCommit, u16)> { + self.0.as_ref().map(|t| (t.spell_id, t.commit, t.word)) + } + /// The pending spell when the standing word answers `wants`. A surface that draws or binds for /// one seam reads this, never [`Self::spell`], or an armed lockpick gets an AoE reticle. pub(crate) fn spell_for(&self, wants: TargetingWants) -> Option { @@ -163,6 +295,24 @@ pub(crate) fn cancel_targeting_on_right_press( targeting.clear(); } +/// [`SpellTargeting::picks_self`] as the frame began, which the world pick reads +/// ([`crate::target`]'s hover): the word the VM's `SpellIsTargeting` answers from, in a declared +/// order against every cast drain that arms or ends the cursor after the input pass. +#[derive(Resource, Default)] +pub(crate) struct PicksSelf(pub(crate) bool); + +/// Publish [`PicksSelf`] before the input pass. +pub(crate) fn publish_picks_self( + targeting: Res, + spells: Option>, + mut picks: ResMut, +) { + let now = targeting.picks_self(spells.as_deref()); + if picks.0 != now { + picks.0 = now; + } +} + /// Push the targeting state into the VM each frame, before the input pass, so a word armed last /// frame stands for this frame's clicks: the ESC chain's word, the bag and doll pickup reroute's /// item half, and `CURRENT_SPELL_CAST_CHANGED` on each edge. That event hides the enchant confirm @@ -171,6 +321,9 @@ pub(crate) fn cancel_targeting_on_right_press( /// and re-arm in one frame still counts. pub(crate) fn feed_targeting_to_vm( targeting: Res, + checks: UnitBindChecks, + tokens: crate::ui_unit::UnitTokens, + selection: Res, mut last: Local>>, script: Option>, ) { @@ -178,13 +331,12 @@ pub(crate) fn feed_targeting_to_vm( let last = last.get(&script); script.set_spell_targeting(targeting.active()); script.set_item_pick_armed(targeting.wants(TargetingWants::Item)); - // `SpellCanTargetUnit`, `0x6e6460`'s unit leg, read off the word. - script.set_spell_can_target_unit( - targeting - .0 - .as_ref() - .is_some_and(|t| t.word & UNIT_WORD_BITS != 0), - ); + // `SpellCanTargetUnit(unit)`: resolve each token and ask `0x6e6460`'s unit leg. + script.set_spell_targetable_units(crate::ui_unit::reach_tokens().filter(|token| { + tokens + .resolve(token, &selection) + .is_some_and(|(entity, _)| targeting.can_target_unit(entity, &checks)) + })); if *last != targeting.spell() { *last = targeting.spell(); script.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]); @@ -207,9 +359,292 @@ pub(crate) fn drain_stop_targeting( } } +/// `BindTarget 0x6e5b40`'s unit arm, for the world click (`0x493540` at `4935d5`) and +/// `SpellTargetUnit` (`0x6e5b10`): the caster under `AttributesEx & 0x80000` (`6e5bf7`) or a unit +/// failing the relation checks binds nothing and the cursor stays up; one out of the spell's range +/// raises "Out of range." or "Target too close" (`6e6063`); otherwise the cast commits at it. The +/// player's selection never moves. +fn bind_target_unit( + ladder: &mut crate::spell::CastLadder, + checks: &UnitBindChecks, + entity: Entity, + guid: u64, +) { + let Some((spell_id, commit, word)) = ladder.ground.pending() else { + return; + }; + if checks.excluded_caster(spell_id, entity) || !checks.relations_clear(word, entity) { + return; + } + if let Some(reason) = checks.range_refusal(spell_id, entity) { + // The reference's abort here (`6e6089`) also sends CMSG_CANCEL_CAST and strands the + // cursor (`6e5b93`) until a cancel: a bug, not copied; targeting stays armed and working. + debug!("ui_action: cast {spell_id} not bound at {guid:#x} — range ({reason:#x})"); + ladder.cast_errors.push_local(spell_id, reason); + return; + } + debug!("ui_action: cast {spell_id} committed at unit {guid:#x}"); + ladder.commit_targeted(spell_id, commit, super::cast_send::TargetedBind::Unit(guid)); +} + +/// Drain `SpellTargetUnit(unit)` (`0x6e6d90`) after the UI pass. The binding already raised the +/// usage and unknown-token errors and dropped a call made while not targeting. Here a token that +/// names no unit raises "Out of range." (0x59) and ends targeting, as the reference's abort +/// clears the word, and a unit goes to [`bind_target_unit`]. +pub(crate) fn drain_spell_target_unit( + script: Option>, + tokens: crate::ui_unit::UnitTokens, + selection: Res, + checks: UnitBindChecks, + mut ladder: crate::spell::CastLadder, +) { + let Some(mut script) = script else { + return; + }; + for token in script.take_spell_target_unit() { + // An earlier token this frame may have bound or ended the cast: not targeting, no-op. + let Some(spell_id) = ladder.ground.spell() else { + continue; + }; + match tokens.resolve(&token, &selection) { + Some((entity, guid)) => bind_target_unit(&mut ladder, &checks, entity, guid), + None => { + debug!("ui_action: SpellTargetUnit({token}) names no unit — cast {spell_id} ends"); + ladder + .cast_errors + .push_local(spell_id, super::validator::ERR_OUT_OF_RANGE); + ladder.ground.clear(); + } + } + } +} + #[cfg(test)] mod tests { use super::*; + use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer}; + use benilla_ui::script::UiScript; + use crossbeam_channel::Receiver; + use std::collections::HashMap; + + const HEAL: u32 = 2050; + /// A spell that excludes its caster (`AttributesEx & 0x80000`). + const NOT_SELF: u32 = 2051; + /// A spell on row 114, 8 to 35 yd, which the reaches pad to 11 to 38. + const BANDED: u32 = 75; + const ME: u64 = 0x10; + const ALLY: u64 = 0xF130_0000_0000_0001; + + /// The cast ladder, the unit-token resolver and a VM, with us at the origin and an ally + /// selected at `distance`. [`HEAL`] and [`NOT_SELF`] use row 5, 0 to 30 yd, which both 1.5 + /// combat reaches pad to 33. + fn unit_world(distance: f32) -> (World, Receiver, Entity) { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut world = World::new(); + world.insert_resource(NetCommands(tx)); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + world.init_resource::(); + let mut spells = crate::ui_action::Spells::empty_for_tests(); + let row5 = |attributes_ex| benilla_formats::SpellDisplay { + range_index: 5, + attributes_ex, + ..Default::default() + }; + spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([ + (HEAL, row5(0)), + (NOT_SELF, row5(0x0008_0000)), + ( + BANDED, + benilla_formats::SpellDisplay { + range_index: 114, + ..Default::default() + }, + ), + ])); + let row = |min, max| benilla_formats::SpellRange { min, max, flags: 0 }; + spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([ + (5, row(0.0, 30.0)), + (114, row(8.0, 35.0)), + ])); + world.insert_resource(spells); + let empty = || ObjectStore(benilla_protocol::ObjectFields::default()); + world.spawn((SelfPlayer, Guid(ME), GlobalTransform::default(), empty())); + let ally = world + .spawn(( + Guid(ALLY), + GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)), + empty(), + )) + .id(); + world.insert_resource(crate::target::Selection { + target: Some(ally), + guid: Some(ALLY), + }); + let mut script = UiScript::new().expect("a VM"); + script.set_spell_targeting(true); + world.insert_non_send_resource(script); + (world, rx, ally) + } + + fn arm(world: &mut World, spell: u32, word: u16) { + world.resource_mut::().enter( + spell, + super::super::cast_send::CastCommit::Spell, + word, + ); + } + + /// Queue `SpellTargetUnit(token)` in the VM, then run the host's drain. + fn spell_target_unit(world: &mut World, token: &str) { + world + .non_send_resource_mut::() + .run(&format!("SpellTargetUnit({token:?})")) + .expect("a known token"); + world + .run_system_cached(drain_spell_target_unit) + .expect("the drain runs"); + } + + fn errors(world: &mut World) -> Vec { + std::mem::take(&mut world.resource_mut::().0) + } + + /// `0x6e6d90` past the binding's checks: a token naming no unit ends the cast with "Out of + /// range."; a unit goes to `BindTarget`, which waits on a relation failure, raises "Out of + /// range." for a unit out of range with the cursor still up, and commits otherwise. The + /// selection never moves. + #[test] + fn spell_target_unit_ends_waits_or_binds_as_the_reference() { + let fail = |reason| vec![crate::ui_action::CastFail::local(HEAL, reason)]; + + // The VM queued a token, but the host is no longer targeting: nothing. + let (mut world, rx, _) = unit_world(10.0); + spell_target_unit(&mut world, "target"); + assert!(rx.try_recv().is_err()); + assert!(errors(&mut world).is_empty()); + + // A known token naming no unit: "Out of range." and targeting ends. + let (mut world, rx, _) = unit_world(10.0); + arm(&mut world, HEAL, 0x0002); + spell_target_unit(&mut world, "party1"); + assert!(rx.try_recv().is_err(), "no send"); + assert_eq!(errors(&mut world), fail(0x59)); + assert!( + !world.resource::().active(), + "the cast ends" + ); + + // A unit the relation refuses (assist, neutral with no catalog): silent, still armed. + let (mut world, rx, _) = unit_world(10.0); + arm(&mut world, HEAL, 0x0100); + spell_target_unit(&mut world, "target"); + assert!(rx.try_recv().is_err()); + assert!(errors(&mut world).is_empty(), "no error"); + assert!(world.resource::().active(), "still armed"); + + // Ourselves under a spell that excludes its caster: silent, still armed. + let (mut world, rx, _) = unit_world(10.0); + arm(&mut world, NOT_SELF, 0x0002); + spell_target_unit(&mut world, "player"); + assert!( + rx.try_recv().is_err(), + "never ourselves under AttributesEx 0x80000" + ); + assert!(errors(&mut world).is_empty(), "no error"); + assert!(world.resource::().active(), "still armed"); + + // In relation but out of range: "Out of range." and still armed. + let (mut world, rx, _) = unit_world(40.0); + arm(&mut world, HEAL, 0x0002); + spell_target_unit(&mut world, "target"); + assert!(rx.try_recv().is_err()); + assert_eq!(errors(&mut world), fail(0x59)); + assert!(world.resource::().active(), "still armed"); + + // In range: the cast goes to the unit and the cursor comes down. + let (mut world, rx, ally) = unit_world(10.0); + arm(&mut world, HEAL, 0x0002); + spell_target_unit(&mut world, "target"); + assert!(matches!( + rx.try_recv(), + Ok(ClientCommand::CastSpell { + spell_id: HEAL, + target: Some(ALLY), + }) + )); + assert!(errors(&mut world).is_empty()); + assert!(!world.resource::().active()); + let selection = world.resource::(); + assert_eq!((selection.target, selection.guid), (Some(ally), Some(ALLY))); + } + + /// `SpellCanTargetUnit` asks `0x6e6460`'s unit leg per token: nil out of range, inside the + /// minimum included, and nil for ourselves under a spell that excludes its caster. + #[test] + fn spell_can_target_unit_is_nil_out_of_range() { + let can = |distance: f32, spell: u32, token: &str| { + let (mut world, _rx, _) = unit_world(distance); + arm(&mut world, spell, 0x0002); + world + .run_system_cached(feed_targeting_to_vm) + .expect("the feed runs"); + world + .non_send_resource::() + .eval::(&format!("return SpellCanTargetUnit({token:?}) == true")) + .expect("a boolean") + }; + assert!(can(10.0, HEAL, "target"), "in range"); + assert!(!can(40.0, HEAL, "target"), "out of range answers nil"); + assert!(can(40.0, HEAL, "player"), "ourselves at distance 0"); + assert!( + !can(10.0, NOT_SELF, "player"), + "never ourselves under AttributesEx 0x80000" + ); + assert!(can(10.0, NOT_SELF, "target")); + assert!(can(20.0, BANDED, "target"), "inside the band"); + assert!( + !can(5.0, BANDED, "target"), + "inside the minimum answers nil" + ); + } + + /// `0x6e61a0`: the pick takes us only while targeting, for a word in `0x850e` whose spell + /// lacks `AttributesEx & 0x80000`; [`PicksSelf`] carries it to the picker. + #[test] + fn the_pick_takes_us_only_for_a_friendly_word_that_admits_the_caster() { + let (mut world, _rx, _) = unit_world(10.0); + let picks = |world: &mut World| { + world + .run_system_cached(publish_picks_self) + .expect("the flag publishes"); + world.resource::().0 + }; + world.init_resource::(); + assert!(!picks(&mut world), "never outside targeting"); + for word in [0x0002, 0x0004, 0x0008, 0x0100, 0x0400, 0x8000, 0x0102] { + arm(&mut world, HEAL, word); + assert!(picks(&mut world), "{word:#06x} takes us"); + } + for word in [0x0080, 0x0200, 0x0040, 0x0010, 0x4800] { + arm(&mut world, HEAL, word); + assert!(!picks(&mut world), "{word:#06x} never takes us"); + } + arm(&mut world, NOT_SELF, 0x0100); + assert!(!picks(&mut world), "a spell that excludes its caster"); + world.resource_mut::().clear(); + assert!(!picks(&mut world), "and off again when the cursor ends"); + } /// A terrain click while a poison is armed must not ship a DEST block for an item spell. #[test] diff --git a/crates/benilla-app/src/spell/targeting/world.rs b/crates/benilla-app/src/spell/targeting/world.rs index 6473df33..47cbe35b 100644 --- a/crates/benilla-app/src/spell/targeting/world.rs +++ b/crates/benilla-app/src/spell/targeting/world.rs @@ -2,12 +2,13 @@ //! the pick flags (`0x481050`) come only from the word `0xcecac0`, so the word picks the leg: //! //! - terrain (`0x492c90` → `0x492580` → `BindLocation 0x6e60f0`): [`commit_ground_cast_on_click`] -//! - object (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`): -//! [`commit_object_cast_on_click`] +//! - object (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), whose GameObject or +//! unit arm the picked object's type chooses: [`commit_object_cast_on_click`] //! //! A location-only word yields pick flags 3, whose `& 0x7c == 0` disables the object pick, so an -//! AoE reticle clicks through a chest. Neither leg gates on range, validity or the lock: the -//! server judges and its `SMSG_CAST_RESULT` is the refusal. +//! AoE reticle clicks through a chest. The terrain and GameObject arms gate on nothing, not range, +//! validity or the lock: the server judges and its `SMSG_CAST_RESULT` is the refusal. The unit arm +//! runs `BindTarget`'s relation and range checks ([`super::bind_target_unit`]). use bevy::prelude::*; @@ -68,21 +69,21 @@ pub(crate) fn commit_ground_cast_on_click( /// 0x493540`, whose first act while targeting is `BindTarget 0x6e5b40` and return, so the click /// never changes the player's target and never reaches the GameObject's unselectable check. /// -/// `BindTarget` picks its arm by the clicked object's typemask; the GameObject arm tests the word's -/// `0x4800`, writes wire bit `TARGET_FLAG_GAMEOBJECT` (`0x800`), clears those `0x4800` word bits, -/// parks the guid (`0xceac60`), and the zero word lets it call `SendCast 0x6e54f0`. A unit click -/// under a lock word binds nothing, since every unit arm tests a bit the word lacks; this is -/// [`go_is_nearest`] here. -/// -/// There is no gate before the send, 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. +/// `BindTarget` picks its arm by the clicked object's typemask. The GameObject arm, when a word in +/// `0x4800` puts GameObjects in the pick and one is the nearest hit ([`go_is_nearest`]), writes +/// wire bit `TARGET_FLAG_GAMEOBJECT` (`0x800`), clears those `0x4800` word bits, parks the guid +/// (`0xceac60`), and the zero word lets it call `SendCast 0x6e54f0`, with no gate before the send, +/// 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`]); a unit click under a lock +/// word binds nothing there, since every unit arm tests a bit the word lacks. /// /// Runs after `select_on_click`, as the terrain commit does. pub(crate) fn commit_object_cast_on_click( mut clicks: MessageReader, // The press's pick: the object the gesture started on. press: Res, + checks: super::UnitBindChecks, mut ladder: crate::spell::CastLadder, ) { let (hovered, hovered_object) = (press.hovered, press.object); @@ -94,14 +95,18 @@ pub(crate) fn commit_object_cast_on_click( if clicks.read().last().is_none() { return; } - // `TargetingWantsGameObject 0x6e62d0`: a poison's bare `0x10` word binds nothing here. + // `TargetingWantsGameObject 0x6e62d0`: without it the pick holds no GameObject. + if !(ladder.ground.wants(TargetingWants::GameObject) + && go_is_nearest(&hovered, &hovered_object)) + { + if let Some((entity, guid)) = hovered.target.zip(hovered.guid) { + super::bind_target_unit(&mut ladder, &checks, entity, guid); + } + return; + } let Some((spell_id, commit)) = ladder.ground.pending_for(TargetingWants::GameObject) else { return; }; - // The object leg only wins when a GameObject is the nearest hit. - if !go_is_nearest(&hovered, &hovered_object) { - return; - } let Some(guid) = hovered_object.guid else { return; }; @@ -130,6 +135,7 @@ mod tests { world.insert_resource(NetCommands(tx)); world.init_resource::(); world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); world.init_resource::(); world.init_resource::(); world.init_resource::(); @@ -266,6 +272,158 @@ mod tests { ); } + /// A unit word waits for a valid unit click and sends that unit in the ordinary + /// `TARGET_FLAG_UNIT` spell shape. An assist word does not bind a neutral unit. + #[test] + fn a_click_on_a_hovered_unit_commits_the_hand_cursor_cast() { + const HEAL: u32 = 2050; + const ALLY: u64 = 0xF130_0000_0000_0001; + let (mut world, rx, id) = fixture(); + world.resource_mut::().enter( + HEAL, + crate::spell::cast_send::CastCommit::Spell, + 0x0002, + ); + world.resource_mut::().hovered = Hovered { + target: Some(Entity::from_raw_u32(1).unwrap()), + guid: Some(ALLY), + distance: 5.0, + ..Hovered::default() + }; + click(&mut world, id); + assert!(matches!( + rx.try_recv(), + Ok(ClientCommand::CastSpell { + spell_id: HEAL, + target: Some(ALLY), + }) + )); + assert!( + !world.resource::().active(), + "the unit bind clears the hand cursor" + ); + + // With no faction catalog the target's reaction is neutral (3): an ASSIST word must stay + // armed and send nothing, exactly as when that unit was selected before the cast. + let (mut world, rx, id) = fixture(); + world.resource_mut::().enter( + HEAL, + crate::spell::cast_send::CastCommit::Spell, + 0x0100, + ); + world.resource_mut::().hovered = Hovered { + target: Some(Entity::from_raw_u32(1).unwrap()), + guid: Some(ALLY), + distance: 5.0, + ..Hovered::default() + }; + click(&mut world, id); + assert!( + rx.try_recv().is_err(), + "a neutral unit must not receive the heal" + ); + assert!( + world.resource::().active(), + "an invalid unit click leaves the targeting word standing" + ); + + // A unit word puts no GameObject in the pick, so a chest nearer than the unit is no + // obstacle: the unit arm still binds. + let (mut world, rx, id) = fixture(); + world.resource_mut::().enter( + HEAL, + crate::spell::cast_send::CastCommit::Spell, + 0x0002, + ); + hover_go(&mut world, 2.0); + world.resource_mut::().hovered = Hovered { + target: Some(Entity::from_raw_u32(1).unwrap()), + guid: Some(ALLY), + distance: 5.0, + ..Hovered::default() + }; + click(&mut world, id); + assert!(matches!( + rx.try_recv(), + Ok(ClientCommand::CastSpell { + spell_id: HEAL, + target: Some(ALLY), + }) + )); + } + + /// `BindTarget`'s range leg (`6e6063`): a unit the relation checks accept but out of the + /// spell's range raises "Out of range." and the cursor stays armed; a click in range commits. + /// Row 5 is 0 to 30 yd, padded by both 1.5 combat reaches to 33. + #[test] + fn an_out_of_range_unit_click_raises_and_keeps_the_cursor() { + use std::collections::HashMap; + const HEAL: u32 = 2050; + const ALLY: u64 = 0xF130_0000_0000_0001; + let (mut world, rx, id) = fixture(); + let mut spells = crate::ui_action::Spells::empty_for_tests(); + spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([( + HEAL, + benilla_formats::SpellDisplay { + range_index: 5, + ..Default::default() + }, + )])); + spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([( + 5, + benilla_formats::SpellRange { + min: 0.0, + max: 30.0, + flags: 0, + }, + )])); + world.insert_resource(spells); + world.spawn((crate::net::SelfPlayer, GlobalTransform::default())); + let ally = world + .spawn(GlobalTransform::from_translation(Vec3::new(40.0, 0.0, 0.0))) + .id(); + world.resource_mut::().enter( + HEAL, + crate::spell::cast_send::CastCommit::Spell, + 0x0002, + ); + world.resource_mut::().hovered = Hovered { + target: Some(ally), + guid: Some(ALLY), + distance: 5.0, + ..Hovered::default() + }; + + click(&mut world, id); + assert!(rx.try_recv().is_err(), "out of range sends nothing"); + assert_eq!( + std::mem::take(&mut world.resource_mut::().0), + vec![crate::ui_action::CastFail::local(HEAL, 0x59)], + "\"Out of range.\"" + ); + assert!( + world.resource::().active(), + "the cursor stays armed" + ); + + world + .entity_mut(ally) + .insert(GlobalTransform::from_translation(Vec3::new(10.0, 0.0, 0.0))); + click(&mut world, id); + assert!(matches!( + rx.try_recv(), + Ok(ClientCommand::CastSpell { + spell_id: HEAL, + target: Some(ALLY), + }) + )); + assert!(world + .resource::() + .0 + .is_empty()); + assert!(!world.resource::().active()); + } + /// Every way the object click must not commit. #[test] fn the_object_commit_holds_its_fire() { diff --git a/crates/benilla-app/src/target/hover.rs b/crates/benilla-app/src/target/hover.rs index 508adba0..4f890139 100644 --- a/crates/benilla-app/src/target/hover.rs +++ b/crates/benilla-app/src/target/hover.rs @@ -83,6 +83,37 @@ pub(super) struct PickPose<'w, 's> { rigs: Query<'w, 's, &'static benilla_world::rig_palette::RigSkin>, } +/// The local identity and whether the pick takes it, one [`SystemParam`] under the picker's param +/// limit. +#[derive(bevy::ecs::system::SystemParam)] +pub(super) struct TargetHoverState<'w> { + /// For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause. + self_guid: Res<'w, crate::net::SelfGuid>, + /// `0x6e61a0`'s pick flag `0x20`, as the frame began. + picks_self: Res<'w, crate::spell::PicksSelf>, +} + +/// The posed pick's geometry under one model root: its skinned parts. Ours are dropped while +/// hidden: first person hides them ([`crate::player::apply_self_model_fade`] at alpha 0), where the +/// reference's camera fade clears its player-visible flag `[0xc4d74c]` (`0x482c41`) and +/// `ShouldRender 0x5ec8f0` refuses the active player, so it is never picked. +#[allow(clippy::type_complexity)] // the picker's own part query, borrowed as-is +fn posed_parts<'a>( + kids: &Children, + parts: &'a Query<( + &Mesh3d, + &benilla_world::rig_palette::RigPart, + Option<&InheritedVisibility>, + )>, + is_self: bool, +) -> Vec<(&'a Mesh3d, &'a benilla_world::rig_palette::RigPart)> { + kids.iter() + .filter_map(|c| parts.get(c).ok()) + .filter(|(_, _, drawn)| !is_self || drawn.is_none_or(|v| v.get())) + .map(|(mesh, part, _)| (mesh, part)) + .collect() +} + /// The unit under the cursor, as the reference's pick (`0x7089c0`) finds it. Broad phase: the ray /// against the playing sequence's bounds sphere. Pass 1: the ray against the posed render mesh, /// nearest wins. Pass 2, the mouse pick's retry when pass 1 hit nothing anywhere: every vertex @@ -102,8 +133,8 @@ pub(super) fn update_hover( mut hovered: ResMut, mesh_assets: Res>, pose: PickPose, - // For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause. - self_guid: Res, + // Our own body joins the pick only while a targeting word takes it (`0x6e61a0`). + target_state: TargetHoverState, // Last frame's pick, which outranks everything in pass 2 (the reference's anti-flicker cache). mut last_pick: Local>, // Unit roots. An undrawn body is not in the reference's draw list, so it takes no mouseover, @@ -111,6 +142,7 @@ pub(super) fn update_hover( roots: Query< ( Entity, + Has, &GlobalTransform, &NetEntity, Option<&ModelAnimations>, @@ -121,11 +153,15 @@ pub(super) fn update_hover( Option<&InheritedVisibility>, Option<&HeldAttached>, ), - (With, Without), + With, >, // The part children of every pick model root: body, worn items and mount. child_sets: Query<&Children>, - parts: Query<(&Mesh3d, &benilla_world::rig_palette::RigPart)>, + parts: Query<( + &Mesh3d, + &benilla_world::rig_palette::RigPart, + Option<&InheritedVisibility>, + )>, // The box fallback: every `CreaturePickPart`, the fallback cube included. meshes: Query< ( @@ -137,7 +173,7 @@ pub(super) fn update_hover( With, >, // The pick's guid and kind; the kind decides which slot of `Hovered` a hit lands in. - units: Query<(&Guid, &NetEntity), Without>, + units: Query<(&Guid, &NetEntity)>, ) { hovered.target = None; hovered.guid = None; @@ -178,7 +214,12 @@ pub(super) fn update_hover( let mut candidates: Vec<(Entity, u8, Vec>, Vec)> = Vec::new(); // Units with skinned parts: out of the box fallback even when the broad phase rejects them. let mut faithful: HashSet = HashSet::new(); - for (entity, gt, net, anims, drv, store, children, mount_child, drawn, held) in &roots { + 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 !matches!( @@ -241,7 +282,7 @@ pub(super) fn update_hover( let Ok(kids) = child_sets.get(root) else { continue; }; - let sk: Vec<_> = kids.iter().filter_map(|c| parts.get(c).ok()).collect(); + let sk = posed_parts(kids, &parts, is_self); let Some(palette) = palette_of(&sk) else { continue; }; @@ -280,6 +321,9 @@ 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 kinds 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 { @@ -331,13 +375,13 @@ pub(super) fn update_hover( if net.kind == EntityKind::Corpse { hovered.corpse = Some(entity); hovered.corpse_guid = Some(guid.0); - } else if selectable_pick(entity, &roots, self_guid.0) { + } else if selectable_pick(entity, &roots, target_state.self_guid.0) { hovered.target = Some(entity); hovered.guid = Some(guid.0); } else { hovered.refused = true; } - } else if selectable_pick(entity, &roots, self_guid.0) { + } else if selectable_pick(entity, &roots, target_state.self_guid.0) { hovered.target = Some(entity); } else { hovered.refused = true; @@ -364,6 +408,7 @@ fn selectable_pick( roots: &Query< ( Entity, + Has, &GlobalTransform, &NetEntity, Option<&ModelAnimations>, @@ -374,11 +419,11 @@ fn selectable_pick( Option<&InheritedVisibility>, Option<&HeldAttached>, ), - (With, Without), + With, >, self_guid: Option, ) -> bool { - let store = roots.get(entity).ok().and_then(|r| r.5); + let store = roots.get(entity).ok().and_then(|r| r.6); super::relations::is_selectable(store, self_guid) } @@ -766,6 +811,121 @@ mod tests { assert_eq!(resolved[4], net, "the net entity resolves to itself"); } + /// First person hides our own parts (`Visibility::Hidden`), and the posed pick drops them, as + /// `ShouldRender 0x5ec8f0` refuses the active player then; anyone else's stay the root's call. + #[test] + fn our_hidden_parts_leave_the_posed_pick() { + let mut world = World::new(); + let root = world.spawn_empty().id(); + let rig = world.spawn_empty().id(); + let part = |world: &mut World, drawn| { + world + .spawn(( + ChildOf(root), + Mesh3d(Handle::default()), + benilla_world::rig_palette::RigPart(rig), + drawn, + )) + .id() + }; + let shown = part(&mut world, InheritedVisibility::VISIBLE); + part(&mut world, InheritedVisibility::HIDDEN); + let count = move |is_self: bool| { + move |kids: Query<&Children>, + parts: Query<( + &Mesh3d, + &benilla_world::rig_palette::RigPart, + Option<&InheritedVisibility>, + )>| { + posed_parts(kids.get(root).expect("the parts"), &parts, is_self).len() + } + }; + assert_eq!(world.run_system_once(count(false)).unwrap(), 2); + assert_eq!( + world.run_system_once(count(true)).unwrap(), + 1, + "ours: only the drawn part" + ); + world.entity_mut(shown).insert(InheritedVisibility::HIDDEN); + assert_eq!( + world.run_system_once(count(true)).unwrap(), + 0, + "first person: nothing of ours to pick" + ); + } + + /// Our own body joins the unit pick only while [`crate::spell::PicksSelf`] says the word takes + /// it (`0x480610` at `480638`), here through the box fallback of a body with no skinned parts. + #[test] + fn our_body_joins_the_pick_only_while_the_word_takes_it() { + use bevy::camera::RenderTargetInfo; + use bevy::window::WindowResolution; + + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(PickOcclusion { + distance: f32::INFINITY, + point: None, + }); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + world.init_resource::(); + world.insert_resource(crate::net::SelfGuid(Some(1))); + world.init_resource::(); + // Ten yards up the Z axis, looking down it through a 100-pixel square. + 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 { + physical_size: UVec2::splat(100), + scale_factor: 1.0, + }); + world.spawn(( + camera, + GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 10.0)), + WorldCamera, + )); + let mut window = Window { + resolution: WindowResolution::new(100, 100), + ..Default::default() + }; + window.set_cursor_position(Some(Vec2::splat(50.0))); + world.spawn((window, PrimaryWindow)); + // Us, at the origin under the cursor, with one drawn pick box. + let me = world + .spawn(( + SelfPlayer, + Guid(1), + NetEntity { + kind: EntityKind::Player, + display_id: None, + scale: 1.0, + }, + GlobalTransform::default(), + InheritedVisibility::VISIBLE, + )) + .id(); + world.spawn(( + ChildOf(me), + CreaturePickPart, + Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0)), + GlobalTransform::default(), + InheritedVisibility::VISIBLE, + )); + let hover = |world: &mut World, picks: bool| { + world.resource_mut::().0 = picks; + world + .run_system_cached(update_hover) + .expect("the pick runs"); + world.resource::().target + }; + + assert_eq!(hover(&mut world, false), None, "not ours to pick"); + assert_eq!(hover(&mut world, true), Some(me), "a word that takes us"); + assert_eq!(hover(&mut world, false), None, "and not once it ends"); + } + /// Naxxramas's "Unholy Axe" is an `InvisibleStalker` body with no vertices holding a real axe, /// which is its whole pick surface. #[test] diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 5e54c3af..9ffe778d 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -310,13 +310,18 @@ impl Plugin for TargetPlugin { crate::spell::targeting::commit_object_cast_on_click, click::act_on_right_click, click::clear_target_requests, - // The unit-token asks (`TargetUnit`, `AssistUnit`, `TargetLastEnemy`: one - // drain, as the reference has one `0x489a40`) and `DropItemOnUnit`'s pet leg, - // independent of each other. + // The UI's unit-token drains. `SpellTargetUnit` first: it binds the cursor's + // cast and never moves the selection. Then the selection asks (`TargetUnit`, + // `AssistUnit`, `TargetLastEnemy`: one drain, as the reference has one + // `0x489a40`) and `DropItemOnUnit`'s pet leg, independent of each other. ( - click::selection_requests, - crate::ui_action::drop_item::drop_item_on_unit, - ), + crate::spell::targeting::drain_spell_target_unit, + ( + click::selection_requests, + crate::ui_action::drop_item::drop_item_on_unit, + ), + ) + .chain(), // The by-name asks: `/target`, the Lua `TargetByName` and `/assist` commit // through `scan::commit`; `/follow` hands its subject to `crate::player`. ( diff --git a/crates/benilla-app/src/ui_pet/drain.rs b/crates/benilla-app/src/ui_pet/drain.rs index afa99cb2..d5b8edcc 100644 --- a/crates/benilla-app/src/ui_pet/drain.rs +++ b/crates/benilla-app/src/ui_pet/drain.rs @@ -202,7 +202,7 @@ pub(super) fn old_target_cleared(previous: Option, now: Option) -> boo /// The old-target clear's `PetStopAttack` (`0x493910`, at `0x493a18` when `0x5ee5a0` finds a /// possessed unit at `0x493a0f`). It sits before the notify branch, so every selection writer runs /// it; a re-select runs no clear (`0x493540`). -pub(super) fn pet_stop_on_old_target_clear( +pub(crate) fn pet_stop_on_old_target_clear( selection: Res, mut previous: Local>, mut bar: ResMut, diff --git a/crates/benilla-app/src/ui_pet/mod.rs b/crates/benilla-app/src/ui_pet/mod.rs index 81915baa..d0f60d7d 100644 --- a/crates/benilla-app/src/ui_pet/mod.rs +++ b/crates/benilla-app/src/ui_pet/mod.rs @@ -19,7 +19,8 @@ mod net; mod unit; use bar::feed_pet_bar; -use drain::{drain_pet_actions, pet_stop_on_old_target_clear}; +use drain::drain_pet_actions; +pub(crate) use drain::pet_stop_on_old_target_clear; use menu::{drain_pet_menu, feed_pet_menu}; use unit::feed_pet_unit; diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 87edbeac..5132dd48 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -624,7 +624,7 @@ impl UnitTokens<'_, '_> { } /// Every token [`UnitTokens`] resolves, `"player"` included (the reference answers d² = 0). -fn reach_tokens() -> impl Iterator { +pub(crate) fn reach_tokens() -> impl Iterator { ["player", "target", "targettarget", "mouseover", "pet"] .into_iter() .chain(crate::ui_party::PARTY_TOKENS) diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index a6d69f04..d99413e6 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -366,6 +366,12 @@ impl SpellDisplay { self.attributes_ex3 & ATTR_EX3_NO_CHANNEL_BAR != 0 } + /// `AttributesEx & 0x80000`: the targeting cursor never takes the caster, neither the world + /// pick (`0x6e61a0` at `6e61cf`) nor `SpellCanTargetUnit`'s unit leg (`0x6e6460` at `6e6507`). + pub fn excludes_caster(&self) -> bool { + self.attributes_ex & ATTR_EX_EXCLUDE_CASTER != 0 + } + /// `AttributesEx & 0x2000_0000` (`0x6e759a`): the channel bar shows the spell's own name /// (`0x6e75a9`), else `CHANNELING` (`0x6e75bc`); nine channels set it, Fishing among them. pub fn channel_bar_own_name(&self) -> bool { diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index 21fef33f..b127fd33 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -197,6 +197,9 @@ const ATTR_DO_NOT_DISPLAY: u32 = 0x80; pub const SPELL_ATTR_IS_TRADESKILL: u32 = 0x20; /// `SPELL_ATTR_EX_NO_AURA_ICON`: hidden from the aura bar, as all three warrior stances are. const ATTR_EX_NO_AURA_ICON: u32 = 0x1000_0000; +/// `SPELL_ATTR_EX_EXCLUDE_CASTER` (vmangos `SpellDefines.h:851`): the targeting cursor never takes +/// the caster (`0x6e61cf`, `0x6e6507`). +const ATTR_EX_EXCLUDE_CASTER: u32 = 0x0008_0000; /// `SPELL_EFFECT_ATTACK`: only 6603 "Attack" has it, but the client tests the effect, not the id. const SPELL_EFFECT_ATTACK: u32 = 78; diff --git a/crates/benilla-formats/src/spells/ranges.rs b/crates/benilla-formats/src/spells/ranges.rs index ed3737b5..d8eec939 100644 --- a/crates/benilla-formats/src/spells/ranges.rs +++ b/crates/benilla-formats/src/spells/ranges.rs @@ -92,6 +92,11 @@ impl SpellRangeCatalog { self.ranges.get(&index) } + /// Fixture constructor for tests; the live path is [`load_spell_ranges`]. + pub fn from_rows(ranges: HashMap) -> Self { + Self { ranges } + } + pub fn len(&self) -> usize { self.ranges.len() } diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index 4f5a3f62..281e3cab 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -403,9 +403,10 @@ impl super::UiScript { self.model_mut().item_pick_armed = armed; } - /// Push what `SpellCanTargetUnit` answers: whether the armed targeting cast can take a unit. - pub fn set_spell_can_target_unit(&mut self, can: bool) { - self.model_mut().spell_can_target_unit = can; + /// Push the unit tokens for which the armed word's unit checks all pass. + pub fn set_spell_targetable_units<'a>(&mut self, tokens: impl IntoIterator) { + self.model_mut().spell_targetable_units = + tokens.into_iter().map(str::to_ascii_lowercase).collect(); } /// Drain the `(bag, slot)` picks since the last call; a paper-doll pick reports as diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 5a0eb9f6..d1d1db02 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -243,6 +243,8 @@ pub(crate) struct Model { pub(crate) target_clear: bool, /// `DropItemOnUnit` tokens (`0x48d960`), gated by the app; a refusal silently keeps the item. pub(crate) drop_item_on_unit: Vec, + /// `SpellTargetUnit` tokens: a unit frame binds the spell currently waiting on the cursor. + pub(crate) spell_target_unit: Vec, /// Channels the server confirmed, in join order: the numbers `GetChannelName` answers. pub(crate) joined_channels: Vec>, @@ -432,9 +434,9 @@ pub(crate) struct Model { /// Spell targeting is on (`SpellIsTargeting`, `0x6e6cd0`); it gates `SpellStopTargeting()`, /// whose nil the ESC chain falls through on (`UIParent.lua:1490`). pub(crate) spell_targeting: bool, - /// `SpellCanTargetUnit` (`0x6e6d00`, `0x6e6460`): false while targeting models only location, - /// item and object words; a unit-target spell resolves or refuses without entering targeting. - pub(crate) spell_can_target_unit: bool, + /// Tokens for which `SpellCanTargetUnit`'s armed unit word clears fully, used by stock unit + /// frames before they call `SpellTargetUnit`. + pub(crate) spell_targetable_units: HashSet, /// `SpellStopTargeting()` fired while targeting: the ESC targeting cancel. pub(crate) spell_stop_targeting: bool, @@ -1061,6 +1063,7 @@ impl Model { target_nearest_friend_requests: Vec::new(), target_by_name_requests: Vec::new(), drop_item_on_unit: Vec::new(), + spell_target_unit: Vec::new(), target_clear: false, joined_channels: Vec::new(), party: party::PartyState::default(), @@ -1148,7 +1151,7 @@ impl Model { casting: false, spell_stop: false, spell_targeting: false, - spell_can_target_unit: false, + spell_targetable_units: HashSet::new(), spell_stop_targeting: false, talents: super::talent::TalentUiState::default(), talent_learns: Vec::new(), diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index 78f2932b..58fb14b1 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -596,14 +596,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // SpellCanTargetUnit("unit") (`0x6e6d00`) asks `0x6e6460`'s unit leg whether the targeting - // word can take the unit. The token is not read: no word benilla can arm (location, item, - // gameobject) takes a unit, and unit words are not built. + // SpellCanTargetUnit("unit") (`0x6e6d00`) asks `0x6e6460`'s unit leg whether the standing + // word clears against that resolved unit. g.set( "SpellCanTargetUnit", - lua.create_function(|lua, _unit: Option| { + lua.create_function(|lua, unit: Option| { let model = lua.app_data_ref::().expect("model app_data"); - if model.spell_can_target_unit { + let can = unit.is_some_and(|unit| { + model + .spell_targetable_units + .contains(&unit.to_ascii_lowercase()) + }); + if can { Ok(Value::Boolean(true)) } else { Ok(Value::Nil) diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 4f2782fc..9e4b8ff3 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -1079,12 +1079,31 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // SpellTargetUnit(unit): a no-op. A unit-target spell never enters targeting mode here, and for - // the location, item and gameobject modes this client arms, the reference's `BindTarget` - // (`0x6e5b40`) rejects a unit too. Stock `PetFrame_OnClick` calls it, so it must exist. + // SpellTargetUnit(unit) (`0x6e6d90`): an argument that is not a string or a number raises + // `Usage:`; while not targeting the call is a silent no-op, before the token is read; an + // unknown token then raises `Unknown unit name`. The token queues for the host, which ends + // targeting with "Out of range." when it names no unit and otherwise runs `BindTarget` + // (`0x6e5b10` → `0x6e5b40`). g.set( "SpellTargetUnit", - lua.create_function(|_, _token: Option| Ok(()))?, + lua.create_function(|lua, token: Value| { + let token = crate::script::binding_abi::string_arg( + lua, + token, + r#"Usage: SpellTargetUnit("unit")"#, + )?; + if !lua + .app_data_ref::() + .expect("model app_data") + .spell_targeting + { + return Ok(()); + } + check_unit_token(&Some(token.clone()))?; + let mut model = lua.app_data_mut::().expect("model app_data"); + model.spell_target_unit.push(token); + Ok(()) + })?, )?; // ClearTarget(): 1 when it cleared a target, nil when there was none, which `ToggleGameMenu`'s diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index de589077..b141be55 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -411,6 +411,11 @@ impl super::UiScript { pub fn take_drop_item_on_unit(&mut self) -> Vec { std::mem::take(&mut self.model_mut().drop_item_on_unit) } + + /// Drain `SpellTargetUnit` calls for the host's pending unit-target spell binder. + pub fn take_spell_target_unit(&mut self) -> Vec { + std::mem::take(&mut self.model_mut().spell_target_unit) + } } /// The non-`"player"` token of a two-unit call such as `UnitIsEnemy(a, b)`, which the snapshot diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index 3b482b0c..70ff364e 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -23,6 +23,56 @@ fn player() -> UnitState { } } +/// `0x6e6d90`'s order: the `Usage:` check, the not-targeting no-op, the token check, the queue. +#[test] +fn spell_target_unit_checks_in_the_references_order() { + let mut s = UiScript::new().unwrap(); + let usage = |s: &mut UiScript, call: &str| { + let err = s.run(call).expect_err(call).to_string(); + assert!( + err.contains(r#"Usage: SpellTargetUnit("unit")"#), + "{call}: {err}" + ); + }; + + // Not targeting: the argument is still type-checked, then nothing, not even the token check. + usage(&mut s, "SpellTargetUnit({})"); + usage(&mut s, "SpellTargetUnit()"); + s.run(r#"SpellTargetUnit("not-a-unit") SpellTargetUnit("player")"#) + .expect("a bad token is no error while not targeting"); + assert!(s.take_spell_target_unit().is_empty(), "and nothing queues"); + + // Targeting: an unknown token raises, a known one queues, resolved or not. + s.set_spell_targeting(true); + usage(&mut s, "SpellTargetUnit(nil)"); + let err = s + .run(r#"SpellTargetUnit("not-a-unit")"#) + .expect_err("an unknown token while targeting") + .to_string(); + assert!(err.contains("Unknown unit name: not-a-unit"), "{err}"); + s.run(r#"SpellTargetUnit("player") SpellTargetUnit("party1")"#) + .unwrap(); + assert_eq!( + s.take_spell_target_unit(), + vec!["player".to_string(), "party1".to_string()] + ); +} + +#[test] +fn spell_can_target_unit_answers_per_validated_token() { + let mut s = UiScript::new().unwrap(); + s.set_spell_targetable_units(["player", "party1"]); + assert!(s + .eval::(r#"return SpellCanTargetUnit("player") == true"#) + .unwrap()); + assert!(s + .eval::(r#"return SpellCanTargetUnit("PARTY1") == true"#) + .unwrap()); + assert!(s + .eval::(r#"return SpellCanTargetUnit("target") == nil"#) + .unwrap()); +} + #[test] fn unit_reaction_reports_the_scale_value_or_nil() { let mut s = UiScript::new().unwrap();