Files
benilla-octo/crates/benilla-app/src/target/scan.rs
T
SamandClaude Opus 5.5 c2d6f7c858 the spell owns its state, and a malformed file is refused, not a crash
- the spell owns its lifecycle state: the in-flight cast, the local cancel, the cooldowns and the spell-modifier tables live in one module (2328)
- a malformed MPQ, DBC, M2 or WDT file is refused instead of crashing the client, nine read sites bounded
- a test that needs the install or the addon corpus fails instead of skipping when the data is there; the Bagnon render suite runs again (2329)
- the dev crash injector is armed on every platform, and no instrument writes a path relative to the working directory

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:08:33 +07:00

1854 lines
87 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TAB / nearest-enemy targeting + combat auto-target — the **Classic-priority selection**
//! (decision 0567) over the 1.12 wire laws.
//!
//! The 1.12 byte algorithm (±30° cone about the *character's* facing, cone-first-then-nearest
//! sort, 10-yd out-of-cone bubble, snapshot list + cursor cycling — wow-re
//! `object-layer/scratch/targeting-nearest-and-autoacquire.md`, §5-verified twice) was
//! implemented faithfully, live-reproduced, and then **replaced at the director's call**: the
//! authentic law skips a close mob that fills your screen (at 2 yd the cone is ~±1 yd wide, and
//! the camera sits ~9 yd behind — "right in front of my cam" is routinely 40°+ off the
//! character axis), and its snapshot cycle walks into behind-you mobs. Blizzard hit the same
//! wall and rewrote targeting for Legion 7.2 — the system Classic Era ships. We follow that
//! design, pinned from the Classic binary's own cvar-description strings (statically recovered;
//! the code section is packer-encrypted) and the 7.2 patch note:
//!
//! - **Screen-space tiering** — a candidate whose projected point lands inside the camera
//! frustum with each screen edge pulled in (`TargetPriorityFrustumPullIn{Sides,Top,Bot}`:
//! "Percentage towards center to pull in [edge] of the screen for in-view checks") is a
//! tier-1 ("100% correct") pick; off-screen candidates are reachable **only when no
//! on-screen candidate exists** (`TargetPriorityAllowAnyOnScreen` = 1 semantics).
//! - **A weighted score, not a lexicographic sort** (`TargetPriorityValueBank`): screen
//! off-centerness + normalized distance − an "attacking me" bonus; lowest wins.
//! - **A fighting-me score bonus, not Classic's hard combat lock**: units attacking me outrank
//! everything peaceful, but TAB can always walk off them onto a fresh target. Classic's
//! `TargetPriorityCombatLock` (pool restricted to in-combat units) was implemented and then
//! retired by the director's feel — decision 0568: with a single attacker it pinned TAB to
//! that mob with no way off.
//! - **History cycling, no snapshot cursor** (`TargetPriorityHighlightHistoryMs` — "time target
//! history should be maintained for repeated activations"): every press re-scores the live
//! world and skips recently-tabbed guids; exhausting the pool clears the history (the wrap).
//! Shift-TAB steps back through the history. A clicked target joins the history when tabbed
//! away from (`TargetPriorityContinueFromManualTarget`'s observable).
//!
//! The **numbers** here (pull-in margins, weights, the history window) are tunable stand-ins —
//! Blizzard's defaults live in the encrypted code section; each const names its cvar so a later
//! pin (the director's in-game `GetCVarInfo` dump) slots straight in. Not modeled, disclosed:
//! `TargetPriorityUpdateDelay` (a timer-refreshed list feeding the hold-to-highlight preview we
//! don't have — per-press rebuild covers selection), `TargetPriorityAutoTargetIgnoreWindow`,
//! `AllowAnyOnScreen=2` (out-of-range picks), PvP weighting, and the 7.2 note's
//! character-OR-camera visibility union (we test the camera only).
//!
//! **What stays byte-law from 1.12** (wire/data semantics, unchanged): the validity filters
//! (dead / [`can_attack`] `0x606980` / CreatureType flag-bit-0 critters), the [`commit`]
//! stop→select→re-swing SetSelection law (`0x493540`), attack-with-no-target acquiring the best
//! candidate and swinging (`0x612df0` @ `6130b5`), and the ATTACKERSTATEUPDATE auto-acquire
//! (`6259c9`–`6259fe`). The 41-yd range keeps 1.12's `targetNearestDistance` default — Classic
//! still ships that cvar ("limited to tab targeting range").
use benilla_formats::CreatureTypeFlags;
use benilla_protocol::{guid, EntityKind};
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use crate::creature_anim::{Engaged, SwingMessage};
use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, GuidIndex, NetEntity, ObjectStore, Reputations, SelfPlayer};
use benilla_assets::{LockRecover, WorldAssets};
use super::relations::{can_assist, can_attack};
use super::ring::reaction_from_player;
use super::{Factions, Selection};
// == The Classic-priority dials ==
// Each names the Classic Era cvar it stands in for. The VALUES are tuned, not pinned — the
// binary's defaults are packer-encrypted; when the director's in-game `GetCVarInfo` dump lands,
// replace these with the real defaults and note it in the 0567 lineage.
/// `targetNearestDistance` — the tab-targeting range (yd). 1.12's byte-verified default 41
/// (`[0x804510]`); Classic still ships the cvar ("limited to tab targeting range").
const TAB_RANGE: f32 = 41.0;
/// `TargetPriorityFrustumPullInSides` — fraction of the viewport width pulled toward center
/// from EACH side edge for the in-view check.
const FRUSTUM_PULL_SIDES: f32 = 0.10;
/// `TargetPriorityFrustumPullInTop` — fraction of the viewport height pulled down from the top.
const FRUSTUM_PULL_TOP: f32 = 0.10;
/// `TargetPriorityFrustumPullInBot` — fraction of the viewport height pulled up from the bottom.
const FRUSTUM_PULL_BOT: f32 = 0.10;
/// `TargetPriorityHighlightHistoryMs` — how long a tabbed guid stays "recently visited" (the
/// skip set for repeated presses).
const HISTORY_SECS: f64 = 4.0;
/// The score's screen term weight: per unit of normalized off-centerness (0 = screen center,
/// 1 ≈ a viewport corner). One slot of the `TargetPriorityValueBank` stand-in.
const W_SCREEN: f32 = 1.0;
/// The score's distance term weight: per unit of `dist / TAB_RANGE`.
const W_DIST: f32 = 1.0;
/// The "attacking me" bonus (`TargetPriorityCombatLock`'s spirit as a score term — the hard
/// lock itself is retired, decision 0568): a mob whose UNIT_FIELD_TARGET is me and whose
/// in-combat flag is set outranks anything peaceful at any screen position (the bonus exceeds
/// the terms' max sum), but never pins the cycle.
const COMBAT_WITH_ME_BONUS: f32 = 3.0;
/// `WOW_TAB_TRACE=1` — the TAB-target field instrument: every press logs each known unit's
/// verdict (the reject reason, or its distance / screen position / score), the sorted pool,
/// the history, and the pick — so a "TAB won't take the mob next to me"
/// report is diagnosable from the log instead of re-guessed. One `OnceLock` read per press when
/// unset; the lines ride `info!` under the `tab-trace:` prefix.
fn tab_trace_on() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_TAB_TRACE").is_some())
}
/// Which side a press is looking for — the reference's **mode** argument, and the only thing that
/// forks on the whole path.
///
/// The four `TargetNearest*` shims are one function with one changed immediate: `0x489a80`
/// (`TargetNearestEnemy`) pushes `edx = 1`, `0x489aa0` (`TargetNearestFriend`) pushes `edx = 2`,
/// and `0x489ac0`/`0x489ae0` push 3 and 4 for the party and raid siblings — every one of them then
/// calls the single cycler `0x493f60(ecx = reverse, edx = mode)`. The mode reaches exactly one
/// place: `0x493e40`'s jump table (`0x493f50 = {0x493e73, 0x493eca, 0x493eed, 0x493f15}`). The
/// enumeration, the creature-type table, the range cvars, the scene-attach gate, the scorer
/// `0x494200`, the comparator `0x494450` and the commit `0x493540` are literally the same
/// instructions for both sides — so this is a parameter on the one scan, never a second scanner
/// (wow-re `object-layer/scratch/targeting-nearest-and-autoacquire.md` PART A;
/// `object-layer/scratch/targeting-by-name.md` "the mode filter").
///
/// Modes 3 and 4 (party / raid) are not built: they need the roster-only candidate set, and the
/// two commands that drive them stay in the binding registry's absent table until they are.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ScanSide {
/// **Mode 1** (`0x493e73`) — the liveness triple (`0x605f90`, health, dynflag/stand-state) and
/// `CanAttack 0x606980`.
Enemy,
/// **Mode 2** (`0x493eca`) — `CanAssist 0x6066f0`, then `UNIT_FIELD_HEALTH > 0`, and nothing
/// else. Two things fall out of that being *shorter* than mode 1 rather than its mirror: a
/// **feigning** friendly (health > 0, `UNIT_DYNFLAG_DEAD` set) is still a candidate, because
/// mode 2 has no dynflag leg; and `CanAssist`'s own ladder is **friendly or better** — neutral
/// fails (`0x60671e cmp eax,4; jge`, on the internal 0-based rank, so Lua's FRIENDLY(5) is
/// internal 4). Its NPC arm is `IsPvP 0x605ff0` on the candidate (owner-chased), so a friendly
/// creature with no `UNIT_FLAG_PVP` is NOT a `TargetNearestFriend` candidate at all — the
/// same predicate that already denies its buffs (decision 1035).
Friend,
}
/// One scored candidate. `score`: lower is better. `on_screen`: inside the pulled-in frustum
/// (the tier-1 gate). The fighting-me bonus is already folded into `score` at build time.
#[derive(Clone, Copy, Debug)]
struct Candidate {
entity: Entity,
guid: u64,
on_screen: bool,
score: f32,
}
/// The priority value (`TargetPriorityValueBank` stand-in): screen off-centerness (absent for
/// off-screen candidates — the tier gate keeps them apart, so their mutual order is distance +
/// combat only) + normalized distance − the attacking-me bonus. Lowest wins.
fn priority_score(off_center: Option<f32>, dist: f32, combat_with_me: bool) -> f32 {
off_center.map_or(0.0, |c| W_SCREEN * c) + W_DIST * (dist / TAB_RANGE)
- if combat_with_me {
COMBAT_WITH_ME_BONUS
} else {
0.0
}
}
/// The sort: tier first (on-screen before off-screen — `AllowAnyOnScreen` keeps the fallback
/// tier from ever outranking a "100% correct" pick), then ascending score.
fn candidate_order(a: &Candidate, b: &Candidate) -> std::cmp::Ordering {
b.on_screen
.cmp(&a.on_screen)
.then(a.score.total_cmp(&b.score))
}
/// The press's pick pool from the sorted candidates: the on-screen tier when it exists, else
/// everything (`AllowAnyOnScreen=1` — "if no 100% correct target is available, allow selecting
/// any valid in-range target"). Classic's HARD combat lock (`TargetPriorityCombatLock` — the
/// pool restricted to in-combat units while starting from one) was implemented and then
/// **retired by the director's feel** (decision 0568): with a single attacker it pinned TAB to
/// that mob with no way off. The fighting-me *score bonus* stays — attackers come first, but
/// the history walks you off them.
fn select_pool(cands: &[Candidate]) -> Vec<Candidate> {
if cands.iter().any(|c| c.on_screen) {
cands.iter().filter(|c| c.on_screen).copied().collect()
} else {
cands.to_vec()
}
}
/// The forward pick over a sorted pool: the best candidate that is neither the current
/// selection nor recently visited; all visited → the wrap (best non-current, history to be
/// cleared by the caller); only the current one left → itself (the commit dedups to a no-op).
/// Returns `(index, wrapped)`.
fn pick_forward(
pool: &[Candidate],
visited: &[u64],
current: Option<u64>,
) -> Option<(usize, bool)> {
if pool.is_empty() {
return None;
}
if let Some(i) = pool
.iter()
.position(|c| Some(c.guid) != current && !visited.contains(&c.guid))
{
return Some((i, false));
}
if let Some(i) = pool.iter().position(|c| Some(c.guid) != current) {
return Some((i, true));
}
Some((0, true))
}
/// The **backward** pick (Shift-TAB / `TargetNearestFriend(1)` — the reference's `reverse` flag):
/// walk the history newest-first for a guid that is neither the current selection nor gone from
/// the pool. `None` = nothing to step back to, and the caller falls through to [`pick_forward`],
/// which is what makes the very first reverse press behave like a forward one.
///
/// The reference cycles a *snapshot list* by a cursor and simply decrements it (`cursor == 0 ?
/// count-1 : cursor-1`, no timer check — the asymmetry is its own); decision 0567 replaced the
/// snapshot with this history, so "back" means "the target before this one" rather than "the
/// previous array slot". Pure, and beside its forward twin, so the two are read together.
fn pick_back(visited: &[u64], pool: &[Candidate], current: Option<u64>) -> Option<usize> {
visited.iter().rev().find_map(|g| {
(Some(*g) != current)
.then(|| pool.iter().position(|c| c.guid == *g))
.flatten()
})
}
/// "Fighting me": the unit's UNIT_FIELD_TARGET is my guid AND its in-combat flag
/// (UNIT_FLAG_IN_COMBAT, bit 19) is set — the `TargetPriorityCombatLock=2` notion ("in-combat
/// with player"), the one that matters solo.
fn combat_with_me(store: &ObjectStore, me: Option<u64>) -> bool {
me.is_some() && store.0.unit_target() == me && store.0.unit_flags() & (1 << 19) != 0
}
/// The CreatureType.dbc flags table (see `benilla-formats`); absent (load failure) = no
/// critter filtering, like the missing-catalog fallbacks elsewhere.
///
/// **Critter alone** — the flag column is 1 for CreatureType 8 and for nothing else in the shipped
/// 5875 DBC (Totem reads 0, and 1.12 has no non-combat-pet row). This prose said "critter/totem",
/// copying a wow-re gloss that its own `targeting-friend-and-lastenemy.md` has since closed
/// against the file; the code was always reading the flag rather than a type list, so only the
/// words were wrong.
#[derive(Resource)]
pub(crate) struct CreatureTypes(CreatureTypeFlags);
/// Startup (after the MPQ chain opens): load CreatureType.dbc for the critter TAB filter.
pub(super) fn load_creature_types(mut commands: Commands, world_assets: Option<Res<WorldAssets>>) {
let Some(world_assets) = world_assets else {
return;
};
let mut chain = world_assets.chain.lock_recover();
match benilla_formats::load_creature_type_flags(&mut chain) {
Ok(flags) => {
info!("creature types: {} rows", flags.len());
commands.insert_resource(CreatureTypes(flags));
}
Err(e) => warn!("CreatureType.dbc unavailable, critters stay TAB-able: {e:#}"),
}
}
/// Everything the scan core reads, bundled (the TAB and attack-acquire systems share it).
///
/// `pub(crate)` rather than `pub(super)` because the **pet bar's** ATTACK arm runs the same
/// `TargetNearestEnemy()` the player's does ([`attack_order_target`]), and its drain lives in
/// `ui_pet`.
///
/// # `Without<SelfPlayer>` is a divergence, and a deliberate one
///
/// **The reference's friendly cycler can select YOU.** Nothing on the `0x493f60` chain excludes
/// the player: `CanAssist(P, P)` is true (`0x6061e0` returns 4 when A == B) and the cone test
/// `0x47f220(p, p)` returns exactly π/2, so the self entry is in-cone whenever your facing falls
/// in (π/3, 2π/3) and — at distance² 0 — sorts first. Plain TAB is spared only because
/// `CanAttack(P, P)` is false. (wow-re `targeting-friend-and-lastenemy.md`, verified
/// exhaustively; its own write-up asks a re-implementation to add the player to both candidate
/// sets.)
///
/// We do not, and the reason is 0567: that behaviour is an artifact of the ±30° facing cone, and
/// 0567 replaced the cone with screen-space priority. Under our scoring the player is at distance
/// 0 and screen-centre *unconditionally*, so porting the byte-level rule would make `CTRL-TAB`
/// self-target on **every** press rather than facing-dependently — faithful to the bytes,
/// unfaithful to the behaviour, which is the trade `CLAUDE.md` §7 hands to the director rather
/// than to this file. Recorded here so it is a known divergence and not a gap (decision 1745).
#[derive(SystemParam)]
#[allow(clippy::type_complexity)] // one bundled system param — the app's convention for big query sets
pub(crate) struct TargetScan<'w, 's> {
units: Query<
'w,
's,
(
Entity,
&'static NetEntity,
&'static Guid,
&'static Transform,
Option<&'static ObjectStore>,
Option<&'static Visibility>,
),
Without<SelfPlayer>,
>,
self_q: Query<
'w,
's,
(
&'static Transform,
Option<&'static ObjectStore>,
Option<&'static Guid>,
),
With<SelfPlayer>,
>,
/// The live world camera — the in-view check's frustum. Absent (glue screens, headless):
/// nothing projects, everything is fallback-tier, and the pick degrades to distance+combat.
camera: Query<
'w,
's,
(&'static Camera, &'static Transform),
(With<benilla_world::view::WorldCamera>, Without<SelfPlayer>),
>,
factions: Option<Res<'w, Factions>>,
reputations: Res<'w, Reputations>,
names: Res<'w, NameCache>,
creature_types: Option<Res<'w, CreatureTypes>>,
/// Every store, ours included — read only by the friend arm's `CanAssist`, whose `IsPvP` leg
/// chases the candidate's owner. `units` above cannot serve: it excludes our own body, which
/// is exactly who owns our pet.
stores: Query<'w, 's, &'static ObjectStore>,
/// The guid → entity map the owner chase lands in. `Option` for the same reason
/// [`crate::ui_unit::UnitTokens`] carries it that way — a UI-only harness runs with no net
/// stack, and a bare `Res` would turn that into a system-validation panic.
index: Option<Res<'w, GuidIndex>>,
}
impl TargetScan<'_, '_> {
/// The `0x493e40` per-candidate validity filter, both arms — the one thing [`ScanSide`] forks.
fn is_valid(
&self,
side: ScanSide,
store: Option<&ObjectStore>,
self_store: Option<&ObjectStore>,
) -> bool {
match side {
// Mode 1 (`0x493e73`) — liveness + hostility (kept byte-law). The liveness leg is the
// reference's own reads-dead triple `0x605f90` — health, the `UNIT_DYNFLAG_DEAD` bit
// (feign death) and stand state 7 — the shared predicate rather than a third
// transcription of it (decision 1022).
ScanSide::Enemy => {
if store.is_some_and(|s| s.0.unit_reads_dead()) {
return false;
}
can_attack(
store,
self.factions.as_deref(),
&self.reputations,
self_store,
)
}
// Mode 2 (`0x493eca`) — `CanAssist 0x6066f0` first, then `[[cand+0x110]+0x40] > 0`.
// The order is the reference's and so is the *narrowness* of the liveness leg: it
// reads raw HEALTH, not the reads-dead triple, so this deliberately does NOT reuse
// `unit_reads_dead` (see [`ScanSide::Friend`]).
ScanSide::Friend => {
can_assist(
store,
self.factions.as_deref(),
&self.reputations,
self_store,
|owner| self.store_of(owner).cloned(),
) && !store.is_some_and(|s| s.0.unit_is_dead())
}
}
}
/// A guid's streamed descriptor, whichever entity holds it — `CanAssist`'s owner chase.
fn store_of(&self, guid: u64) -> Option<&ObjectStore> {
let entity = *self.index.as_ref()?.0.get(&guid)?;
self.stores.get(entity).ok()
}
/// Our own descriptor, for whichever leg needs the reaction's second party outside [`build`].
fn self_store(&self) -> Option<&ObjectStore> {
self.self_q.single().ok().and_then(|(_, store, _)| store)
}
/// Our own guid — `IsSelectable`'s `CREATEDBY` clause needs the active player's, and so does
/// the commit's self-target exception.
fn self_guid(&self) -> Option<u64> {
self.self_q
.single()
.ok()
.and_then(|(_, _, g)| g)
.map(|g| g.0)
}
/// An entity's streamed descriptor — the commit gate's `IsSelectable` input.
fn store_at(&self, entity: Entity) -> Option<&ObjectStore> {
self.stores.get(entity).ok()
}
/// `0x6130a3`'s keep-or-drop test on a guid we are already holding: is the actor hostile to it?
///
/// The reference reads `0x6061e0(actor, target) >= 4` — the **reaction alone**, not the full
/// `0x606980` CanAttack, which it saves for the final gate at `0x613167`. A guid whose unit is
/// not streamed answers `false`, which is the same exit `0x613099` takes when its object lookup
/// misses.
///
/// **The direction is ACTOR → target**, byte-read at the call: `0x61309b push eax` (the
/// candidate) `; mov ecx,edi` (the actor) `; call 0x6061e0`, then `cmp eax,4; jl keep`. So it is
/// [`reaction_from_player`], the leg-3 direction that answers a reputation-slot faction with the
/// **at-war bit** — *not* [`ring_reaction`], which this used to call and which reads the
/// standing from the other side (1674; the two are deliberately not each other's mirror, and
/// substituting one for the other is the defect 1530 named). With the standing here, pressing
/// Attack while holding a not-at-war Cenarion Circle NPC kept it as the target and then failed
/// the final gate; the reference drops it and goes and finds a real enemy.
///
/// **The actor is the player here, and in the reference it is the caller's** — the pet on the
/// pet arm (`0x4bd40d` passes the pet object). The two agree: vmangos gives a pet its owner's
/// faction template outright (`Pet.cpp:248`), and the reputation leg is the owner's by
/// definition. Reading the pet's own store instead would also fight [`build`], whose entire
/// candidate scan is player-relative (screen, distance, "fighting me").
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
})
}
/// The streamed unit behind a guid, as `(entity, store)` — the final gate's input.
fn unit_by_guid(&self, guid: u64) -> Option<(Entity, Option<&ObjectStore>)> {
self.units
.iter()
.find(|(_, _, g, _, _, _)| g.0 == guid)
.map(|(e, _, _, _, store, _)| (e, store))
}
/// The full build: walk every known unit, filter (the kept 1.12 legality laws), project
/// through the live camera, score, sort (tier, then score). Fresh every press — the live
/// world is the list (no snapshot to go stale).
///
/// `side` reaches exactly one line — [`Self::is_valid`] — which is the reference's own shape:
/// the mode byte forks `0x493e40` and nothing else on the path.
///
/// **Our own body is never a candidate**: the query is `Without<SelfPlayer>`. For the enemy
/// side that is free (`CanAttack(me, me)` is false anyway); for the friend side it is a
/// deliberate divergence, because the reference's enumeration walks ClntObjMgr table #1 —
/// which holds the player's own object — and no self-compare has been derived on the mode-2
/// path. Self sits at dist² 0 dead-centre, so a faithful `TargetNearestFriend` would appear to
/// always self-target; we refuse to ship that on an underived gate. RE dispatched.
fn build(&self, side: ScanSide) -> Vec<Candidate> {
let Ok((self_tf, self_store, self_guid)) = self.self_q.single() else {
return Vec::new();
};
let me = self_guid.map(|g| g.0);
let cam = self.camera.single().ok();
// Normalized off-centerness of a world point on the pulled-in screen: `None` when
// off-screen / behind the camera / outside the pulled-in frustum; `Some(0)` at the
// viewport center, ~1 at a corner. The point is the unit's root + 1 yd (chest-ish) so
// a mob whose feet sit just below the pulled-in bottom edge still reads as on-screen.
let project = |world: Vec3| -> Option<f32> {
let (camera, cam_pose) = cam?;
let cam_tf = GlobalTransform::from(*cam_pose);
let vp = camera.logical_viewport_size()?;
let screen = camera.world_to_viewport(&cam_tf, world).ok()?;
let x0 = vp.x * FRUSTUM_PULL_SIDES;
let x1 = vp.x * (1.0 - FRUSTUM_PULL_SIDES);
let y0 = vp.y * FRUSTUM_PULL_TOP;
let y1 = vp.y * (1.0 - FRUSTUM_PULL_BOT);
if screen.x < x0 || screen.x > x1 || screen.y < y0 || screen.y > y1 {
return None;
}
Some(screen.distance(vp * 0.5) / (vp.length() * 0.5))
};
let trace = tab_trace_on();
let trace_unit = |guid: u64, tf: &Transform, verdict: &str| {
if !trace {
return;
}
info!(
"tab-trace: {guid:#x} \"{}\" {:.1} yd — {verdict}",
self.names.peek(guid).unwrap_or("?"),
(tf.translation - self_tf.translation).length()
);
};
let mut out = Vec::new();
for (entity, net, guid_c, tf, store, vis) in &self.units {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
if !self.is_valid(side, store, self_store) {
trace_unit(
guid_c.0,
tf,
match side {
ScanSide::Enemy => "REJECT dead-or-unattackable",
ScanSide::Friend => "REJECT dead-or-unassistable",
},
);
continue;
}
// The creature-type gate (CreatureType.dbc flag bit 0 — critters; kept byte-law).
// An unresolved type (name not yet queried) passes, like the client's
// out-of-range table index.
if guid::is_creature_or_pet(guid_c.0) {
if let (Some(types), Some(ty)) = (
self.creature_types.as_deref(),
guid::entry(guid_c.0).and_then(|e| self.names.creature_type(e)),
) {
if types.0.no_tab_target(ty) {
trace_unit(guid_c.0, tf, "REJECT critter-type");
continue;
}
}
}
// The scene-attach/hide notion (kept): an explicitly hidden root is out.
if vis == Some(&Visibility::Hidden) {
trace_unit(guid_c.0, tf, "REJECT hidden");
continue;
}
let dist = (tf.translation - self_tf.translation).length();
if dist > TAB_RANGE {
trace_unit(guid_c.0, tf, "REJECT range (41 yd)");
continue;
}
let off_center = project(tf.translation + Vec3::Y);
// **Enemy side only.** The bonus exists to surface *the thing attacking me*
// (`TargetPriorityCombatLock`'s spirit, decisions 0567/0568) — a notion with no
// friendly meaning, and the reference has no such term on either side. Left ungated it
// would fire on an ally who is in combat and targeting me — a healer on you — and
// shove them to the head of the CTRL-TAB pool ahead of everyone, by accident rather
// than by anybody's decision.
let cwm = side == ScanSide::Enemy && store.is_some_and(|s| combat_with_me(s, me));
let score = priority_score(off_center, dist, cwm);
if trace {
let screen = off_center
.map(|c| format!("center+{c:.2}"))
.unwrap_or_else(|| "OFF-SCREEN".into());
trace_unit(
guid_c.0,
tf,
&format!(
"{screen}{} score {score:+.2}",
if cwm { " FIGHTING-ME" } else { "" }
),
);
}
out.push(Candidate {
entity,
guid: guid_c.0,
on_screen: off_center.is_some(),
score,
});
}
out.sort_by(candidate_order);
if trace {
for (i, c) in out.iter().enumerate() {
info!(
"tab-trace: list[{i}] {:#x} \"{}\" {} score {:+.2}",
c.guid,
self.names.peek(c.guid).unwrap_or("?"),
if c.on_screen { "on-screen" } else { "fallback" },
c.score
);
}
}
out
}
}
/// The recent-TAB history (`TargetPriorityHighlightHistoryMs`): guids picked within
/// [`HISTORY_SECS`], skipped by the forward pick so repeated presses walk fresh targets; a wrap
/// clears it. Replaces the 1.12 snapshot list + cursor (decision 0567).
#[derive(Resource, Default)]
pub(super) struct TabHistory {
/// `(guid, when)` — insertion-ordered; the back is the most recent (Shift-TAB's walk).
visited: Vec<(u64, f64)>,
/// Which side the standing history belongs to. **One history, cleared on a side switch** —
/// the reference's `cachedMode != mode` rebuild condition (`0x493f60`), which resets the
/// cursor to 0 and drops the candidate list whenever the mode byte changes. Without it a
/// CTRL-TAB through the friendly pool would poison the enemy cycle's skip set.
side: Option<ScanSide>,
}
impl TabHistory {
fn prune(&mut self, now: f64) {
self.visited.retain(|&(_, t)| now - t < HISTORY_SECS);
}
/// `0x493f60`'s cached-mode check: a press on the other side starts from nothing.
fn enter(&mut self, side: ScanSide) {
if self.side != Some(side) {
self.visited.clear();
self.side = Some(side);
}
}
fn guids(&self) -> Vec<u64> {
self.visited.iter().map(|&(g, _)| g).collect()
}
/// Record a visit (re-visiting moves the guid to most-recent).
fn push(&mut self, guid: u64, now: f64) {
self.visited.retain(|&(g, _)| g != guid);
self.visited.push((guid, now));
}
}
/// What one [`commit`] did: whether the selection changed, and whether the engaged-switch law
/// already re-pointed the swing (`CMSG_ATTACKSWING` at the new target) — the attack branches
/// read `swung` to avoid double-sending their own swing.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct CommitOutcome {
pub(super) changed: bool,
pub(super) swung: bool,
}
/// Commit a target through the SetSelection path (`0x493540`), byte-complete: dedup, then — on a
/// switch while auto-attacking — the **stop → select → re-swing** law read directly from the
/// binary (wow-re disasm, 2026-07-14):
///
/// 1. `0x493637–0x4936ac` latches "attacking AND the old target is still a live attackable unit"
/// into `[ebp-1]` before anything changes;
/// 2. the silent old-target clear (`0x493910(current, ecx=0)`) calls **StopAttack `0x5ecac0`
/// unconditionally at `0x493a08`** (engaged-gated internally) → `CMSG_ATTACKSTOP`, and its
/// builder `0x624370` sets `[player+0xc54] = 1` ("stop in flight");
/// 3. the new guid lands in the globals and `CMSG_SET_SELECTION` goes out (`0x493857`);
/// 4. the tail `0x4938a1–0x4938c8`: iff `[ebp-1]` and the new selection is not MYSELF →
/// `Attack 0x5ecb70`, which validates the NEW target (alive-or-feign + `CanAttack 0x606980`,
/// `5ecc10–5ecc35` — an invalid one STOPS instead, `5ecc37`) and then **sends
/// `CMSG_ATTACKSWING` at it** — `[+0xc54]` bypasses the already-attacking gate (`5eccda`) so
/// the swing goes out despite the still-set old lock, and the lock re-stores to the new guid
/// (`5ecd4b`). The swing FOLLOWS the switch (no sheath ceremony: engaged means the weapon is
/// already out). The `new_attackable` param carries Attack's new-target validation — callers
/// pass their already-computed classification (the cursor's Attack kind / the scan's
/// alive+hostile) rather than re-deriving it here.
///
/// Approximations, disclosed: `[ebp-1]`'s old-target legs (alive-or-feign + `CanAttack` + the
/// `0x605f30`/`+0x1fc` player-state gates) collapse to "an old selection existed" — on vmangos
/// the victim's death tears `Engaged` down via the echoed `SMSG_ATTACKSTOP` before a human can
/// TAB, so `engaged && had_old` covers the same observable; our `engaged` is the server-echoed
/// [`Engaged`], not the ref's instant local lock (one-RTT lag on a switch fired mid-attack-
/// start); and the ref's invalid-new-target Attack sends a second (duplicate) ATTACKSTOP we
/// don't. Shared with the UI's `TargetUnit` drain (`super::target_unit_requests`) so every
/// non-mouse selection writer commits identically — a `TargetUnit("player")` mid-combat stops
/// the swing and does NOT re-point (the tail's self exception).
pub(super) fn commit(
selection: &mut Selection,
seam: &mut crate::creature_anim::AttackSeam,
entity: Entity,
guid: u64,
target_store: Option<&ObjectStore>,
engaged: bool,
self_guid: Option<u64>,
new_attackable: bool,
) -> CommitOutcome {
// **`IsSelectable`, and it comes BEFORE the dedup** — `0x4935ec`–`0x4935f3`, the third of
// `0x493540`'s early-outs (wow-re `object-layer/scratch/selection-attack-seam.md` §3.1): the
// resolved object's slot-`+0x58` vcall answering 0 is a bare RETURN, so a non-selectable unit
// is a **complete** no-op — no stop, no `CMSG_SET_SELECTION`, no `PLAYER_TARGET_CHANGED`, no
// re-swing, and the target you already had is left exactly where it was.
//
// This is the one gate, at the one place the reference puts it, so every writer — the click,
// the right-click, TAB, `TargetUnit`/`/target`/`/assist`, the attack auto-acquire and the
// ATTACKERSTATEUPDATE self-defence acquire — is covered without knowing it exists. Without it
// a Stratholme Baron Rivendare in his pre-event state (vmangos gives him `NOT_SELECTABLE |
// SPAWNING` until the ziggurats fall) took a target on the very swing he opened with.
if !super::relations::is_selectable(target_store, self_guid) {
return CommitOutcome::default();
}
if selection.guid == Some(guid) {
return CommitOutcome::default(); // the setter's dedup: bail if already current
}
let had_old = selection.guid.is_some();
selection.target = Some(entity);
selection.guid = Some(guid);
let stop_and_repoint = engaged && had_old;
if stop_and_repoint {
// `0x493a08 call 0x5ecac0` — the real StopAttack, so the switch also cancels a queued
// on-next-swing strike (the seam's `0x6e6f30` tail). Before this routed through the seam
// it was a bare `CMSG_ATTACKSTOP` and a queued Heroic Strike survived the switch.
seam.stop(engaged);
}
let _ = seam.net.0.send(ClientCommand::SetSelection { guid });
let swung = stop_and_repoint && new_attackable && self_guid != Some(guid);
if swung {
// `0x4938c8 call 0x5ecb70` with a stop in flight (`[+0xc54]`, set by the call above), so
// the swing goes out despite the still-set lock. Its tail cancels a running auto-repeat —
// wow-re `melee-autorepeat-exclusion.md` §6 REFUTES `nocked-ammo-cancel.md`'s
// direct-callers-only census, which had this chain never reaching `0x6ea080`.
seam.start(guid, engaged, true);
}
CommitOutcome {
changed: true,
swung,
}
}
/// **One press, either side** — the whole of the Classic-priority cycle, shared by the enemy TAB
/// ([`tab_target`]) and the friendly one ([`target_nearest_friend_requests`]) because the
/// reference shares it too: all four `TargetNearest*` shims are one call into `0x493f60` with a
/// different mode byte, and the byte reaches only the per-candidate filter ([`ScanSide`]).
///
/// Re-score the live world, pool by tier, skip the recent history forward (or walk it backward),
/// commit through the byte-law [`commit`].
fn cycle(
side: ScanSide,
reverse: bool,
now: f64,
scan: &TargetScan,
history: &mut TabHistory,
selection: &mut Selection,
seam: &mut crate::creature_anim::AttackSeam,
engaged: bool,
) {
history.enter(side);
history.prune(now);
let trace = tab_trace_on();
if trace {
info!(
"tab-trace: {side:?} press (reverse={reverse}), selection {:?}, history {}",
selection.guid.map(|g| format!("{g:#x}")),
history.visited.len()
);
}
let cands = scan.build(side);
if cands.is_empty() {
if trace {
info!("tab-trace: no candidates — target unchanged");
}
return;
}
let pool = select_pool(&cands);
if trace {
info!(
"tab-trace: pool {}/{} ({} tier)",
pool.len(),
cands.len(),
if cands.iter().any(|c| c.on_screen) {
"on-screen"
} else {
"fallback"
}
);
}
// The pick. Reverse walks the history back ([`pick_back`]); an empty walk falls through to the
// forward rule.
let visited = history.guids();
let back = reverse
.then(|| pick_back(&visited, &pool, selection.guid))
.flatten();
let (entity, guid, wrapped) = match back {
Some(i) => (pool[i].entity, pool[i].guid, false),
None => {
let Some((i, wrapped)) = pick_forward(&pool, &visited, selection.guid) else {
return;
};
(pool[i].entity, pool[i].guid, wrapped)
}
};
if wrapped {
history.visited.clear();
}
// The outgoing selection joins the history — a clicked target counts as visited once
// tabbed away from (`TargetPriorityContinueFromManualTarget`'s observable).
if let Some(old) = selection.guid {
history.push(old, now);
}
let out = commit(
selection,
seam,
entity,
guid,
scan.store_at(entity),
engaged,
// The real guid, where this used to pass `None`: `IsSelectable`'s `CREATEDBY` clause reads
// it. Inert for the swing leg it was already feeding — the candidate query excludes our own
// body, so a TAB pick can never *be* us.
scan.self_guid(),
// Attack `0x5ecb70`'s new-target validation, which is what this flag carries: a mode-2
// pick can never pass it. `CanAssist` demands reaction ≥ 4 and `CanAttack`'s mixed arm
// demands < 4, so the two candidate sets are disjoint on that leg — a CTRL-TAB in the
// middle of a fight moves the selection and must NOT re-point the swing at an ally.
// (Disclosed corner: `CanAttack`'s duel / FFA-PvP arms don't read the reaction, so a
// duelling friendly is attackable AND assistable; we still refuse the re-swing there,
// which is the conservative half.)
side == ScanSide::Enemy,
);
if out.changed {
history.push(guid, now);
}
if trace {
info!(
"tab-trace: pick {guid:#x} \"{}\"{} — {}",
scan.names.peek(guid).unwrap_or("?"),
if wrapped { " (wrapped)" } else { "" },
if out.changed {
"selection changed"
} else {
"NO-OP (already the selection)"
}
);
}
}
/// TARGETNEARESTENEMY / TARGETPREVIOUSENEMY (0997: two commands through the binding table now,
/// defaults TAB / SHIFT-TAB — no shift fork here anymore). The dispatch already applied the typing
/// gate (a focused EditBox owns TAB) and the exact-modifier law.
pub(super) fn tab_target(
binds: Res<crate::bindings::BindingsState>,
time: Res<Time>,
scan: TargetScan,
mut history: ResMut<TabHistory>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
) {
let reverse = binds.fired(crate::bindings::cmd::TARGET_PREVIOUS_ENEMY);
if !reverse && !binds.fired(crate::bindings::cmd::TARGET_NEAREST_ENEMY) {
return;
}
cycle(
ScanSide::Enemy,
reverse,
time.elapsed_secs_f64(),
&scan,
&mut history,
&mut selection,
&mut seam,
!engaged.is_empty(),
);
}
/// Drain `TargetNearestFriend([reverse])` — the same cycle with mode 2 (`0x489aa0` →
/// `0x493f60(reverse, 2)`).
///
/// Lua-driven rather than binding-driven, because that is where the reference puts it: TAB reaches
/// the enemy scan through a Rust command here only because 0997 wired the two enemy commands
/// straight to [`tab_target`]; the friendly pair's `Bindings.xml` bodies are literally
/// `TargetNearestFriend()` and `TargetNearestFriend(1)`, so a binding row for them needs no new
/// mechanism — it needs the global, which now exists. Every FrameXML and addon caller lands here
/// too.
///
/// Each queued press is one cycle, in call order, exactly as repeated key presses would be.
pub(super) fn target_nearest_friend_requests(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
time: Res<Time>,
scan: TargetScan,
mut history: ResMut<TabHistory>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
) {
let Some(mut script) = script else {
return;
};
let presses = script.take_target_nearest_friend_requests();
if presses.is_empty() {
return;
}
let now = time.elapsed_secs_f64();
let engaged = !engaged.is_empty();
for reverse in presses {
cycle(
ScanSide::Friend,
reverse,
now,
&scan,
&mut history,
&mut selection,
&mut seam,
engaged,
);
}
}
/// `0x6130b5` — **`TargetNearestEnemy()` itself**, and the whole of "pressing Attack at nothing
/// finds something in front of you".
///
/// The acquire the validator runs is not a private helper: it is `0x493f60(ecx = 0, edx = 1)`, the
/// same cycler the four Lua `TargetNearest*` bindings drive (`0x489a80`–`0x489af3` are the same
/// call with `edx = 1..4` for enemy / friend / party / raid, and `ecx` the *reverse* flag). So
/// "in front of me" is not a separate rule with its own cone — it is this module's priority scan,
/// which is why the acquire commits through [`commit`] like any press: `CMSG_SET_SELECTION` goes
/// out and **your** target really moves, which is what lets the pet's order and your own swing
/// aim at the same thing.
///
/// Returns the committed `(entity, guid)`, or `None` after raising `0x6130d9`'s
/// `ERR_NO_ATTACK_TARGET`.
///
/// Divergence, deliberate and inherited: the reference's cycler keeps a cached, time-expiring
/// candidate list and a cursor into it, so a *repeated* acquire walks; ours re-scores the live
/// world every call and always hands back the head. That is decision 0567's trade for the whole
/// module (history cycling replaces the snapshot cursor), and an acquire is a fresh pick in both.
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` — the acquire ran and came back empty, so the selection re-read at `0x6130c1`
// still finds nothing: errorId `0xa0` `ERR_NO_ATTACK_TARGET`.
debug!("attack acquire: nothing to attack (ERR_NO_ATTACK_TARGET)");
errors
.0
.push(crate::ui_action::UiError::key("ERR_NO_ATTACK_TARGET"));
return None;
};
// `engaged = false`: the switch law only fires when we were already swinging at the OLD
// selection, and every path that reaches here had no selection or a non-hostile one — neither
// is a thing you can be engaged with.
commit(
selection,
seam,
c.entity,
c.guid,
scan.store_at(c.entity),
false,
scan.self_guid(),
false,
);
Some((c.entity, c.guid))
}
/// `0x612df0`'s **Phase B** — the attack order's *target* arm, and the reason pressing the pet's
/// Attack button at nothing still sends the pet at something.
///
/// Phase A (the actor's own eligibility — [`crate::ui_action::attack_actor_refusal`]) has already
/// run by the time this is reached; what is left is choosing **what** to attack. The candidate the
/// caller passes is `{0,0}` for a bare press, and it gets replaced by the current selection twice
/// over — once by `CastPetAction` itself for a nil Lua argument (`0x4bd212`), once by the
/// validator for a `{0,0}` candidate (`0x61306b`) — so the candidate *is* the selection:
///
/// ```text
/// 0x61306b candidate == 0 -> candidate = the current selection
/// 0x61309b 0x6061e0(actor, candidate) -> the actor's reaction toward it
/// 0x6130a3 reaction >= 4 -> candidate = 0 (friendly or neutral is not a target)
/// 0x613100 else 0x493540(candidate) ; promote it into the selection (a dedup when it came from there)
/// 0x6130b5 candidate == 0 -> 0x493f60(0, 1) == TargetNearestEnemy()
/// 0x6130c1 re-read the selection ; whatever the acquire committed
/// 0x6130d9 still nothing -> ERR_NO_ATTACK_TARGET, EAX = 0, no packet
/// 0x613167 final gate -> ERR_INVALID_ATTACK_TARGET, EAX = 0, no packet
/// ```
///
/// Two things fall out that are worth knowing before looking at it in game. **Pressing Attack
/// while you hold a friendly target retargets you** — the reaction test drops it and the acquire
/// runs, so your selection moves to the nearest enemy. And the guid this returns is what goes in
/// the packet: the validator writes its answer back through the caller's out-param (`0x6130c6`),
/// and `0x4bd491` reads that slot when it builds `CMSG_PET_ACTION`.
///
/// The final gate is transcribed rather than folded into the reaction test above it, because the
/// reference deliberately keeps them apart: a *dead* selection is `ERR_INVALID_ATTACK_TARGET`, not
/// a reason to go find something else. `0x613159`'s odd second leg is verbatim — a zero-health
/// target passes iff `UNIT_DYNAMIC_FLAGS` bit 5 is set.
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> {
// `0x61306b` + `0x6130a3`: the selection is the candidate, and it survives only if the actor is
// hostile to it. Otherwise `0x6130b5` acquires.
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 final gate on whatever we ended up with. `0x61312e`'s legs are the ACTOR's and
// are Phase A's, already run by the caller; these two are the target's.
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;
}
Some(guid)
}
/// `0x6130a3`'s fork on its own: keep what you are holding, or fall through to the acquire.
///
/// The whole content is that **the reaction alone decides**, and it decides against the *actor*.
/// A friendly or neutral selection is not "an invalid target" — the reference zeroes the candidate
/// (`0x6130a8`) and goes looking, which is why pressing pet-Attack while you have a quest giver
/// selected moves your target to the nearest mob instead of raising an error.
fn keeps_held_target(selection: Option<u64>, hostile: impl Fn(u64) -> bool) -> Option<u64> {
selection.filter(|&g| hostile(g))
}
/// `0x613152`–`0x613169` — the final gate's two **target** legs, whatever route the guid arrived
/// by.
///
/// - alive, transcribed verbatim including its odd second half: a zero-health target passes iff
/// `UNIT_DYNAMIC_FLAGS` bit 5 is set (`0x613159 shr edx,5; test dl,1`);
/// - `CanAttack 0x606980` — the full predicate this time, not the bare reaction the fork above uses.
///
/// A target with no streamed descriptor is alive by the first leg (nothing known to disqualify) and
/// then **refused by the second**, which is [`can_attack`]'s own missing-data posture since 1674:
/// the reference reaches this gate holding two live CGUnits and has no null path at all, so the
/// honest stand-in is to refuse rather than to swing at an object we know nothing about. The result
/// is `ERR_INVALID_ATTACK_TARGET`, which is the same answer the gate gives for every other way a
/// target can be wrong.
fn attack_target_valid(
store: Option<&ObjectStore>,
factions: Option<&Factions>,
reputations: &Reputations,
self_store: Option<&ObjectStore>,
) -> bool {
let alive = store.is_none_or(|s| {
s.0.unit_health().is_none_or(|h| h > 0) || s.0.unit_dynamic_flags() & (1 << 5) != 0
});
alive && can_attack(store, factions, reputations, self_store)
}
/// Request from the action layer: the attack action fired with NO selection — pick the best
/// candidate and swing at it (`0x612df0` @ `6130b5`).
#[derive(Message)]
pub(crate) struct AttackNearestRequest;
/// Auto-acquire on attack (behavior 1): a fresh scan, commit the best candidate (the sort's
/// head — best on-screen, else best overall), then the attack-start pair (auto-draw +
/// `CMSG_ATTACKSWING`, exactly the action-bar attack's path). None found ⇒ the reference shows
/// error `0xa0` "There is nothing to attack" (we log; the red error banner is its own arc). The
/// TAB history is not touched — an auto-pick is not a press (Classic's
/// `TargetPriorityAutoTargetIgnoreWindow` nuance, disclosed unmodeled).
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; // something got selected between the action and this frame — the normal path owns it
}
// The actor-eligibility block (decision 0481, widened to `0x612df0`'s full Phase A): every one
// of its refusals sits BEFORE the nearest-core `0x6130b5` — a mounted, stunned or dead press
// never even scans. Gated at the responder so every requester (the Attack button's no-target
// arm, the melee probe) shares it. The actor here is us.
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;
}
// `0x6130b5` and `0x6130d9`, shared with the pet bar's ATTACK arm — see
// [`acquire_nearest_enemy`]. The selection is empty (checked above), which is exactly the
// `candidate == 0` leg of [`attack_order_target`]; the follow-through below is what differs.
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`'s continuation — StartAttack through the one seam, so the acquire path gets the
// auto-draw AND the auto-repeat cancel its hand-rolled copy used to miss. The selection was
// empty on entry, so we cannot have been engaged: no stop is in flight.
seam.start(guid, false, false);
}
/// **`TargetLastEnemy`'s memory** — the last *attackable* unit that was selected.
///
/// The reference keeps two guid pairs beside the current selection, both written inside
/// `SetSelection 0x493540`: `[0xb4e2e0]/[0xb4e2e4]`, the plain outgoing target (`TargetLastTarget`
/// reads it, `0x493622`/`0x493628` write it), and `[0xb4e2e8]/[0xb4e2ec]`, the last **attackable**
/// one (`TargetLastEnemy` reads it at `0x489b45`, `0x49377d` writes it) — wow-re
/// `object-layer/scratch/selection-attack-seam.md` §3.1. So the memory belongs at the **selection
/// commit**, which is where this puts it, and it is deliberately not a second thing to remember at
/// each of [`commit`]'s six call sites: [`remember_last_enemy`] reads the frame's settled
/// selection instead, so a selection writer added later cannot forget to stamp.
///
/// **A stale guid is never cleared and never resurrects anything.** The reference's globals are
/// plain guids that nothing zeroes when the unit despawns; what protects it is the shim's route
/// through the select-if-resolves helper `0x489a40`, whose third arm — the guid resolves to no
/// streamed object and is on no roster — is a bare `ret`: **not a deselect**. So we keep the guid
/// and let the drain no-op on it (`crate::target::click`), which is the same observable.
#[derive(Resource, Default)]
pub(crate) struct LastEnemy(pub(crate) Option<u64>);
/// Stamp [`LastEnemy`] from the frame's settled selection — the `0x49377d` write, sampled at the
/// end of the target chain rather than threaded through [`commit`].
///
/// Sampling is not a shortcut: the observable "the last attackable guid I had selected" is the
/// same either way, because the only guid a commit could stamp is the one still standing here.
/// What it buys is that every present and future selection writer — the click, TAB, the two
/// auto-acquires, `TargetUnit`, `/target`, `/assist` — is covered without knowing this exists.
///
/// **Ordered before `ring::update_ring`'s death-clear on purpose**: a hostile that dies while
/// selected must still be remembered, because the reference's shim has no liveness gate either —
/// `TargetLastEnemy` back onto a corpse is faithful, and it is what you want a second after a kill.
///
/// **The whole gate, not just the attackability leg.** `0x49372f`–`0x493778` is five conjuncts and
/// this shipped with one of them (wow-re `targeting-friend-and-lastenemy.md`, §5 trio — the note
/// was dispatched from this work and landed after it): the player object resolves, **the player is
/// not dead or a ghost**, **the player is not mounted**, the new target reads
/// `HEALTH > 0 || UNIT_DYNFLAG_DEAD`, and `CanAttack(player, new)`. Without the middle three we
/// remembered in three states the reference does not — targeting a hostile while mounted, while
/// dead or ghost, or targeting an already-dead one — which is a `TargetLastEnemy` that lands
/// somewhere the real client's would not.
///
/// The odd-looking fourth conjunct is transcribed rather than simplified: `HEALTH > 0` **or** the
/// dead-looking dynflag, so a feigning unit stays remembered while a genuinely dead one does not.
///
/// One disclosed divergence from a commit-time stamp remains, and it is the sampling itself: a
/// unit selected while neutral that *turns* hostile while still selected is remembered here and
/// would not be at the reference's write, which runs once at `SetSelection`. That is the better
/// answer of the two, and it is the only case where they differ.
pub(super) fn remember_last_enemy(
selection: Res<Selection>,
stores: Query<&ObjectStore>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
mut last: ResMut<LastEnemy>,
) {
let Some((entity, guid)) = selection.target.zip(selection.guid) else {
return;
};
// Conjunct 1: the player object resolves at all.
let Some(me) = self_store.iter().next() else {
return;
};
// Conjuncts 2 and 3 — the two states of OUR body that suppress the stamp. `player_is_ghost`
// is its own read because a ghost's wire health is 1, so `unit_is_dead` is false for one
// (`player.rs`'s note on the UnitIsDead/UnitIsGhost/UnitIsDeadOrGhost trio).
if me.0.unit_is_dead() || me.0.player_is_ghost() || me.0.unit_mount_display_id() != 0 {
return;
}
let store = stores.get(entity).ok();
// Conjunct 4 — the new target is not a corpse. Verbatim: health, OR the dead-looking flag.
let alive_enough = store.is_some_and(|s| !s.0.unit_is_dead() || s.0.unit_dynflag_dead());
if !alive_enough {
return;
}
// Conjunct 5.
if can_attack(store, factions.as_deref(), &reputations, Some(me)) {
last.0 = Some(guid);
}
}
/// Auto-acquire the attacker (behavior 2): the ATTACKERSTATEUPDATE victim handler's
/// self-defense block (`6259c9`–`6259fe`) — packet victim == me AND my selection strictly
/// empty → `SetSelection(attacker)`. Selection only: no counter-attack, never overwrites.
pub(super) fn auto_acquire_attacker(
mut swings: MessageReader<SwingMessage>,
self_player: Query<(Entity, &Guid), With<SelfPlayer>>,
guids: Query<&Guid>,
// The attacker's descriptor — `0x493540`'s `IsSelectable` gate reads it. A mob that swings at
// us while flagged `NOT_SELECTABLE` must not become our target: this is the acquire the
// Stratholme Baron's opening hit came in on.
stores: Query<&ObjectStore>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
) {
for s in swings.read() {
if selection.target.is_some() {
continue;
}
let Ok((me, my_guid)) = self_player.single() else {
continue;
};
if s.victim != Some(me) {
continue;
}
let Ok(guid) = guids.get(s.attacker) else {
continue;
};
debug!(
"auto-target: attacker {:#x} (victim = me, no target)",
guid.0
);
// Selection is empty here (checked above): no engaged-switch law, selection only.
commit(
&mut selection,
&mut seam,
s.attacker,
guid.0,
stores.get(s.attacker).ok(),
false,
Some(my_guid.0),
false,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::NetCommands;
/// One `commit` through a one-shot system, returning its outcome.
fn go(
world: &mut World,
guid: u64,
engaged: bool,
self_guid: Option<u64>,
attackable: bool,
) -> CommitOutcome {
go_with(world, guid, None, engaged, self_guid, attackable)
}
/// …and the same with a descriptor for the new target, which is what `IsSelectable` reads.
/// `None` is the reference's *unresolved* object (`0x4935c8`), which skips the vcall.
fn go_with(
world: &mut World,
guid: u64,
store: Option<ObjectStore>,
engaged: bool,
self_guid: Option<u64>,
attackable: bool,
) -> CommitOutcome {
use bevy::ecs::system::RunSystemOnce;
world
.run_system_once(
move |mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam| {
commit(
&mut selection,
&mut seam,
Entity::PLACEHOLDER,
guid,
store.as_ref(),
engaged,
self_guid,
attackable,
)
},
)
.expect("commit runs as a one-shot system")
}
/// A world with everything the seams need, and nothing else — both commit tests' fixture.
fn commit_world() -> (World, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::AutoRepeatActive>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<crate::player::StandStateRequest>>();
world.init_resource::<Selection>();
world.spawn(SelfPlayer);
(world, rx)
}
/// The `SetSelection` wire law, byte-read from `0x493540` (the director's "TAB while
/// auto-attacking kills the attack" bug): an engaged SWITCH is **stop → select → re-swing**
/// — the attack follows the new target — while a plain select sends only the selection, a
/// self-target stops without re-pointing, and an unattackable new target (Attack `0x5ecb70`'s
/// validation) switches and stops but never swings.
///
/// Driven through a World because `commit` now runs the two real seams
/// ([`crate::creature_anim::AttackSeam`]) rather than emitting bare packets — which is what
/// the second half of this test pins: the switch's stop un-queues an on-next-swing strike
/// (`0x493a08 call 0x5ecac0` → `0x6e6f30`) and its re-swing cancels a running auto-repeat
/// (`0x4938c8 call 0x5ecb70` → `0x5ecd8c`). Both were missing while this path hand-rolled
/// its own packets.
#[test]
fn commit_follows_the_stop_select_reswing_law() {
let (mut world, rx) = commit_world();
let drain = |rx: &crossbeam_channel::Receiver<ClientCommand>| {
rx.try_iter()
.map(|c| match c {
ClientCommand::SetSelection { .. } => "select",
ClientCommand::AttackStop => "stop",
ClientCommand::AttackSwing { .. } => "swing",
ClientCommand::CancelCast { .. } => "cancel-cast",
ClientCommand::CancelAutoRepeat => "cancel-repeat",
_ => "other",
})
.collect::<Vec<_>>()
};
// Not engaged: first select and a switch are selection-only; a same-guid re-commit dedups.
assert!(go(&mut world, 0xA, false, Some(1), true).changed);
assert!(go(&mut world, 0xB, false, Some(1), true).changed);
assert!(!go(&mut world, 0xB, false, Some(1), true).changed);
assert_eq!(drain(&rx), ["select", "select"]);
// Engaged switch onto an attackable unit: the byte order stop → select → swing.
let out = go(&mut world, 0xC, true, Some(1), true);
assert!(out.changed && out.swung);
assert_eq!(drain(&rx), ["stop", "select", "swing"]);
// Engaged switch onto MYSELF (TargetUnit("player") mid-combat): stop, no re-point.
let out = go(&mut world, 0x1, true, Some(1), true);
assert!(out.changed && !out.swung);
assert_eq!(drain(&rx), ["stop", "select"]);
// Engaged switch onto an unattackable unit (vendor/corpse): stop, no swing at it.
let out = go(&mut world, 0xD, true, Some(1), false);
assert!(out.changed && !out.swung);
assert_eq!(drain(&rx), ["stop", "select"]);
// Engaged FIRST select (no old target): the [ebp-1] latch is off — selection only.
*world.resource_mut::<Selection>() = Selection::default();
let out = go(&mut world, 0xE, true, Some(1), true);
assert!(out.changed && !out.swung);
assert_eq!(drain(&rx), ["select"]);
// **The two edges the seam brought.** A queued strike and a running auto-repeat, then an
// engaged switch onto an attackable unit: the stop un-queues the strike, the re-swing
// kills the repeat.
world
.resource_mut::<crate::spell::QueuedMeleeSpell>()
.arm(78);
world.resource_mut::<crate::spell::AutoRepeatActive>().0 = Some(75);
let out = go(&mut world, 0xF, true, Some(1), true);
assert!(out.changed && out.swung);
assert_eq!(
drain(&rx),
["stop", "cancel-cast", "select", "swing", "cancel-repeat"]
);
assert_eq!(
world.resource::<crate::spell::QueuedMeleeSpell>().current(),
None,
"the switch un-queued Heroic Strike"
);
assert_eq!(
world.resource::<crate::spell::AutoRepeatActive>().0,
None,
"and the re-swing killed Auto Shot"
);
}
/// **`IsSelectable` refuses the whole commit** — `0x493540`'s third early-out
/// (`0x4935ee`/`0x4935f3`), which sits *before* the dedup, so the refusal is a complete no-op:
/// nothing on the wire, no `PLAYER_TARGET_CHANGED`, and the target already in hand survives.
///
/// The reported case is Stratholme's Baron Rivendare before the ziggurats fall — vmangos gives
/// him `UNIT_FLAG_NOT_SELECTABLE | UNIT_FLAG_SPAWNING` (`instance_stratholme.cpp`), so on the
/// real client he can be neither clicked, tabbed, nor auto-acquired when he opens on you.
#[test]
fn not_selectable_refuses_the_commit_and_keeps_the_old_target() {
use benilla_protocol::ObjectFields;
const FLAGS: u16 = 46;
const CREATEDBY: u16 = 14;
const OBJECT_TYPE: u16 = 2;
const NOT_SELECTABLE: u32 = 1 << 25;
// `OBJECT_FIELD_TYPE` bit 3 = a CGUnit_C, the only kind whose slot `+0x58` is not the
// `xor eax,eax` base stub.
let unit = |pairs: &[(u16, u32)]| {
let mut all = vec![(OBJECT_TYPE, 0x8u32)];
all.extend_from_slice(pairs);
ObjectStore(ObjectFields::from_pairs(&all))
};
let (mut world, rx) = commit_world();
let sent = |rx: &crossbeam_channel::Receiver<ClientCommand>| rx.try_iter().count();
// A plain live mob commits.
let plain = unit(&[]);
assert!(go_with(&mut world, 0xA, Some(plain), false, Some(1), true).changed);
assert_eq!(sent(&rx), 1);
// The Baron: flagged, created by nobody. Refused outright — and 0xA is still the target.
let baron = unit(&[(FLAGS, NOT_SELECTABLE | 0x2)]);
let out = go_with(&mut world, 0xB, Some(baron), true, Some(1), true);
assert!(!out.changed && !out.swung, "the commit is a complete no-op");
assert_eq!(sent(&rx), 0, "nothing goes out — not even the stop");
assert_eq!(
world.resource::<Selection>().guid,
Some(0xA),
"the target already in hand survives the refused click"
);
// The `CREATEDBY` clause: the same flag on a unit **I** created still selects (my totem).
let mine = unit(&[(FLAGS, NOT_SELECTABLE), (CREATEDBY, 1), (CREATEDBY + 1, 0)]);
assert!(go_with(&mut world, 0xC, Some(mine), false, Some(1), true).changed);
assert_eq!(sent(&rx), 1);
// Somebody else's flagged creation is not mine to click.
let theirs = unit(&[(FLAGS, NOT_SELECTABLE), (CREATEDBY, 9), (CREATEDBY + 1, 0)]);
assert!(!go_with(&mut world, 0xD, Some(theirs), false, Some(1), true).changed);
assert_eq!(sent(&rx), 0);
// A non-unit object answers the base stub — a GameObject guid can never be the selection.
let go_obj = ObjectStore(ObjectFields::from_pairs(&[(OBJECT_TYPE, 0x20)]));
assert!(!go_with(&mut world, 0xE, Some(go_obj), false, Some(1), true).changed);
assert_eq!(sent(&rx), 0);
// And an unresolved object skips the vcall entirely (the out-of-range party member).
assert!(go_with(&mut world, 0xF, None, false, Some(1), true).changed);
assert_eq!(sent(&rx), 1);
}
fn cand(guid: u64, score: f32, on_screen: bool) -> Candidate {
Candidate {
entity: Entity::PLACEHOLDER,
guid,
on_screen,
score,
}
}
/// The priority value: a centered far mob beats a screen-edge close one; a close central
/// mob beats a far central one; the fighting-me bonus dominates both terms.
#[test]
fn priority_score_weighs_center_distance_and_combat() {
// Dead-center at 30 yd vs near-the-edge at 3 yd: center wins.
assert!(
priority_score(Some(0.05), 30.0, false) < priority_score(Some(0.9), 3.0, false),
"a centered far mob outranks a screen-edge close one"
);
// Same centrality: nearest wins.
assert!(priority_score(Some(0.2), 5.0, false) < priority_score(Some(0.2), 35.0, false));
// Fighting-me beats a perfectly centered peaceful mob at any position in range.
assert!(
priority_score(Some(0.9), 40.0, true) < priority_score(Some(0.0), 1.0, false),
"the combat bonus dominates the geometric terms"
);
}
/// The sort and the pool: on-screen candidates always precede off-screen ones; the pool is
/// the on-screen tier when it exists (AllowAnyOnScreen=1), everything otherwise. NO combat
/// lock (decision 0568): a fighting-me attacker in the pool never restricts it — TAB can
/// always walk off an attacker onto a fresh target (the bonus orders, the history moves).
#[test]
fn tier_pool_never_locks() {
let mut v = [cand(1, 0.1, false), cand(2, 5.0, true), cand(3, 0.5, true)];
v.sort_by(candidate_order);
assert_eq!(
v.map(|c| c.guid),
[3, 2, 1],
"on-screen first (even at a worse score), then ascending score"
);
// Tier: with any on-screen candidate, off-screen ones drop out of the pool.
let pool = select_pool(&v);
assert_eq!(pool.iter().map(|c| c.guid).collect::<Vec<_>>(), [3, 2]);
// No on-screen candidates at all → the fallback tier is everything.
let off = [cand(1, 0.3, false), cand(2, 0.1, false)];
assert_eq!(select_pool(&off).len(), 2);
// An attacker (fighting-me) in the pool does NOT shrink it — the 0568 unlock: with the
// attacker current and visited, the pick walks onto the peaceful mob.
let mixed = [cand(1, -2.0, true), cand(2, 0.2, true)];
let pool = select_pool(&mixed);
assert_eq!(pool.len(), 2, "no combat lock — the full tier stays");
assert_eq!(
pick_forward(&pool, &[1], Some(1)),
Some((1, false)),
"TAB moves off the attacker onto the fresh target"
);
}
/// The forward pick: best unvisited non-current first; exhausted history wraps (caller
/// clears it) to the best non-current; a pool of only the current selection returns it
/// (the commit dedups to a no-op).
#[test]
fn forward_pick_skips_history_then_wraps() {
let pool = [
cand(0xA, 0.1, true),
cand(0xB, 0.2, true),
cand(0xC, 0.3, true),
];
// Fresh: best.
assert_eq!(pick_forward(&pool, &[], None), Some((0, false)));
// Current is best, nothing visited: next-best.
assert_eq!(pick_forward(&pool, &[0xA], Some(0xA)), Some((1, false)));
// Two visited: the third.
assert_eq!(
pick_forward(&pool, &[0xA, 0xB], Some(0xB)),
Some((2, false))
);
// All visited: wrap to the best non-current.
assert_eq!(
pick_forward(&pool, &[0xA, 0xB, 0xC], Some(0xC)),
Some((0, true))
);
// Only the current selection in the pool: itself, wrapped (dedup no-op downstream).
let solo = [cand(0xA, 0.1, true)];
assert_eq!(pick_forward(&solo, &[0xA], Some(0xA)), Some((0, true)));
assert_eq!(pick_forward(&[], &[], None), None);
}
/// **Phase B's fork** (`0x6130a3`): a hostile selection is kept, everything else acquires.
///
/// The case that reads wrong until you have read the binary is the *friendly* one — it is not
/// an error, it is a reason to go find a real target. That is what makes "press Attack with no
/// target and the pet goes for what is in front of you" the same code path as "press Attack
/// while talking to a vendor and your target jumps to the wolf behind him".
#[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");
}
/// The final gate's target legs (`0x613152`–`0x613169`), including the transcribed oddity: 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() {
use benilla_protocol::ObjectFields;
const HEALTH: u16 = 22;
const DYNFLAGS: u16 = 143;
const FLAGS: u16 = 46;
const TPL: u16 = 35;
let reps = Reputations::default();
let unit = |pairs: &[(u16, u32)]| ObjectStore(ObjectFields::from_pairs(pairs));
// `CanAttack` needs both sides now (1674), and with no catalog both reactions resolve to
// neutral — the mixed arm's `< 4`, so a plain live mob is attackable and the legs below
// are the ones actually under test.
let me = unit(&[(TPL, 1), (FLAGS, 1 << 3)]);
let valid = |s: &ObjectStore| attack_target_valid(Some(s), None, &reps, Some(&me));
assert!(valid(&unit(&[(HEALTH, 120)])), "a live mob");
assert!(!valid(&unit(&[(HEALTH, 0)])), "a corpse");
assert!(
valid(&unit(&[(HEALTH, 0), (DYNFLAGS, 1 << 5)])),
"0x613159's second leg, verbatim"
);
assert!(
!valid(&unit(&[(HEALTH, 120), (FLAGS, 1 << 25)])),
"NOT_SELECTABLE is one of CanAttack's disqualifiers"
);
// No descriptor on either side is a REFUSAL now — see the fn doc. The reference holds two
// live CGUnits here and has no null path; swinging at an object we know nothing about was
// the approximation's posture, not the binary's.
assert!(!attack_target_valid(None, None, &reps, Some(&me)));
assert!(!attack_target_valid(
Some(&unit(&[(HEALTH, 120)])),
None,
&reps,
None
));
}
/// The **backward** pick, beside its forward twin: newest-first through the history, skipping
/// the current selection and anything that has left the pool; nothing to step back to ⇒ `None`,
/// which is what makes the first reverse press fall through to the forward rule.
///
/// This is the whole of `reverse` — the flag `TargetNearestEnemy`/`TargetNearestFriend` take as
/// their optional Lua argument (`0x489a80`/`0x489aa0` fetch it with `0x6f1c10`, default 0) and
/// hand to the one cycler as `ecx`. Forward and backward walk the same order in opposite
/// directions: with A,B,C visited in that order, back from C is B and back from B is A.
#[test]
fn pick_back_walks_the_history_newest_first() {
let pool = [
cand(0xA, 0.1, true),
cand(0xB, 0.2, true),
cand(0xC, 0.3, true),
];
// Forward over a fresh pool visits A, then B, then C — the order `pick_back` reverses.
assert_eq!(pick_forward(&pool, &[], None), Some((0, false)));
assert_eq!(pick_forward(&pool, &[0xA], Some(0xA)), Some((1, false)));
assert_eq!(
pick_forward(&pool, &[0xA, 0xB], Some(0xB)),
Some((2, false))
);
// Back from C is B; back from B is A — the exact reverse of the walk above.
assert_eq!(pick_back(&[0xA, 0xB, 0xC], &pool, Some(0xC)), Some(1));
assert_eq!(pick_back(&[0xA, 0xB], &pool, Some(0xB)), Some(0));
// Nothing behind us: the caller falls through to the forward rule.
assert_eq!(pick_back(&[], &pool, None), None);
assert_eq!(pick_back(&[0xA], &pool, Some(0xA)), None);
// A remembered guid that has left the pool (died, streamed out) is skipped, not picked.
let shrunk = [cand(0xA, 0.1, true), cand(0xC, 0.3, true)];
assert_eq!(pick_back(&[0xA, 0xB, 0xC], &shrunk, Some(0xC)), Some(0));
}
/// The history is **per side**: a friendly press wipes the enemy cycle's skip set and vice
/// versa — the reference's `cachedMode != mode` rebuild condition (`0x493f60`), which drops the
/// candidate list and resets the cursor whenever the mode byte changes.
#[test]
fn a_side_switch_clears_the_history() {
let mut h = TabHistory::default();
h.enter(ScanSide::Enemy);
h.push(0xA, 0.0);
h.push(0xB, 1.0);
h.enter(ScanSide::Enemy); // same side: nothing happens
assert_eq!(h.guids(), [0xA, 0xB]);
h.enter(ScanSide::Friend); // the other side: start from nothing
assert!(h.guids().is_empty());
}
/// Field indices, for the store builders below (`benilla-protocol`'s own numbering).
const F_TYPE: u16 = 2;
const F_HEALTH: u16 = 22;
const F_MAXHEALTH: u16 = 28;
const F_FLAGS: u16 = 46;
const F_DYNFLAGS: u16 = 143;
const F_MOUNTDISPLAYID: u16 = 133;
const F_DUEL_ARBITER: u16 = 188;
const F_PLAYER_FLAGS: u16 = 190;
const F_DUEL_TEAM: u16 = 196;
/// `UNIT_FLAG_PVP_ATTACKABLE` — behaviourally "player-controlled"; the wire always carries it
/// on a player, which is what selects `CanAttack`'s and `CanAssist`'s player arms.
const CONTROLLED: u32 = 0x8;
/// `OBJECT_FIELD_TYPE` for a Player object (OBJECT|UNIT|PLAYER).
const TYPE_PLAYER: u32 = 0x19;
fn store(pairs: &[(u16, u32)]) -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs))
}
/// **The two sides of one scan, over one world** — the mode byte is the only fork, so this
/// drives the real [`TargetScan::build`] twice and asserts each side's pool.
///
/// With no `FactionTemplate.dbc` loaded every reaction resolves to neutral, which is exactly
/// the regime that separates the two filters: neutral is **attackable** (`CanAttack`'s mixed
/// arm is `< 4`) and **not assistable** (`CanAssist`'s ladder is `>= 4`). To get a genuinely
/// friendly reaction with no catalog the ally here is a **same-team duel partner** — the one
/// rung of `UnitReaction 0x6061e0` that answers 4 off descriptor fields alone (decision 0633).
/// It is also a fair model of the real case: `TargetNearestFriend` is mostly about players.
///
/// Four candidates pin four separate claims:
/// * the neutral mob is an enemy candidate and never a friendly one — **the control that must
/// not change**;
/// * the friendly player is a friendly candidate and never an enemy one;
/// * a friendly **corpse** is out — mode 2's `UNIT_FIELD_HEALTH > 0` leg;
/// * a friendly **feigner** is IN — mode 2 has no `UNIT_DYNFLAG_DEAD` leg, though mode 1's
/// reads-dead triple does. That asymmetry is transcribed, not tidied.
#[test]
fn the_two_sides_of_the_scan_never_pick_each_others_units() {
use bevy::ecs::system::RunSystemOnce;
const ARBITER: u32 = 7;
const MOB: u64 = 0xF0E;
const ALLY: u64 = 0xA11;
const ALLY_CORPSE: u64 = 0xDEAD;
const ALLY_FEIGN: u64 = 0xFE16;
let mut world = World::new();
world.init_resource::<Reputations>();
world.init_resource::<NameCache>();
// Us: a live player, mid-duel on team 1.
world.spawn((
SelfPlayer,
Transform::default(),
Guid(1),
store(&[
(F_TYPE, TYPE_PLAYER),
(F_FLAGS, CONTROLLED),
(F_HEALTH, 100),
(F_MAXHEALTH, 100),
(F_DUEL_ARBITER, ARBITER),
(F_DUEL_TEAM, 1),
]),
));
let unit = |guid: u64, x: f32, fields: &[(u16, u32)]| {
(
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
Guid(guid),
Transform::from_xyz(x, 0.0, 0.0),
store(fields),
)
};
let ally_fields = |extra: &[(u16, u32)]| {
let mut v = vec![
(F_TYPE, TYPE_PLAYER),
(F_FLAGS, CONTROLLED),
(F_HEALTH, 100),
(F_MAXHEALTH, 100),
(F_DUEL_ARBITER, ARBITER),
(F_DUEL_TEAM, 1),
];
v.extend_from_slice(extra);
v
};
world.spawn(unit(MOB, 5.0, &[(F_HEALTH, 100), (F_MAXHEALTH, 100)]));
world.spawn(unit(ALLY, 10.0, &ally_fields(&[])));
world.spawn(unit(ALLY_CORPSE, 15.0, &ally_fields(&[(F_HEALTH, 0)])));
world.spawn(unit(
ALLY_FEIGN,
20.0,
&ally_fields(&[(F_DYNFLAGS, 1 << 5)]),
));
let pool = |world: &mut World, side: ScanSide| {
world
.run_system_once(move |scan: TargetScan| {
scan.build(side).iter().map(|c| c.guid).collect::<Vec<_>>()
})
.expect("the scan runs as a one-shot system")
};
// Mode 1: the neutral mob only. Every duel-team ally is refused by `CanAttack`'s
// both-player-controlled arm (a friendly reaction returns 0 outright).
assert_eq!(pool(&mut world, ScanSide::Enemy), [MOB]);
// Mode 2: the live ally and the feigning one — never the mob, never the corpse. With no
// camera in the world nothing projects, so the order is pure distance (ALLY at 10 yd
// before ALLY_FEIGN at 20).
assert_eq!(pool(&mut world, ScanSide::Friend), [ALLY, ALLY_FEIGN]);
}
/// [`LastEnemy`] tracks the last **attackable** selection and nothing else — `TargetLastEnemy`'s
/// whole memory (`[0xb4e2e8]/[0xb4e2ec]`, `0x49377d`).
///
/// The three claims that make the verb behave: it follows a hostile switch; a **friendly**
/// selection does not overwrite it (that is what separates it from `TargetLastTarget`'s pair);
/// and **clearing the target leaves it standing**, which is the whole point — you press `G`
/// after an Esc, not before one.
#[test]
fn last_enemy_remembers_the_last_attackable_selection_across_a_clear() {
use bevy::ecs::system::RunSystemOnce;
const ARBITER: u32 = 7;
let mut world = World::new();
world.init_resource::<Reputations>();
world.init_resource::<Selection>();
world.init_resource::<LastEnemy>();
world.spawn((
SelfPlayer,
store(&[
(F_TYPE, TYPE_PLAYER),
(F_FLAGS, CONTROLLED),
(F_HEALTH, 100),
(F_MAXHEALTH, 100),
(F_DUEL_ARBITER, ARBITER),
(F_DUEL_TEAM, 1),
]),
));
let mob = |world: &mut World| {
world
.spawn(store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100)]))
.id()
};
let a = mob(&mut world);
let b = mob(&mut world);
// A same-team duel partner: friendly reaction, so `CanAttack`'s PvP arm refuses.
let friend = world
.spawn(store(&[
(F_TYPE, TYPE_PLAYER),
(F_FLAGS, CONTROLLED),
(F_HEALTH, 100),
(F_MAXHEALTH, 100),
(F_DUEL_ARBITER, ARBITER),
(F_DUEL_TEAM, 1),
]))
.id();
let select = |world: &mut World, target: Option<(Entity, u64)>| {
let mut sel = world.resource_mut::<Selection>();
sel.target = target.map(|(e, _)| e);
sel.guid = target.map(|(_, g)| g);
world
.run_system_once(remember_last_enemy)
.expect("the sampler runs as a one-shot system");
world.resource::<LastEnemy>().0
};
assert_eq!(
world.resource::<LastEnemy>().0,
None,
"nothing hostile targeted yet"
);
assert_eq!(select(&mut world, Some((a, 0xA))), Some(0xA));
assert_eq!(select(&mut world, Some((b, 0xB))), Some(0xB));
// A friendly selection is not an enemy — the memory holds at B.
assert_eq!(select(&mut world, Some((friend, 0xF))), Some(0xB));
// …and neither is an empty one. This is the case the verb exists for.
assert_eq!(select(&mut world, None), Some(0xB));
// **The four conjuncts besides `CanAttack`** (`0x49372f`-`0x493778`). Each is asserted by
// moving one state and re-selecting an ordinary hostile that would otherwise stamp: the
// memory has to hold at B every time. Written as one test rather than four because the
// whole point is that they are one gate, and a gate with a leg missing is what shipped.
let dead_mob = world
.spawn(store(&[(F_HEALTH, 0), (F_MAXHEALTH, 100)]))
.id();
assert_eq!(
select(&mut world, Some((dead_mob, 0xD))),
Some(0xB),
"a corpse is not remembered — the target's health leg"
);
// …unless it carries the dead-looking dynflag, which is a FEIGN and stays remembered.
let feigner = world
.spawn(store(&[
(F_HEALTH, 0),
(F_MAXHEALTH, 100),
(F_DYNFLAGS, 0x20),
]))
.id();
assert_eq!(
select(&mut world, Some((feigner, 0xE))),
Some(0xE),
"`HEALTH > 0 || dynflag 0x20` is an OR, transcribed not simplified"
);
// Our own body's three states. Each is set, a live hostile selected, and the memory must
// not move off 0xE.
let with_self = |world: &mut World, fields: &[(u16, u32)]| {
let me = world
.query_filtered::<Entity, With<SelfPlayer>>()
.single(world)
.expect("one self player");
let mut base = vec![
(F_TYPE, TYPE_PLAYER),
(F_FLAGS, CONTROLLED),
(F_HEALTH, 100),
(F_MAXHEALTH, 100),
(F_DUEL_ARBITER, ARBITER),
(F_DUEL_TEAM, 1),
];
base.extend_from_slice(fields);
world.entity_mut(me).insert(store(&base));
};
let c = mob(&mut world);
with_self(&mut world, &[(F_MOUNTDISPLAYID, 1234)]);
assert_eq!(
select(&mut world, Some((c, 0xC))),
Some(0xE),
"mounted: the reference does not stamp"
);
with_self(&mut world, &[(F_HEALTH, 0)]);
assert_eq!(
select(&mut world, Some((c, 0xC))),
Some(0xE),
"dead: the reference does not stamp"
);
with_self(&mut world, &[(F_PLAYER_FLAGS, 0x10)]);
assert_eq!(
select(&mut world, Some((c, 0xC))),
Some(0xE),
"ghost: a ghost's wire health is 1, so this needs its own leg"
);
// The control: put the body back and the same selection stamps.
with_self(&mut world, &[]);
assert_eq!(
select(&mut world, Some((c, 0xC))),
Some(0xC),
"the gate is the three states, not the selection"
);
}
/// The history: pruning honors the window, a re-visit moves to most-recent.
#[test]
fn history_prunes_and_reorders() {
let mut h = TabHistory::default();
h.push(0xA, 0.0);
h.push(0xB, 1.0);
h.push(0xA, 2.0); // re-visit: A moves to the back
assert_eq!(h.guids(), [0xB, 0xA]);
h.prune(1.0 + HISTORY_SECS); // B (t=1.0) ages out at exactly the window
assert_eq!(h.guids(), [0xA]);
}
}