From bacbb791db25c2ab9a732ebfac2289ddfad1f4bf Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:32:22 +0700 Subject: [PATCH] The player's Attack runs the attack validator's target pick: a friend selected retargets the nearest enemy in call order, a corpse or a fruitless scan says "You cannot attack that target.", nothing selected and no enemy says "There is nothing to attack." (#249) (#501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The player's Attack (the Attack Target key, `AttackTarget()` and the Attack action button) now picks its target as 1.12.1's validator `0x612df0` does, where before it swung at whatever was selected, a friendly unit or a corpse included. - With nothing selected, or a unit the player is friendly toward (reaction ≥ 4, `0x6130a3`), it runs the Tab cycler for the nearest enemy (`0x6130b5` → `0x493f60`), which commits the new selection at once, so `AttackTarget()` then `/cast` in one script casts at the new target. - It then reads the selection again (`0x6130c1`): none raises "There is nothing to attack." (`0x6130d9`); a unit neither alive nor feigning, or one `CanAttack` (`0x606980`) rejects, raises "You cannot attack that target." (`0x613171`). A friendly unit with no enemy near stays selected and meets that second gate. - The swing toggle runs only once a target is accepted (`0x6131a0`), so Attack at a corpse mid-swing gives the error and keeps the swing. - The pet's attack order shares the same pick. vmangos stops a swing at a friendly or dead target server-side (`CombatHandler.cpp:46-59`), which the client no longer sends. Fixes #249 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/capture/probe_melee.rs | 10 +- crates/benilla-app/src/game_plugins.rs | 2 +- crates/benilla-app/src/script_calls.rs | 273 +++++++++++++++--- crates/benilla-app/src/target/mod.rs | 9 +- crates/benilla-app/src/target/ring.rs | 9 +- crates/benilla-app/src/target/scan.rs | 231 ++++++--------- crates/benilla-app/src/ui_action/drain.rs | 188 +++++------- crates/benilla-app/src/ui_action/mod.rs | 4 +- crates/benilla-app/src/ui_pet/drain.rs | 6 +- crates/benilla-ui/src/script/calls.rs | 4 +- 10 files changed, 421 insertions(+), 315 deletions(-) diff --git a/crates/benilla-app/src/capture/probe_melee.rs b/crates/benilla-app/src/capture/probe_melee.rs index f0caae42..1f607dc2 100644 --- a/crates/benilla-app/src/capture/probe_melee.rs +++ b/crates/benilla-app/src/capture/probe_melee.rs @@ -1,7 +1,7 @@ -//! The melee live probe (`WOW_PROBE=melee`), inert without the env: once in-world it fires the -//! attack-nearest core ([`AttackNearestRequest`]) every few seconds, so the character fights -//! whatever is closest while `WOW_MOVE_TRACE=` records the swing, impact and combat-text -//! timeline. +//! The melee live probe (`WOW_PROBE=melee`), inert without the env: once in-world it presses the +//! player's Attack with nothing selected ([`AttackNearestRequest`]) every few seconds, so the +//! character acquires and fights whatever is nearest while `WOW_MOVE_TRACE=` records the +//! swing, impact and combat-text timeline. //! //! Never run it unattended (`docs/METHOD.md`, "The local server"): it fights with no health //! awareness and the character dies. @@ -21,7 +21,7 @@ impl Plugin for ProbeMeleePlugin { } /// Every 3 s: swing at the selection (idempotent while auto-attacking, and it covers an attacker -/// the acquire core skips), or with none, run the acquire core. +/// the acquire skips), or with none, press Attack, which acquires. fn melee_probe( time: ProbeClock, mut last: Local, diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 7cc7a570..d2a39f53 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -674,7 +674,7 @@ pub(crate) mod schedule_tests { /// 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_956; + const UPDATE_ACTIONABLE_CEILING: usize = 4_890; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/script_calls.rs b/crates/benilla-app/src/script_calls.rs index b35ba2a8..249f7eb7 100644 --- a/crates/benilla-app/src/script_calls.rs +++ b/crates/benilla-app/src/script_calls.rs @@ -30,6 +30,7 @@ pub(crate) fn apply_script_calls( crate::ui_action::ActionPress, crate::ui_items::ScriptItemUse, crate::ui_pet::PetPress, + crate::ui_action::AttackPress, )>, ) { let Some(mut script) = script else { @@ -43,7 +44,7 @@ pub(crate) fn apply_script_calls( } ScriptCall::TargetLastTarget => appliers.p0().target_last_target(), ScriptCall::ClearTarget => appliers.p0().clear_target(), - ScriptCall::AttackTarget => appliers.p4().attack_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(), @@ -54,7 +55,10 @@ pub(crate) fn apply_script_calls( crate::ui_shapeshift::cast_form(&mut appliers.p3(), spell_id); } ScriptCall::UseAction(press) => { - crate::ui_action::use_action(&mut appliers.p4(), script, 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); @@ -116,6 +120,17 @@ mod tests { const NEW: u64 = 0x22; /// Not in the (absent) spell catalog, so the cast's wire target is the selection as it stands. const HEAL: u32 = 2050; + /// A mob the TAB scan can pick: it alone carries a `NetEntity`. + const MOB: u64 = 0x31; + /// `UNIT_FIELD_FACTIONTEMPLATE`, `UNIT_FIELD_HEALTH`, `UNIT_FIELD_FLAGS`. + const TEMPLATE: u16 = 35; + const HEALTH: u16 = 22; + const FLAGS: u16 = 46; + /// The fixture's templates: ours (group 1, friendly toward group 2, enemy of group 4), a + /// friendly NPC's (group 2) and a hostile mob's (group 4). + const OURS: u32 = 1; + const FRIEND: u32 = 2; + const FOE: u32 = 3; struct Frame { app: App, @@ -141,7 +156,17 @@ mod tests { ])), ) }; + // We are a player (`OBJECT_FIELD_TYPE` 0x19, `UNIT_FLAG_PVP_ATTACKABLE`), which selects + // `CanAttack`'s player arm: with no `FactionTemplate.dbc` a plain unit is attackable. let me = world.spawn((SelfPlayer, unit(ME, 0.0))).id(); + world + .entity_mut(me) + .insert(ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (2, 0x19), + (22, 100), + (28, 100), + (46, 0x8), + ]))); let old = world.spawn(unit(OLD, 5.0)).id(); let new = world.spawn(unit(NEW, 6.0)).id(); let index = &mut world.resource_mut::().0; @@ -220,6 +245,99 @@ mod tests { f.app.world().resource::().guid } + /// `MOB` at 3 yd, alive, with `fields` over its health. + fn spawn_mob(f: &mut Frame, fields: &[(u16, u32)]) { + let world = f.app.world_mut(); + let mut pairs = vec![(HEALTH, 100), (28, 100)]; + pairs.extend_from_slice(fields); + let mob = world + .spawn(( + Guid(MOB), + crate::net::NetEntity { + kind: benilla_protocol::EntityKind::Unit, + display_id: None, + scale: 1.0, + }, + Transform::from_xyz(3.0, 0.0, 0.0), + GlobalTransform::from_translation(Vec3::new(3.0, 0.0, 0.0)), + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&pairs)), + )) + .id(); + world.resource_mut::().0.insert(MOB, mob); + } + + /// Give the world a faction table, us [`OURS`] and `OLD` the `old` template with `fields` + /// over it. + fn with_factions(f: &mut Frame, old: u32, fields: &[(u16, u32)]) { + use benilla_formats::{FactionCatalog, FactionTemplate}; + use std::collections::HashMap; + let tpl = |faction, group_mask, friend_group_mask, enemy_group_mask| FactionTemplate { + faction, + group_mask, + friend_group_mask, + enemy_group_mask, + enemies: [0; 4], + friends: [0; 4], + }; + let world = f.app.world_mut(); + world.insert_resource(crate::target::Factions::from_catalog( + FactionCatalog::from_rows( + HashMap::from([ + (OURS, tpl(OURS, 1, 2, 4)), + (FRIEND, tpl(FRIEND, 2, 0, 0)), + (FOE, tpl(FOE, 4, 0, 0)), + ]), + HashMap::new(), + ), + )); + let index = &world.resource::().0; + let (me, old_unit) = (index[&ME], index[&OLD]); + world + .entity_mut(me) + .insert(ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (2, 0x19), + (HEALTH, 100), + (28, 100), + (FLAGS, 0x8), + (TEMPLATE, OURS), + ]))); + let mut pairs = vec![(HEALTH, 100), (28, 100), (TEMPLATE, old)]; + pairs.extend_from_slice(fields); + world + .entity_mut(old_unit) + .insert(ObjectStore(benilla_protocol::ObjectFields::from_pairs( + &pairs, + ))); + } + + /// The `CMSG_ATTACKSWING`s sent, and whether a `CMSG_ATTACKSTOP` went out. + fn swings(f: &Frame) -> (Vec, bool) { + let mut stopped = false; + let swings = + f.rx.try_iter() + .filter_map(|c| match c { + ClientCommand::AttackSwing { guid } => Some(guid), + ClientCommand::AttackStop => { + stopped = true; + None + } + _ => None, + }) + .collect(); + (swings, stopped) + } + + /// The keys of the error lines raised. + fn errors(f: &Frame) -> Vec<&'static str> { + f.app + .world() + .resource::() + .0 + .iter() + .map(|e| e.key) + .collect() + } + /// `/target` then `/cast`: `TargetUnit` commits through `SetSelection 0x493540` before it /// returns, and `CastSpellByName` (`0x4b4ab0`) casts at the selection as it stands then. #[test] @@ -265,39 +383,9 @@ mod tests { /// it returns, so the cast after it in the same script goes to the unit the TAB cycle picked. #[test] fn a_cast_after_target_nearest_enemy_goes_to_the_picked_unit() { - const MOB: u64 = 0x31; let mut f = frame(true); - let world = f.app.world_mut(); - // With no `FactionTemplate.dbc` a plain unit is attackable; OLD and NEW carry no - // `NetEntity`, so the scan's one candidate is the mob. - let mob = world - .spawn(( - Guid(MOB), - crate::net::NetEntity { - kind: benilla_protocol::EntityKind::Unit, - display_id: None, - scale: 1.0, - }, - Transform::from_xyz(3.0, 0.0, 0.0), - GlobalTransform::from_translation(Vec3::new(3.0, 0.0, 0.0)), - ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ - (22, 100), - (28, 100), - ])), - )) - .id(); - world.resource_mut::().0.insert(MOB, mob); - // We are a player (`OBJECT_FIELD_TYPE` 0x19, `UNIT_FLAG_PVP_ATTACKABLE`), which selects - // `CanAttack`'s player arm. - let me = world.resource::().0[&ME]; - world - .entity_mut(me) - .insert(ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ - (2, 0x19), - (22, 100), - (28, 100), - (46, 0x8), - ]))); + // OLD and NEW carry no `NetEntity`, so the scan's one candidate is the mob. + spawn_mob(&mut f, &[]); run(&mut f, r#"TargetNearestEnemy() CastSpellByName("Heal")"#); assert_eq!(casts(&f), vec![(HEAL, Some(MOB))]); assert_eq!(selected(&f), Some(MOB)); @@ -331,20 +419,117 @@ mod tests { /// left it. #[test] fn attack_target_swings_at_the_selection_in_call_order() { - let swings = |f: &Frame| -> Vec { - f.rx.try_iter() - .filter_map(|c| match c { - ClientCommand::AttackSwing { guid } => Some(guid), - _ => None, - }) - .collect() - }; let mut f = frame(true); run(&mut f, r#"AttackTarget() TargetUnit("party1")"#); - assert_eq!(swings(&f), vec![OLD]); + assert_eq!(swings(&f), (vec![OLD], false)); let mut f = frame(true); run(&mut f, r#"TargetUnit("party1") AttackTarget()"#); - assert_eq!(swings(&f), vec![NEW]); + assert_eq!(swings(&f), (vec![NEW], false)); + } + + /// `0x6130a3`: a unit we are friendly toward is dropped (`0x6130a8`) and `TargetNearestEnemy` + /// runs (`0x6130b5`), moving the selection; the swing goes at what it picked (`0x6130c1`). + /// The Attack button reaches the same `0x6131a0` through `TryCast` (`0x6e4c90`). + #[test] + fn attack_with_a_friend_selected_swings_at_the_nearest_enemy_instead() { + for lua in ["AttackTarget()", "UseAction(1)"] { + let mut f = frame(true); + with_factions(&mut f, FRIEND, &[]); + spawn_mob(&mut f, &[(TEMPLATE, FOE)]); + f.app + .world_mut() + .resource_mut::() + .buttons + .insert( + 0, + ActionButton { + slot: 0, + action: crate::ui_action::SPELL_ATTACK, + kind: benilla_protocol::messages::ACTION_KIND_SPELL, + }, + ); + run(&mut f, lua); + assert_eq!(selected(&f), Some(MOB), "{lua}"); + assert_eq!(swings(&f), (vec![MOB], false), "{lua}"); + assert_eq!(errors(&f), Vec::<&str>::new(), "{lua}"); + } + } + + /// The retarget commits before `AttackTarget` returns, so a cast after it in the same script + /// goes to the enemy it picked, from a friend or from nothing selected. + #[test] + fn a_cast_after_attack_target_goes_to_the_enemy_it_picked() { + for selected_before in [true, false] { + let mut f = frame(selected_before); + with_factions(&mut f, FRIEND, &[]); + spawn_mob(&mut f, &[(TEMPLATE, FOE)]); + run(&mut f, r#"AttackTarget() CastSpellByName("Heal")"#); + assert_eq!( + casts(&f), + vec![(HEAL, Some(MOB))], + "selected before: {selected_before}" + ); + } + } + + /// With no enemy in reach the scan moves nothing (`0x493f60`), so the selection re-read at + /// `0x6130c1` is the friend, which the final gate's `CanAttack` refuses (`0x613171`); only an + /// empty selection is "There is nothing to attack." (`0x6130d9`). Neither swings. + #[test] + fn attack_with_no_enemy_near_refuses_by_what_is_selected() { + let mut f = frame(true); + with_factions(&mut f, FRIEND, &[]); + run(&mut f, "AttackTarget()"); + assert_eq!(selected(&f), Some(OLD)); + assert_eq!(errors(&f), vec!["ERR_INVALID_ATTACK_TARGET"]); + assert_eq!(swings(&f), (vec![], false)); + + let mut f = frame(false); + with_factions(&mut f, FRIEND, &[]); + run(&mut f, "AttackTarget()"); + assert_eq!(selected(&f), None); + assert_eq!(errors(&f), vec!["ERR_NO_ATTACK_TARGET"]); + assert_eq!(swings(&f), (vec![], false)); + } + + /// A hostile corpse is kept (`0x6130a3` reads the reaction alone), so no scan runs past it to + /// the live mob, and the final gate refuses a unit neither alive nor feigning (`0x613152`– + /// `0x613165`): "You cannot attack that target.", and no swing, which vmangos would stop + /// (`CombatHandler.cpp:53-58`). + #[test] + fn attack_at_a_corpse_cannot_attack_it() { + let mut f = frame(true); + with_factions(&mut f, FOE, &[(HEALTH, 0)]); + spawn_mob(&mut f, &[(TEMPLATE, FOE)]); + run(&mut f, "AttackTarget()"); + assert_eq!(selected(&f), Some(OLD)); + assert_eq!(errors(&f), vec!["ERR_INVALID_ATTACK_TARGET"]); + assert_eq!(swings(&f), (vec![], false)); + } + + /// `0x6131a0` toggles only past the validator: attacking a live foe stops, and a press the + /// validator refuses neither stops nor swings. + #[test] + fn attack_toggles_off_only_past_the_validator() { + for (health, stopped, refused) in [ + (100, true, None), + (0, false, Some("ERR_INVALID_ATTACK_TARGET")), + ] { + let mut f = frame(true); + with_factions(&mut f, FOE, &[(HEALTH, health)]); + let world = f.app.world_mut(); + let me = world.resource::().0[&ME]; + world + .entity_mut(me) + .insert(crate::creature_anim::Engaged(OLD)); + run(&mut f, "AttackTarget()"); + assert_eq!(swings(&f), (vec![], stopped), "health {health}"); + assert_eq!( + errors(&f), + refused.into_iter().collect::>(), + "health {health}" + ); + } } /// A macro's lines run inside `UseAction` (`0x4e6098 call 0x4f1460`), so what they call lands diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 632f1405..ae43540f 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -62,10 +62,10 @@ pub(crate) use flash::CombatFlash; pub(crate) use price_discount::vendor_price_discount; #[cfg(test)] pub(crate) use price_discount::{stormwind_fixture, HUMAN_WARRIOR}; -// The attack-with-no-target request, and the same nearest-enemy core called synchronously for the -// pet bar's Attack, whose order must leave in the frame it was pressed. pub(crate) use relations::{can_assist, can_attack, can_interact, corpse_friendly}; -pub(crate) use scan::{attack_order_target, AttackNearestRequest, TargetScan}; +// The attack validator's target half, run in the call that asks, for the player's Attack and the +// pet bar's; the melee probe's press with nothing selected. +pub(crate) use scan::{AttackNearestRequest, AttackPick}; // The chat layer's by-name asks (`/target`, `/assist`). pub(crate) use by_name::{AssistRequest, TargetByNameRequest}; // The reaction decode and its faction catalog, which also tint the target frame @@ -337,7 +337,8 @@ impl Plugin for TargetPlugin { // The TAB keys' cycler (`0x493f60`, mode 1); the `TargetNearest*` Lua calls // run the same cycle as script calls. scan::tab_target, - scan::acquire_and_attack, + // The ATTACKTARGET binding and the melee probe's press, the player's Attack. + crate::ui_action::attack_target_binding, flash::drive_flash, // The last-enemy stamp before the ring's death-clear, so a hostile that dies // selected is still remembered (the reference's `TargetLastEnemy` has no diff --git a/crates/benilla-app/src/target/ring.rs b/crates/benilla-app/src/target/ring.rs index 7265865a..b13477e9 100644 --- a/crates/benilla-app/src/target/ring.rs +++ b/crates/benilla-app/src/target/ring.rs @@ -1104,7 +1104,7 @@ mod tests { use crossbeam_channel::Receiver; use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer}; - use crate::target::{attack_order_target, Selection, TargetScan}; + use crate::target::{AttackPick, Selection}; use benilla_world::model_fade::DespawnFade; const ME: u64 = 0x0000_0000_0000_0007; @@ -1123,7 +1123,10 @@ mod tests { transport_progress: None, transport: None, spline: None, + // `OBJECT_FIELD_TYPE` (2) 0x9, object and unit, as a creature's create carries it: + // `SetSelection`'s `IsSelectable` refuses any other type. fields: ObjectFields::from_pairs(&[ + (2, 0x9), (FIELD_UNIT_HEALTH, 100), (FIELD_UNIT_MAXHEALTH, 100), (FIELD_UNIT_LEVEL, 9), @@ -1223,11 +1226,11 @@ mod tests { *world.resource_mut::() = Selection::default(); world .run_system_once( - |scan: TargetScan, + |mut pick: AttackPick, mut sel: ResMut, mut seam: crate::creature_anim::AttackSeam, mut errors: ResMut| { - attack_order_target(&scan, &mut sel, &mut seam, &mut errors) + pick.target(&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 005fadfb..554870f5 100644 --- a/crates/benilla-app/src/target/scan.rs +++ b/crates/benilla-app/src/target/scan.rs @@ -289,33 +289,6 @@ impl TargetScan<'_, '_> { self.stores.get(entity).ok() } - /// `0x6130a3`'s keep test: the actor's reaction to the held guid alone (`0x61309b`), not the - /// final gate's `CanAttack`; an unstreamed guid is not hostile (`0x613099`). 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`). - fn reaction_hostile(&self, guid: u64) -> bool { - let self_store = self.self_store(); - self.units - .iter() - .find(|(_, _, g, _, _)| g.0 == guid) - .is_some_and(|(_, _, _, _, store)| { - reaction_from_player( - self.factions.as_deref(), - &self.reputations, - store, - self_store, - ) < 4 - }) - } - - fn unit_by_guid(&self, guid: u64) -> Option<(Entity, Option<&ObjectStore>)> { - self.units - .iter() - .find(|(_, _, g, _, _)| g.0 == guid) - .map(|(e, _, _, _, store)| (e, store)) - } - /// Filter, project, score and sort every known unit, fresh each press. fn build(&self, side: ScanSide) -> Vec { let Ok((self_tf, self_store, self_guid)) = self.self_q.single() else { @@ -637,73 +610,101 @@ pub(super) fn tab_target( ); } -/// The attack acquire (`0x6130b5`) is `TargetNearestEnemy()` itself, `0x493f60(0, 1)`, so it moves -/// the player's target. Deviation: a repeated acquire returns the same head, where the reference's -/// cursor walks on, because this module re-scores the live world on every call. -fn acquire_nearest_enemy( - scan: &TargetScan, - selection: &mut Selection, - seam: &mut crate::creature_anim::AttackSeam, - errors: &mut crate::ui_action::UiErrorKeys, -) -> Option<(Entity, u64)> { - let cands = scan.build(ScanSide::Enemy); - let Some(c) = cands.first() else { - // `0x6130d9`: still nothing after the acquire, error `0xa0`. - debug!("attack acquire: nothing to attack (ERR_NO_ATTACK_TARGET)"); - errors - .0 - .push(crate::ui_action::UiError::key("ERR_NO_ATTACK_TARGET")); - return None; - }; - // Not engaged: every path here held no selection or a non-hostile one. - commit( - selection, - seam, - c.entity, - c.guid, - scan.store_at(c.entity), - false, - scan.self_guid(), - false, - ); - Some((c.entity, c.guid)) +/// The target half of the attack validator `0x612df0` (`0x61305d`–`0x613198`), shared by the +/// player's Attack (`0x6131aa`) and the pet bar's (`0x4bd40d`): what it reads beyond the +/// selection, the cycler it runs as `TargetNearestEnemy` (`0x6130b5`, `0x493f60(0, 1)`), and +/// whether we are swinging, which that cycle's selection switch reads. +#[derive(SystemParam)] +pub(crate) struct AttackPick<'w, 's> { + scan: TargetScan<'w, 's>, + history: ResMut<'w, TabHistory>, + time: Res<'w, Time>, + engaged: Query<'w, 's, (), (With, With)>, } -/// `0x612df0`'s target arm: the selection (`0x61306b`) if the actor is hostile to it (`0x6130a3`), -/// else an acquire (`0x6130b5`), then the final gate (`0x613167`). The result is the guid -/// `CMSG_PET_ACTION` carries (`0x4bd491`); a dead selection is `ERR_INVALID_ATTACK_TARGET`, not a -/// reason to acquire. -pub(crate) fn attack_order_target( - scan: &TargetScan, - selection: &mut Selection, - seam: &mut crate::creature_anim::AttackSeam, - errors: &mut crate::ui_action::UiErrorKeys, -) -> Option { - let guid = match keeps_held_target(selection.guid, |g| scan.reaction_hostile(g)) { - Some(kept) => kept, - None => acquire_nearest_enemy(scan, selection, seam, errors)?.1, - }; - // `0x613167`'s target legs; its actor legs (`0x61312e`) ran in the caller. - let store = scan.unit_by_guid(guid).and_then(|(_, s)| s); - if !attack_target_valid( - store, - scan.factions.as_deref(), - &scan.reputations, - scan.self_store(), - ) { - debug!("attack order: {guid:#x} fails the final gate (ERR_INVALID_ATTACK_TARGET)"); - errors - .0 - .push(crate::ui_action::UiError::key("ERR_INVALID_ATTACK_TARGET")); - return None; +impl AttackPick<'_, '_> { + /// Whether we are attacking (`0x60ecb0`), as the server last echoed it. + pub(crate) fn engaged(&self) -> bool { + !self.engaged.is_empty() } - Some(guid) -} -/// `0x6130a3`: keep a selection the actor is hostile to. A friendly or neutral one is dropped -/// (`0x6130a8`) and the acquire runs, so pet-Attack with a quest giver selected retargets a mob. -fn keeps_held_target(selection: Option, hostile: impl Fn(u64) -> bool) -> Option { - selection.filter(|&g| hostile(g)) + /// Our own descriptor and guid, the actor of the player's Attack. + pub(crate) fn player(&self) -> (Option<&ObjectStore>, Option) { + (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`). + pub(crate) fn target( + &mut self, + 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)) { + let engaged = self.engaged(); + cycle( + ScanSide::Enemy, + false, + self.time.elapsed_secs_f64(), + &self.scan, + &mut self.history, + selection, + seam, + engaged, + ); + } + let Some(guid) = selection.guid else { + debug!("attack: nothing to attack (ERR_NO_ATTACK_TARGET)"); + errors + .0 + .push(crate::ui_action::UiError::key("ERR_NO_ATTACK_TARGET")); + return None; + }; + // `0x613118`, the second resolve: a miss refuses with no message (`0x613123`). + let Some(store) = self.scan.store_of(guid) else { + debug!("attack: {guid:#x} does not resolve, refused silently"); + return None; + }; + // The final gate's target legs; its actor legs (`0x61312e`, `0x613139`) repeat phase A's + // dead and mounted refusals, which the caller ran. + if !attack_target_valid( + Some(store), + self.scan.factions.as_deref(), + &self.scan.reputations, + self.scan.self_store(), + ) { + debug!("attack: {guid:#x} fails the final gate (ERR_INVALID_ATTACK_TARGET)"); + errors + .0 + .push(crate::ui_action::UiError::key("ERR_INVALID_ATTACK_TARGET")); + return None; + } + Some(guid) + } + + /// `0x6130a3`'s keep test: the actor's reaction to the held unit alone (`0x61309b`), not the + /// final gate's `CanAttack`, so a neutral mob is kept. A guid that does not resolve + /// (`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`). + fn keeps(&self, guid: u64) -> bool { + self.scan.store_of(guid).is_none_or(|store| { + reaction_from_player( + self.scan.factions.as_deref(), + &self.scan.reputations, + Some(store), + self.scan.self_store(), + ) < 4 + }) + } } /// The final gate's target legs (`0x613152`–`0x613169`): alive, where a zero-health target passes @@ -721,49 +722,10 @@ fn attack_target_valid( alive && can_attack(store, factions, reputations, self_store) } -/// The attack action fired with no selection: acquire and swing (`0x612df0` at `0x6130b5`). +/// The melee probe's press with nothing selected: the player's Attack, which acquires. #[derive(Message)] pub(crate) struct AttackNearestRequest; -/// Acquire the best candidate and start attacking it; the TAB history is untouched. -pub(super) fn acquire_and_attack( - mut requests: MessageReader, - scan: TargetScan, - mut selection: ResMut, - mut seam: crate::creature_anim::AttackSeam, - self_store: Query<&crate::net::ObjectStore, With>, - mut ui_error_keys: ResMut, -) { - if requests.read().last().is_none() { - return; - } - if selection.guid.is_some() { - return; // selected since the action: the normal path owns it - } - // `0x612df0`'s actor checks precede the acquire: a mounted, stunned or dead press never scans. - let self_guid = scan - .self_q - .iter() - .next() - .and_then(|(_, _, g)| g) - .map(|g| g.0); - if crate::ui_action::attack_actor_refusal( - self_store.iter().next(), - self_guid, - &mut ui_error_keys, - ) { - return; - } - let Some((_, guid)) = - acquire_nearest_enemy(&scan, &mut selection, &mut seam, &mut ui_error_keys) - else { - return; - }; - debug!("attack acquire: best candidate {guid:#x} → select + swing"); - // `0x6131a0`: StartAttack through the seam, with no stop in flight. - seam.start(guid, false, false); -} - /// `TargetLastEnemy`'s memory, the reference's last-attackable guid `[0xb4e2e8]`, read at /// `0x489b45` and written at `0x49377d` in `SetSelection`. Never cleared: an unresolved guid is a /// bare `ret` in the select helper `0x489a40`, not a deselect. @@ -1171,15 +1133,6 @@ mod tests { assert_eq!(pick_forward(&[], &[], None), None); } - /// `0x6130a3`: a friendly selection is a reason to acquire, not an error. - #[test] - fn phase_b_keeps_only_a_hostile_selection() { - let hostile = |g: u64| g == 0xBAD; - assert_eq!(keeps_held_target(Some(0xBAD), hostile), Some(0xBAD)); - assert_eq!(keeps_held_target(Some(0x600D), hostile), None, "friendly"); - assert_eq!(keeps_held_target(None, hostile), None, "no selection"); - } - /// `0x613152`–`0x613169`: a zero-health target is invalid unless dynamic-flag bit 5 is set. #[test] fn the_attack_orders_final_gate_reads_health_then_can_attack() { diff --git a/crates/benilla-app/src/ui_action/drain.rs b/crates/benilla-app/src/ui_action/drain.rs index 64ff885a..e0cf0b3d 100644 --- a/crates/benilla-app/src/ui_action/drain.rs +++ b/crates/benilla-app/src/ui_action/drain.rs @@ -55,61 +55,67 @@ pub(super) fn item_action_route( } } -/// The ATTACKTARGET binding (default T), whose stock body is `AttackTarget()`: the same toggle as -/// the Lua call ([`ActionPress::attack_target`]). -pub(super) fn attack_target_binding( +/// The ATTACKTARGET binding (default T), whose stock body is `AttackTarget()`, and the melee +/// probe's press with nothing selected: both the player's Attack ([`AttackPress::attack_target`]). +pub(crate) fn attack_target_binding( binds: Res, - targeting: cast_target::CastTargeting, - mut acquire: MessageWriter, - mut ui_errors: ResMut, - mut ladder: CastLadder, + mut probe: MessageReader, + mut press: AttackPress, ) { - if binds.fired(crate::bindings::cmd::ATTACK_TARGET) { - attack_target(&targeting, &mut acquire, &mut ui_errors, &mut ladder); + let probed = probe.read().last().is_some() && press.selection.guid.is_none(); + if binds.fired(crate::bindings::cmd::ATTACK_TARGET) || probed { + press.attack_target(); } } -/// `AttackTarget` (`0x489b50` → `0x6131a0(0,0)`): the action bar's attack arm without a slot, as -/// `UseAction`'s Attack also lands in `0x612df0`. A held selection toggles the swing at it; none -/// acquires the nearest enemy. -fn attack_target( - targeting: &cast_target::CastTargeting, - acquire: &mut MessageWriter, - ui_errors: &mut UiErrorKeys, - ladder: &mut CastLadder, -) { - if attack_actor_refusal( - targeting.self_store.iter().next(), - targeting.context().self_guid, - ui_errors, - ) { - return; - } - match targeting.selection.guid { - Some(guid) => { - let Ok((e, engaged)) = ladder.self_player.single() else { - return; - }; - debug!( - "AttackTarget {} at {guid:#x}", - if engaged { "toggled off" } else { "swing" } - ); - // The action button's own toggle (`0x6131a0`). - crate::creature_anim::toggle_attack_local( - e, - guid, - engaged, - &mut ladder.queued_melee, - &mut ladder.auto_repeat, - &mut ladder.sheath, - &mut ladder.ecs, - &ladder.commands, - ); - } - None => { - debug!("AttackTarget with no target — acquiring nearest"); - acquire.write(crate::target::AttackNearestRequest); +/// What the player's Attack reads and writes: `AttackTarget` (`0x489b50`), the ATTACKTARGET +/// binding and `UseAction`'s Attack slot (through `TryCast`, `0x6e4c90`) all run `0x6131a0`. +#[derive(bevy::ecs::system::SystemParam)] +pub(crate) struct AttackPress<'w, 's> { + pick: crate::target::AttackPick<'w, 's>, + selection: ResMut<'w, crate::target::Selection>, + seam: crate::creature_anim::AttackSeam<'w, 's>, + ui_errors: ResMut<'w, UiErrorKeys>, +} + +impl AttackPress<'_, '_> { + /// `0x6131a0`: the attack validator `0x612df0` (`0x6131aa`) runs first, its actor checks then + /// its target pick, which may move the selection; a refusal ends the press. Then the toggle: + /// attacking (`0x60ecb0`) stops (`0x5ecac0`), else it starts at the validator's target + /// (`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 { + return; + }; + let Ok(e) = seam.me.single() else { + return; + }; + let engaged = pick.engaged(); + debug!( + "attack {} at {guid:#x}", + if engaged { "toggled off" } else { "swing" } + ); + crate::creature_anim::toggle_attack_local( + e, + guid, + engaged, + &mut seam.queued_melee, + &mut seam.auto_repeat, + &mut seam.sheath, + &mut seam.ecs, + &seam.net, + ); } } @@ -179,7 +185,6 @@ fn self_bound<'a>( pub(crate) struct ActionPress<'w, 's> { actions: Res<'w, PlayerActions>, targeting: cast_target::CastTargeting<'w, 's>, - acquire: MessageWriter<'w, crate::target::AttackNearestRequest>, // The by-key error line. Not a `CastLadder` field: Bevy panics, at runtime and not in unit // tests, on a resource reachable twice from one system. ui_errors: ResMut<'w, UiErrorKeys>, @@ -187,30 +192,27 @@ pub(crate) struct ActionPress<'w, 's> { gate: crate::ui_bind_confirm::BindGate<'w>, } -impl ActionPress<'_, '_> { - /// `AttackTarget()` from the call queue, at the selection the calls before it left. - pub(crate) fn attack_target(&mut self) { - attack_target( - &self.targeting, - &mut self.acquire, - &mut self.ui_errors, - &mut self.ladder, - ); - } +/// What a `UseAction` press leaves to another applier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[must_use] +pub(crate) enum UseOutcome { + Done, + /// The Attack slot passed `TryCast`'s dead rung: the player's Attack ([`AttackPress`]) runs. + Attack, } -/// `UseAction` (`0x4e5ee0`) on the pressed slot: a cast, a swing, an item use or a macro run, at -/// the selection as the calls before it left it. A macro's lines run inside it (`0x4e6098 call -/// 0x4f1460`), so the calls they make queue for [`crate::script_calls`] to apply next. +/// `UseAction` (`0x4e5ee0`) on the pressed slot: a cast, an item use or a macro run, at the +/// selection as the calls before it left it, or the Attack slot, which it hands back for the +/// player's Attack to run. A macro's lines run inside it (`0x4e6098 call 0x4f1460`), so the calls +/// they make queue for [`crate::script_calls`] to apply next. pub(crate) fn use_action( p: &mut ActionPress, script: &mut UiScript, press: benilla_ui::script::ActionUse, -) { +) -> UseOutcome { let ActionPress { actions, targeting, - acquire, ui_errors, ladder, gate, @@ -219,54 +221,17 @@ pub(crate) fn use_action( let action = press.action; let slot = match u8::try_from(action.saturating_sub(1)) { Ok(s) => s, - Err(_) => return, + Err(_) => return UseOutcome::Done, }; match actions.buttons.get(&slot) { Some(b) if b.kind == ACTION_KIND_SPELL && b.action == SPELL_ATTACK => { // `UseAction` casts Attack through TryCast, so its dead rung comes first: "You are // dead", where the binding, which skips TryCast, reads "Can't attack while dead." + // Then TryCast's effect-0x4e short-circuit runs the Attack toggle (`0x6e4c90`). if ladder.dead_refusal(SPELL_ATTACK, targeting.self_store.iter().next()) { - return; - } - // The attack validator's actor gates (`0x612df0`) precede both the swing and the - // nearest-enemy scan (`0x6130b5`), so both arms gate here. - if attack_actor_refusal( - targeting.self_store.iter().next(), - targeting.context().self_guid, - ui_errors, - ) { - return; - } - match selection.guid { - Some(guid) => { - // A toggle (`0x6131a0`, via `TryCast`'s effect-0x4e short-circuit - // `0x6e4c7a`): attacking (`0x60ecb0`) stops (`0x5ecac0`), else starts - // (`0x5ecb70`). Only the start cancels auto-repeat (`0x5ecd8c`), so - // stopping melee leaves Auto Shot running. - let Ok((e, engaged)) = ladder.self_player.single() else { - return; - }; - debug!( - "ui_action: attack {} at {guid:#x}", - if engaged { "toggled off" } else { "swing" } - ); - crate::creature_anim::toggle_attack_local( - e, - guid, - engaged, - &mut ladder.queued_melee, - &mut ladder.auto_repeat, - &mut ladder.sheath, - &mut ladder.ecs, - &ladder.commands, - ); - } - // No target: the reference swings at the nearest enemy (`0x6130b5`). - None => { - debug!("ui_action: attack with no target — acquiring nearest"); - acquire.write(crate::target::AttackNearestRequest); - } + return UseOutcome::Done; } + return UseOutcome::Attack; } Some(b) if b.kind == ACTION_KIND_SPELL => { // In `UseAction 0x4e5ee0` only: re-pressing the spell whose targeting cursor is @@ -278,7 +243,7 @@ pub(crate) fn use_action( b.action ); ladder.ground.clear(); - return; + return UseOutcome::Done; } // A live `ActiveIconID` spell re-pressed cancels its aura (`0x4e55f0`, cancel // `0x4e60c1`). The form-match toggle is `CastSpell`'s alone, not `UseAction`'s. @@ -290,7 +255,7 @@ pub(crate) fn use_action( .commands .0 .send(crate::net::ClientCommand::CancelAura { spell_id: b.action }); - return; + return UseOutcome::Done; } } } @@ -310,7 +275,7 @@ pub(crate) fn use_action( // red error line: nothing was attempted. Some(b) if b.kind == ACTION_KIND_ITEM => { let Some(store) = targeting.self_store.iter().next() else { - return; + return UseOutcome::Done; }; let template = ladder .items @@ -322,7 +287,7 @@ pub(crate) fn use_action( "ui_action: item action {action} (entry {}) has no template yet — skipped", b.action ); - return; + return UseOutcome::Done; }; let route = item_action_route(&template, |s| { crate::ui_items::find_item(&store.0, &ladder.objects, b.action, s) @@ -335,7 +300,7 @@ pub(crate) fn use_action( "ui_action: item action {action} (entry {}) is nowhere in the inventory — skipped", b.action ); - return; + return UseOutcome::Done; } }; if equip { @@ -402,6 +367,7 @@ pub(crate) fn use_action( } None => debug!("ui_action: UseAction({action}) on an empty slot"), } + UseOutcome::Done } /// Applies the queued `PickupAction`/`PlaceAction` writes (packed 0 clears) to the store, which diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index d4564369..58ee712f 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -17,7 +17,7 @@ use benilla_assets::{AssetSet, LockRecover, WorldAssets}; mod cast_fail; mod drain; // A `UseAction` press, applied in call order by `crate::script_calls`. -pub(crate) use drain::{use_action, ActionPress}; +pub(crate) use drain::{attack_target_binding, use_action, ActionPress, AttackPress, UseOutcome}; #[cfg(test)] mod drain_tests; pub(crate) mod drop_item; @@ -242,8 +242,6 @@ impl Plugin for UiActionPlugin { .in_set(CooldownEvents) .after(feed::feed_actions), drain::drain_action_sets.after(UiInput), - // The ATTACKTARGET binding, after the dispatch wrote this frame's key fires. - drain::attack_target_binding.after(UiInput), // The target chain queues the openers earlier in the frame. drain::drain_go_openers.after(UiInput), // The latches must be current before the target chain's cursor reads them. diff --git a/crates/benilla-app/src/ui_pet/drain.rs b/crates/benilla-app/src/ui_pet/drain.rs index c7112b88..f08c6679 100644 --- a/crates/benilla-app/src/ui_pet/drain.rs +++ b/crates/benilla-app/src/ui_pet/drain.rs @@ -31,7 +31,7 @@ pub(crate) struct PetPress<'w, 's> { pub(crate) pet: PetUnit<'w, 's>, pub(crate) spells: Option>, ui_errors: ResMut<'w, crate::ui_action::UiErrorKeys>, - scan: crate::target::TargetScan<'w, 's>, + pick: crate::target::AttackPick<'w, 's>, seam: crate::creature_anim::AttackSeam<'w, 's>, } @@ -62,7 +62,7 @@ impl PetPress<'_, '_> { pet, spells, ui_errors, - scan, + pick, seam, } = self; let pet_guid = bar.spells.pet_guid; @@ -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 crate::target::attack_order_target(scan, selection, seam, ui_errors) { + || match pick.target(selection, seam, ui_errors) { Some(guid) => { target_guid = guid; false diff --git a/crates/benilla-ui/src/script/calls.rs b/crates/benilla-ui/src/script/calls.rs index 9bca09de..9dc326a8 100644 --- a/crates/benilla-ui/src/script/calls.rs +++ b/crates/benilla-ui/src/script/calls.rs @@ -24,8 +24,8 @@ pub enum ScriptCall { TargetLastTarget, /// `ClearTarget()` (`0x489ff0` → `0x493540(0,0)`), a no-op when nothing is selected by then. ClearTarget, - /// `AttackTarget()` (`0x489b50` → `0x6131a0(0,0)`): the attack toggle at the selection as it - /// stands then. + /// `AttackTarget()` (`0x489b50` → `0x6131a0(0,0)`): the attack validator's pick from the + /// selection as it stands then, which may move it to the nearest enemy, then the toggle. AttackTarget, /// `SpellTargetUnit(unit)` (`0x6e6d90`), made while the cursor was up. SpellTargetUnit(String),