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)

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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-28 13:32:22 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c6767d6a79
commit bacbb791db
10 changed files with 421 additions and 315 deletions
@@ -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=<path>` 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=<path>` 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<f64>,
+1 -1
View File
@@ -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) {
+229 -44
View File
@@ -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::<GuidIndex>().0;
@@ -220,6 +245,99 @@ mod tests {
f.app.world().resource::<Selection>().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::<GuidIndex>().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::<GuidIndex>().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<u64>, 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::<crate::ui_action::UiErrorKeys>()
.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::<GuidIndex>().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::<GuidIndex>().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<u64> {
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::<crate::ui_action::PlayerActions>()
.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::<GuidIndex>().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::<Vec<_>>(),
"health {health}"
);
}
}
/// A macro's lines run inside `UseAction` (`0x4e6098 call 0x4f1460`), so what they call lands
+5 -4
View File
@@ -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
+6 -3
View File
@@ -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>() = Selection::default();
world
.run_system_once(
|scan: TargetScan,
|mut pick: AttackPick,
mut sel: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
mut errors: ResMut<crate::ui_action::UiErrorKeys>| {
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")
+92 -139
View File
@@ -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<Candidate> {
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<Engaged>, With<SelfPlayer>)>,
}
/// `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<u64> {
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<u64>, hostile: impl Fn(u64) -> bool) -> Option<u64> {
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<u64>) {
(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<u64> {
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<AttackNearestRequest>,
scan: TargetScan,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
self_store: Query<&crate::net::ObjectStore, With<SelfPlayer>>,
mut ui_error_keys: ResMut<crate::ui_action::UiErrorKeys>,
) {
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() {
+77 -111
View File
@@ -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<crate::bindings::BindingsState>,
targeting: cast_target::CastTargeting,
mut acquire: MessageWriter<crate::target::AttackNearestRequest>,
mut ui_errors: ResMut<UiErrorKeys>,
mut ladder: CastLadder,
mut probe: MessageReader<crate::target::AttackNearestRequest>,
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<crate::target::AttackNearestRequest>,
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
+1 -3
View File
@@ -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.
+3 -3
View File
@@ -31,7 +31,7 @@ pub(crate) struct PetPress<'w, 's> {
pub(crate) pet: PetUnit<'w, 's>,
pub(crate) spells: Option<Res<'w, Spells>>,
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
+2 -2
View File
@@ -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),