A friendly or resurrection spell with no usable target brings up the targeting cursor, and autoSelfCast boots at 1.12.1's 0 (#445)

A friendly or resurrection spell that binds nothing now enters the unit targeting cursor, as `TryCast` does for any residual word without the enemy bit (`0x6e507b`–`0x6e50ab`). `autoSelfCast` boots at its reference default of `"0"`, and its `Deviates` row is gone.

- **Binding.** The cursor binds a unit through a world click, and through `SpellTargetUnit` from the unit frames (`BindTarget 0x6e5b40`). The selection never moves.
- **Relation checks.** Heals and buffs ask `CanAssist 0x6066f0` instead of reaction alone, so a friendly NPC without the PvP flag is no longer assistable. That is part of #50.
- **Range.** `SpellCanTargetUnit` and the cursor answer per unit through `0x6e6460`'s unit leg, including the spell's min/max range (`6e677c`–`6e6802`). A click on a unit out of range raises "Out of range." or "Target too close" and keeps the cursor armed. The reference's abort there also strands the cursor, and that is not copied.
- **Your own model** joins the world pick only for a word `0x6e61a0` admits (`0x850e`, no `AttributesEx 0x80000`), and never in first person.

Fixes #136

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
Juozas Polikevičius
2026-09-27 07:56:21 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Sam
parent 6982109d86
commit e4cc695f84
22 changed files with 1166 additions and 136 deletions
+1 -9
View File
@@ -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",
+7 -2
View File
@@ -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) {
+9 -12
View File
@@ -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,
+120 -37
View File
@@ -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<f32>,
}
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<u8> {
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<SelfPlayer>>,
stores: Query<'w, 's, &'static ObjectStore>,
index: Option<Res<'w, GuidIndex>>,
self_guid: Res<'w, SelfGuid>,
auto_self_cast: Res<'w, AutoSelfCast>,
factions: Option<Res<'w, Factions>>,
@@ -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<crate::cvars::CvarChanged>, mut auto: ResMut<AutoSe
}
}
impl Default for AutoSelfCast {
fn default() -> 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,
+9 -3
View File
@@ -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::<AutoSelfCast>()
.init_resource::<SpellTargeting>()
.init_resource::<targeting::EnchantConfirmItem>()
.init_resource::<targeting::PicksSelf>()
.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
@@ -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<SpellTargeting>,
unit_checks: super::UnitBindChecks,
occlusion: Res<PickOcclusion>,
hovered: Res<crate::target::Hovered>,
hovered_object: Res<crate::target::HoveredObject>,
@@ -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::<crate::go_templates::GameObjectTemplates>();
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::net::GuidIndex>();
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::<WorldCursor>();
world.init_resource::<SpellTargeting>();
world.init_resource::<crate::target::HoveredObject>();
world.init_resource::<crate::go_templates::GameObjectTemplates>();
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::net::GuidIndex>();
world.insert_resource(crate::net::Reputations(Vec::new()));
world.init_resource::<PickOcclusion>();
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::<SpellTargeting>().enter(
spell,
crate::spell::CastCommit::Spell,
word,
);
world
.run_system_once(drive_targeting_cursor)
.expect("the targeting cursor drives");
!world.resource::<WorldCursor>().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"
);
}
}
+450 -15
View File
@@ -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<Res<'w, crate::net::GuidIndex>>,
self_q: Query<
'w,
's,
(Entity, Option<&'static crate::net::ObjectStore>),
With<crate::net::SelfPlayer>,
>,
factions: Option<Res<'w, crate::target::Factions>>,
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<benilla_world::view::WorldCamera>>,
spells: Option<Res<'w, crate::ui_action::Spells>>,
}
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<u8> {
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<u32> {
@@ -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<SpellTargeting>,
spells: Option<Res<crate::ui_action::Spells>>,
mut picks: ResMut<PicksSelf>,
) {
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<SpellTargeting>,
checks: UnitBindChecks,
tokens: crate::ui_unit::UnitTokens,
selection: Res<crate::target::Selection>,
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
) {
@@ -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<NonSendMut<benilla_ui::script::UiScript>>,
tokens: crate::ui_unit::UnitTokens,
selection: Res<crate::target::Selection>,
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<ClientCommand>, Entity) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::net::GuidIndex>();
world.insert_resource(crate::net::Reputations(Vec::new()));
world.init_resource::<crate::spell::PendingCast>();
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::Cooldowns>();
world.init_resource::<crate::spell::SpellModifiers>();
world.init_resource::<crate::ui_action::CastErrors>();
world.init_resource::<crate::spell::AutoRepeatActive>();
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
world.init_resource::<SpellTargeting>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<crate::ui_party::GroupState>();
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::<SpellTargeting>().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::<UiScript>()
.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<crate::ui_action::CastFail> {
std::mem::take(&mut world.resource_mut::<crate::ui_action::CastErrors>().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::<SpellTargeting>().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::<SpellTargeting>().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::<SpellTargeting>().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::<SpellTargeting>().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::<SpellTargeting>().active());
let selection = world.resource::<crate::target::Selection>();
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::<UiScript>()
.eval::<bool>(&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::<PicksSelf>().0
};
world.init_resource::<PicksSelf>();
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::<SpellTargeting>().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]
+176 -18
View File
@@ -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<WorldClick>,
// The press's pick: the object the gesture started on.
press: Res<crate::target::PressPick>,
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::<crate::items::Items>();
world.init_resource::<crate::net::GuidIndex>();
world.insert_resource(crate::net::Reputations(Vec::new()));
world.init_resource::<crate::spell::PendingCast>();
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::Cooldowns>();
@@ -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::<super::super::SpellTargeting>().enter(
HEAL,
crate::spell::cast_send::CastCommit::Spell,
0x0002,
);
world.resource_mut::<crate::target::PressPick>().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::<super::super::SpellTargeting>().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::<super::super::SpellTargeting>().enter(
HEAL,
crate::spell::cast_send::CastCommit::Spell,
0x0100,
);
world.resource_mut::<crate::target::PressPick>().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::<super::super::SpellTargeting>().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::<super::super::SpellTargeting>().enter(
HEAL,
crate::spell::cast_send::CastCommit::Spell,
0x0002,
);
hover_go(&mut world, 2.0);
world.resource_mut::<crate::target::PressPick>().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::<super::super::SpellTargeting>().enter(
HEAL,
crate::spell::cast_send::CastCommit::Spell,
0x0002,
);
world.resource_mut::<crate::target::PressPick>().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::<crate::ui_action::CastErrors>().0),
vec![crate::ui_action::CastFail::local(HEAL, 0x59)],
"\"Out of range.\""
);
assert!(
world.resource::<super::super::SpellTargeting>().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::<crate::ui_action::CastErrors>()
.0
.is_empty());
assert!(!world.resource::<super::super::SpellTargeting>().active());
}
/// Every way the object click must not commit.
#[test]
fn the_object_commit_holds_its_fire() {
+171 -11
View File
@@ -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<Hovered>,
mesh_assets: Res<Assets<Mesh>>,
pose: PickPose,
// For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause.
self_guid: Res<crate::net::SelfGuid>,
// 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<Option<Entity>>,
// 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<SelfPlayer>,
&GlobalTransform,
&NetEntity,
Option<&ModelAnimations>,
@@ -121,11 +153,15 @@ pub(super) fn update_hover(
Option<&InheritedVisibility>,
Option<&HeldAttached>,
),
(With<Guid>, Without<SelfPlayer>),
With<Guid>,
>,
// 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<CreaturePickPart>,
>,
// The pick's guid and kind; the kind decides which slot of `Hovered` a hit lands in.
units: Query<(&Guid, &NetEntity), Without<SelfPlayer>>,
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<AssetId<Mesh>>, Vec<Mat4>)> = Vec::new();
// Units with skinned parts: out of the box fallback even when the broad phase rejects them.
let mut faithful: HashSet<Entity> = HashSet::new();
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<SelfPlayer>,
&GlobalTransform,
&NetEntity,
Option<&ModelAnimations>,
@@ -374,11 +419,11 @@ fn selectable_pick(
Option<&InheritedVisibility>,
Option<&HeldAttached>,
),
(With<Guid>, Without<SelfPlayer>),
With<Guid>,
>,
self_guid: Option<u64>,
) -> 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::<CameraControl>();
world.init_resource::<PointerOverUi>();
world.insert_resource(PickOcclusion {
distance: f32::INFINITY,
point: None,
});
world.init_resource::<crate::vplates::PlateHover>();
world.init_resource::<Hovered>();
world.init_resource::<Assets<Mesh>>();
world.init_resource::<benilla_world::rig_palette::RigPalettes>();
world.insert_resource(crate::net::SelfGuid(Some(1)));
world.init_resource::<crate::spell::PicksSelf>();
// 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::<crate::spell::PicksSelf>().0 = picks;
world
.run_system_cached(update_hover)
.expect("the pick runs");
world.resource::<Hovered>().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]
+11 -6
View File
@@ -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`.
(
+1 -1
View File
@@ -202,7 +202,7 @@ pub(super) fn old_target_cleared(previous: Option<u64>, now: Option<u64>) -> 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<Selection>,
mut previous: Local<Option<u64>>,
mut bar: ResMut<PetBar>,
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -624,7 +624,7 @@ impl UnitTokens<'_, '_> {
}
/// Every token [`UnitTokens`] resolves, `"player"` included (the reference answers d² = 0).
fn reach_tokens() -> impl Iterator<Item = &'static str> {
pub(crate) fn reach_tokens() -> impl Iterator<Item = &'static str> {
["player", "target", "targettarget", "mouseover", "pet"]
.into_iter()
.chain(crate::ui_party::PARTY_TOKENS)
@@ -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 {
+3
View File
@@ -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;
@@ -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<u32, SpellRange>) -> Self {
Self { ranges }
}
pub fn len(&self) -> usize {
self.ranges.len()
}
+4 -3
View File
@@ -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<Item = &'a str>) {
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
+7 -4
View File
@@ -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<String>,
/// `SpellTargetUnit` tokens: a unit frame binds the spell currently waiting on the cursor.
pub(crate) spell_target_unit: Vec<String>,
/// Channels the server confirmed, in join order: the numbers `GetChannelName` answers.
pub(crate) joined_channels: Vec<Option<String>>,
@@ -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<String>,
/// `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(),
+9 -5
View File
@@ -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<String>| {
lua.create_function(|lua, unit: Option<String>| {
let model = lua.app_data_ref::<Model>().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)
+23 -4
View File
@@ -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<String>| 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::<Model>()
.expect("model app_data")
.spell_targeting
{
return Ok(());
}
check_unit_token(&Some(token.clone()))?;
let mut model = lua.app_data_mut::<Model>().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
+5
View File
@@ -411,6 +411,11 @@ impl super::UiScript {
pub fn take_drop_item_on_unit(&mut self) -> Vec<String> {
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<String> {
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
@@ -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::<bool>(r#"return SpellCanTargetUnit("player") == true"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return SpellCanTargetUnit("PARTY1") == true"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return SpellCanTargetUnit("target") == nil"#)
.unwrap());
}
#[test]
fn unit_reaction_reports_the_scale_value_or_nil() {
let mut s = UiScript::new().unwrap();