diff --git a/crates/benilla-app/src/script_calls.rs b/crates/benilla-app/src/script_calls.rs index 249f7eb7..6afe032e 100644 --- a/crates/benilla-app/src/script_calls.rs +++ b/crates/benilla-app/src/script_calls.rs @@ -36,39 +36,51 @@ pub(crate) fn apply_script_calls( let Some(mut script) = script else { return; }; - in_call_order(&mut script, |script, call| match call { - ScriptCall::Select(request) => appliers.p0().select(request), - ScriptCall::TargetByName { name, exact } => appliers.p0().target_by_name(&name, exact), - ScriptCall::TargetNearest { mode, reverse } => { - appliers.p0().target_nearest(mode, reverse); - } - ScriptCall::TargetLastTarget => appliers.p0().target_last_target(), - ScriptCall::ClearTarget => appliers.p0().clear_target(), - ScriptCall::AttackTarget => appliers.p7().attack_target(), - ScriptCall::SpellTargetUnit(token) => appliers.p1().spell_target_unit(&token), - ScriptCall::SpellStopTargeting => appliers.p1().stop_targeting(), - ScriptCall::SpellStopCasting => appliers.p2().stop_casting(), - ScriptCall::CastSpell(spell_id) => { - crate::ui_spellbook::cast_spell(&mut appliers.p3(), spell_id); - } - ScriptCall::CastShapeshiftForm(spell_id) => { - crate::ui_shapeshift::cast_form(&mut appliers.p3(), spell_id); - } - ScriptCall::UseAction(press) => { - let used = crate::ui_action::use_action(&mut appliers.p4(), script, press); - if used == crate::ui_action::UseOutcome::Attack { - appliers.p7().attack_target(); + in_call_order(&mut script, |script, call| { + match call { + ScriptCall::Select(request) => appliers.p0().select(request), + ScriptCall::TargetByName { name, exact } => appliers.p0().target_by_name(&name, exact), + ScriptCall::TargetNearest { mode, reverse } => { + appliers.p0().target_nearest(mode, reverse); } + ScriptCall::TargetLastTarget => appliers.p0().target_last_target(), + ScriptCall::ClearTarget => appliers.p0().clear_target(), + ScriptCall::AttackTarget => appliers.p7().attack_target(), + ScriptCall::SpellTargetUnit(token) => appliers.p1().spell_target_unit(&token), + ScriptCall::SpellStopTargeting => appliers.p1().stop_targeting(), + ScriptCall::SpellStopCasting => appliers.p2().stop_casting(), + ScriptCall::CastSpell(spell_id) => { + crate::ui_spellbook::cast_spell(&mut appliers.p3(), spell_id); + } + ScriptCall::CastShapeshiftForm(spell_id) => { + crate::ui_shapeshift::cast_form(&mut appliers.p3(), spell_id); + } + ScriptCall::UseAction(press) => { + let used = crate::ui_action::use_action(&mut appliers.p4(), script, press); + if used == crate::ui_action::UseOutcome::Attack { + appliers.p7().attack_target(); + } + } + ScriptCall::UseContainerItem { bag, slot } => { + appliers.p5().use_container_item(script, bag, slot); + } + ScriptCall::UseInventoryItem(id) => appliers.p5().use_inventory_item(script, id), + ScriptCall::CastPetSpell(spell_id) => { + crate::ui_pet_book::cast_pet_spell(&mut appliers.p6(), spell_id); + } + ScriptCall::PetAction(slot) => appliers.p6().press_slot(slot), + ScriptCall::PetOrder(packed) => appliers.p6().order(packed), } - ScriptCall::UseContainerItem { bag, slot } => { - appliers.p5().use_container_item(script, bag, slot); + // TryCast's attack pick can move the selection mid-cast (`0x6e4efb`), so a press held + // there is picked and resumed before the next call reads the selection. + let Some(held) = appliers.p3().ladder.take_held() else { + return; + }; + if appliers.p7().pick_for_cast(held) { + let mut cast = appliers.p3(); + let ctx = cast.targeting.context(); + cast.ladder.resume_after_pick(held, &ctx); } - ScriptCall::UseInventoryItem(id) => appliers.p5().use_inventory_item(script, id), - ScriptCall::CastPetSpell(spell_id) => { - crate::ui_pet_book::cast_pet_spell(&mut appliers.p6(), spell_id); - } - ScriptCall::PetAction(slot) => appliers.p6().press_slot(slot), - ScriptCall::PetOrder(packed) => appliers.p6().order(packed), }); } @@ -108,7 +120,9 @@ mod tests { ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfGuid, SelfPlayer, }; use crate::target::Selection; - use benilla_protocol::messages::{ActionButton, GroupMemberEntry, ACTION_KIND_MACRO}; + use benilla_protocol::messages::{ + ActionButton, GroupMemberEntry, ACTION_KIND_MACRO, ACTION_KIND_SPELL, + }; use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView}; use bevy::ecs::system::RunSystemOnce; use crossbeam_channel::Receiver; @@ -568,4 +582,120 @@ mod tests { assert_eq!(casts(&f), vec![(HEAL, Some(OLD))]); assert_eq!(selected(&f), Some(NEW)); } + + /// Sinister Strike: `AttributesEx & 0x200`, one of predicate `0x6e5200`'s bits, and the enemy + /// word (implicit target 6). + const SINISTER_STRIKE: u32 = 1752; + + /// Sinister Strike in the catalog and on action slot 1. + fn with_strike(f: &mut Frame) { + let world = f.app.world_mut(); + let strike = benilla_formats::SpellDisplay { + attributes_ex: 0x200, + implicit_target_a1: 6, + ..Default::default() + }; + world.insert_resource(crate::ui_action::Spells { + catalog: benilla_formats::SpellCatalog::from_displays( + [(SINISTER_STRIKE, strike)].into(), + ), + ..crate::ui_action::Spells::empty_for_tests() + }); + world + .resource_mut::() + .buttons + .insert( + 0, + ActionButton { + slot: 0, + action: SINISTER_STRIKE, + kind: ACTION_KIND_SPELL, + }, + ); + } + + /// The selections, strikes and swings sent, in wire order. + fn strike_wire(f: &Frame) -> Vec<(&'static str, u64)> { + f.rx.try_iter() + .filter_map(|c| match c { + ClientCommand::SetSelection { guid } => Some(("select", guid)), + ClientCommand::CastSpell { + spell_id: SINISTER_STRIKE, + target: Some(guid), + } => Some(("cast", guid)), + ClientCommand::AttackSwing { guid } => Some(("swing", guid)), + _ => None, + }) + .collect() + } + + /// TryCast's attack pick (`0x6e4efb` → `0x612df0`): a strike pressed with no target selects the + /// nearest enemy before its bind, so the selection goes out ahead of the cast and its swing, + /// and the call after it reads the selection only once the cast is done. + #[test] + fn a_strike_with_no_target_selects_the_nearest_enemy_and_casts_at_it() { + let mut f = frame(false); + spawn_mob(&mut f, &[]); + with_strike(&mut f); + run(&mut f, r#"UseAction(1) TargetUnit("party1")"#); + assert_eq!( + strike_wire(&f), + vec![ + ("select", MOB), + ("cast", MOB), + ("swing", MOB), + ("select", NEW) + ] + ); + } + + /// A self-cast press passes the caster to the pick (`0x4e610e`), and the reaction to oneself is + /// 4 (`0x606200`), so the pick Tabs to the enemy. With no faction table the caster would read + /// neutral and be kept. + #[test] + fn a_self_cast_strike_selects_the_nearest_enemy() { + let mut f = frame(false); + spawn_mob(&mut f, &[]); + with_strike(&mut f); + run(&mut f, "UseAction(1, 0, 1)"); + assert_eq!( + strike_wire(&f), + vec![("select", MOB), ("cast", MOB), ("swing", MOB)] + ); + } + + /// The pick's keep test (`0x6130a3`) drops a friend, so a strike at one retargets the nearest + /// enemy, where the bind alone would refuse it as an invalid target. + #[test] + fn a_strike_at_a_friend_retargets_the_nearest_enemy() { + let mut f = frame(true); + with_factions(&mut f, FRIEND, &[]); + spawn_mob(&mut f, &[(TEMPLATE, FOE)]); + with_strike(&mut f); + run(&mut f, "UseAction(1)"); + assert_eq!( + strike_wire(&f), + vec![("select", MOB), ("cast", MOB), ("swing", MOB)] + ); + } + + /// `0x6130d9`: with no enemy to acquire, the strike stops at the pick with "There is nothing to + /// attack.", not the bind's "You have no target.". + #[test] + fn a_strike_with_nothing_to_attack_says_so_and_sends_nothing() { + let mut f = frame(false); + with_strike(&mut f); + run(&mut f, "UseAction(1)"); + assert_eq!(casts(&f), vec![]); + assert_eq!(selected(&f), None); + let world = f.app.world(); + assert_eq!( + world.resource::().0, + vec![crate::ui_action::UiError::key("ERR_NO_ATTACK_TARGET")] + ); + assert!(world + .resource::() + .0 + .is_empty()); + } } diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index 9a9dd4b3..7b88b395 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -3,11 +3,11 @@ //! //! The rungs: the auto-repeat toggle-off (the reference's action-button handler, ahead of TryCast; //! its order against the profession intercept is unobservable), the profession intercept, dead or a -//! ghost, the targeting abort, in-flight, reagents and totems, the equipped item, target binding -//! and range, then the validator `0x6094f0` (not-ready and GCD, power, crowd control, mounted, -//! water, moving, form), the deferred cast-arm refusal and the targeting cursor. The commit tail -//! follows: ranged stance, auto-repeat arm, the send, the auto-attack start, the GCD. A refusal is -//! local and pre-commit: no packet, no GCD, no pending arm, only the red line. +//! ghost, the targeting abort, in-flight, reagents and totems, the equipped item, combo points, the +//! attack pick, target binding and range, then the validator `0x6094f0` (not-ready and GCD, power, +//! crowd control, mounted, water, moving, form), the deferred cast-arm refusal and the targeting +//! cursor. The commit tail follows: ranged stance, auto-repeat arm, the send, the auto-attack start, +//! the GCD. A refusal is local and pre-commit: no packet, no GCD, no pending arm, only the red line. //! //! An item use takes the same ladder: `CGItem::Use 0x5d8d00` calls `0x6e5a90`, whose body is //! `call 0x6e4b60` with the item as TryCast's third argument (read at `6e4d76` and `6e4f33`). Three @@ -64,6 +64,22 @@ impl CastCommit { } } +/// The class byte TryCast's finisher rung tests (`6e4e98`). +const CLASS_ROGUE: u8 = 4; + +/// A player's spell press held at TryCast's attack pick (`6e4edf` → `0x612df0`), which can move +/// the selection the ladder only reads. [`crate::script_calls`] runs the pick and resumes it. +#[derive(Resource, Default)] +pub(crate) struct HeldForPick(Option); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct HeldCast { + pub(crate) spell_id: u32, + /// Pressed with the self-cast modifier: TryCast's guid argument is then the caster + /// (`0x4e610e`), which the pick tries before the selection. + pub(crate) on_self: bool, +} + /// Everything the cast-send path reads, as one [`SystemParam`]: [`CastLadder::send`] is the only /// way into [`send_spell_cast`], for every caster surface. #[derive(SystemParam)] @@ -86,6 +102,7 @@ pub(crate) struct CastLadder<'w, 's> { pub(crate) auto_repeat: ResMut<'w, AutoRepeatActive>, pub(crate) trade_skill_opens: ResMut<'w, crate::ui_tradeskill::TradeSkillOpens>, pub(crate) ground: ResMut<'w, super::targeting::SpellTargeting>, + pub(crate) held_for_pick: ResMut<'w, HeldForPick>, } /// What a targeting-cursor click bound: what `BindLocation 0x6e60f0` or `BindTarget 0x6e5b40` @@ -189,14 +206,38 @@ impl CastLadder<'_, '_> { caster_dead_refusal(spell_id, def, self_store, &mut self.cast_errors) } - /// Run the ladder for `spell_id` and commit as `commit` says. + /// Run the ladder for `spell_id` and commit as `commit` says, with no attack pick: no 1.12 + /// item use, form, craft or `modalNextSpell` chain casts a spell of predicate `0x6e5200`. pub(crate) fn send( &mut self, spell_id: u32, ctx: &cast_target::CastContext, commit: CastCommit, ) { - self.send_bound(spell_id, ctx, commit, None); + self.send_bound(spell_id, ctx, commit, None, None); + } + + /// A player's spell press (`UseAction`, `CastSpell`, `CastSpellByName`): the ladder with + /// TryCast's attack pick, where the cast may stop in [`HeldForPick`]. + pub(crate) fn send_spell( + &mut self, + spell_id: u32, + ctx: &cast_target::CastContext, + on_self: bool, + ) { + let held = HeldCast { spell_id, on_self }; + self.send_bound(spell_id, ctx, CastCommit::Spell, None, Some(held)); + } + + /// The press held at the attack pick, if one is. + pub(crate) fn take_held(&mut self) -> Option { + self.held_for_pick.0.take() + } + + /// A held press after its pick, at the unit the pick left selected. The rungs above the pick + /// run again; none reads the selection, so each passes as it did. + pub(crate) fn resume_after_pick(&mut self, held: HeldCast, ctx: &cast_target::CastContext) { + self.send_bound(held.spell_id, ctx, CastCommit::Spell, None, None); } /// The ladder for a cast the caller already bound to a GameObject (a lock opener): TryCast @@ -208,7 +249,7 @@ impl CastLadder<'_, '_> { ctx: &cast_target::CastContext, go_guid: u64, ) { - self.send_bound(spell_id, ctx, CastCommit::Spell, Some(go_guid)); + self.send_bound(spell_id, ctx, CastCommit::Spell, Some(go_guid), None); } fn send_bound( @@ -217,12 +258,14 @@ impl CastLadder<'_, '_> { ctx: &cast_target::CastContext, commit: CastCommit, on_object: Option, + hold: Option, ) { send_spell_cast( spell_id, ctx, commit, on_object, + hold, &self.commands, &self.self_player, self.spells.as_deref(), @@ -238,6 +281,7 @@ impl CastLadder<'_, '_> { &mut self.auto_repeat, &mut self.trade_skill_opens, &mut self.ground, + &mut self.held_for_pick, ); } } @@ -275,6 +319,8 @@ fn send_spell_cast( commit: CastCommit, // A caller-bound GameObject for a spell commit; an item commit carries its own. bound_object: Option, + // Set for a player's spell press, which parks in `held_for_pick` at the attack pick. + hold: Option, commands: &NetCommands, self_player: &Query<(Entity, Has), With>, spells: Option<&Spells>, @@ -290,6 +336,7 @@ fn send_spell_cast( auto_repeat: &mut AutoRepeatActive, trade_skill_opens: &mut crate::ui_tradeskill::TradeSkillOpens, ground: &mut super::targeting::SpellTargeting, + held_for_pick: &mut HeldForPick, ) { let now = Instant::now(); let def = spells.and_then(|s| s.catalog.get(spell_id)); @@ -358,6 +405,34 @@ fn send_spell_cast( } } } + // The finisher rung (`6e4e50`–`6e4ecd`), above the attack pick: a finishing move + // (`AttributesEx & 0x500000`) with the combo-point byte at 0 refuses 0x46 "That ability + // requires combo points" for a rogue (class byte 4, `6e4e98`), else 0x12. Like the button + // greying, it reads no target. + if let (Some(d), Some(store)) = (def, ctx.rel.self_store) { + if d.needs_combo_points() && store.0.player_combo_points().unwrap_or(0) == 0 { + let reason = if store.0.unit_class() == Some(CLASS_ROGUE) { + 0x46 + } else { + 0x12 + }; + debug!("ui_action: cast {spell_id} refused locally — no combo points ({reason:#x})"); + cast_errors.push_local(spell_id, reason); + return; + } + } + // TryCast's attack pick (`6e4edf`–`6e4f02`): a spell of predicate `0x6e5200`, pressed while + // not attacking (`0x60ecb0`), goes through the attack validator `0x612df0`, which keeps a + // hostile selection or selects the nearest enemy. It sits above the bind and the range gate, + // so an acquired target out of reach reads "Out of range.". + if let Some(held) = hold { + let engaged = self_player.single().is_ok_and(|(_, engaged)| engaged); + if def.is_some_and(|d| d.initiates_combat()) && !engaged { + debug!("ui_action: cast {spell_id} holds for the attack pick"); + held_for_pick.0 = Some(held); + return; + } + } // ArmCast (`0x6e5250`) resolves the wire target from the spell's targeting constraints, never // the raw selection. A caller-bound GameObject skips the walk, as TryCast's resolve (`6e4ef4` → // `0x612df0`) takes a passed guid pair. @@ -754,6 +829,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); // A bare World has no message storage until it is asked for. world.init_resource::>(); world.init_resource::>(); @@ -926,11 +1002,13 @@ mod tests { const AUTO_SHOT: u32 = 75; const RAPTOR_STRIKE: u32 = 2973; const SERPENT_STING: u32 = 1978; + const EVISCERATE: u32 = 2098; const MOB: u64 = 0x0000_0000_0000_00f1; /// Auto Shot (auto-repeat `AttributesEx2 & 0x20`, ranged `Attributes & 0x2`), Raptor Strike - /// (on-next-swing `0x404`) and Serpent Sting, as the 1.12 rows classify them. `Targets & 0x2` - /// binds the selection without faction data. + /// (on-next-swing `0x404`), Serpent Sting and Eviscerate (a finisher, `AttributesEx 0x100000`, + /// that starts combat, `0x200`), as the 1.12 rows classify them. `Targets & 0x2` binds the + /// selection without faction data. fn combat_catalog() -> Spells { use std::collections::HashMap; let auto_shot = benilla_formats::SpellDisplay { @@ -953,11 +1031,17 @@ mod tests { modal_next_spell: AUTO_SHOT, ..Default::default() }; + let eviscerate = benilla_formats::SpellDisplay { + targets: 0x2, + attributes_ex: 0x0010_0200, + ..Default::default() + }; Spells { catalog: benilla_formats::SpellCatalog::from_displays(HashMap::from([ (AUTO_SHOT, auto_shot), (RAPTOR_STRIKE, raptor_strike), (SERPENT_STING, serpent_sting), + (EVISCERATE, eviscerate), ])), ..Spells::empty_for_tests() } @@ -1076,6 +1160,90 @@ mod tests { .expect("the ladder runs as a one-shot system"); } + /// TryCast's attack pick (`6e4edf`): a press of a `0x6e5200` spell stops there, above the + /// bind, for its owner to pick a unit. Attacking skips the pick (`0x60ecb0`), and neither a + /// spell without the bits nor a send that is not a press stops. + #[test] + fn a_combat_press_holds_at_the_attack_pick_unless_attacking() { + let press = |world: &mut World, spell_id: u32| { + world + .run_system_once(move |mut ladder: CastLadder| { + ladder.send_spell(spell_id, &ctx(), false); + ladder.take_held() + }) + .expect("the ladder runs as a one-shot system") + }; + let (mut world, rx) = combat_world(false); + assert_eq!( + press(&mut world, RAPTOR_STRIKE), + Some(HeldCast { + spell_id: RAPTOR_STRIKE, + on_self: false + }) + ); + assert!(rx.try_recv().is_err(), "a held press sends nothing"); + assert!(world.resource::().0.is_empty()); + assert_eq!(press(&mut world, SERPENT_STING), None, "no 0x6e5200 bit"); + + let (mut world, _rx) = combat_world(true); + assert_eq!(press(&mut world, RAPTOR_STRIKE), None, "attacking"); + + let (mut world, _rx) = combat_world(false); + send(&mut world, RAPTOR_STRIKE, CastCommit::Spell); + assert_eq!(world.resource::().0, None, "not a press"); + } + + /// The rungs above the pick refuse before it: a press mid-cast (`6e4d97`) and a finisher with no + /// combo points (`6e4e50`) hold nothing, so they never select a unit. A rogue reads 0x46, any + /// other class 0x12 (`6e4e98`). + #[test] + fn the_rungs_above_the_attack_pick_refuse_before_it() { + use benilla_protocol::ObjectFields; + let (mut world, rx) = combat_world(false); + world + .resource_mut::() + .arm(SERPENT_STING, Instant::now(), true); + let held = world + .run_system_once(|mut ladder: CastLadder| { + ladder.send_spell(RAPTOR_STRIKE, &ctx(), false); + ladder.take_held() + }) + .expect("the ladder runs as a one-shot system"); + assert_eq!(held, None, "in flight"); + assert_eq!( + world.resource::().0, + vec![CastFail::local(RAPTOR_STRIKE, 0x61)] + ); + assert!(rx.try_recv().is_err()); + + // `UNIT_FIELD_BYTES_0` (field 36) byte 1 is the class; `PLAYER_FIELD_BYTES` holds no + // combo points. + for (class, reason) in [(4u32, 0x46), (11, 0x12)] { + let (mut world, rx) = combat_world(false); + let held = world + .run_system_once(move |mut ladder: CastLadder| { + let me = crate::net::ObjectStore(ObjectFields::from_pairs(&[(36, class << 8)])); + let ctx = cast_target::CastContext { + rel: cast_target::TargetRelations { + self_store: Some(&me), + ..ctx().rel + }, + ..ctx() + }; + ladder.send_spell(EVISCERATE, &ctx, false); + ladder.take_held() + }) + .expect("the ladder runs as a one-shot system"); + assert_eq!(held, None, "class {class}: no combo points"); + assert_eq!( + world.resource::().0, + vec![CastFail::local(EVISCERATE, reason)], + "class {class}" + ); + assert!(rx.try_recv().is_err()); + } + } + /// The commit's StopAttack (`0x6e5976`): `CMSG_ATTACKSTOP` (`0x624370`), then `CancelQueuedCast /// 0x6e6f30` hands the queued strike to `CancelCast 0x6e4940(dl=1)` and its `CMSG_CANCEL_CAST`. #[test] diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index 8482fb24..48afa439 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -23,7 +23,7 @@ pub(crate) mod validator; // The one cast path: every caster takes [`CastLadder`] and commits through [`CastCommit`]; // `send_spell_cast` is private to `cast_send`, so no second send path can exist. -pub(crate) use cast_send::{CastCommit, CastLadder, TargetedBind}; +pub(crate) use cast_send::{CastCommit, CastLadder, HeldCast, HeldForPick, TargetedBind}; pub(crate) use cast_target::AutoSelfCast; pub(crate) use cooldowns::Cooldowns; pub(crate) use inflight::{ @@ -62,6 +62,7 @@ impl Plugin for SpellPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index 839f1fe5..d2df19db 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -505,6 +505,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::>(); world.init_resource::(); let mut spells = crate::ui_action::Spells::empty_for_tests(); diff --git a/crates/benilla-app/src/spell/targeting/world.rs b/crates/benilla-app/src/spell/targeting/world.rs index cef9281a..3626ab85 100644 --- a/crates/benilla-app/src/spell/targeting/world.rs +++ b/crates/benilla-app/src/spell/targeting/world.rs @@ -149,6 +149,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::>(); world.init_resource::>(); // The commit legs read the press latch, not the live hover. diff --git a/crates/benilla-app/src/target/ring.rs b/crates/benilla-app/src/target/ring.rs index b13477e9..51639be4 100644 --- a/crates/benilla-app/src/target/ring.rs +++ b/crates/benilla-app/src/target/ring.rs @@ -1230,7 +1230,7 @@ mod tests { mut sel: ResMut, mut seam: crate::creature_anim::AttackSeam, mut errors: ResMut| { - pick.target(&mut sel, &mut seam, &mut errors) + pick.target(None, &mut sel, &mut seam, &mut errors) }, ) .expect("the scan runs on the built client") diff --git a/crates/benilla-app/src/target/scan.rs b/crates/benilla-app/src/target/scan.rs index 554870f5..0b9f6c2c 100644 --- a/crates/benilla-app/src/target/scan.rs +++ b/crates/benilla-app/src/target/scan.rs @@ -633,21 +633,24 @@ impl AttackPick<'_, '_> { (self.scan.self_store(), self.scan.self_guid()) } - /// The guid the swing or the pet's order goes at, or `None` once the refusal is raised. The - /// candidate is the selection (`0x61306b`): `AttackTarget` passes none (`0x489b50` → - /// `0x6131a0(0,0)`), nor does the pet bar (`0x4be42b`). An empty selection (`0x61307f`) or a - /// held unit the actor is friendly toward (`0x6130a3`, dropped at `0x6130a8`) runs - /// `TargetNearestEnemy` (`0x6130b5`), which commits its pick before this returns, so a call - /// after this one reads it. The target is then the selection as it stands (`0x6130c1`): a - /// fruitless scan leaves a friendly unit selected for the final gate to refuse, and only an - /// empty selection is `ERR_NO_ATTACK_TARGET` (`0x6130d9`). + /// The guid the swing, the pet's order or the cast goes at, or `None` once the refusal is + /// raised. The candidate is the guid the caller passed, else the selection (`0x61306b`): + /// `AttackTarget` passes none (`0x489b50` → `0x6131a0(0,0)`), nor does the pet bar + /// (`0x4be42b`); TryCast passes the caster on a self-cast press (`0x4e610e`). An empty + /// candidate (`0x61307f`) or a unit the actor is friendly toward (`0x6130a3`, dropped at + /// `0x6130a8`), the caster included, runs `TargetNearestEnemy` (`0x6130b5`), which commits its + /// pick before this returns, so a call after this one reads it. The target is then the + /// selection as it stands (`0x6130c1`): a fruitless scan leaves a friendly unit selected for + /// the final gate to refuse, and only an empty selection is `ERR_NO_ATTACK_TARGET` + /// (`0x6130d9`). pub(crate) fn target( &mut self, + passed: Option, selection: &mut Selection, seam: &mut crate::creature_anim::AttackSeam, errors: &mut crate::ui_action::UiErrorKeys, ) -> Option { - if !selection.guid.is_some_and(|g| self.keeps(g)) { + if !passed.or(selection.guid).is_some_and(|g| self.keeps(g)) { let engaged = self.engaged(); cycle( ScanSide::Enemy, @@ -694,8 +697,12 @@ impl AttackPick<'_, '_> { /// (`0x613099`) is kept as well, for the second resolve to refuse. Actor → target is /// [`reaction_from_player`] (the at-war bit), never `ring_reaction`. The reference's actor is /// the caller, a pet on the pet arm (`0x4bd40d`), which vmangos gives its owner's faction - /// (`Pet.cpp:248`). + /// (`Pet.cpp:248`). The reaction to oneself is 4 before any faction rung (`0x606200`), so a + /// passed caster is never kept, FFA flag or not. fn keeps(&self, guid: u64) -> bool { + if self.scan.self_guid() == Some(guid) { + return false; + } self.scan.store_of(guid).is_none_or(|store| { reaction_from_player( self.scan.factions.as_deref(), diff --git a/crates/benilla-app/src/ui_action/drain.rs b/crates/benilla-app/src/ui_action/drain.rs index e0cf0b3d..7f6266ef 100644 --- a/crates/benilla-app/src/ui_action/drain.rs +++ b/crates/benilla-app/src/ui_action/drain.rs @@ -11,7 +11,7 @@ use benilla_ui::script::UiScript; use crate::net::{ClientCommand, NetCommands}; -use crate::spell::{cast_target, CastCommit, CastLadder}; +use crate::spell::{cast_target, CastLadder}; use super::{attack_actor_refusal, PlayerActions, UiErrorKeys, SPELL_ATTACK}; @@ -85,19 +85,10 @@ impl AttackPress<'_, '_> { /// (`0x5ecb70`). Only the start cancels auto-repeat (`0x5ecd8c`), so stopping melee leaves /// Auto Shot running. pub(crate) fn attack_target(&mut self) { - let Self { - pick, - selection, - seam, - ui_errors, - } = self; - let (me, my_guid) = pick.player(); - if attack_actor_refusal(me, my_guid, ui_errors) { - return; - } - let Some(guid) = pick.target(selection, seam, ui_errors) else { + let Some(guid) = self.validate(None) else { return; }; + let Self { pick, seam, .. } = self; let Ok(e) = seam.me.single() else { return; }; @@ -117,6 +108,29 @@ impl AttackPress<'_, '_> { &seam.net, ); } + + /// TryCast's attack pick (`0x6e4efb`) for a spell press held there + /// ([`crate::spell::HeldForPick`]): the validator alone, with no swing after it. Whether the + /// cast goes on, at the unit the pick left selected. + pub(crate) fn pick_for_cast(&mut self, held: crate::spell::HeldCast) -> bool { + let caster = self.pick.player().1.filter(|_| held.on_self); + self.validate(caster).is_some() + } + + /// The attack validator `0x612df0`: its actor checks, then its target pick. + fn validate(&mut self, passed: Option) -> Option { + let Self { + pick, + selection, + seam, + ui_errors, + } = self; + let (me, my_guid) = pick.player(); + if attack_actor_refusal(me, my_guid, ui_errors) { + return None; + } + pick.target(passed, selection, seam, ui_errors) + } } /// Sends the queued GameObject openers through the cast ladder. The reference reaches `TryCast` @@ -265,10 +279,10 @@ pub(crate) fn use_action( selection.guid, if press.on_self { ", on self" } else { "" } ); - ladder.send( + ladder.send_spell( b.action, &self_bound(targeting.context(), press), - CastCommit::Spell, + press.on_self, ); } // An item action names an entry, so the click finds a copy. A miss only logs, with no diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index 283703d1..c21265f9 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -445,6 +445,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -539,6 +540,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/ui_items/drain.rs b/crates/benilla-app/src/ui_items/drain.rs index 88b443bc..2e8d5028 100644 --- a/crates/benilla-app/src/ui_items/drain.rs +++ b/crates/benilla-app/src/ui_items/drain.rs @@ -803,6 +803,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .insert_resource(NetCommands(tx)); @@ -1747,6 +1748,7 @@ mod bind_confirm_tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .insert_resource(NetCommands(tx)); diff --git a/crates/benilla-app/src/ui_pet/drain.rs b/crates/benilla-app/src/ui_pet/drain.rs index f08c6679..aff04730 100644 --- a/crates/benilla-app/src/ui_pet/drain.rs +++ b/crates/benilla-app/src/ui_pet/drain.rs @@ -90,7 +90,7 @@ impl PetPress<'_, '_> { let mut target_guid = selection.guid.unwrap_or(0); let refused = if is_attack_order(entry) { crate::ui_action::attack_actor_refusal(pet_store, pet.self_guid.0, ui_errors) - || match pick.target(selection, seam, ui_errors) { + || match pick.target(None, selection, seam, ui_errors) { Some(guid) => { target_guid = guid; false diff --git a/crates/benilla-app/src/ui_spellbook.rs b/crates/benilla-app/src/ui_spellbook.rs index 2226780b..e79c0923 100644 --- a/crates/benilla-app/src/ui_spellbook.rs +++ b/crates/benilla-app/src/ui_spellbook.rs @@ -21,7 +21,6 @@ use benilla_ui::script::{ScriptValue, SpellBookState, SpellSlotView, SpellTabVie use crate::entities::ItemDisplays; use crate::items::Items; use crate::net::NetCommands; -use crate::spell::CastCommit; use crate::ui_action::{melee_auto_attack_icon, ranged_weapon_icon, PlayerActions, Spells}; use crate::ui_script::gate; use crate::ui_unit::UnitFeed; @@ -461,7 +460,7 @@ pub(crate) fn cast_spell(cast: &mut crate::spell::ScriptCast, spell_id: u32) { "ui_spellbook: cast {spell_id} (target {:?})", targeting.selection.guid ); - ladder.send(spell_id, &targeting.context(), CastCommit::Spell); + ladder.send_spell(spell_id, &targeting.context(), false); } #[cfg(test)] diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 111a473c..7b56245a 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -619,7 +619,8 @@ fn real_spell_catalog_reads_tooltip_columns() { /// The attack-start masks, rows from vmangos: [`SpellDisplay::on_next_swing`] (`0x404`), /// [`SpellDisplay::initiates_auto_attack`] (adding `AttributesEx & 0x200`) and -/// [`SpellDisplay::initiates_auto_attack_at_go`] (`AttributesEx2 & 0x100000`). +/// [`SpellDisplay::initiates_auto_attack_at_go`] (`AttributesEx2 & 0x100000`), whose union is +/// [`SpellDisplay::initiates_combat`] (`0x6e5200`). #[test] fn real_spell_catalog_classifies_combat_initiation() { let data = crate::wow_data_or_skip!(); @@ -634,6 +635,7 @@ fn real_spell_catalog_classifies_combat_initiation() { (772, "Rend", false, true, false), // Ex 0x8000200 (7386, "Sunder Armor", false, true, false), // Ex 0x8000200 (1464, "Slam", false, true, false), // Ex 0x8000200 + (1752, "Sinister Strike", false, true, false), // Ex 0x8000200 (100, "Charge", false, false, false), // Ex 0x400: neither bit, bit 20 clear (6673, "Battle Shout", false, false, false), // Ex 0x0 (6603, "Attack", false, false, false), // the auto-attack pseudo-spell itself @@ -677,6 +679,14 @@ fn real_spell_catalog_classifies_combat_initiation() { "{name} ({id}) initiates_auto_attack_at_go (Ex2 {:#x})", d.attributes_ex2 ); + assert_eq!( + d.initiates_combat(), + next_swing || initiates || at_go, + "{name} ({id}) initiates_combat (Attributes {:#x}, Ex {:#x}, Ex2 {:#x})", + d.attributes, + d.attributes_ex, + d.attributes_ex2 + ); // Bit 20 suppresses the send-time start, so no spell starts the attack twice. assert!( !(d.initiates_auto_attack() && d.initiates_auto_attack_at_go()), @@ -717,6 +727,42 @@ fn real_spell_catalog_classifies_combat_initiation() { ); } +/// TryCast's attack pick (`0x6e4edf`) is built for a spell press alone, so no form, recipe +/// (`SPELL_EFFECT_CREATE_ITEM` 24, `SPELL_EFFECT_ENCHANT_ITEM` 53) or `modalNextSpell` chain may +/// carry predicate `0x6e5200`. No item's use spell does either (vmangos `item_template`). +#[test] +fn real_spell_catalog_keeps_the_attack_pick_to_spell_presses() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + let (mut checked, mut chained) = (0, 0); + for (id, d) in cat.iter() { + let recipe = d.effects.iter().any(|&e| e == 24 || e == 53); + if d.shapeshift_form.is_some() || recipe { + checked += 1; + assert!( + !d.initiates_combat(), + "{} ({id}) is a form or a recipe of predicate 0x6e5200", + d.name + ); + } + if d.modal_next_spell != 0 { + chained += 1; + let next = cat.get(d.modal_next_spell); + assert!( + !next.is_some_and(|n| n.initiates_combat()), + "{} ({id}) chains {} of predicate 0x6e5200", + d.name, + d.modal_next_spell + ); + } + } + assert!( + checked > 0 && chained > 0, + "the forms, recipes and Auto Shot's chain are read" + ); +} + /// `modalNextSpell`, column 38, which `0x6e7447` reads to start Auto Shot after a hunter shot: /// nonzero on 57 of 22357 rows, 52 of them naming spell 75. #[test] diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index d99413e6..7ca16db6 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -459,13 +459,20 @@ impl SpellDisplay { self.attributes_ex & ATTR_EX_CHANNELED != 0 } + /// Predicate `0x6e5200`: an on-next-swing, `INITIATES_COMBAT` or `INITIATE_COMBAT_POST_CAST` + /// spell. TryCast runs the attack validator's target pick for it (`0x6e4edf`), so a press + /// with no hostile selection acquires one, as the Attack button does. + pub fn initiates_combat(&self) -> bool { + self.attributes & ATTR_ON_NEXT_SWING != 0 + || self.attributes_ex & ATTR_EX_INITIATES_COMBAT != 0 + || self.attributes_ex2 & ATTR_EX2_INITIATE_COMBAT_POST_CAST != 0 + } + /// Casting this starts melee auto-attack at the send unless one runs (`TryCast` tail /// `0x6e51b5`): predicate `0x6e5200` with `AttributesEx2` bit 20 clear, so an on-next-swing /// or `INITIATES_COMBAT` spell. Every cast path shares the tail; there is no macro opt-out. pub fn initiates_auto_attack(&self) -> bool { - (self.attributes & ATTR_ON_NEXT_SWING != 0 - || self.attributes_ex & ATTR_EX_INITIATES_COMBAT != 0) - && self.attributes_ex2 & ATTR_EX2_INITIATE_COMBAT_POST_CAST == 0 + self.initiates_combat() && !self.initiates_auto_attack_at_go() } /// This spell's own `SMSG_SPELL_GO` starts melee auto-attack (`0x6131a0`) at its first hit