From 602c30389a8d743104d9ae175188dec377e5d9b2 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:59:17 +0700 Subject: [PATCH] spell range: two running units get GetMinMaxRange's moving bonus (#702) A warrior chasing a running mob was refused Hamstring at 6-7 yd with "Out of range.", where 1.12.1 lets it go out. After both arms, `GetMinMaxRange 0x6e3480` adds 2.6667 yd (`[0x811914]`) to the max when all of these hold (`0x6e3648`-`0x6e36a2`): - the caster and the target unit both move, with a bit of `0x200d` set; - both run faster than twice their walk speed (`0x5fc350`); - the row is melee, or the target is a player. On the melee row, the auto-attack target stands in when no unit is passed. `min_max_range` now takes each unit's motion and adds the bonus. Every caller passes what its reference call site passes. The ambiguity ceiling rises by the 11 pairs this adds, with the reason beside it. Test: Hamstring goes out at 7.5 yd with both units running and is refused standing still. Fixes #678 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../benilla-app/src/creature_anim/select.rs | 2 +- crates/benilla-app/src/game_plugins.rs | 12 +- crates/benilla-app/src/player/state.rs | 9 + crates/benilla-app/src/spell/cast_target.rs | 32 +- crates/benilla-app/src/spell/mod.rs | 2 + crates/benilla-app/src/spell/mods.rs | 54 +- crates/benilla-app/src/spell/range_units.rs | 364 ++++++++++++ .../benilla-app/src/spell/targeting/cursor.rs | 178 +++++- crates/benilla-app/src/spell/targeting/mod.rs | 49 +- crates/benilla-app/src/spell/validator.rs | 76 ++- crates/benilla-app/src/ui_action/state.rs | 160 +++++- .../benilla-app/src/ui_tooltip/deps_tests.rs | 191 +++++- .../benilla-app/src/ui_tooltip/feed_tests.rs | 210 +++++++ .../benilla-app/src/ui_tooltip/spell_deps.rs | 49 +- .../benilla-app/src/ui_tooltip/spell_feed.rs | 73 +-- crates/benilla-app/src/ui_tooltip/tests.rs | 35 +- crates/benilla-formats/src/lib.rs | 14 +- crates/benilla-formats/src/spells/mod.rs | 5 +- crates/benilla-formats/src/spells/ranges.rs | 543 +++++++++++++++--- 19 files changed, 1794 insertions(+), 264 deletions(-) create mode 100644 crates/benilla-app/src/spell/range_units.rs diff --git a/crates/benilla-app/src/creature_anim/select.rs b/crates/benilla-app/src/creature_anim/select.rs index 80bc35ae..0c7a574e 100644 --- a/crates/benilla-app/src/creature_anim/select.rs +++ b/crates/benilla-app/src/creature_anim/select.rs @@ -705,7 +705,7 @@ pub(super) fn scaled_rate(clip: &AnimClip, speed: f32, model_scale: f32) -> Opti /// remote's relayed flags ([`RemoteMotion`]), a creature's spline, else still. /// `creature_swimming` supplies the `SWIMMING` the wire never sends for a creature; `modes`, the /// `SMSG_SPLINE_MOVE_*` grants, join the remote and creature flags (one word in the reference). -pub(super) fn unify( +pub(crate) fn unify( movement: Option<&MovementState>, remote: Option<&RemoteMotion>, spline: Option<&Spline>, diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index d845d0a7..7486a11f 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -667,12 +667,20 @@ pub(crate) mod schedule_tests { /// over the `CastLadder`, the selection and 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. Such a gesture and a script call in one frame take - /// either order. + /// either order; + /// - the range compare's readers (`ui_action::state::feed_action_state`, + /// `ui_tooltip::spell_feed::feed_spell_tooltips`, `spell::targeting::feed_targeting_to_vm`, + /// the targeting cursor and the object-click commit) against the writers of `Player` and + /// `RemoteMotion` (`camera_saved::load_camera_pose`, `world_focus`'s focus publish and settle + /// release, `transport::compose_riders` and `ground_deck_riders`): the readers take the + /// movement flags of the caster (`Player::move_flags`) and its target + /// (`RemoteMotion::flags`), and those writers touch `login_pitch`, the settle fields and a + /// rider's pose, never the flags. /// /// 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 = 4_849; + const UPDATE_ACTIONABLE_CEILING: usize = 4_860; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index 2adbcaf2..d1a62cf7 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -486,6 +486,15 @@ pub(super) struct PendingKnockback { } impl Player { + /// A player whose last streamed movement word is `flags`, for the gates that read it. + #[cfg(test)] + pub(crate) fn with_move_flags(flags: u32) -> Self { + Self { + move_flags: flags, + ..Self::default() + } + } + /// Take the jump a hover grant owes, clearing the latch: `Jump(0)` refuses only ROOT and /// FALLING (`0x7c625c`), skipping the hover test (`0x7c6236`), and a refused jump is dropped, /// not retried. diff --git a/crates/benilla-app/src/spell/cast_target.rs b/crates/benilla-app/src/spell/cast_target.rs index 5f0a8f68..7a90122e 100644 --- a/crates/benilla-app/src/spell/cast_target.rs +++ b/crates/benilla-app/src/spell/cast_target.rs @@ -197,15 +197,17 @@ impl CastContext<'_> { } } -/// Positions and combat reaches for the pre-send range refusal: the client's cast runs +/// Positions and the two units for the pre-send range refusal: the client's cast runs /// `CanTargetUnit 0x6e4440` and `IsTargetInRange 0x6e47b0` before the commit, so an out-of-range /// press refuses locally and none of the commit tail runs. #[derive(Clone, Copy)] pub(crate) struct RangeInputs { pub(crate) self_pos: Option, pub(crate) target_pos: Option, - pub(crate) self_reach: f32, - pub(crate) target_reach: Option, + /// The caster's combat reach and motion. + pub(crate) caster: benilla_formats::RangeUnit, + /// The bound unit, which `GetMinMaxRange` reads for the ranged pad and the moving bonus. + pub(crate) target: Option, } impl RangeInputs { @@ -223,14 +225,7 @@ impl RangeInputs { .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, - mods, - ) + super::validator::cast_range_refusal(def, row, self.caster, self.target, dist_sq, mods) } } @@ -239,9 +234,8 @@ impl Default for RangeInputs { Self { self_pos: None, target_pos: None, - // The descriptor's default combat reach. - self_reach: 1.5, - target_reach: None, + caster: benilla_formats::RangeUnit::still(super::DEFAULT_REACH), + target: None, } } } @@ -268,6 +262,8 @@ pub(crate) struct CastTargeting<'w, 's> { /// `Option`: the body exists only in world, and the cast-result handler must fetch this param /// anywhere. With no body the moving gate passes and the server decides. player: Option>, + /// The caster's and the selection's reach and motion, for the range gate. + range_units: super::RangeUnits<'w, 's>, } impl CastTargeting<'_, '_> { @@ -304,12 +300,8 @@ impl CastTargeting<'_, '_> { .target .and_then(|e| self.transforms.get(e).ok()) .map(|t| t.translation), - self_reach: self - .self_store - .iter() - .next() - .map_or(1.5, |s| s.0.unit_combat_reach()), - target_reach: target_store.map(|s| s.0.unit_combat_reach()), + caster: self.range_units.caster(), + target: self.selection.target.and_then(|e| self.range_units.unit(e)), }, main_hand_item: self .self_store diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index 67107a25..08f74602 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -20,6 +20,7 @@ mod group_relation; mod inflight; mod mods; pub(crate) mod net; +mod range_units; pub(crate) mod targeting; pub(crate) mod usable; pub(crate) mod validator; @@ -36,6 +37,7 @@ pub(crate) use inflight::{ pub(crate) use mods::{ModsDiff, SpellModifiers, OP_CAST_TIME, OP_COST, OP_GCD, OP_RADIUS}; #[cfg(test)] pub(crate) use mods::{OP_COOLDOWN, OP_RANGE}; +pub(crate) use range_units::{RangeUnits, DEFAULT_REACH}; // `TargetingWants` is exported for the ground reticle, which draws for the location word alone. pub(crate) use targeting::{ ground_cast_radius, CorpsePick, PicksSelf, ScriptCursor, SpellTargeting, TargetingWants, diff --git a/crates/benilla-app/src/spell/mods.rs b/crates/benilla-app/src/spell/mods.rs index 80a2d8b4..6e1d4a31 100644 --- a/crates/benilla-app/src/spell/mods.rs +++ b/crates/benilla-app/src/spell/mods.rs @@ -227,16 +227,17 @@ impl SpellModifiers { }) } - /// `GetMinMaxRange` modifies only the maximum at its common tail. The on-next-swing - /// `Attributes & 0x404` arm returns before that tail (`0x6e350f`). + /// `GetMinMaxRange` modifies only the maximum at its common tail, after the moving bonus + /// (`0x6e3648`-`0x6e36a2`, then op 5 at `0x6e3746`). The on-next-swing `Attributes & 0x404` arm + /// returns before that tail (`0x6e350f`). pub(crate) fn min_max_range( &self, d: &SpellDisplay, row: Option<&SpellRange>, - caster_reach: f32, + caster: benilla_formats::RangeUnit, targets: benilla_formats::RangeTargets, ) -> Option<(f32, f32)> { - let (min, max) = benilla_formats::min_max_range(d, row, caster_reach, targets)?; + let (min, max) = benilla_formats::min_max_range(d, row, caster, targets)?; Some(( min, if d.on_next_swing() { @@ -521,23 +522,58 @@ mod tests { flags: 0, }; mods.set(false, 5, OP_RANGE, 20); + let caster = benilla_formats::RangeUnit::still(1.5); let target = benilla_formats::RangeTargets { - target: Some(1.5), + target: Some(benilla_formats::RangeUnit::still(1.5)), attack_target: None, }; - let (min, max) = mods.min_max_range(&d, Some(&row), 1.5, target).unwrap(); + let (min, max) = mods.min_max_range(&d, Some(&row), caster, target).unwrap(); assert_eq!(min, 11.0); assert!((max - 45.6).abs() < 0.001, "38 yards with +20% range"); - assert_eq!(mods.min_max_range(&d, None, 1.5, target), None); + assert_eq!(mods.min_max_range(&d, None, caster, target), None); let on_next_swing = SpellDisplay { attributes: 0x404, ..d }; assert_eq!( - mods.min_max_range(&on_next_swing, Some(&row), 1.5, target), - benilla_formats::min_max_range(&on_next_swing, Some(&row), 1.5, target), + mods.min_max_range(&on_next_swing, Some(&row), caster, target), + benilla_formats::min_max_range(&on_next_swing, Some(&row), caster, target), "the reference's early return bypasses op 5" ); } + + /// The moving bonus is part of the max before op 5 scales it (`0x6e3648`-`0x6e36a2`, then + /// `0x6e3746`): a +20% talent on the melee row of two running units scales 5 + 2.6667. + #[test] + fn range_modifier_scales_the_moving_bonus() { + let mut mods = mage_tables(); + let d = mage(&[5]); + let melee = SpellRange { + min: 0.0, + max: 5.0, + flags: 1, + }; + mods.set(false, 5, OP_RANGE, 20); + let runner = benilla_formats::RangeUnit { + motion: benilla_formats::UnitMotion { + flags: 1, + speed: 7.0, + walk_speed: 2.5, + }, + ..benilla_formats::RangeUnit::still(1.5) + }; + let targets = benilla_formats::RangeTargets { + target: Some(runner), + attack_target: None, + }; + let (_, max) = mods + .min_max_range(&d, Some(&melee), runner, targets) + .unwrap(); + let bonus = benilla_formats::MOVING_RANGE_BONUS; + assert!( + (max - (5.0 + bonus) * 1.2).abs() < 1e-4, + "the modifier scales the bonused max, not the bare 5.0: {max}" + ); + } } diff --git a/crates/benilla-app/src/spell/range_units.rs b/crates/benilla-app/src/spell/range_units.rs new file mode 100644 index 00000000..9f035982 --- /dev/null +++ b/crates/benilla-app/src/spell/range_units.rs @@ -0,0 +1,364 @@ +//! The units `GetMinMaxRange` (`0x6e3480`) reads, built from the world: the caster, a target unit, +//! and the caster's auto-attack target, each as its combat reach, player bit and motion +//! ([`benilla_formats::RangeUnit`]). + +use benilla_formats::{RangeUnit, UnitMotion}; +use benilla_protocol::messages::ObjectType; +use benilla_protocol::MoveSpeeds; +use bevy::ecs::system::SystemParam; +use bevy::prelude::*; + +use crate::creature_anim::select::unify; +use crate::creature_anim::{move_flags, Engaged}; +use crate::net::{ + current_speed, CreatureSwimming, Embodied, GuidIndex, ObjectStore, RemoteMotion, SelfPlayer, + Spline, UnitMoveModes, UnitSpeeds, +}; +use crate::player::Player; + +/// The descriptor's default combat reach, before a unit's store streams. +pub(crate) const DEFAULT_REACH: f32 = 1.5; + +/// One unit's motion as `0x7c4c90` and the moving test read it (`0x5fc350`, `0x6e364c`): `flags` +/// is the movement word (`[[unit+0x118]+0x40]`), and the current speed is 0 with no direction bit +/// (`0x7c4c99`), then the live server spline's speed, its length over its duration (`0x7c4caa`), +/// then the swim, walk, run or run-back speed the flags pick ([`current_speed`]). A unit with no +/// speeds streamed reads no current speed here, so it is not running; every unit's create block +/// applies its speeds, so none is seen that way. +pub(crate) fn unit_motion( + flags: u32, + speeds: Option<&MoveSpeeds>, + spline: Option<&Spline>, +) -> UnitMotion { + let Some(speeds) = speeds else { + return UnitMotion { + flags, + ..UnitMotion::default() + }; + }; + let speed = match spline { + Some(spline) if flags & move_flags::ANY_MOVE != 0 => spline.speed(), + _ => current_speed(speeds, flags), + }; + UnitMotion { + flags, + speed, + walk_speed: speeds.walk, + } +} + +/// What a unit's range inputs are read from: its descriptor, and the state its movement flags and +/// speeds come from ([`unify`], [`UnitSpeeds`]). +type UnitParts = ( + &'static ObjectStore, + Option<&'static RemoteMotion>, + Option<&'static Spline>, + Option<&'static UnitMoveModes>, + Option<&'static UnitSpeeds>, + Has, + Has, +); + +/// The reader of the range units: any streamed unit, our own caster, and a unit's auto-attack +/// target. +#[derive(SystemParam)] +pub(crate) struct RangeUnits<'w, 's> { + units: Query<'w, 's, UnitParts>, + caster: Query<'w, 's, Entity, With>, + /// The auto-attack target guid, `[unit+0xc48]`. + engaged: Query<'w, 's, &'static Engaged>, + index: Option>, + /// The flags word of the body we drive, [`Player::move_flags`]. + player: Option>, +} + +impl RangeUnits<'_, '_> { + /// The unit `entity` is, `None` before its descriptor streams. The body we drive reads the + /// word our controller builds, one answer for the wire and the local gates; any other unit's + /// is its relayed or spline-implied flags with the modes the server granted. + pub(crate) fn unit(&self, entity: Entity) -> Option { + let (store, remote, spline, modes, speeds, swimming, driven) = + self.units.get(entity).ok()?; + let flags = if driven { + self.player.as_deref().map_or(0, Player::move_flags) + } else { + unify(None, remote, spline, swimming, modes).flags + }; + Some(RangeUnit { + reach: store.0.unit_combat_reach(), + player: store.0.object_type() == Some(ObjectType::Player), + motion: unit_motion(flags, speeds.map(|s| &s.0), spline), + }) + } + + /// The caster: our own player, at the default reach and standing until its descriptor streams. + pub(crate) fn caster(&self) -> RangeUnit { + self.caster + .iter() + .next() + .and_then(|e| self.unit(e)) + .unwrap_or(RangeUnit::still(DEFAULT_REACH)) + } + + /// The auto-attack target of `entity`, the unit `GetMinMaxRange` looks up itself for its melee + /// arm (`0x6e3552`-`0x6e3584`). `None` with no target engaged or before it streams, where the + /// arm reads the caster's own reach (`0x6e3594`). + pub(crate) fn attack_target(&self, entity: Entity) -> Option { + let guid = self.engaged.get(entity).ok()?.0; + let target = *self.index.as_ref()?.0.get(&guid)?; + self.unit(target) + } + + /// The auto-attack target of our own player. + pub(crate) fn caster_attack_target(&self) -> Option { + self.attack_target(self.caster.iter().next()?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creature_anim::move_flags as f; + use crate::net::UnitSpeeds; + + fn speeds() -> MoveSpeeds { + MoveSpeeds { + walk: 2.5, + run: 7.0, + run_back: 4.5, + swim: 4.722_222, + swim_back: 2.5, + turn_rate: std::f32::consts::PI, + } + } + + fn spline(length: f32, secs: u64) -> Spline { + Spline { + points: vec![[0.0; 3], [length, 0.0, 0.0]], + start: std::time::Instant::now(), + duration: std::time::Duration::from_secs(secs), + id: 1, + grounded: true, + run_mode: true, + deck: None, + } + } + + /// `0x7c4c90` for a forward runner answers the run speed, and `0x5fc350` finds it above twice + /// the walk speed. + #[test] + fn a_forward_runner_is_running() { + let m = unit_motion(f::FORWARD, Some(&speeds()), None); + assert_eq!((m.flags, m.speed, m.walk_speed), (f::FORWARD, 7.0, 2.5)); + assert!(m.moves_at_run_speed()); + } + + /// Walk mode picks `min(walk, run)`, no direction bit is speed 0, and swimming is the swim + /// speed, which is below twice the walk speed. + #[test] + fn a_walker_a_stander_and_a_swimmer_are_not_running() { + let s = speeds(); + for (name, flags) in [ + ("walking", f::FORWARD | f::WALK_MODE), + ("standing", 0), + ("jumping in place", f::FALLING), + ("swimming", f::FORWARD | f::SWIMMING), + ("backward", f::BACKWARD), + ] { + let m = unit_motion(flags, Some(&s), None); + assert!(!m.moves_at_run_speed(), "{name}: {m:?}"); + } + // A swim speed past twice the walk speed is running, as the compare has it. + let fast = MoveSpeeds { swim: 6.0, ..s }; + assert!(unit_motion(f::FORWARD | f::SWIMMING, Some(&fast), None).moves_at_run_speed()); + } + + /// A live spline is the current speed (`0x7c4caa`), whatever the flags pick: a mob fleeing at + /// 8 yd/s runs, and one wandering at 2.5 does not though its flags are a runner's (a creature's + /// spline sets forward and no walk mode, [`unify`]); with no direction bit it is 0. + #[test] + fn a_spline_is_its_own_speed() { + let s = speeds(); + let fleeing = unit_motion(f::FORWARD, Some(&s), Some(&spline(16.0, 2))); + assert_eq!(fleeing.speed, 8.0); + assert!(fleeing.moves_at_run_speed()); + let wandering = unit_motion(f::FORWARD, Some(&s), Some(&spline(5.0, 2))); + assert_eq!(wandering.speed, 2.5); + assert!(!wandering.moves_at_run_speed()); + assert_eq!(unit_motion(0, Some(&s), Some(&spline(16.0, 2))).speed, 0.0); + } + + /// No speeds streamed: not running. + #[test] + fn a_unit_without_speeds_is_not_running() { + assert!(!unit_motion(f::FORWARD, None, Some(&spline(16.0, 2))).moves_at_run_speed()); + } + + fn store(typemask: u32, reach: f32) -> ObjectStore { + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (2, typemask), + (22, 100), + (130, reach.to_bits()), + ])) + } + + fn remote(flags: u32) -> RemoteMotion { + RemoteMotion { + wow_pos: [0.0; 3], + orientation: 0.0, + flags, + pitch: 0.0, + speed: 0.0, + vertical_velocity: 0.0, + jump_xy_vel: [0.0; 2], + fall_start_z: None, + pending: Default::default(), + relay: Default::default(), + last_apply_ms: 0.0, + last_apply_pos: [0.0; 3], + } + } + + const MOB: u64 = 0xF130_0000_0000_0009; + + /// The body we drive reads the word our controller built, a remote player its relayed flags, + /// a creature its spline; the player bit is `OBJECT_FIELD_TYPE` bit 4, and the caster's + /// auto-attack target is looked up through its `Engaged` guid. + #[test] + fn a_unit_is_read_off_its_own_state() { + use bevy::ecs::system::RunSystemOnce; + + let mut world = World::new(); + world.insert_resource(Player::with_move_flags(f::FORWARD)); + let run = UnitSpeeds(speeds()); + let me = world + .spawn((SelfPlayer, Embodied, run, store(0x19, 2.0))) + .id(); + let friend = world + .spawn((remote(f::STRAFE_LEFT), run, store(0x19, 1.5))) + .id(); + let mob = world.spawn((spline(16.0, 2), run, store(0x09, 1.25))).id(); + let wanderer = world.spawn((spline(5.0, 2), run, store(0x09, 1.5))).id(); + let idle = world.spawn((run, store(0x09, 1.5))).id(); + let bare = world.spawn(run).id(); + world.entity_mut(me).insert(Engaged(MOB)); + world.insert_resource(GuidIndex([(MOB, mob)].into_iter().collect())); + world + .run_system_once(move |units: RangeUnits| { + let me = units.caster(); + assert_eq!(me.reach, 2.0); + assert!(me.player && me.motion.moves_at_run_speed(), "{me:?}"); + let friend = units.unit(friend).unwrap(); + assert!( + friend.player && friend.motion.moves_at_run_speed(), + "{friend:?}" + ); + let mob_unit = units.unit(mob).unwrap(); + assert!(!mob_unit.player, "a creature"); + assert_eq!(mob_unit.reach, 1.25); + assert_eq!(mob_unit.motion.speed, 8.0, "its spline's speed"); + assert!(mob_unit.motion.moves_at_run_speed()); + let wanderer = units.unit(wanderer).unwrap(); + assert!(!wanderer.motion.moves_at_run_speed(), "{wanderer:?}"); + let idle = units.unit(idle).unwrap(); + assert!(!idle.motion.moves_at_run_speed(), "{idle:?}"); + assert!(units.unit(bare).is_none(), "no descriptor streamed"); + assert_eq!(units.caster_attack_target(), Some(mob_unit)); + assert_eq!(units.attack_target(mob), None, "the mob engages nothing"); + }) + .expect("the units read"); + } + + /// Our own body is the word `Player` holds, so the same body standing still, walking or with + /// no controller in the world is not running. + #[test] + fn the_driven_body_follows_the_controllers_word() { + use bevy::ecs::system::RunSystemOnce; + + let caster_at = |player: Option| { + let mut world = World::new(); + if let Some(player) = player { + world.insert_resource(player); + } + world.spawn((SelfPlayer, Embodied, UnitSpeeds(speeds()), store(0x19, 1.5))); + world + .run_system_once(|units: RangeUnits| units.caster().motion.moves_at_run_speed()) + .expect("the caster reads") + }; + assert!(caster_at(Some(Player::with_move_flags(f::FORWARD)))); + assert!(caster_at(Some(Player::with_move_flags( + f::STRAFE_RIGHT | f::FALLING + )))); + assert!(!caster_at(Some(Player::with_move_flags(0)))); + assert!(!caster_at(Some(Player::with_move_flags( + f::FORWARD | f::WALK_MODE + )))); + assert!(!caster_at(Some(Player::with_move_flags(f::BACKWARD)))); + assert!(!caster_at(None)); + } + + /// The send path's range gate ([`super::super::cast_target::CastTargeting`]): a warrior running + /// after a running mob presses Hamstring (the melee row) at 7 yards and is not refused, and + /// the same press with either one standing is "Out of range." + #[test] + fn the_press_reaches_the_moving_bonus_with_both_units_running() { + use super::super::cast_target::CastTargeting; + use benilla_formats::{SpellDisplay, SpellRange}; + use bevy::ecs::system::RunSystemOnce; + + const ME: u64 = 0xF130_0000_0000_0001; + let refusal = |caster_runs: bool, mob_runs: bool, yards: f32| { + let mut world = World::new(); + world.insert_resource(Player::with_move_flags(if caster_runs { + f::FORWARD + } else { + 0 + })); + world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); + world.insert_resource(crate::net::SelfGuid(Some(ME))); + let run = UnitSpeeds(speeds()); + world.spawn(( + SelfPlayer, + Embodied, + run, + store(0x19, 1.5), + Transform::default(), + )); + let mob = world + .spawn((run, store(0x09, 1.5), Transform::from_xyz(yards, 0.0, 0.0))) + .id(); + if mob_runs { + world.entity_mut(mob).insert(spline(16.0, 2)); + } + world.insert_resource(crate::target::Selection { + target: Some(mob), + guid: Some(MOB), + ..Default::default() + }); + let hamstring = SpellDisplay { + range_index: 2, + ..Default::default() + }; + let melee = SpellRange { + min: 0.0, + max: 5.0, + flags: 1, + }; + world + .run_system_once(move |targeting: CastTargeting| { + targeting.context().range.refusal( + &hamstring, + Some(&melee), + &crate::spell::SpellModifiers::default(), + ) + }) + .expect("the context builds") + }; + assert_eq!(refusal(true, true, 7.0), None, "both running"); + assert_eq!(refusal(true, true, 8.0), Some(0x59), "past 7.667"); + assert_eq!(refusal(true, false, 7.0), Some(0x59), "the mob stands"); + assert_eq!(refusal(false, true, 7.0), Some(0x59), "the caster stands"); + assert_eq!(refusal(false, false, 4.9), None, "the floor stands"); + } +} diff --git a/crates/benilla-app/src/spell/targeting/cursor.rs b/crates/benilla-app/src/spell/targeting/cursor.rs index c4f6f31a..eb6471a6 100644 --- a/crates/benilla-app/src/spell/targeting/cursor.rs +++ b/crates/benilla-app/src/spell/targeting/cursor.rs @@ -5,7 +5,7 @@ use bevy::prelude::*; -use benilla_formats::{RangeTargets, SpellDisplay, SpellRange}; +use benilla_formats::{RangeTargets, RangeUnit, SpellDisplay, SpellRange}; use crate::net::SelfPlayer; use crate::spell::SpellModifiers; @@ -17,15 +17,16 @@ use super::{SpellTargeting, TargetingWants}; /// What `GetMinMaxRange 0x6e3480` reads for the cursor's two range verdicts: the ground point /// (`CheckGroundPointInRange 0x6e6810`, a null target at `0x6e6879`) and the GameObject leg of /// `0x6e6460` (the object as target at `0x6e679b`). Neither passes a unit, which the function -/// tests for at `0x6e34e1`-`0x6e34f9`: the ranged arm pads nothing, and the melee arm sums the -/// caster's reach with its auto-attack target's. +/// tests for at `0x6e34e1`-`0x6e34f9`: the ranged arm pads nothing and takes no moving bonus, +/// and the melee arm sums the caster's reach with its auto-attack target's and asks that unit's +/// motion, with the caster's, for the bonus. #[derive(Clone, Copy)] struct RangeCall<'a> { spell: &'a SpellDisplay, row: &'a SpellRange, mods: &'a SpellModifiers, - self_reach: f32, - attack_target_reach: Option, + caster: RangeUnit, + attack_target: Option, } /// The call's inputs, or `None` where the data is absent (no catalog, an unknown spell, a row @@ -37,8 +38,8 @@ fn range_call<'a>(checks: &'a super::BindChecks, spell_id: u32) -> Option, self_pos: Vec3, point: Vec3) - }; let targets = RangeTargets { target: None, - attack_target: call.attack_target_reach, + attack_target: call.attack_target, }; // The self row is `{0, 0}` (`0x6e35dd`, `0x6e35ea`) and `min_max_range` answers `None` for it: // only the caster's own spot passes it here. let (min, max) = call .mods - .min_max_range(call.spell, Some(call.row), call.self_reach, targets) + .min_max_range(call.spell, Some(call.row), call.caster, targets) .unwrap_or_default(); let dist_sq = self_pos.distance_squared(point); min * min <= dist_sq && dist_sq <= max * max @@ -249,8 +250,8 @@ mod tests { spell, row, mods, - self_reach: 1.5, - attack_target_reach: None, + caster: RangeUnit::still(1.5), + attack_target: None, }) } @@ -350,8 +351,8 @@ mod tests { spell: &spell, row: &melee, mods: &mods, - self_reach, - attack_target_reach, + caster: RangeUnit::still(self_reach), + attack_target: attack_target_reach.map(RangeUnit::still), }; ground_point_in_range(Some(call), Vec3::ZERO, Vec3::new(d, 0.0, 0.0)) }; @@ -366,6 +367,55 @@ mod tests { assert!(!ground(1.5, Some(4.0), 7.0)); } + /// The moving bonus (`0x6e3648`-`0x6e36a2`) on the ground verdict: with no unit passed, only + /// the melee arm has a unit for it, the auto-attack target, and both must run. The ranged arm + /// has none, whoever the caster's auto-attack target is. + #[test] + fn the_ground_verdict_takes_the_moving_bonus_on_the_melee_row_alone() { + let running = RangeUnit { + // A player, which would earn a ranged target the bonus, were one passed. + player: true, + motion: benilla_formats::UnitMotion { + flags: 1, + speed: 7.0, + walk_speed: 2.5, + }, + ..RangeUnit::still(1.5) + }; + let melee = SpellRange { + min: 0.0, + max: 5.0, + flags: 1, + }; + let blizzard = SpellRange { + min: 0.0, + max: 30.0, + flags: 0, + }; + let spell = SpellDisplay::default(); + let mods = SpellModifiers::default(); + let ground = |row: &SpellRange, caster, attack_target, d: f32| { + let call = RangeCall { + spell: &spell, + row, + mods: &mods, + caster, + attack_target, + }; + ground_point_in_range(Some(call), Vec3::ZERO, Vec3::new(d, 0.0, 0.0)) + }; + // 5.0 floor + 2.6667 = 7.667 for two runners. + assert!(ground(&melee, running, Some(running), 7.5)); + assert!(!ground(&melee, running, Some(running), 7.8)); + // One of them standing, or no auto-attack target, and the floor stands. + let still = RangeUnit::still(1.5); + assert!(!ground(&melee, running, Some(still), 7.5)); + assert!(!ground(&melee, still, Some(running), 7.5)); + assert!(!ground(&melee, running, None, 7.5)); + // The ranged arm never reads the auto-attack target: 30, not 32.67. + assert!(!ground(&blizzard, running, Some(running), 31.0)); + } + /// Fixture rows mirror the real table: row 14 is 8.0 (Blizzard), row 8 is 5.0 (Flamestrike). #[test] fn ground_cast_radius_mirrors_get_current_cast_radius() { @@ -800,6 +850,108 @@ mod tests { ); } + /// The unit leg's range compare, the tail of `SpellCanTargetUnit 0x6e6460`, hands + /// `GetMinMaxRange` the hovered unit (`0x6e679b`, `0x6e67d1`): a melee spell is in range to + /// 7.667 yards while the caster and that unit both run, and to the 5.0 floor otherwise. + #[test] + fn the_unit_leg_takes_the_moving_bonus_on_the_melee_row() { + use bevy::ecs::system::RunSystemOnce; + use std::collections::HashMap; + + const HAMSTRING: u32 = 1715; + let verdict = |caster_runs: bool, unit_runs: bool, 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.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); + world.init_resource::(); + world.insert_resource(crate::player::Player::with_move_flags(if caster_runs { + crate::creature_anim::move_flags::FORWARD + } else { + 0 + })); + let mut spells = Spells::empty_for_tests(); + spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([( + HAMSTRING, + benilla_formats::SpellDisplay { + range_index: 2, + ..Default::default() + }, + )])); + spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([( + 2, + SpellRange { + min: 0.0, + max: 5.0, + flags: 1, + }, + )])); + world.insert_resource(spells); + let speeds = crate::net::UnitSpeeds(benilla_protocol::MoveSpeeds { + walk: 2.5, + run: 7.0, + run_back: 4.5, + swim: 4.7, + swim_back: 2.5, + turn_rate: 3.1, + }); + let live = || { + crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(22, 100)])) + }; + world.spawn(( + SelfPlayer, + crate::net::Embodied, + speeds, + Transform::default(), + GlobalTransform::default(), + live(), + )); + let unit = world + .spawn(( + speeds, + GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)), + live(), + )) + .id(); + if unit_runs { + world.entity_mut(unit).insert(crate::net::Spline { + points: vec![[distance, 0.0, 0.0], [distance + 16.0, 0.0, 0.0]], + start: std::time::Instant::now(), + duration: std::time::Duration::from_secs(2), + id: 1, + grounded: true, + run_mode: true, + deck: None, + }); + } + world.insert_resource(crate::target::Hovered { + target: Some(unit), + guid: Some(0xF130_0000_0000_0001), + distance: 5.0, + ..Default::default() + }); + world.resource_mut::().enter( + HAMSTRING, + crate::spell::CastCommit::Spell, + 0x0002, + ); + world + .run_system_once(drive_targeting_cursor) + .expect("the targeting cursor drives"); + !world.resource::().unable + }; + assert!(verdict(true, true, 7.5), "both running: inside 7.667"); + assert!(!verdict(true, true, 8.0), "past it"); + assert!(!verdict(false, true, 7.5), "the caster stands"); + assert!(!verdict(true, false, 7.5), "the unit stands"); + assert!(verdict(false, false, 4.9), "the floor"); + } + /// `0x6e6460`'s corpse leg over a hovered corpse: bones, a hostile corpse under the ally bit /// and a corpse out of range are grey. The range pads a corpse by the caster's reach twice /// (`6e3605`–`6e361e`): Resurrection's 30 yd with a 3.0 reach is 36. diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index 3614e9eb..2a85140f 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -74,8 +74,9 @@ pub(crate) struct BindChecks<'w, 's> { poses: Query<'w, 's, &'static GlobalTransform, Without>, spells: Option>, spell_mods: Res<'w, super::SpellModifiers>, - /// The caster's auto-attack target guid, `[caster+0xc48]`, which `GetMinMaxRange` looks up. - engaged: Query<'w, 's, &'static crate::creature_anim::Engaged, With>, + /// The caster, a unit and the caster's auto-attack target (`[caster+0xc48]`, which + /// `GetMinMaxRange` looks up) as the range compare reads them. + range_units: super::RangeUnits<'w, 's>, } impl BindChecks<'_, '_> { @@ -123,28 +124,25 @@ impl BindChecks<'_, '_> { /// unknown spell or a missing row passes. fn range_refusal(&self, spell_id: u32, entity: Entity) -> Option { let mut range = self.range_inputs(entity); - range.target_reach = self - .stores - .get(entity) - .ok() - .map(|s| s.0.unit_combat_reach()); + range.target = self.range_units.unit(entity); self.refusal(spell_id, range) } /// The same compare against a corpse. `GetMinMaxRange 0x6e3480` pads a corpse's bounds as a /// unit's (`6e35fe`), but with no unit to read the second reach from it reads the caster's - /// again (`6e3605`–`6e361e`). + /// again (`6e3605`–`6e361e`). On the ranged arm, which the corpse spells use, no unit means no + /// moving bonus; a melee row would fall back to the auto-attack target (`0x6e3580`). fn corpse_range_refusal(&self, spell_id: u32, entity: Entity) -> Option { let mut range = self.range_inputs(entity); - range.target_reach = Some(range.self_reach); + range.target = Some(benilla_formats::RangeUnit::still(range.caster.reach)); self.refusal(spell_id, range) } - /// Our position and reach and the candidate's position, each position the pose the hover - /// picks against. + /// Our position, reach and motion and the candidate's position, each position the pose the + /// hover picks against. fn range_inputs(&self, entity: Entity) -> super::cast_target::RangeInputs { let me = self.self_q.iter().next(); - let mut range = super::cast_target::RangeInputs { + super::cast_target::RangeInputs { self_pos: me .and_then(|(e, _)| self.poses.get(e).ok()) .map(GlobalTransform::translation), @@ -153,30 +151,9 @@ impl BindChecks<'_, '_> { .get(entity) .ok() .map(GlobalTransform::translation), - ..Default::default() - }; - range.self_reach = self.self_reach(); - range - } - - /// The caster's combat reach, the descriptor's default until its store streams. - fn self_reach(&self) -> f32 { - self.self_q - .iter() - .next() - .and_then(|(_, store)| store) - .map_or(super::cast_target::RangeInputs::default().self_reach, |s| { - s.0.unit_combat_reach() - }) - } - - /// The reach of the caster's auto-attack target, the unit `GetMinMaxRange 0x6e3480` looks up - /// itself for its melee arm when no unit is passed (`6e3552`-`6e3584`). `None` with no target - /// engaged or before it streams, where the arm reads the caster's own reach (`6e3594`). - fn attack_target_reach(&self) -> Option { - let guid = self.engaged.single().ok()?.0; - let entity = *self.index.as_ref()?.0.get(&guid)?; - Some(self.stores.get(entity).ok()?.0.unit_combat_reach()) + caster: self.range_units.caster(), + target: None, + } } fn refusal(&self, spell_id: u32, range: super::cast_target::RangeInputs) -> Option { diff --git a/crates/benilla-app/src/spell/validator.rs b/crates/benilla-app/src/spell/validator.rs index 7eba0f9f..1f6bcb2f 100644 --- a/crates/benilla-app/src/spell/validator.rs +++ b/crates/benilla-app/src/spell/validator.rs @@ -5,7 +5,7 @@ //! the greying walk `0x6e3d60`. use super::SpellModifiers; -use benilla_formats::{SpellDisplay, SpellRange}; +use benilla_formats::{RangeUnit, SpellDisplay, SpellRange}; /// The range refusals `CanTargetUnit 0x6e4440` emits: "Out of range." and "Target too close". pub(super) const ERR_OUT_OF_RANGE: u8 = 0x59; @@ -14,23 +14,23 @@ pub(super) const ERR_TOO_CLOSE: u8 = 0x76; /// The range refusal, before `ArmCast`/`SendCast`, so an out-of-range press never runs the commit /// tail (the ranged sheath snap `0x6e5930` included): squared 3D distance against /// [`benilla_formats::min_max_range`], beyond max² out of range, inside a nonzero min² too close. -/// `target_reach` is the bound unit's, the target `IsTargetInRange 0x6e47b0` hands -/// `GetMinMaxRange` (`0x6e47ca`). The function looks the auto-attack target up only when that -/// target is no unit, which a bound unit never is, so none is passed. No range row or no -/// distance passes; the server judges. +/// `target` is the bound unit, the target `IsTargetInRange 0x6e47b0` hands `GetMinMaxRange` +/// (`0x6e47ca`): its reach pads a ranged row and its motion, with the caster's, earns the moving +/// bonus. The function looks the auto-attack target up only when that target is no unit, which a +/// bound unit never is, so none is passed. No range row or no distance passes; the server judges. pub(super) fn cast_range_refusal( spell: &SpellDisplay, row: Option<&SpellRange>, - self_reach: f32, - target_reach: Option, + caster: RangeUnit, + target: Option, dist_sq: Option, mods: &SpellModifiers, ) -> Option { let targets = benilla_formats::RangeTargets { - target: target_reach, + target, attack_target: None, }; - let (min, max) = mods.min_max_range(spell, row, self_reach, targets)?; + let (min, max) = mods.min_max_range(spell, row, caster, targets)?; let d2 = dist_sq?; if d2 > max * max { return Some(ERR_OUT_OF_RANGE); @@ -251,10 +251,10 @@ mod tests { max: 35.0, flags: 0, }; - let reach = Some(1.5); + let me = RangeUnit::still(1.5); + let reach = Some(RangeUnit::still(1.5)); // Both bounds carry the bare reach pad (self 1.5 + target 1.5): min = 11, max = 38. - let refuse = - |d2: f32| cast_range_refusal(&d, Some(&auto_shot), 1.5, reach, Some(d2), &mods); + let refuse = |d2: f32| cast_range_refusal(&d, Some(&auto_shot), me, reach, Some(d2), &mods); assert_eq!(refuse(3.0 * 3.0), Some(ERR_TOO_CLOSE)); assert_eq!(refuse(20.0 * 20.0), None); assert_eq!(refuse(60.0 * 60.0), Some(ERR_OUT_OF_RANGE)); @@ -265,7 +265,7 @@ mod tests { max: 35.0, flags: 0, }; - let refuse = |d2: f32| cast_range_refusal(&d, Some(&fireball), 1.5, reach, Some(d2), &mods); + let refuse = |d2: f32| cast_range_refusal(&d, Some(&fireball), me, reach, Some(d2), &mods); assert_eq!(refuse(0.1), None); assert_eq!(refuse(60.0 * 60.0), Some(ERR_OUT_OF_RANGE)); // The bound target is the pad's second reach (`0x6e47ca`): 35 + 1.5 + 1.5 = 38. @@ -273,7 +273,7 @@ mod tests { assert_eq!(refuse(39.0 * 39.0), Some(ERR_OUT_OF_RANGE)); // With no unit bound the row stands as it is: 35. assert_eq!( - cast_range_refusal(&d, Some(&fireball), 1.5, None, Some(37.0 * 37.0), &mods), + cast_range_refusal(&d, Some(&fireball), me, None, Some(37.0 * 37.0), &mods), Some(ERR_OUT_OF_RANGE) ); @@ -285,14 +285,14 @@ mod tests { }; let melee_spell = spell_with_range(2, 0); assert_eq!( - cast_range_refusal(&melee_spell, Some(&melee), 1.5, reach, Some(0.1), &mods), + cast_range_refusal(&melee_spell, Some(&melee), me, reach, Some(0.1), &mods), None ); assert_eq!( cast_range_refusal( &melee_spell, Some(&melee), - 1.5, + me, reach, Some(15.0 * 15.0), &mods @@ -302,15 +302,55 @@ mod tests { // No row or no distance passes. assert_eq!( - cast_range_refusal(&d, None, 1.5, reach, Some(1.0), &mods), + cast_range_refusal(&d, None, me, reach, Some(1.0), &mods), None ); assert_eq!( - cast_range_refusal(&d, Some(&auto_shot), 1.5, reach, None, &mods), + cast_range_refusal(&d, Some(&auto_shot), me, reach, None, &mods), None ); } + /// A warrior running after a running mob presses Hamstring (the melee row) at 7 yards: + /// `GetMinMaxRange` adds the moving bonus to the 5.0 floor (`0x6e3648`-`0x6e36a2`), so the + /// press goes out to 7.667, and a standing pair is refused at the same distance. + #[test] + fn cast_range_refusal_lets_two_running_units_reach_the_moving_bonus() { + let mods = SpellModifiers::default(); + let hamstring = spell_with_range(2, 0); + let melee = SpellRange { + min: 0.0, + max: 5.0, + flags: 1, + }; + let running = RangeUnit { + motion: benilla_formats::UnitMotion { + flags: 1, + speed: 7.0, + walk_speed: 2.5, + }, + ..RangeUnit::still(1.5) + }; + let press = |caster, target, yards: f32| { + cast_range_refusal( + &hamstring, + Some(&melee), + caster, + Some(target), + Some(yards * yards), + &mods, + ) + }; + let standing = RangeUnit::still(1.5); + assert_eq!(press(running, running, 7.0), None); + assert_eq!(press(running, running, 7.5), None); + assert_eq!(press(running, running, 8.0), Some(ERR_OUT_OF_RANGE)); + assert_eq!(press(standing, standing, 7.0), Some(ERR_OUT_OF_RANGE)); + assert_eq!(press(running, standing, 7.0), Some(ERR_OUT_OF_RANGE)); + assert_eq!(press(standing, running, 7.0), Some(ERR_OUT_OF_RANGE)); + assert_eq!(press(standing, standing, 5.0), None); + } + /// The mounted refusal (`0x609c6c`) and its bit-24 exemption (`0x609c6f`). #[test] fn cast_mounted_refusal_honors_the_bit24_exemption() { diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index e5f49d2c..9ede17f4 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -77,7 +77,8 @@ pub(super) fn feed_action_state( mut cooldowns: ResMut, clock: Res, auto_repeat: Res, - // One tuple for Bevy's 16-param ceiling: cast tracking, macro bindings and spell mods. + // One tuple for Bevy's 16-param ceiling: cast tracking, macro bindings, spell mods and the + // range units. cast_state: ( Res, Res, @@ -85,6 +86,8 @@ pub(super) fn feed_action_state( Res, Res, Res, + // The caster's and the target's reach and motion, which the range compare reads. + crate::spell::RangeUnits, ), self_q: Query<(&ObjectStore, &Transform, Has, Option<&Casting>), With>, selection: Res, @@ -117,7 +120,7 @@ pub(super) fn feed_action_state( memory.last_cd_trace = Some(now); } - let (pending, queued_melee, channel, targeting, bound, spell_mods) = &cast_state; + let (pending, queued_melee, channel, targeting, bound, spell_mods, range_units) = &cast_state; let me = self_q.iter().next(); // The bags, walked once per frame for every reagent, totem and item count below. let carried = me @@ -129,14 +132,15 @@ pub(super) fn feed_action_state( .unwrap_or(0); let casting_spell = me.and_then(|(_, _, _, c)| c.map(|c| c.spell_id)); let current_cast = pending.current(now).or(casting_spell); - let self_reach = me.map_or(1.5, |(s, _, _, _)| s.0.unit_combat_reach()); + let caster = range_units.caster(); let self_pos = me.map(|(_, t, _, _)| t.translation); - // The target's reach and squared distance (the reference tests dx²+dy²+dz², `0x6e47b0`). - let target = selection - .guid - .and_then(|g| objects.entity(g)) - .and_then(|e| units.get(e).ok()); - let target_reach = target.map(|(s, _)| s.0.unit_combat_reach()); + // The target's reach, motion and squared distance (the reference tests dx²+dy²+dz², + // `0x6e47b0`). + let target_entity = selection.guid.and_then(|g| objects.entity(g)); + let target = target_entity.and_then(|e| units.get(e).ok()); + let target_unit = target_entity + .filter(|_| target.is_some()) + .and_then(|e| range_units.unit(e)); let dist_sq = match (self_pos, target) { (Some(a), Some((_, t))) => Some(a.distance_squared(t.translation)), _ => None, @@ -219,19 +223,20 @@ pub(super) fn feed_action_state( // and tests `|min|` and `|max|` against `FLT_EPSILON`. The auto-attack target the // melee arm falls back to cannot move that: its max is at least the 5.0 floor. st.has_range = spell_mods - .min_max_range(d, row, self_reach, RangeTargets::default()) + .min_max_range(d, row, caster, RangeTargets::default()) .is_some_and(|(min, max)| min.abs() > f32::EPSILON || max.abs() > f32::EPSILON); // `IsActionInRange 0x4e56f0` runs `CanTargetUnit 0x6e4440`, which binds the selected // target, then `IsTargetInRange 0x6e47b0`, which hands `GetMinMaxRange` that target - // (`0x6e47ca`): its reach pads a ranged row. Nil without a target. + // (`0x6e47ca`): its reach pads a ranged row, and its motion with the caster's earns + // the moving bonus. Nil without a target. st.in_range = match dist_sq { Some(d2) if st.has_range => { let targets = RangeTargets { - target: target_reach, + target: target_unit, attack_target: None, }; spell_mods - .min_max_range(d, row, self_reach, targets) + .min_max_range(d, row, caster, targets) .map(|(min, max)| d2 >= min * min && d2 <= max * max) } _ => None, @@ -606,6 +611,135 @@ mod tests { ); } + /// `IsActionInRange` hands `GetMinMaxRange` the selected unit, so a melee button reads in + /// range out to 7.667 yards while the player and the selected mob both run, and to the 5.0 + /// floor otherwise (`0x6e3648`-`0x6e36a2`). + #[test] + fn a_melee_button_reaches_the_moving_bonus_while_both_units_run() { + use benilla_protocol::messages::ActionButton; + use benilla_protocol::ObjectFields; + + const HAMSTRING: u32 = 1715; + const SELECTED: u64 = 0xB2; + + let verdict = |caster_runs: bool, mob_runs: bool, distance: f32| { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + let mut actions = PlayerActions::default(); + actions.buttons.insert( + 0, + ActionButton { + slot: 0, + action: HAMSTRING, + kind: ACTION_KIND_SPELL, + }, + ); + let melee = benilla_formats::SpellRange { + min: 0.0, + max: 5.0, + flags: 1, + }; + app.insert_resource(actions) + .insert_resource(crate::ui_macro::MacroBoundSpells::default()) + .insert_resource(Spells { + catalog: benilla_formats::SpellCatalog::from_displays( + [( + HAMSTRING, + SpellDisplay { + range_index: 2, + ..Default::default() + }, + )] + .into_iter() + .collect(), + ), + forms: Default::default(), + ranges: benilla_formats::SpellRangeCatalog::from_rows( + [(2, melee)].into_iter().collect(), + ), + cast_times: Default::default(), + durations: Default::default(), + radii: Default::default(), + }) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(crate::player::Player::with_move_flags(if caster_runs { + crate::creature_anim::move_flags::FORWARD + } else { + 0 + })) + .insert_resource(NetCommands(tx)); + let speeds = crate::net::UnitSpeeds(benilla_protocol::MoveSpeeds { + walk: 2.5, + run: 7.0, + run_back: 4.5, + swim: 4.7, + swim_back: 2.5, + turn_rate: 3.1, + }); + app.world_mut().spawn(( + SelfPlayer, + crate::net::Embodied, + speeds, + Transform::default(), + ObjectStore(ObjectFields::from_pairs(&[(22, 100), (23, 500)])), + )); + // A mob at the descriptor's default 1.5 reach; a live spline at 8 yd/s is its run. + let mob = app + .world_mut() + .spawn(( + speeds, + Transform::from_xyz(distance, 0.0, 0.0), + ObjectStore(ObjectFields::from_pairs(&[(22, 100)])), + )) + .id(); + if mob_runs { + app.world_mut().entity_mut(mob).insert(crate::net::Spline { + points: vec![[distance, 0.0, 0.0], [distance + 16.0, 0.0, 0.0]], + start: Instant::now(), + duration: std::time::Duration::from_secs(2), + id: 1, + grounded: true, + run_mode: true, + deck: None, + }); + } + app.world_mut() + .resource_mut::() + .0 + .insert(SELECTED, mob); + app.insert_resource(Selection { + target: Some(mob), + guid: Some(SELECTED), + last: None, + }); + app.insert_non_send_resource(UiScript::new().unwrap()); + app.add_systems(Update, feed_action_state); + app.update(); + let script = app.world().non_send_resource::(); + script + .eval::("return IsActionInRange(1) or -1") + .unwrap() + }; + + // 1.5 + 1.5 + 1.3333 is under the 5.0 floor; the bonus goes on the floor: 7.667. + assert_eq!(verdict(true, true, 7.5), 1, "both running: inside 7.667"); + assert_eq!(verdict(true, true, 8.0), 0, "past it"); + assert_eq!(verdict(false, true, 7.5), 0, "the player stands"); + assert_eq!(verdict(true, false, 7.5), 0, "the mob stands"); + assert_eq!(verdict(false, false, 4.9), 1, "the floor"); + } + /// An item slot's on-use spell takes the `0x6e3d60` walk (`0x4e5050`), and every shipped /// Food/Drink spell has `Attributes` bit 28, `ATTR_NOT_IN_COMBAT` (the walk's leg 8). #[test] diff --git a/crates/benilla-app/src/ui_tooltip/deps_tests.rs b/crates/benilla-app/src/ui_tooltip/deps_tests.rs index 803f4790..d7a76293 100644 --- a/crates/benilla-app/src/ui_tooltip/deps_tests.rs +++ b/crates/benilla-app/src/ui_tooltip/deps_tests.rs @@ -12,7 +12,7 @@ use std::collections::BTreeSet; use benilla_protocol::ObjectFields; use benilla_ui::script::SpellTooltipView; -use super::spell_deps::{Changes, Deps, Reagents, Seen, UnitField}; +use super::spell_deps::{Changes, Deps, RangeSeen, Reagents, Seen, UnitField}; use super::spell_feed::{build_view, reagent_state, PetInputs, ViewCaster, ViewCtx}; use super::tests::{real_spells, TestCtx}; use crate::items::Items; @@ -23,17 +23,23 @@ use crate::spell::usable::{ use crate::spell::{ModsDiff, SpellModifiers}; use crate::ui_action::Spells; use crate::ui_items::TestObjects; +use benilla_formats::{RangeUnit, UnitMotion}; /// One side of each input group, so that moving an input is an xor: `false` is the empty side (no -/// bind point, form 0, no percentages, default reach, nothing worn, no skills, zero cells), `true` -/// the populated one. +/// bind point, form 0, no percentages, default reach, no auto-attack target, both units standing, +/// nothing worn, no skills, zero cells), `true` the populated one. #[derive(Clone, Copy, Default, PartialEq, Eq, Hash, Debug)] struct Inputs { home: bool, form: bool, /// A `Chance` bit each: block, dodge, parry, crit. avoid: u8, + /// The caster's reach and, with a target engaged, its reach. reach: bool, + /// An auto-attack target: the melee range cell's second unit. + engaged: bool, + /// The caster and the auto-attack target run: with a target engaged, the moving bonus. + pace: bool, /// The equipment slots holding an item that meets the view's requirement. worn: u32, /// The disarm flag. @@ -58,6 +64,8 @@ impl Inputs { form: true, avoid: 0xf, reach: true, + engaged: true, + pace: true, worn: EQUIPMENT_MASK, disarm: true, unit: 0x1ff, @@ -72,6 +80,8 @@ impl Inputs { form: self.form ^ o.form, avoid: self.avoid ^ o.avoid, reach: self.reach ^ o.reach, + engaged: self.engaged ^ o.engaged, + pace: self.pace ^ o.pace, worn: self.worn ^ o.worn, disarm: self.disarm ^ o.disarm, unit: self.unit ^ o.unit, @@ -121,6 +131,24 @@ impl Inputs { }, )); } + if self.engaged { + parts.push(( + "the auto-attack target".to_string(), + Inputs { + engaged: true, + ..alone + }, + )); + } + if self.pace { + parts.push(( + "the units' run".to_string(), + Inputs { + pace: true, + ..alone + }, + )); + } for slot in (0..EQUIPMENT_SLOTS).filter(|s| self.worn >> s & 1 == 1) { let only = Inputs { worn: 1 << slot, @@ -182,7 +210,9 @@ impl Inputs { home: !deps.home, form: !deps.form, avoid: !deps.avoidance & 0xf, - reach: !deps.reach, + reach: !deps.range_units, + engaged: !deps.range_units, + pace: !deps.range_units, worn: EQUIPMENT_MASK & !deps.worn, disarm: !deps.disarm, unit: !deps.unit & 0x1ff, @@ -264,7 +294,10 @@ struct Mat { store: ObjectStore, mods: SpellModifiers, home: Option<&'static str>, - target_reach: Option, + /// The caster as the range cell reads it: the store's reach, and running or not. + caster: RangeUnit, + /// The auto-attack target, when one is engaged. + attack_target: Option, form: u8, } @@ -323,6 +356,15 @@ fn mat(layout: &Layout, inputs: Inputs, p: Params) -> Mat { if inputs.reach { pairs.push((130, 4.0f32.to_bits())); } + let motion = if inputs.pace { + UnitMotion { + flags: 1, + speed: 7.0, + walk_speed: 2.5, + } + } else { + UnitMotion::default() + }; let mut slot = 0; for (i, &line) in layout.lines.iter().enumerate() { if inputs.lines >> i & 1 == 1 { @@ -352,15 +394,29 @@ fn mat(layout: &Layout, inputs: Inputs, p: Params) -> Mat { store: ObjectStore(ObjectFields::from_pairs(&pairs)), mods, home: inputs.home.then_some("Zzz"), - // The two reaches move together: the caster's 1.5 to 4.0, and an auto-attack target at 3.0. - target_reach: inputs.reach.then_some(3.0), + // The two reaches move together: the caster's 1.5 to 4.0, and an auto-attack target's 4.0 + // to 6.0, which needs one engaged to read. The target's start clears the melee row's 5.0 + // floor beside the caster's 1.5, so its presence moves the range cell. + caster: RangeUnit { + reach: if inputs.reach { 4.0 } else { 1.5 }, + motion, + ..RangeUnit::still(1.5) + }, + attack_target: inputs.engaged.then_some(RangeUnit { + reach: if inputs.reach { 6.0 } else { 4.0 }, + motion, + ..RangeUnit::still(1.5) + }), form, } } /// What differs between two scenarios, through the feed's own diffs. fn changes(before: &Mat, now: &Mat, objects: &Objects, items: &Items) -> Changes { - let seen = |m: &Mat| Seen::of(Some(&m.store), m.home, m.target_reach, objects, items); + let seen = |m: &Mat| { + let range = RangeSeen::of(&m.caster, m.attack_target.as_ref()); + Seen::of(Some(&m.store), m.home, range, objects, items) + }; seen(now) .changes_since(&seen(before)) .with_mods(now.mods.diff(&before.mods)) @@ -466,7 +522,8 @@ fn build( t.spell_mods = m.mods.clone(); let mut ctx = t.ctx_for(objects, m.form, None, Some(&m.store)); ctx.home_area = m.home; - ctx.attack_target_reach = m.target_reach; + ctx.range_caster = m.caster; + ctx.attack_target = m.attack_target; build_view(id, spells, &mut ctx) } @@ -664,6 +721,26 @@ fn every_view_that_moves_with_an_input_is_requeued_by_that_input() { assert_covered("the combat reaches", &s); assert!(s.moved >= 100, "only {} views read a reach", s.moved); row("combat reach", &s); + // The melee range cell's second unit, engaged; and, with it engaged, both units running. + let engaged = Inputs { + engaged: true, + ..empty + }; + let s = sweep(&mut rig, empty, &any, &own, &cause(engaged)); + assert_covered("the auto-attack target", &s); + assert!(s.moved >= 100, "only {} views read the target", s.moved); + row("auto-attack target", &s); + let pace = Inputs { + pace: true, + ..empty + }; + let s = sweep(&mut rig, engaged, &any, &own, &cause(pace)); + assert_covered("the units' run", &s); + assert!(s.moved >= 100, "only {} views read the run", s.moved); + row("both units running", &s); + // With no target engaged the melee arm has no unit for the bonus: nothing moves. + let s = sweep(&mut rig, empty, &any, &own, &cause(pace)); + assert_eq!(s.moved, 0, "the moving bonus needs a unit to read"); let all_worn = Inputs { worn: EQUIPMENT_MASK, ..empty @@ -813,7 +890,8 @@ fn a_reagent_moving_requeues_the_views_that_list_it() { store, mods: mods.clone(), home: None, - target_reach: None, + caster: RangeUnit::still(1.5), + attack_target: None, form: 0, }; @@ -917,7 +995,7 @@ fn a_pet_view_reads_no_player_form_equipment_reach_or_unit_field_but_the_ranged_ }; assert!( !deps.form - && !deps.reach + && !deps.range_units && deps.worn == 0 && !deps.disarm && deps.unit & !UnitField::RangedTime.bit() == 0, @@ -980,9 +1058,14 @@ fn no_pet_field_moves_a_pet_view_unwatched() { Some(&player.store), ); ctx.caster = ViewCaster::Pet(Some(pet)); - ctx.combat_reach = pet.unit_combat_reach(); + ctx.range_caster = RangeUnit::still(pet.unit_combat_reach()); build_view(id, spells, &mut ctx).map(|(view, _)| view) }; + // The feed reads the pet's reach off its entity's store, which these fields stand in for. + let pet_inputs = |pet: &ObjectFields| { + let unit = RangeUnit::still(pet.unit_combat_reach()); + PetInputs::of(None, Some(pet), RangeSeen::of(&unit, None)) + }; let base = pet_with(0..0); let before: Vec<_> = ids.iter().map(|&id| build_one(t, id, &base)).collect(); let (mut moved, mut misses) = (0, Vec::new()); @@ -993,7 +1076,7 @@ fn no_pet_field_moves_a_pet_view_unwatched() { continue; } moved += 1; - if PetInputs::of(None, Some(&pet), None) != PetInputs::of(None, Some(&base), None) { + if pet_inputs(&pet) != pet_inputs(&base) { continue; } let culprits: Vec = (first..first + 48) @@ -1184,8 +1267,8 @@ fn the_search_answers_the_same_wherever_the_watched_state_is_the_same() { form: 0, store: Some(&store), caster: ViewCaster::Player, - combat_reach: 1.5, - attack_target_reach: None, + range_caster: RangeUnit::still(1.5), + attack_target: None, objects: &objects, items: &mut items, commands: &commands, @@ -1261,13 +1344,30 @@ fn the_seen_diff_names_each_input_it_saw_move() { let objects = objs.get(); let items = Items::default(); let player = |pairs: &[(u16, u32)]| ObjectStore(ObjectFields::from_pairs(pairs)); - let seen = |s: &ObjectStore| Seen::of(Some(s), None, None, &objects, &items); + // The feed reads the caster's reach off its store, and passes the units it read. + let seen_with = |s: &ObjectStore, caster_motion: UnitMotion, attack: Option<&RangeUnit>| { + let caster = RangeUnit { + motion: caster_motion, + ..RangeUnit::still(s.0.unit_combat_reach()) + }; + Seen::of( + Some(s), + None, + RangeSeen::of(&caster, attack), + &objects, + &items, + ) + }; + let seen = |s: &ObjectStore| seen_with(s, UnitMotion::default(), None); let base = player(&[(22, 100)]); for (i, field) in [1106u16, 1107, 1108, 1109].into_iter().enumerate() { let now = player(&[(22, 100), (field, 4.5f32.to_bits())]); let c = seen(&now).changes_since(&seen(&base)); assert_eq!(c.avoidance, 1 << i, "percentage field {field}"); - assert_eq!((c.worn, c.form, c.reach, c.home), (0, false, false, false)); + assert_eq!( + (c.worn, c.form, c.range_units, c.home), + (0, false, false, false) + ); assert_eq!((c.unit, c.disarm), (0, false)); } // A guid the object index does not hold is an unresolved item: the slot moved. @@ -1282,12 +1382,59 @@ fn the_seen_diff_names_each_input_it_saw_move() { let now = player(&[(22, 100), (138, 1 << 16)]); assert!(seen(&now).changes_since(&seen(&base)).form); let now = player(&[(22, 100), (130, 3.0f32.to_bits())]); - assert!(seen(&now).changes_since(&seen(&base)).reach); - let engaged = Seen::of(Some(&base), None, Some(2.0), &objects, &items); - assert!(engaged.changes_since(&seen(&base)).reach); - let bound = Seen::of(Some(&base), Some("Ironforge"), None, &objects, &items); + assert!(seen(&now).changes_since(&seen(&base)).range_units); + // The auto-attack target: engaged, its reach, then each unit starting to run on its own. + let target = RangeUnit::still(2.0); + let engaged = seen_with(&base, UnitMotion::default(), Some(&target)); + assert!(engaged.changes_since(&seen(&base)).range_units); + let target_reach = RangeUnit::still(3.0); + let wider = seen_with(&base, UnitMotion::default(), Some(&target_reach)); + assert!(wider.changes_since(&engaged).range_units); + let running = UnitMotion { + flags: 1, + speed: 7.0, + walk_speed: 2.5, + }; + let target_runs = RangeUnit { + motion: running, + ..target + }; + // The moving bonus needs both units running with a target engaged: a run of either one alone + // moves nothing a melee row shows. + let caster_runs = seen_with(&base, running, Some(&target)); + assert!(caster_runs.changes_since(&engaged).is_empty()); + let target_run = seen_with(&base, UnitMotion::default(), Some(&target_runs)); + assert!(target_run.changes_since(&engaged).is_empty()); + let both_run = seen_with(&base, running, Some(&target_runs)); + assert!( + both_run.changes_since(&caster_runs).range_units, + "the target starts running beside a running caster" + ); + assert!( + both_run.changes_since(&target_run).range_units, + "the caster starts running beside a running target" + ); + // With no target engaged there is no unit for the bonus. + let alone = seen_with(&base, running, None); + assert!(alone.changes_since(&seen(&base)).is_empty()); + // A walk is not a run: the answer a melee row follows does not move. + let walking = UnitMotion { + flags: 1, + speed: 2.5, + walk_speed: 2.5, + }; + let caster_walk = seen_with(&base, walking, Some(&target_runs)); + assert!(caster_walk.changes_since(&target_run).is_empty()); + assert!(both_run.changes_since(&caster_walk).range_units); + let bound = Seen::of( + Some(&base), + Some("Ironforge"), + RangeSeen::default(), + &objects, + &items, + ); assert!(bound.changes_since(&seen(&base)).home); - let gone = Seen::of(None, None, None, &objects, &items); + let gone = Seen::of(None, None, RangeSeen::default(), &objects, &items); assert_eq!(gone.changes_since(&seen(&base)).worn, EQUIPMENT_MASK); assert!(seen(&base).changes_since(&seen(&base)).is_empty()); // The disarm flag, and no other flag. diff --git a/crates/benilla-app/src/ui_tooltip/feed_tests.rs b/crates/benilla-app/src/ui_tooltip/feed_tests.rs index 9737d1d8..113a42b6 100644 --- a/crates/benilla-app/src/ui_tooltip/feed_tests.rs +++ b/crates/benilla-app/src/ui_tooltip/feed_tests.rs @@ -9,6 +9,38 @@ use crate::net::{NetCommands, ObjectStore, SelfPlayer}; use crate::ui_action::{PlayerActions, Spells}; use bevy::prelude::*; +/// `SpellRange.dbc` rows 2, the melee row, and 4, an authored 0 to 35. +fn melee_and_authored_rows() -> benilla_formats::SpellRangeCatalog { + let row = |min, max, flags| benilla_formats::SpellRange { min, max, flags }; + benilla_formats::SpellRangeCatalog::from_rows( + [(2, row(0.0, 5.0, 1)), (4, row(0.0, 35.0, 0))].into(), + ) +} + +/// A creature on a live spline at `speed` yd/s over two seconds: a mover at that speed. +fn on_spline(speed: f32) -> crate::net::Spline { + crate::net::Spline { + points: vec![[0.0; 3], [speed * 2.0, 0.0, 0.0]], + start: std::time::Instant::now(), + duration: std::time::Duration::from_secs(2), + id: 1, + grounded: true, + run_mode: true, + deck: None, + } +} + +fn walking_and_running_speeds() -> crate::net::UnitSpeeds { + crate::net::UnitSpeeds(benilla_protocol::MoveSpeeds { + walk: 2.5, + run: 7.0, + run_back: 4.5, + swim: 4.7, + swim_back: 2.5, + turn_rate: 3.1, + }) +} + /// A feed over a bare app: the book holds `make(1)`'s spells, the player is `fields`, and each /// description names the generation of the catalog it was built from. The feed re-pushes a view /// only when it must, so which generation a tooltip shows says which views were rebuilt. @@ -82,6 +114,7 @@ impl FeedApp { let catalog = (self.make)(generation).into_iter().collect(); self.app.insert_resource(Spells { catalog: benilla_formats::SpellCatalog::from_displays(catalog), + ranges: melee_and_authored_rows(), ..Spells::empty_for_tests() }); } @@ -237,3 +270,180 @@ fn a_modifier_change_rebuilds_only_the_matching_familys_views() { "the mage spells with bit 40" ); } + +/// The melee range cell reads the two units' motion (`0x6e3648`-`0x6e36a2`): the caster and its +/// auto-attack target both running at run speed, or ceasing to, rebuilds the views on the melee +/// row and no other, and a change that leaves the bonus where it was rebuilds nothing. +#[test] +fn a_units_run_rebuilds_only_the_melee_range_views() { + use crate::creature_anim::{move_flags, Engaged}; + use crate::net::Embodied; + + const MOB: u64 = 0xF130_0000_0000_0009; + let spell = |range_index: u32, generation: u32| benilla_formats::SpellDisplay { + name: "Spell".into(), + range_index, + description: Some(format!("gen{generation}")), + ..Default::default() + }; + let spells = move |g: u32| { + vec![ + (30, spell(2, g)), // the melee row + (31, spell(4, g)), // an authored row, which reads no unit + (32, spell(0, g)), // no row at all + ] + }; + let mut feed = FeedApp::new(spells, &[(22, 100)]); + let speeds = walking_and_running_speeds(); + let mob = feed + .app + .world_mut() + .spawn((speeds, ObjectStore(ObjectFields::from_pairs(&[(22, 100)])))) + .id(); + feed.app + .world_mut() + .resource_mut::() + .0 + .insert(MOB, mob); + let player = feed.player; + feed.app + .world_mut() + .entity_mut(player) + .insert((Embodied, speeds, Engaged(MOB))); + let flags = |feed: &mut FeedApp, flags: u32| { + feed.app + .insert_resource(crate::player::Player::with_move_flags(flags)); + feed.app.update(); + }; + flags(&mut feed, 0); + assert_eq!(feed.generations(), [1, 1, 1]); + + // The bonus needs both units running: the caster alone changes nothing a view shows. + feed.catalog(2); + flags(&mut feed, move_flags::FORWARD); + assert_eq!(feed.generations(), [1, 1, 1], "the caster starts to run"); + + feed.catalog(3); + feed.app.world_mut().entity_mut(mob).insert(on_spline(8.0)); + feed.app.update(); + assert_eq!( + feed.generations(), + [3, 1, 1], + "its target starts to run: the moving bonus" + ); + + // Still running, by another flag: each unit's answer is where it was. + feed.catalog(4); + flags(&mut feed, move_flags::FORWARD | move_flags::STRAFE_LEFT); + assert_eq!(feed.generations(), [3, 1, 1], "nothing was rebuilt"); + + // A walk is no run. + feed.catalog(5); + flags(&mut feed, move_flags::FORWARD | move_flags::WALK_MODE); + assert_eq!(feed.generations(), [5, 1, 1], "the caster walks"); +} + +/// The pet's melee views read the pet's and its own auto-attack target's motion: a pet chasing a +/// running mob reads the moving bonus in its range cell, and stops when either halts. +#[test] +fn a_pet_view_reads_the_pets_and_its_targets_run() { + use crate::creature_anim::Engaged; + use benilla_ui::script::{PetBookState, SpellSlotView}; + + const ME: u64 = 0x10; + const IMP: u64 = 0xF140_0000_0000_0077; + const MOB: u64 = 0xF130_0000_0000_0009; + let claw = benilla_formats::SpellDisplay { + id: 1082, + name: "Claw".into(), + range_index: 2, + ..Default::default() + }; + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.insert_resource(Spells { + catalog: benilla_formats::SpellCatalog::from_displays([(1082, claw)].into()), + ranges: melee_and_authored_rows(), + ..Spells::empty_for_tests() + }) + .insert_resource(NetCommands(tx)) + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, feed_spell_tooltips); + let speeds = walking_and_running_speeds(); + let store = |pairs: &[(u16, u32)]| ObjectStore(ObjectFields::from_pairs(pairs)); + let me = app + .world_mut() + .spawn(( + SelfPlayer, + store(&[(8, IMP as u32), (9, (IMP >> 32) as u32), (34, 60)]), + )) + .id(); + let imp = app + .world_mut() + .spawn((speeds, Engaged(MOB), store(&[(34, 14)]))) + .id(); + let mob = app.world_mut().spawn((speeds, store(&[(22, 100)]))).id(); + let mut index = app.world_mut().resource_mut::(); + index.0.insert(ME, me); + index.0.insert(IMP, imp); + index.0.insert(MOB, mob); + + let mut script = UiScript::new().unwrap(); + script.set_pet_book(PetBookState { + token: Some("CAT".into()), + slots: vec![SpellSlotView { + spell_id: 1082, + name: "Claw".into(), + ..Default::default() + }], + }); + script + .run( + r#" + SPELL_RANGE = "%s yd range" + local a = CreateFrame("Button", "B"); a:SetPoint("CENTER", 0, 0) + a:SetWidth(10); a:SetHeight(10) + CreateFrame("GameTooltip", "TT") + function RANGE() + TT:SetOwner(B, "ANCHOR_RIGHT"); TT:SetSpell(1, "pet") + for k = 1, TT:NumLines() do + for _, side in ipairs({"TTTextLeft", "TTTextRight"}) do + local t = getglobal(side .. k):GetText() + if t and string.find(t, "yd range") then return t end + end + end + end + "#, + ) + .unwrap(); + app.insert_non_send_resource(script); + let range = |app: &mut App| { + app.update(); + let script = app.world().non_send_resource::(); + script.eval::>("return RANGE()").unwrap() + }; + let _ = range(&mut app); + assert_eq!(range(&mut app).as_deref(), Some("5 yd range"), "both stand"); + app.world_mut().entity_mut(imp).insert(on_spline(8.0)); + assert_eq!( + range(&mut app).as_deref(), + Some("5 yd range"), + "the pet runs, its target stands" + ); + app.world_mut().entity_mut(mob).insert(on_spline(8.0)); + assert_eq!( + range(&mut app).as_deref(), + Some("8 yd range"), + "both run: 5.0 + 2.6667" + ); + app.world_mut() + .entity_mut(mob) + .remove::(); + assert_eq!( + range(&mut app).as_deref(), + Some("5 yd range"), + "the target halts" + ); +} diff --git a/crates/benilla-app/src/ui_tooltip/spell_deps.rs b/crates/benilla-app/src/ui_tooltip/spell_deps.rs index 9dba4386..13242dd2 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_deps.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_deps.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; +use benilla_formats::RangeUnit; use benilla_protocol::ObjectFields; use crate::items::Items; @@ -124,9 +125,9 @@ pub(super) struct Deps { pub(super) home: bool, /// The required-form test read the shapeshift form. pub(super) form: bool, - /// The range cell's melee arm read the caster's or the auto-attack target's combat reach; - /// the ranged arm, given no target, reads neither. - pub(super) reach: bool, + /// The range cell's melee arm read the caster's and the auto-attack target's combat reach and + /// motion; the ranged arm, given no target, reads neither. + pub(super) range_units: bool, /// The chance line read these percentages, a [`Chance`] bit each. pub(super) avoidance: u8, /// The equipped-item search read these equipment slots, a bit each. @@ -183,7 +184,7 @@ impl Deps { changes.everything || (changes.home && self.home) || (changes.form && self.form) - || (changes.reach && self.reach) + || (changes.range_units && self.range_units) || changes.avoidance & self.avoidance != 0 || changes.worn & self.worn != 0 || (changes.disarm && self.disarm) @@ -207,7 +208,7 @@ pub(super) struct Changes { pub(super) everything: bool, pub(super) home: bool, pub(super) form: bool, - pub(super) reach: bool, + pub(super) range_units: bool, /// A [`Chance`] bit each. pub(super) avoidance: u8, /// The equipment slots whose item moved in a way the search can tell, a bit each. @@ -226,7 +227,7 @@ impl Changes { !self.everything && !self.home && !self.form - && !self.reach + && !self.range_units && self.avoidance == 0 && self.worn == 0 && !self.disarm @@ -259,6 +260,29 @@ impl Changes { /// inline red, `0x854120`). pub(super) type Reagents = BTreeMap; +/// What the range cell's melee arm reads of two units: the caster's combat reach and its +/// auto-attack target's, as bit patterns (`None` with none engaged, where the arm reads the +/// caster's reach twice), and whether the moving bonus applies, the one fact of the units' motion +/// the cell's number follows: both move at run speed +/// ([`benilla_formats::UnitMotion::moves_at_run_speed`]) with a target engaged to read. +#[derive(Clone, Copy, Default, PartialEq)] +pub(super) struct RangeSeen { + caster_reach: u32, + attack_target_reach: Option, + bonus: bool, +} + +impl RangeSeen { + pub(super) fn of(caster: &RangeUnit, attack_target: Option<&RangeUnit>) -> Self { + Self { + caster_reach: caster.reach.to_bits(), + attack_target_reach: attack_target.map(|u| u.reach.to_bits()), + bonus: caster.motion.moves_at_run_speed() + && attack_target.is_some_and(|u| u.motion.moves_at_run_speed()), + } + } +} + /// The player-side inputs one frame saw, which the pushed views were built against. #[derive(Clone, PartialEq)] pub(super) struct Seen { @@ -275,8 +299,8 @@ pub(super) struct Seen { disarmed: bool, /// The [`Chance`] percentages as bit patterns. avoidance: [Option; 4], - /// The caster's and its auto-attack target's combat reach as bit patterns. - reach: (Option, Option), + /// The caster's and its auto-attack target's reach, and whether the moving bonus applies. + range_units: RangeSeen, /// The unit fields the cost, cast and cooldown cells read. unit: UnitFields, } @@ -285,7 +309,7 @@ impl Seen { pub(super) fn of( player: Option<&ObjectStore>, home: Option<&str>, - attack_target_reach: Option, + range_units: RangeSeen, objects: &Objects, items: &Items, ) -> Self { @@ -298,10 +322,7 @@ impl Seen { disarmed: player.is_some_and(crate::items::is_disarmed), avoidance: Chance::ALL .map(|which| player.and_then(|s| which.read(&s.0)).map(f32::to_bits)), - reach: ( - player.map(|s| s.0.unit_combat_reach().to_bits()), - attack_target_reach.map(f32::to_bits), - ), + range_units, unit: UnitFields::of(player), } } @@ -311,7 +332,7 @@ impl Seen { let mut changes = Changes { home: self.home != prev.home, form: self.form != prev.form, - reach: self.reach != prev.reach, + range_units: self.range_units != prev.range_units, disarm: self.disarmed != prev.disarmed, unit: self.unit.diff(&prev.unit), ..Changes::default() diff --git a/crates/benilla-app/src/ui_tooltip/spell_feed.rs b/crates/benilla-app/src/ui_tooltip/spell_feed.rs index dd0701ce..ec122449 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_feed.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_feed.rs @@ -12,7 +12,7 @@ use benilla_ui::script::UiScript; use benilla_ui::strings::Arg; use super::keyed; -use super::spell_deps::{Chance, Changes, Deps, Reagents, Seen, UnitField}; +use super::spell_deps::{Chance, Changes, Deps, RangeSeen, Reagents, Seen, UnitField}; use crate::items::Items; use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer}; use crate::spell::usable::{self, CostBasis}; @@ -53,11 +53,13 @@ pub(super) struct ViewCtx<'a, 'w, 's> { /// Whom the level terms and the cost read: the player or its pet. The form and the reaches /// are that same unit's. pub(super) caster: ViewCaster<'a>, - /// The caster's `UNIT_FIELD_COMBATREACH`; 1.5 is the descriptor default. - pub(super) combat_reach: f32, - /// The caster's auto-attack target's reach, which `0x6e3480` looks up itself - /// (`[caster+0xc48]`) for its melee arm alone; with none, the caster's reach counts twice. - pub(super) attack_target_reach: Option, + /// The caster as `0x6e3480` reads it: its `UNIT_FIELD_COMBATREACH` (1.5 is the descriptor + /// default) and its motion. + pub(super) range_caster: benilla_formats::RangeUnit, + /// The caster's auto-attack target, which `0x6e3480` looks up itself (`[caster+0xc48]`) for + /// its melee arm alone, reading its reach and motion; with none, the caster's reach counts + /// twice and there is no moving bonus. + pub(super) attack_target: Option, /// The object index the worn-item search and each reagent's carried count resolve through. pub(super) objects: &'a Objects<'w, 's>, pub(super) items: &'a mut Items, @@ -186,19 +188,19 @@ pub(super) fn build_view( let row = spells.ranges.get(d.range_index); // The pet views read the pet's reaches, which are among the pet's own inputs. if matches!(caster, ViewCaster::Player) - && benilla_formats::min_max_range_reads_reach(d, row) + && benilla_formats::min_max_range_reads_units(d, row) { - deps.borrow_mut().reach = true; + deps.borrow_mut().range_units = true; } // The call passes a null target (`0x52e9c2`): the auto-attack target is the melee arm's // own lookup, and the ranged arm, with no target, pads nothing. let targets = benilla_formats::RangeTargets { target: None, - attack_target: vctx.attack_target_reach, + attack_target: vctx.attack_target, }; let (min, max) = vctx .spell_mods - .min_max_range(d, row, vctx.combat_reach, targets)?; + .min_max_range(d, row, vctx.range_caster, targets)?; if max <= 0.0 { return None; } @@ -477,7 +479,7 @@ pub(super) fn reagent_state( } /// The pet unit's fields a pet view reads (`0x52e610` with the unit selector set): its level, the -/// cost bases `0x612c50` reads, its form, and its and its melee target's reach, as bit patterns. +/// cost bases `0x612c50` reads, its form, and its and its melee target's reach and motion. #[derive(Clone, Copy, PartialEq, Default)] pub(super) struct PetInputs { guid: Option, @@ -486,15 +488,14 @@ pub(super) struct PetInputs { max_health: u32, max_power: [u32; 5], form: u8, - combat_reach: u32, - attack_target_reach: Option, + range_units: RangeSeen, } impl PetInputs { pub(super) fn of( guid: Option, pet: Option<&benilla_protocol::ObjectFields>, - attack_target_reach: Option, + range_units: RangeSeen, ) -> Self { let Some(p) = pet else { return Self { @@ -509,8 +510,7 @@ impl PetInputs { max_health: p.unit_max_health().unwrap_or(0), max_power: std::array::from_fn(|i| p.unit_max_power(i as u8).unwrap_or(0)), form: p.unit_shapeshift_form(), - combat_reach: p.unit_combat_reach().to_bits(), - attack_target_reach: attack_target_reach.map(f32::to_bits), + range_units, } } } @@ -532,9 +532,9 @@ pub(super) fn feed_spell_tooltips( actions: Option>, spell_sources: SpellTooltipSources, self_q: Query<&ObjectStore, With>, - // The player's and the pet's auto-attack target, the melee range cell's second reach. - engaged_q: Query<&crate::creature_anim::Engaged, With>, - engaged_units: Query<&crate::creature_anim::Engaged>, + // The caster's and the pet's reach and motion and each one's auto-attack target, which the + // melee range cell reads. + range_units: crate::spell::RangeUnits, objects: Objects, home_bind: Option>, area_names: Option>, @@ -625,17 +625,16 @@ pub(super) fn feed_spell_tooltips( .and_then(|id| area_names.as_deref()?.0.resolve(id as i32)) .map(str::to_string); let self_store = self_q.single().ok(); - let target_reach = - |e: &crate::creature_anim::Engaged| objects.object(e.0).map(|f| f.unit_combat_reach()); - let attack_target_reach = engaged_q.single().ok().and_then(target_reach); + let caster_unit = range_units.caster(); + let attack_target = range_units.caster_attack_target(); // What moved since the last frame, which each pushed view is checked against: the bind // point (Astral Recall's `$z`), the form, the skills, what the equipped-item search learns of // each equipment slot and the disarm flag, the level, cost bases and ranged attack time, the - // block, dodge, parry and crit percentages, and the two combat reaches. + // block, dodge, parry and crit percentages, and the two units the melee range cell reads. let seen = Seen::of( self_store, home_area.as_deref(), - attack_target_reach, + RangeSeen::of(&caster_unit, attack_target.as_ref()), &objects, &items, ); @@ -667,15 +666,19 @@ pub(super) fn feed_spell_tooltips( changes = changes.with_reagents(&memory.reagents, &reagent_state); memory.reagents = reagent_state; memory.requeue(&changes, &mut wanted, &mut wanted_pet); - // The pet's inputs: a new pet, or its level, bases, form or reach moving, rebuilds the pet - // views alone. + // The pet's inputs: a new pet, or its level, bases, form, reach or motion moving, rebuilds the + // pet views alone. let pet_guid = self_store.and_then(|s| s.0.unit_pet_guid()); let pet = pet_guid.and_then(|g| objects.object(g)); - let pet_target_reach = pet_guid - .and_then(|g| objects.entity(g)) - .and_then(|e| engaged_units.get(e).ok()) - .and_then(target_reach); - let pet_inputs = PetInputs::of(pet_guid, pet, pet_target_reach); + let pet_entity = pet_guid.and_then(|g| objects.entity(g)); + let pet_unit = + pet_entity + .and_then(|e| range_units.unit(e)) + .unwrap_or(benilla_formats::RangeUnit::still( + crate::spell::DEFAULT_REACH, + )); + let pet_target = pet_entity.and_then(|e| range_units.attack_target(e)); + let pet_inputs = PetInputs::of(pet_guid, pet, RangeSeen::of(&pet_unit, pet_target.as_ref())); if memory.pet != Some(pet_inputs) { memory.pet = Some(pet_inputs); wanted_pet.extend(memory.pet_pushed.drain().map(|(id, _)| id)); @@ -693,8 +696,8 @@ pub(super) fn feed_spell_tooltips( form: self_store.map_or(0, |s| s.0.unit_shapeshift_form()), store: self_store, caster: ViewCaster::Player, - combat_reach: self_store.map_or(1.5, |s| s.0.unit_combat_reach()), - attack_target_reach, + range_caster: caster_unit, + attack_target, objects: &objects, items: &mut items, commands: &commands, @@ -707,8 +710,8 @@ pub(super) fn feed_spell_tooltips( // The pet views read the pet's form and reach where the player's read the player's. vctx.caster = ViewCaster::Pet(pet); vctx.form = pet.map_or(0, |p| p.unit_shapeshift_form()); - vctx.combat_reach = pet.map_or(1.5, |p| p.unit_combat_reach()); - vctx.attack_target_reach = pet_target_reach; + vctx.range_caster = pet_unit; + vctx.attack_target = pet_target; let built_pet = build_views(wanted_pet, spells, &mut vctx, memory, true); (built, built_pet) }; diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index bb2749cd..28d76aa6 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -62,7 +62,7 @@ impl TestCtx { target_reach: f32, ) -> ViewCtx<'a, 'w, 's> { let mut ctx = self.ctx_for(objects, 0, None, store); - ctx.attack_target_reach = Some(target_reach); + ctx.attack_target = Some(benilla_formats::RangeUnit::still(target_reach)); ctx } @@ -78,8 +78,10 @@ impl TestCtx { form, store, caster: ViewCaster::Player, - combat_reach: store.map_or(1.5, |s| s.0.unit_combat_reach()), - attack_target_reach: None, + range_caster: benilla_formats::RangeUnit::still( + store.map_or(1.5, |s| s.0.unit_combat_reach()), + ), + attack_target: None, objects, items: &mut self.items, commands: &self.commands, @@ -612,6 +614,33 @@ fn range_cell_on_real_data() { .expect("Sinister Strike view"); assert_eq!(v.range.as_deref(), Some("7 yd range")); + // Both units running: the moving bonus goes on the floor, 5.0 + 2.6667 = 7.667, rounded to 8. + let running = benilla_formats::UnitMotion { + flags: 1, + speed: 7.0, + walk_speed: 2.5, + }; + let runner = benilla_formats::RangeUnit { + motion: running, + ..benilla_formats::RangeUnit::still(1.5) + }; + let mut ctx = t.ctx_for(&objects, 0, None, Some(&store)); + ctx.range_caster = runner; + ctx.attack_target = Some(runner); + let v = spell_tooltip_view(1752, &spells, &mut ctx).expect("Sinister Strike view"); + assert_eq!(v.range.as_deref(), Some("8 yd range")); + // One of them standing, or no auto-attack target to read: the floor. + ctx.attack_target = Some(benilla_formats::RangeUnit::still(1.5)); + let v = spell_tooltip_view(1752, &spells, &mut ctx).expect("Sinister Strike view"); + assert_eq!(v.range.as_deref(), Some("5 yd range")); + ctx.attack_target = None; + let v = spell_tooltip_view(1752, &spells, &mut ctx).expect("Sinister Strike view"); + assert_eq!(v.range.as_deref(), Some("5 yd range")); + // The ranged arm passes no target, so the bonus never reaches an authored row: Fireball's 35. + ctx.attack_target = Some(runner); + let v = spell_tooltip_view(133, &spells, &mut ctx).expect("Fireball view"); + assert_eq!(v.range.as_deref(), Some("35 yd range")); + // An authored row: Fireball's 0-35. let v = spell_tooltip_view( 133, diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index f10634c1..87e044a1 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -197,16 +197,16 @@ mod spells; pub use spells::{ cc_exemption, grants_immunity, load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_dispel_types, load_spell_durations, load_spell_radii, - load_spell_ranges, min_max_range, min_max_range_reads_reach, soft_modify, substitute, - CcExemption, FormRefusal, LearnAnnouncement, LearnEffect, OpenLock, RangeTargets, + load_spell_ranges, min_max_range, min_max_range_reads_units, soft_modify, substitute, + CcExemption, FormRefusal, LearnAnnouncement, LearnEffect, OpenLock, RangeTargets, RangeUnit, ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes, SpellDisplay, SpellDuration, SpellDurationCatalog, SpellMods, SpellRadius, SpellRadiusCatalog, - SpellRange, SpellRangeCatalog, TokenContext, TokenNumber, ATTR_CASTABLE_WHILE_DEAD, + SpellRange, SpellRangeCatalog, TokenContext, TokenNumber, UnitMotion, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, - ON_NEXT_SWING_RANGE, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, - SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL, - SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_PROSPECTING, SPELL_EFFECT_SKILL_STEP, - SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL, + MOVING_BONUS_FLAGS, MOVING_RANGE_BONUS, ON_NEXT_SWING_RANGE, SPELL_ATTR_IS_TRADESKILL, + SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, + SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_PROSPECTING, + SPELL_EFFECT_SKILL_STEP, SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL, }; mod skill_lines; pub use skill_lines::{ diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index 2cfad737..c3a49cb4 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -32,8 +32,9 @@ mod immunity; pub use immunity::{cc_exemption, grants_immunity, CcExemption}; pub use radius::{load_spell_radii, SpellRadius, SpellRadiusCatalog}; pub use ranges::{ - load_spell_ranges, min_max_range, min_max_range_reads_reach, RangeTargets, SpellRange, - SpellRangeCatalog, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE, + load_spell_ranges, min_max_range, min_max_range_reads_units, RangeTargets, RangeUnit, + SpellRange, SpellRangeCatalog, UnitMotion, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, + MOVING_BONUS_FLAGS, MOVING_RANGE_BONUS, ON_NEXT_SWING_RANGE, }; pub use soft_float::modify as soft_modify; pub use tokens::{substitute, SpellMods, TokenContext, TokenNumber}; diff --git a/crates/benilla-formats/src/spells/ranges.rs b/crates/benilla-formats/src/spells/ranges.rs index 21b055e7..ec7502da 100644 --- a/crates/benilla-formats/src/spells/ranges.rs +++ b/crates/benilla-formats/src/spells/ranges.rs @@ -33,18 +33,78 @@ pub const MELEE_RANGE_FLOOR: f32 = 5.0; /// The on-next-swing short-circuit's max, `[0x8118d4]` (`0x6e3504`). pub const ON_NEXT_SWING_RANGE: f32 = 100.0; -/// The combat reaches `GetMinMaxRange` (`0x6e3480`) reads besides the caster's own, `None` where -/// that unit is absent. The two are different inputs: only the first is an argument of the call. +/// The movement-flag bits the moving bonus tests of each unit, `0x200d` (`0x6e3658`): forward, +/// either strafe, and jumping. Backward, turning and swimming are not among them. +pub const MOVING_BONUS_FLAGS: u32 = 0x200d; +/// The moving bonus, `[0x811914]` (`0x6e369c`), 2.6667: added to the max after both arms. +pub const MOVING_RANGE_BONUS: f32 = f32::from_bits(0x402a_aaab); + +/// One unit's motion as the moving bonus reads it. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct UnitMotion { + /// The movement flags word, `[[unit+0x118]+0x40]` (`0x6e364f`-`0x6e3655`, `0x6e3661`). + pub flags: u32, + /// The current speed, `0x7c4c90` on `[unit+0x9a8]` (`0x5fc35c`-`0x5fc365`): 0 with no + /// direction bit, else the unit's spline speed, or the swim, walk, run or run-back speed the + /// flags pick. + pub speed: f32, + /// The walk speed, `[unit+0xa30]` (`0x5fc354`). + pub walk_speed: f32, +} + +impl UnitMotion { + /// `0x5fc350` answering 0: the current speed is above twice the walk speed. The reference + /// doubles the walk speed (`fadd st, st`), compares it with the speed (`fcompp`) and tests + /// C0 (`test ah, 1`, `0x5fc373`-`0x5fc376`), which is set by a doubled walk speed below the + /// speed and by an unordered pair, so a NaN reads as running and an equal pair as walking. + pub fn is_running(&self) -> bool { + let doubled = 2.0 * self.walk_speed; + self.speed > doubled || self.speed.is_nan() || doubled.is_nan() + } + + /// One unit's half of the bonus test: a movement flag in [`MOVING_BONUS_FLAGS`] + /// (`0x6e364c`-`0x6e366a`) and [`Self::is_running`] (`0x6e366c`-`0x6e3681`). + pub fn moves_at_run_speed(&self) -> bool { + self.flags & MOVING_BONUS_FLAGS != 0 && self.is_running() + } +} + +/// A unit as `GetMinMaxRange` (`0x6e3480`) reads it: its combat reach, whether it is a player, and +/// its motion. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RangeUnit { + /// `UNIT_FIELD_COMBATREACH`, `[[unit+0x110]+0x1f0]`. + pub reach: f32, + /// `OBJECT_FIELD_TYPE` bit 4 (`0x6e3689`-`0x6e3695`). Only a unit target's read counts: the + /// ranged arm's bonus asks it of the target, and the melee arm's never does. The caster's + /// own is the gate of the `Attributes & 2` scale (`0x6e36b4`-`0x6e36bf`), which is not applied. + pub player: bool, + pub motion: UnitMotion, +} + +impl RangeUnit { + /// A unit that stands still: no motion, so it never earns the moving bonus. + pub fn still(reach: f32) -> Self { + Self { + reach, + player: false, + motion: UnitMotion::default(), + } + } +} + +/// The units `GetMinMaxRange` (`0x6e3480`) reads besides the caster, `None` where that unit is +/// absent. The two are different inputs: only the first is an argument of the call. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct RangeTargets { /// The call's `target` argument (`[ebx+0x10]`), when it is a unit (typemask bit 3, /// `0x6e34e1`-`0x6e34f9`): a null target, and one that is no unit, read `None`. Both arms /// read its reach, and the ranged arm pads only for a target passed in (`0x6e35d8`). - pub target: Option, + pub target: Option, /// The caster's auto-attack target, `[caster+0xc48]`, which the function looks up itself /// (`0x47bf60`, `0x6e356a`). Only the melee arm reads it, and only with no unit target passed /// (`0x6e3552`-`0x6e3584`). A caller that has no such unit to hand passes `None`. - pub attack_target: Option, + pub attack_target: Option, } /// `GetMinMaxRange` (`0x6e3480`): a spell's `{min, max}` cast range, summed in `f64` because the @@ -55,55 +115,71 @@ pub struct RangeTargets { /// pads it: the max always, and the min only when nonzero (the `fcomp`-vs-0.0 guard), so a min-0 /// spell never refuses `TOO_CLOSE`. `None`: no row, or the self row (id 1, `{0, 0}`). /// -/// Not applied: the PvP `max += 2.6667` (`0x6e3648`, gated by `0x5fc350`), and for a player the -/// `Attributes & 2` scale (`0x6e36aa`), `max *= RangedModRange · 0.01` of the ranged-slot item, -/// which a relic (range mod 0, InventoryType 28, admitted by `0x809200`) would zero, though no -/// spell a relic class learns carries the bit. +/// After both arms the max takes the moving bonus ([`MOVING_RANGE_BONUS`], `0x6e3648`-`0x6e36a2`) +/// when there is a unit (the target, or on the melee arm the auto-attack target), the caster and +/// that unit each [`UnitMotion::moves_at_run_speed`], and the row is the melee row or the unit a +/// player. It comes after the melee floor and before the range modifier (op 5, `0x6e3746`), and +/// the min never takes it. The on-next-swing short-circuit skips it. +/// +/// Not applied: for a player the `Attributes & 2` scale (`0x6e36aa`), `max *= RangedModRange · +/// 0.01` of the ranged-slot item, which a relic (range mod 0, InventoryType 28, admitted by +/// `0x809200`) would zero, though no spell a relic class learns carries the bit. pub fn min_max_range( spell: &crate::spells::SpellDisplay, row: Option<&SpellRange>, - caster_reach: f32, + caster: RangeUnit, targets: RangeTargets, ) -> Option<(f32, f32)> { if spell.on_next_swing() { return Some((0.0, ON_NEXT_SWING_RANGE)); } let row = row?; - if row.is_melee() { - let reach = targets - .target - .or(targets.attack_target) - .unwrap_or(caster_reach); - let sum = f64::from(reach) + f64::from(caster_reach) + f64::from(COMBAT_REACH_ADD); + // The `esi` both arms leave (`0x6e3648`): the unit the bonus reads, if any. + let (min, max, unit) = if row.is_melee() { + let unit = targets.target.or(targets.attack_target); + let reach = unit.map_or(caster.reach, |u| u.reach); + let sum = f64::from(reach) + f64::from(caster.reach) + f64::from(COMBAT_REACH_ADD); let max = if sum > f64::from(MELEE_RANGE_FLOOR) { sum as f32 } else { MELEE_RANGE_FLOOR }; - return Some((0.0, max)); - } - if row.min == 0.0 && row.max == 0.0 { - return None; - } - let Some(target_reach) = targets.target else { - return Some((row.min, row.max)); - }; - let pad = f64::from(caster_reach) + f64::from(target_reach); - let min = if row.min == 0.0 { - 0.0 + (0.0, max, unit) } else { - (pad + f64::from(row.min)) as f32 + if row.min == 0.0 && row.max == 0.0 { + return None; + } + let Some(target) = targets.target else { + return Some((row.min, row.max)); + }; + let pad = f64::from(caster.reach) + f64::from(target.reach); + let min = if row.min == 0.0 { + 0.0 + } else { + (pad + f64::from(row.min)) as f32 + }; + (min, (f64::from(row.max) + pad) as f32, Some(target)) }; - Some((min, (f64::from(row.max) + pad) as f32)) + let earns_bonus = |unit: &RangeUnit| { + caster.motion.moves_at_run_speed() + && unit.motion.moves_at_run_speed() + && (row.is_melee() || unit.player) + }; + let max = if unit.as_ref().is_some_and(earns_bonus) { + (f64::from(max) + f64::from(MOVING_RANGE_BONUS)) as f32 + } else { + max + }; + Some((min, max)) } -/// Whether [`min_max_range`] reads a reach for this spell and row when it is passed no unit -/// target, as the spell tooltip calls it (`push 0`, `0x52e9c2`): only the melee arm, which sums -/// the auto-attack target's reach, else the caster's own, with the caster's. The ranged arm pads -/// only for a target passed in, so a tooltip prints its row as it is; the on-next-swing -/// short-circuit, the missing row and the self row read none. With a unit target the ranged arm -/// reads both reaches too. -pub fn min_max_range_reads_reach( +/// Whether [`min_max_range`] reads the units it is not handed for this spell and row when it is +/// passed no unit target, as the spell tooltip calls it (`push 0`, `0x52e9c2`): only the melee +/// arm, which sums the auto-attack target's reach, else the caster's own, with the caster's, and +/// asks both units' motion for the moving bonus. The ranged arm pads only for a target passed in, +/// so a tooltip prints its row as it is; the on-next-swing short-circuit, the missing row and the +/// self row read none. With a unit target the ranged arm reads both units too. +pub fn min_max_range_reads_units( spell: &crate::spells::SpellDisplay, row: Option<&SpellRange>, ) -> bool { @@ -175,8 +251,25 @@ mod tests { /// auto-attack target that the melee arm looks up itself. fn reaches(target: Option, attack: Option) -> RangeTargets { RangeTargets { - target, - attack_target: attack, + target: target.map(RangeUnit::still), + attack_target: attack.map(RangeUnit::still), + } + } + + const FORWARD: u32 = 0x1; + const BACKWARD: u32 = 0x2; + /// A unit running forward: the speed `0x7c4c90` answers for it is the run speed, above twice + /// the walk speed. + const RUNNING: UnitMotion = UnitMotion { + flags: FORWARD, + speed: 7.0, + walk_speed: 2.5, + }; + + fn runner(reach: f32) -> RangeUnit { + RangeUnit { + motion: RUNNING, + ..RangeUnit::still(reach) } } @@ -209,35 +302,74 @@ mod tests { // Two 1.5-reach units (4.333) floor at 5.0, let d = spell(2, 0); assert_eq!( - min_max_range(&d, Some(&MELEE), 1.5, reaches(Some(1.5), None)), + min_max_range( + &d, + Some(&MELEE), + RangeUnit::still(1.5), + reaches(Some(1.5), None) + ), Some((0.0, MELEE_RANGE_FLOOR)) ); // a 4.0 pair (9.333) clears it. - let (_, max) = min_max_range(&d, Some(&MELEE), 4.0, reaches(Some(4.0), None)).unwrap(); + let (_, max) = min_max_range( + &d, + Some(&MELEE), + RangeUnit::still(4.0), + reaches(Some(4.0), None), + ) + .unwrap(); assert!((max - 9.3333).abs() < 1e-3); // With no target the caster's reach counts twice, as in the client: 9.333 alone too. - let (_, max) = min_max_range(&d, Some(&MELEE), 4.0, RangeTargets::default()).unwrap(); + let (_, max) = min_max_range( + &d, + Some(&MELEE), + RangeUnit::still(4.0), + RangeTargets::default(), + ) + .unwrap(); assert!((max - 9.3333).abs() < 1e-3); // Charge's 8-25 row pads both bounds by the bare reach sum; the 1.3333 is melee-only. - let (min, max) = min_max_range(&d, Some(&CHARGE), 1.5, reaches(Some(1.5), None)).unwrap(); + let (min, max) = min_max_range( + &d, + Some(&CHARGE), + RangeUnit::still(1.5), + reaches(Some(1.5), None), + ) + .unwrap(); assert!((min - (8.0 + 3.0)).abs() < 1e-3); assert!((max - (25.0 + 3.0)).abs() < 1e-3); // Fireball's 0-35 row pads the max only: the fcomp-vs-0.0 guard keeps the min at zero. - let (min, max) = min_max_range(&d, Some(&FIREBALL), 1.5, reaches(Some(1.5), None)).unwrap(); + let (min, max) = min_max_range( + &d, + Some(&FIREBALL), + RangeUnit::still(1.5), + reaches(Some(1.5), None), + ) + .unwrap(); assert_eq!(min, 0.0); assert!((max - 38.0).abs() < 1e-3); // No target, as the tooltip calls it (`target = NULL`, `0x52e9c2`): the raw bounds. assert_eq!( - min_max_range(&d, Some(&CHARGE), 1.5, RangeTargets::default()), + min_max_range( + &d, + Some(&CHARGE), + RangeUnit::still(1.5), + RangeTargets::default() + ), Some((8.0, 25.0)) ); // The on-next-swing attribute short-circuits to 100 without reading the row. assert_eq!( - min_max_range(&spell(1, 0x400), None, 1.5, RangeTargets::default()), + min_max_range( + &spell(1, 0x400), + None, + RangeUnit::still(1.5), + RangeTargets::default() + ), Some((0.0, ON_NEXT_SWING_RANGE)) ); @@ -247,7 +379,12 @@ mod tests { flags: 0, }; assert_eq!( - min_max_range(&d, Some(&self_row), 1.5, RangeTargets::default()), + min_max_range( + &d, + Some(&self_row), + RangeUnit::still(1.5), + RangeTargets::default() + ), None ); } @@ -259,22 +396,42 @@ mod tests { let d = spell(4, 0); // Fireball's 35 with no target passed but an auto-attack target of reach 1.5: 35, not 38. assert_eq!( - min_max_range(&d, Some(&FIREBALL), 1.5, reaches(None, Some(1.5))), + min_max_range( + &d, + Some(&FIREBALL), + RangeUnit::still(1.5), + reaches(None, Some(1.5)) + ), Some((0.0, 35.0)) ); // The same row with the target passed, and the caster's reach 1.5: 35 + 1.5 + 1.5. assert_eq!( - min_max_range(&d, Some(&FIREBALL), 1.5, reaches(Some(1.5), None)), + min_max_range( + &d, + Some(&FIREBALL), + RangeUnit::still(1.5), + reaches(Some(1.5), None) + ), Some((0.0, 38.0)) ); // An auto-attack target beside it pads nothing: the target passed is the pad's second reach. assert_eq!( - min_max_range(&d, Some(&FIREBALL), 1.5, reaches(Some(1.5), Some(4.0))), + min_max_range( + &d, + Some(&FIREBALL), + RangeUnit::still(1.5), + reaches(Some(1.5), Some(4.0)) + ), Some((0.0, 38.0)) ); // A bounded row's min stays unpadded without a target too: Charge's 8-25. assert_eq!( - min_max_range(&d, Some(&CHARGE), 1.5, reaches(None, Some(1.5))), + min_max_range( + &d, + Some(&CHARGE), + RangeUnit::still(1.5), + reaches(None, Some(1.5)) + ), Some((8.0, 25.0)) ); } @@ -285,9 +442,14 @@ mod tests { fn the_melee_arm_falls_back_from_the_target_to_the_attack_target_to_the_caster() { let d = spell(2, 0); let max = |target, attack| { - min_max_range(&d, Some(&MELEE), 4.0, reaches(target, attack)) - .unwrap() - .1 + min_max_range( + &d, + Some(&MELEE), + RangeUnit::still(4.0), + reaches(target, attack), + ) + .unwrap() + .1 }; let sum = |reach: f32| reach + 4.0 + COMBAT_REACH_ADD; // With no target passed the auto-attack target's 6.0 counts: 6 + 4 + 1.3333. @@ -299,10 +461,10 @@ mod tests { } /// The predicate is exact for the tooltip's call (`0x52e9c2`, no unit target passed): it is - /// true for a spell and row exactly when some change of the caster's reach or of the - /// auto-attack target's, present or not, moves [`min_max_range`]'s answer. + /// true for a spell and row exactly when some change of the caster's reach or motion, or of the + /// auto-attack target's reach, motion or presence, moves [`min_max_range`]'s answer. #[test] - fn the_reach_predicate_matches_what_min_max_range_reads_with_no_target() { + fn the_units_predicate_matches_what_min_max_range_reads_with_no_target() { let row = |min, max, flags| Some(SpellRange { min, max, flags }); let rows = [ None, @@ -312,34 +474,277 @@ mod tests { row(8.0, 25.0, 0), row(0.0, 5.0, 1), ]; + let still = RangeUnit::still; + let runner = |reach| RangeUnit { + motion: RUNNING, + ..still(reach) + }; + // (caster, auto-attack target) before and after. + let moves = [ + ((still(1.5), None), (still(4.0), None)), + ( + (still(1.5), Some(still(1.5))), + (still(1.5), Some(still(4.0))), + ), + ( + (still(1.5), Some(still(1.5))), + (still(4.0), Some(still(1.5))), + ), + ((still(1.5), None), (still(1.5), Some(still(4.0)))), + ((still(1.5), Some(still(1.5))), (still(1.5), None)), + // Motion alone: the caster's, then the auto-attack target's. + ( + (still(1.5), Some(runner(1.5))), + (runner(1.5), Some(runner(1.5))), + ), + ( + (runner(1.5), Some(still(1.5))), + (runner(1.5), Some(runner(1.5))), + ), + ]; for attributes in [0, 0x4, 0x400, 0x404] { let spell = crate::spells::SpellDisplay { attributes, ..Default::default() }; for row in &rows { - // (caster reach, auto-attack target's reach) before and after. - let moves = [ - ((1.5, None), (4.0, None)), - ((1.5, Some(1.5)), (1.5, Some(4.0))), - ((1.5, Some(1.5)), (4.0, Some(1.5))), - ((1.5, None), (1.5, Some(4.0))), - ((1.5, Some(1.5)), (1.5, None)), - ] - .iter() - .any(|&((a, a_attack), (b, b_attack))| { - min_max_range(&spell, row.as_ref(), a, reaches(None, a_attack)) - != min_max_range(&spell, row.as_ref(), b, reaches(None, b_attack)) + let answer = |caster, attack_target| { + let targets = RangeTargets { + target: None, + attack_target, + }; + min_max_range(&spell, row.as_ref(), caster, targets) + }; + let moved = moves.iter().any(|&((a, a_attack), (b, b_attack))| { + answer(a, a_attack) != answer(b, b_attack) }); assert_eq!( - min_max_range_reads_reach(&spell, row.as_ref()), - moves, + min_max_range_reads_units(&spell, row.as_ref()), + moved, "attributes {attributes:#x}, row {row:?}" ); } } } + /// `0x5fc350` answers 0 for a current speed above twice the walk speed (`fcompp`, then C0), + /// and 1 for an equal pair; an unordered pair sets C0 too. + #[test] + fn running_is_a_speed_above_twice_the_walk_speed() { + let at = |speed, walk_speed| UnitMotion { + flags: FORWARD, + speed, + walk_speed, + }; + assert!(at(7.0, 2.5).is_running(), "a run"); + assert!(!at(2.5, 2.5).is_running(), "a walk"); + assert!( + !at(5.0, 2.5).is_running(), + "equal to twice the walk speed is walking" + ); + assert!(at(5.001, 2.5).is_running()); + assert!(!at(0.0, 2.5).is_running(), "no direction bit reads speed 0"); + assert!(at(f32::NAN, 2.5).is_running(), "unordered sets C0"); + assert!(at(7.0, f32::NAN).is_running()); + // A unit whose speeds never streamed (all zero) is not running. + assert!(!UnitMotion::default().is_running()); + } + + /// The moving bonus (`0x6e3648`-`0x6e36a2`) on the melee row: two running units of reach 1.5 + /// sum 4.333, which the melee floor raises to 5.0 first, so the bonus makes 7.667. + #[test] + fn two_running_units_earn_the_moving_bonus_on_the_melee_row_after_the_floor() { + let d = spell(2, 0); + let max = |caster: RangeUnit, target: RangeUnit| { + min_max_range( + &d, + Some(&MELEE), + caster, + RangeTargets { + target: Some(target), + attack_target: None, + }, + ) + .unwrap() + }; + let running = runner(1.5); + let (min, both) = max(running, running); + assert_eq!(min, 0.0, "the min never takes the bonus"); + assert!((both - (MELEE_RANGE_FLOOR + MOVING_RANGE_BONUS)).abs() < 1e-6); + assert!((both - 7.6667).abs() < 1e-3); + // The bonus is added after the floor, not folded into the reach sum: a 4.0 pair + // (9.333) takes it on top. + let (_, wide) = max(runner(4.0), runner(4.0)); + assert!((wide - (9.3333 + 2.6667)).abs() < 1e-3); + // One unit walking, or standing, or running backward: none. + let walking = UnitMotion { + flags: FORWARD, + speed: 2.5, + walk_speed: 2.5, + }; + let backward = UnitMotion { + flags: BACKWARD, + ..RUNNING + }; + for (name, motion) in [ + ("walking", walking), + ("standing", UnitMotion::default()), + ("backward", backward), + ( + "turning", + UnitMotion { + flags: 0x10, + ..RUNNING + }, + ), + ( + "swimming", + UnitMotion { + flags: 0x20_0000, + ..RUNNING + }, + ), + ] { + let slow = RangeUnit { + motion, + ..runner(1.5) + }; + assert_eq!(max(running, slow).1, MELEE_RANGE_FLOOR, "the target {name}"); + assert_eq!(max(slow, running).1, MELEE_RANGE_FLOOR, "the caster {name}"); + } + } + + /// The flag test is `0x200d`: forward, either strafe, or jumping, each alone. + #[test] + fn the_moving_test_takes_forward_either_strafe_and_jumping() { + for (name, flags, bonus) in [ + ("forward", FORWARD, true), + ("strafe left", 0x4, true), + ("strafe right", 0x8, true), + ("jumping", 0x2000, true), + ("forward and jumping", FORWARD | 0x2000, true), + ("backward", BACKWARD, false), + ("root", 0x1000, false), + ("falling far", 0x4000, false), + ("still", 0, false), + ] { + let unit = RangeUnit { + motion: UnitMotion { flags, ..RUNNING }, + ..RangeUnit::still(1.5) + }; + let got = min_max_range( + &spell(2, 0), + Some(&MELEE), + unit, + RangeTargets { + target: Some(unit), + attack_target: None, + }, + ) + .unwrap() + .1; + let want = if bonus { + MELEE_RANGE_FLOOR + MOVING_RANGE_BONUS + } else { + MELEE_RANGE_FLOOR + }; + assert!((got - want).abs() < 1e-6, "{name}: {got}"); + } + } + + /// The bonus reads the unit `esi` holds after both arms: the melee arm's auto-attack target + /// when no target is passed (`0x6e3580`), never that target beside one passed in. + #[test] + fn the_melee_bonus_reads_the_attack_target_when_no_target_is_passed() { + let d = spell(2, 0); + let bonus = MELEE_RANGE_FLOOR + MOVING_RANGE_BONUS; + let call = |target: Option, attack_target: Option| { + min_max_range( + &d, + Some(&MELEE), + runner(1.5), + RangeTargets { + target, + attack_target, + }, + ) + .unwrap() + .1 + }; + let still = RangeUnit::still(1.5); + assert!((call(None, Some(runner(1.5))) - bonus).abs() < 1e-6); + assert_eq!(call(None, Some(still)), MELEE_RANGE_FLOOR); + // No unit at all: the caster's own reach twice, and no unit to move. + assert_eq!(call(None, None), MELEE_RANGE_FLOOR); + // A target passed in takes the place of the auto-attack target for the bonus too. + assert_eq!(call(Some(still), Some(runner(1.5))), MELEE_RANGE_FLOOR); + assert!((call(Some(runner(1.5)), Some(still)) - bonus).abs() < 1e-6); + } + + /// On a ranged row the bonus needs a target unit passed in, and a player: a creature target + /// earns none (`0x6e3683`-`0x6e3695`), nor does the auto-attack target, which that arm never + /// reads. + #[test] + fn the_ranged_bonus_needs_a_player_target_passed_in() { + let d = spell(4, 0); + let player = RangeUnit { + player: true, + ..runner(1.5) + }; + let creature = runner(1.5); + let ranged = |row: &SpellRange, target: Option, attack_target| { + min_max_range( + &d, + Some(row), + runner(1.5), + RangeTargets { + target, + attack_target, + }, + ) + .unwrap() + }; + // Fireball's 35 with the 1.5 + 1.5 pad, and the bonus for a running player only. + let (min, max) = ranged(&FIREBALL, Some(player), None); + assert_eq!(min, 0.0); + assert!((max - (38.0 + MOVING_RANGE_BONUS)).abs() < 1e-4); + assert_eq!(ranged(&FIREBALL, Some(creature), None), (0.0, 38.0)); + // Charge's min pads and takes no bonus; its max does. + let (min, max) = ranged(&CHARGE, Some(player), None); + assert!((min - 11.0).abs() < 1e-4); + assert!((max - (28.0 + MOVING_RANGE_BONUS)).abs() < 1e-4); + // A standing player target, or a running player as the auto-attack target alone: none. + let standing_player = RangeUnit { + player: true, + ..RangeUnit::still(1.5) + }; + assert_eq!(ranged(&FIREBALL, Some(standing_player), None), (0.0, 38.0)); + assert_eq!(ranged(&FIREBALL, None, Some(player)), (0.0, 35.0)); + // The caster must run too. + let caster = RangeUnit::still(1.5); + let targets = RangeTargets { + target: Some(player), + attack_target: None, + }; + assert_eq!( + min_max_range(&d, Some(&FIREBALL), caster, targets), + Some((0.0, 38.0)) + ); + } + + /// The on-next-swing short-circuit returns before the bonus (`0x6e350f`). + #[test] + fn the_on_next_swing_range_takes_no_bonus() { + let targets = RangeTargets { + target: Some(runner(1.5)), + attack_target: None, + }; + assert_eq!( + min_max_range(&spell(2, 0x400), Some(&MELEE), runner(1.5), targets), + Some((0.0, ON_NEXT_SWING_RANGE)) + ); + } + /// Row 2 is the melee family, 114 Auto Shot's 8-35, 95 Charge's 8-25. #[test] fn real_spell_ranges_read_the_byte_laws_rows() {