A ghost is dead to the attack check, the action bar, the cast ladder and the cursor (#389)
A ghost could swing, got a lit action bar, a sword cursor over enemies, and an Attack button that did not refuse. benilla tested death as health 0 only, and a released ghost has health 1. Fixes #246. Fixes #248. Fixes #250. Fixes #251. - **One predicate** (`0x605f30`): dead or ghost is health 0 or below, or a player with the `PLAYER_FLAGS` ghost bit `0x10`. It lives on the object fields and every site uses it: the attack check (`0x612e10`), the usable walk's first check (`0x6e3dad`), summon, trade and the death screen. - **#248:** the cast ladder refuses a dead or ghost caster with `SPELL_FAILED_CASTER_DEAD` and sends nothing, unless the spell's `Attributes` has `0x800000` (`0x6e4c49`-`0x6e4c68`); the Attack action button takes it first ("You are dead"). - **#251:** the sword cursor also needs a live, unmounted, unpacified player in control (`0x482648`-`0x4826cb`). A right-click now decides attack from the reference's CanInteract/CanAttack fork (`0x60c18c`-`0x60c247`), not from the cursor kind, so a ghost right-clicking a hostile NPC with a service flag selects it and does nothing else, instead of opening its window. Tests: seven tests, each with a ghost at health 1; `scripts/check.sh` is green. To see it: release spirit. The action bar stays grey; Attack Target reads "Can't attack while dead."; hovering a hostile mob shows the pointer; right-clicking a hostile vendor selects it and opens nothing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Opus 5.5
parent
459a9a5697
commit
b8a32a06e7
@@ -251,7 +251,7 @@ fn feed_death(
|
||||
}
|
||||
|
||||
// ── The offer and confirm announcements ──
|
||||
if let Some(name) = offer_announcement(memo, &death_net, dead || ghost, |offer| {
|
||||
if let Some(name) = offer_announcement(memo, &death_net, store.0.is_dead_or_ghost(), |offer| {
|
||||
// A player caster's name is empty on the wire; hold the popup until the cache resolves it.
|
||||
if offer.name.is_empty() {
|
||||
names.resolve(offer.caster, &net).map(str::to_owned)
|
||||
|
||||
@@ -657,12 +657,14 @@ pub(crate) mod schedule_tests {
|
||||
/// - the area-spirit-healer poll over `Transform` and `NetEntity`: it is level-triggered, so a
|
||||
/// unit seen a frame early or late is decided again next frame;
|
||||
/// - `ui_models::forget_dead_vm_tiles` against `portrait::glue_booth::sync_glue_scene` over
|
||||
/// `MatAnimTable`, a slot allocator whose owners touch disjoint slots.
|
||||
/// `MatAnimTable`, a slot allocator whose owners touch disjoint slots;
|
||||
/// - the cursor classifier against `world_focus`'s focus publish and settle release over
|
||||
/// `Player`: it reads `control_lost`, and they write only `settling` and `world_stale`.
|
||||
///
|
||||
/// 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 = 5_462;
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 5_464;
|
||||
const UPDATE_ACTIONABLE_SLACK: usize = 40;
|
||||
|
||||
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
//! the reference's `TryCast 0x6e4b60` and its commit `SendCast 0x6e54f0` rung for rung.
|
||||
//!
|
||||
//! The rungs: the auto-repeat toggle-off (the reference's action-button handler, ahead of TryCast;
|
||||
//! its order against the profession intercept is unobservable), the profession intercept, the
|
||||
//! targeting abort, in-flight, reagents and totems, the equipped item, target binding and range,
|
||||
//! then the validator `0x6094f0` (not-ready and GCD, power, crowd control, mounted, water, moving,
|
||||
//! form), the deferred cast-arm refusal and the targeting cursor. The commit tail follows: ranged
|
||||
//! stance, auto-repeat arm, the send, the auto-attack start, the GCD. A refusal is local and
|
||||
//! pre-commit: no packet, no GCD, no pending arm, only the red line.
|
||||
//! its order against the profession intercept is unobservable), the profession intercept, dead or a
|
||||
//! ghost, the targeting abort, in-flight, reagents and totems, the equipped item, target binding
|
||||
//! and range, then the validator `0x6094f0` (not-ready and GCD, power, crowd control, mounted,
|
||||
//! water, moving, form), the deferred cast-arm refusal and the targeting cursor. The commit tail
|
||||
//! follows: ranged stance, auto-repeat arm, the send, the auto-attack start, the GCD. A refusal is
|
||||
//! local and pre-commit: no packet, no GCD, no pending arm, only the red line.
|
||||
//!
|
||||
//! An item use takes the same ladder: `CGItem::Use 0x5d8d00` calls `0x6e5a90`, whose body is
|
||||
//! `call 0x6e4b60` with the item as TryCast's third argument (read at `6e4d76` and `6e4f33`). Three
|
||||
@@ -163,6 +163,17 @@ impl CastLadder<'_, '_> {
|
||||
self.ground.clear();
|
||||
}
|
||||
|
||||
/// TryCast's dead rung alone, for the Attack button, whose short-circuit (`0x6e4c7a`) leaves
|
||||
/// the ladder right after it.
|
||||
pub(crate) fn dead_refusal(
|
||||
&mut self,
|
||||
spell_id: u32,
|
||||
self_store: Option<&crate::net::ObjectStore>,
|
||||
) -> bool {
|
||||
let def = self.spells.as_ref().and_then(|s| s.catalog.get(spell_id));
|
||||
caster_dead_refusal(spell_id, def, self_store, &mut self.cast_errors)
|
||||
}
|
||||
|
||||
/// Run the ladder for `spell_id` and commit as `commit` says.
|
||||
pub(crate) fn send(
|
||||
&mut self,
|
||||
@@ -216,6 +227,25 @@ impl CastLadder<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// TryCast's dead rung (`0x6e4c49`), after the profession intercept and ahead of the Attack
|
||||
/// short-circuit (`0x6e4c7a`): a dead or ghost caster (`0x605f30`) is refused with 0x13, "You are
|
||||
/// dead", unless the spell is castable while dead (`Attributes & 0x800000`, `0x6e4c55`).
|
||||
fn caster_dead_refusal(
|
||||
spell_id: u32,
|
||||
def: Option<&benilla_formats::SpellDisplay>,
|
||||
self_store: Option<&crate::net::ObjectStore>,
|
||||
cast_errors: &mut CastErrors,
|
||||
) -> bool {
|
||||
if !self_store.is_some_and(|s| s.0.is_dead_or_ghost())
|
||||
|| def.is_some_and(|d| d.attributes & benilla_formats::ATTR_CASTABLE_WHILE_DEAD != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
debug!("ui_action: cast {spell_id} refused locally — dead or a ghost (0x13)");
|
||||
cast_errors.push_local(spell_id, 0x13);
|
||||
true
|
||||
}
|
||||
|
||||
/// Run the ladder for one cast and commit it. The commit tail (`0x6e54f0`): a ranged spell arms the
|
||||
/// ranged stance (`0x6e5930`, `SetSheatheState(2,1,1)`; the echoed START re-requests it), an
|
||||
/// auto-repeat spell sets the sticky armed state (`0x6e593b`, `|= 0x200`, the Load/Hold idle's
|
||||
@@ -263,6 +293,9 @@ fn send_spell_cast(
|
||||
crate::creature_anim::cancel_auto_repeat_local(self_e, auto_repeat, ecs, commands);
|
||||
return;
|
||||
}
|
||||
if caster_dead_refusal(spell_id, def, ctx.rel.self_store, cast_errors) {
|
||||
return;
|
||||
}
|
||||
// TryCast's IsTargeting leg (`6e4d62`): a new press while the cursor is up clears the word,
|
||||
// with no packet, and continues down the ladder.
|
||||
if ground.active() {
|
||||
@@ -738,6 +771,112 @@ mod tests {
|
||||
.expect("the ladder runs as a one-shot system");
|
||||
}
|
||||
|
||||
/// TryCast's dead rung (`0x6e4c49`): a corpse and a ghost (health 1, the `PLAYER_FLAGS` bit)
|
||||
/// are refused with 0x13 and no packet, after the profession intercept; `Attributes &
|
||||
/// 0x800000` waives it, and the Attack button's own entry takes the same rung.
|
||||
#[test]
|
||||
fn a_dead_or_ghost_caster_is_refused_as_dead_unless_the_spell_waives_it() {
|
||||
use crate::net::ObjectStore;
|
||||
use benilla_formats::SpellDisplay;
|
||||
use benilla_protocol::ObjectFields;
|
||||
const PLAIN: u32 = 100;
|
||||
const WHILE_DEAD: u32 = 200;
|
||||
const PROFESSION: u32 = 300;
|
||||
const ATTACK: u32 = crate::ui_action::SPELL_ATTACK;
|
||||
|
||||
let (mut world, rx) = world();
|
||||
let mut spells = crate::ui_action::Spells::empty_for_tests();
|
||||
spells.catalog = benilla_formats::SpellCatalog::from_displays(
|
||||
[
|
||||
(PLAIN, SpellDisplay::default()),
|
||||
(
|
||||
WHILE_DEAD,
|
||||
SpellDisplay {
|
||||
attributes: benilla_formats::ATTR_CASTABLE_WHILE_DEAD,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
PROFESSION,
|
||||
SpellDisplay {
|
||||
effects: [benilla_formats::SPELL_EFFECT_TRADE_SKILL, 0, 0],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
// Attack's `Attributes` are `0x10`, no waiver.
|
||||
(
|
||||
ATTACK,
|
||||
SpellDisplay {
|
||||
attributes: 0x10,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
world.insert_resource(spells);
|
||||
// Fields 22 health, 28 max health, 190 `PLAYER_FLAGS`. Leaked: the closure must be 'static.
|
||||
let leak = |pairs: &[(u16, u32)]| -> &'static ObjectStore {
|
||||
Box::leak(Box::new(ObjectStore(ObjectFields::from_pairs(pairs))))
|
||||
};
|
||||
let corpse = leak(&[(22, 0), (28, 100)]);
|
||||
let ghost = leak(&[(22, 1), (28, 100), (190, 0x10)]);
|
||||
let press = |world: &mut World, spell_id: u32, store: &'static ObjectStore| {
|
||||
world
|
||||
.run_system_once(move |mut ladder: CastLadder| {
|
||||
let base = ctx();
|
||||
let with_store = cast_target::CastContext {
|
||||
rel: cast_target::TargetRelations {
|
||||
self_store: Some(store),
|
||||
..base.rel
|
||||
},
|
||||
..base
|
||||
};
|
||||
ladder.send(spell_id, &with_store, CastCommit::Spell);
|
||||
})
|
||||
.expect("one-shot");
|
||||
};
|
||||
|
||||
for (label, store) in [("corpse", corpse), ("ghost", ghost)] {
|
||||
press(&mut world, PLAIN, store);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"{label}: a refused press never sends"
|
||||
);
|
||||
assert_eq!(
|
||||
std::mem::take(&mut world.resource_mut::<CastErrors>().0),
|
||||
vec![CastFail::local(PLAIN, 0x13)],
|
||||
"{label}: \"You are dead\""
|
||||
);
|
||||
let refused = world
|
||||
.run_system_once(move |mut ladder: CastLadder| {
|
||||
ladder.dead_refusal(ATTACK, Some(store))
|
||||
})
|
||||
.expect("one-shot");
|
||||
assert!(refused, "{label}: the Attack button stops at the same rung");
|
||||
assert_eq!(
|
||||
std::mem::take(&mut world.resource_mut::<CastErrors>().0),
|
||||
vec![CastFail::local(ATTACK, 0x13)]
|
||||
);
|
||||
}
|
||||
|
||||
// The profession intercept (`6e4bce`) comes first: the book opens, no error.
|
||||
press(&mut world, PROFESSION, ghost);
|
||||
assert_eq!(
|
||||
world.resource::<crate::ui_tradeskill::TradeSkillOpens>().0,
|
||||
vec![PROFESSION]
|
||||
);
|
||||
assert!(world.resource::<CastErrors>().0.is_empty());
|
||||
|
||||
press(&mut world, WHILE_DEAD, ghost);
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { spell_id, .. }) if spell_id == WHILE_DEAD),
|
||||
"the castable-while-dead attribute waives the rung"
|
||||
);
|
||||
assert!(world.resource::<CastErrors>().0.is_empty());
|
||||
}
|
||||
|
||||
/// A mashed chest opener goes out once as the GameObject block; the re-click is `6e4d43`'s
|
||||
/// silent same-spell bail, with no packet and no red line.
|
||||
#[test]
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
//! current target), the bit-25 cooldown fold (11) and power (12).
|
||||
//!
|
||||
//! Not built, each answering usable: leg 2's caster aura-immunity helpers (`0x6e9f20/40/60`), leg
|
||||
//! 4's `AttributesEx3` sub-conditions, and the ghost state beyond plain death. CanAssist in leg 10b
|
||||
//! is a reaction-rank test (friendly or better), not the reference's `0x6066f0`.
|
||||
//! 4's `AttributesEx3` sub-conditions. CanAssist in leg 10b is a reaction-rank test (friendly or
|
||||
//! better), not the reference's `0x6066f0`.
|
||||
|
||||
use benilla_protocol::messages::ItemUseSpell;
|
||||
|
||||
@@ -276,8 +276,9 @@ pub(crate) fn spell_usable(
|
||||
if d.effects[0] == SPELL_EFFECT_TRADE_SKILL {
|
||||
return (true, false);
|
||||
}
|
||||
// Leg 1: dead casters need the castable-while-dead attribute.
|
||||
if ctx.store.0.unit_health() == Some(0) && d.attributes & ATTR_CASTABLE_WHILE_DEAD == 0 {
|
||||
// Leg 1 (`0x6e3dad`): a dead or ghost caster (`0x605f30`) needs the castable-while-dead
|
||||
// attribute (`0x6e3db6`).
|
||||
if ctx.store.0.is_dead_or_ghost() && d.attributes & ATTR_CASTABLE_WHILE_DEAD == 0 {
|
||||
return (false, false);
|
||||
}
|
||||
// Leg 3 (`0x6e4000`): every reagent count carried, every totem tool present.
|
||||
@@ -416,8 +417,8 @@ mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
// Field indices, private in the protocol crate: health 22, power1 23, flags 46, aurastate
|
||||
// 125, bytes_1 138.
|
||||
// Field indices, private in the protocol crate: health 22, power1 23, max health 28, flags
|
||||
// 46, aurastate 125, bytes_1 138, player flags 190.
|
||||
fn player(pairs: &[(u16, u32)]) -> ObjectStore {
|
||||
// `UNIT_FLAG_PVP_ATTACKABLE` (bit 3), which every player carries: `CanAttack 0x606980`
|
||||
// picks its arm on that bit for both parties.
|
||||
@@ -691,14 +692,17 @@ mod tests {
|
||||
let d = SpellDisplay::default();
|
||||
assert_eq!(walk(&d, &alive), (true, false));
|
||||
|
||||
// Leg 1: dead; the attribute waives it.
|
||||
let dead = player(&[(22, 0)]);
|
||||
// Leg 1: dead, or a ghost at health 1 (field 190 bit `0x10`); the attribute waives it.
|
||||
let dead = player(&[(22, 0), (28, 100)]);
|
||||
assert_eq!(walk(&d, &dead), (false, false));
|
||||
let ghost = player(&[(22, 1), (28, 100), (190, 0x10)]);
|
||||
assert_eq!(walk(&d, &ghost), (false, false));
|
||||
let while_dead = SpellDisplay {
|
||||
attributes: ATTR_CASTABLE_WHILE_DEAD,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(walk(&while_dead, &dead), (true, false));
|
||||
assert_eq!(walk(&while_dead, &ghost), (true, false));
|
||||
|
||||
// Leg 3: a missing reagent.
|
||||
let reagent = SpellDisplay {
|
||||
|
||||
@@ -409,7 +409,9 @@ pub(super) fn act_on_right_click(
|
||||
let (Some(entity), Some(guid)) = (hovered.target, hovered.guid) else {
|
||||
return;
|
||||
};
|
||||
let attack = cursor.kind == cursor_mode::CursorKind::Attack;
|
||||
// The dispatcher's attack arm (`0x60c18c`), not the sword: a dead, ghost or mounted player
|
||||
// still takes it and is refused inside (`0x60c1a1`, `0x60c1bc`), never reaching a service.
|
||||
let attack = press.attack_fork.0;
|
||||
let target = stores.get(entity).ok().map(|(s, _)| s);
|
||||
// ── The dead-target fork of the unit dispatcher `0x60bea0` ──
|
||||
// Loot routes by classification (dead and `UNIT_DYNFLAG_LOOTABLE`), not the cursor kind, whose
|
||||
@@ -446,7 +448,8 @@ pub(super) fn act_on_right_click(
|
||||
);
|
||||
}
|
||||
let me = self_player.single().ok();
|
||||
// A mid-combat click on a vendor or corpse switches and stops, never swings (`0x5ecb70`).
|
||||
// A mid-combat click on a vendor or corpse switches and stops, never swings (`0x5ecb70`); the
|
||||
// sword, not the fork, as the re-swing also needs the player's own legs.
|
||||
let outcome = scan::commit(
|
||||
&mut selection,
|
||||
&mut seam,
|
||||
@@ -455,7 +458,7 @@ pub(super) fn act_on_right_click(
|
||||
target,
|
||||
me.is_some_and(|(_, _, e)| e),
|
||||
me.map(|(_, g, _)| g.0),
|
||||
attack,
|
||||
press.attack(),
|
||||
);
|
||||
match unit_branch(attack, dead_fork, leg) {
|
||||
UnitBranch::Attack => {
|
||||
@@ -1791,6 +1794,78 @@ mod tests {
|
||||
"the release must act on the unit whose plate the press was over"
|
||||
);
|
||||
}
|
||||
|
||||
/// The dispatcher's attack arm (`0x60c18c`) is taken on the fork, not the sword: a ghost over
|
||||
/// a hostile NPC sees the pointer, and its right-click is refused silently inside the arm
|
||||
/// (`0x60c1a1`), with no swing and no service packet; a live player on the same fork swings.
|
||||
#[test]
|
||||
fn a_ghost_takes_the_attack_arm_and_is_refused_silently() {
|
||||
const F_PLAYER_FLAGS: u16 = 190;
|
||||
const F_NPC_FLAGS: u16 = 147;
|
||||
for (label, own, swings) in [
|
||||
(
|
||||
"ghost",
|
||||
&[(F_HEALTH, 1), (F_MAXHEALTH, 100), (F_PLAYER_FLAGS, 0x10)][..],
|
||||
false,
|
||||
),
|
||||
("live", &[(F_HEALTH, 100), (F_MAXHEALTH, 100)][..], true),
|
||||
] {
|
||||
let (tx, rx) = crossbeam_channel::unbounded::<ClientCommand>();
|
||||
let (mut world, _boar) = right_click_world();
|
||||
world.insert_resource(NetCommands(tx));
|
||||
let me = world
|
||||
.query_filtered::<Entity, With<SelfPlayer>>()
|
||||
.single(&world)
|
||||
.unwrap();
|
||||
world.entity_mut(me).insert(store(own));
|
||||
// A hostile gossip NPC: its service bit must never be read on the attack arm.
|
||||
let npc = world
|
||||
.spawn((
|
||||
Guid(BOAR + 1),
|
||||
store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100), (F_NPC_FLAGS, 0x1)]),
|
||||
))
|
||||
.id();
|
||||
*world.resource_mut::<PressPick>() = PressPick {
|
||||
hovered: Hovered {
|
||||
target: Some(npc),
|
||||
guid: Some(BOAR + 1),
|
||||
distance: 3.0,
|
||||
..Hovered::default()
|
||||
},
|
||||
attack_fork: cursor_mode::AttackFork(true),
|
||||
..PressPick::default()
|
||||
};
|
||||
world
|
||||
.resource_mut::<Messages<WorldRightClick>>()
|
||||
.write(WorldRightClick);
|
||||
world.run_system_once(act_on_right_click).unwrap();
|
||||
let sent: Vec<_> = rx.try_iter().collect();
|
||||
assert!(
|
||||
matches!(sent.first(), Some(ClientCommand::SetSelection { guid }) if *guid == BOAR + 1),
|
||||
"{label}: the select stands: {sent:?}"
|
||||
);
|
||||
assert!(
|
||||
!sent
|
||||
.iter()
|
||||
.any(|c| matches!(c, ClientCommand::GossipHello { .. })),
|
||||
"{label}: the attack arm never reaches the service leg: {sent:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
sent.iter()
|
||||
.any(|c| matches!(c, ClientCommand::AttackSwing { .. })),
|
||||
swings,
|
||||
"{label}: {sent:?}"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.resource::<crate::ui_action::UiErrorKeys>()
|
||||
.0
|
||||
.is_empty(),
|
||||
"{label}: the arm says nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A meeting stone's own use slot (`0x5f69d0`) replaces the shared sender: the click hands it
|
||||
/// to the join validator and puts no `0xB1` on the wire, which vmangos' type-23 `Use` ignores.
|
||||
#[test]
|
||||
|
||||
@@ -122,6 +122,23 @@ pub(crate) mod npc_flags {
|
||||
}
|
||||
|
||||
pub(super) const UNIT_FLAG_SKINNABLE: u32 = 0x0400_0000;
|
||||
const UNIT_FLAG_PACIFIED: u32 = 0x0002_0000;
|
||||
|
||||
/// The hovered unit is one the unit dispatcher `0x60bea0` forks to its attack arm for (`0x60c192`):
|
||||
/// alive or feigning, refused by `CanInteract` and passed by `CanAttack`. The sword also needs the
|
||||
/// player's own legs ([`player_attack_legs`]), which the arm tests again inside.
|
||||
#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) struct AttackFork(pub(crate) bool);
|
||||
|
||||
/// The player's legs of the classifier's attack leg (`0x482648`–`0x4826a2`), in its order: not
|
||||
/// dead or a ghost (`0x48264a`), not mounted (`0x482661`), not pacified (`0x48268f`) and in
|
||||
/// control (`[0xb4b3e4]`, `0x48269b`). Any failure clears to Point (`0x4826cb`).
|
||||
pub(crate) fn player_attack_legs(own: &ObjectStore, control_lost: bool) -> bool {
|
||||
!own.0.is_dead_or_ghost()
|
||||
&& own.0.unit_mount_display_id() == 0
|
||||
&& own.0.unit_flags() & UNIT_FLAG_PACIFIED == 0
|
||||
&& !control_lost
|
||||
}
|
||||
|
||||
/// NPC-service range, squared: gray beyond 5.5556 yd (`0xb4b32c` = `[0x804328]²`, checked
|
||||
/// boundary-inclusive at `0x482320`). The NPC windows' walk-away close uses the same constant.
|
||||
@@ -504,7 +521,9 @@ pub(super) fn classify_cursor(
|
||||
hovered_object: Res<HoveredObject>,
|
||||
factions: Option<Res<Factions>>,
|
||||
reputations: Res<Reputations>,
|
||||
mut cursor: ResMut<WorldCursor>,
|
||||
(mut cursor, mut fork): (ResMut<WorldCursor>, ResMut<AttackFork>),
|
||||
// `control_lost` is the attack leg's `[0xb4b3e4]` clear.
|
||||
player: Res<crate::player::Player>,
|
||||
units: Query<(
|
||||
&Transform,
|
||||
Option<&ObjectStore>,
|
||||
@@ -581,6 +600,7 @@ pub(super) fn classify_cursor(
|
||||
};
|
||||
// The reference picks once over all objects; benilla picks units and GameObjects apart and
|
||||
// classifies whichever is nearer under the cursor.
|
||||
let attack_fork = std::cell::Cell::new(false);
|
||||
let resolve_unit = || {
|
||||
let (unit_tf, store, _) = units.get(hovered.target?).ok()?;
|
||||
let store = store?;
|
||||
@@ -620,17 +640,19 @@ pub(super) fn classify_cursor(
|
||||
return service_cursor(store.0.unit_npc_flags(), status)
|
||||
.map(|kind| (kind, dist_sq > SERVICE_RANGE_SQ));
|
||||
}
|
||||
// `CanAttack 0x606980` (`0x48269a`), the predicate TAB, the combat flash and
|
||||
// `UnitCanAttack` share.
|
||||
if super::can_attack(
|
||||
// `CanAttack 0x606980` (`0x482680`), the predicate TAB, the combat flash and
|
||||
// `UnitCanAttack` share; not attackable clears the cursor (`0x4826cb`): Point.
|
||||
if !super::can_attack(
|
||||
Some(store),
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
Some(self_store),
|
||||
) {
|
||||
return Some((CursorKind::Attack, dist_sq > ATTACK_RANGE_SQ));
|
||||
return None;
|
||||
}
|
||||
None // the reference's cursor clear (`0x4826cb`): Point
|
||||
attack_fork.set(true);
|
||||
player_attack_legs(self_store, player.control_lost)
|
||||
.then_some((CursorKind::Attack, dist_sq > ATTACK_RANGE_SQ))
|
||||
};
|
||||
// The corpse classifier `0x482740`.
|
||||
let resolve_corpse = || {
|
||||
@@ -660,6 +682,10 @@ pub(super) fn classify_cursor(
|
||||
if *cursor != want {
|
||||
*cursor = want;
|
||||
}
|
||||
let want_fork = AttackFork(attack_fork.get());
|
||||
if *fork != want_fork {
|
||||
*fork = want_fork;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1225,6 +1251,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The sword's player legs (`0x48264a`–`0x4826a2`): a corpse, a ghost at health 1, a rider, a
|
||||
/// pacified player and one without control each see the pointer.
|
||||
#[test]
|
||||
fn the_sword_needs_a_live_unmounted_unpacified_player_in_control() {
|
||||
// Health 22, max health 28, unit flags 46, mount display 133, player flags 190.
|
||||
let own = |pairs: &[(u16, u32)]| {
|
||||
let mut all = vec![(22, 100), (28, 100)];
|
||||
all.extend_from_slice(pairs);
|
||||
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&all))
|
||||
};
|
||||
assert!(player_attack_legs(&own(&[]), false));
|
||||
assert!(!player_attack_legs(&own(&[(22, 0)]), false), "dead");
|
||||
assert!(
|
||||
!player_attack_legs(&own(&[(22, 1), (190, 0x10)]), false),
|
||||
"a ghost"
|
||||
);
|
||||
assert!(!player_attack_legs(&own(&[(133, 1147)]), false), "mounted");
|
||||
assert!(
|
||||
!player_attack_legs(&own(&[(46, UNIT_FLAG_PACIFIED)]), false),
|
||||
"pacified"
|
||||
);
|
||||
assert!(!player_attack_legs(&own(&[]), true), "no control");
|
||||
}
|
||||
|
||||
/// `UNIT_NPC_FLAGS` REPAIR, the service bit the ladder never tests.
|
||||
const REPAIR_ONLY: u32 = 0x4000;
|
||||
|
||||
|
||||
@@ -144,6 +144,9 @@ pub(crate) struct PressPick {
|
||||
/// The context cursor as of the press, which the right-click ladder forks on; the reference's
|
||||
/// new-target validation (`0x5ecb70`) also reads the press's pick, not a live one.
|
||||
pub(crate) cursor: cursor_mode::WorldCursor,
|
||||
/// The unit dispatcher's attack fork as of the press, which the sword alone cannot carry: the
|
||||
/// sword also needs the player's own legs, and a refused player still takes the attack arm.
|
||||
pub(crate) attack_fork: cursor_mode::AttackFork,
|
||||
}
|
||||
|
||||
impl PressPick {
|
||||
@@ -162,6 +165,7 @@ pub(crate) fn latch_press_pick(
|
||||
object: Res<HoveredObject>,
|
||||
occlusion: Res<PickOcclusion>,
|
||||
cursor: Res<cursor_mode::WorldCursor>,
|
||||
attack_fork: Res<cursor_mode::AttackFork>,
|
||||
mut press: ResMut<PressPick>,
|
||||
) {
|
||||
// Either button's down edge arms a pick (`0x514810`, clickModes 1 and 2); a chord does not
|
||||
@@ -176,6 +180,7 @@ pub(crate) fn latch_press_pick(
|
||||
object: *object,
|
||||
occlusion: *occlusion,
|
||||
cursor: *cursor,
|
||||
attack_fork: *attack_fork,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -250,6 +255,7 @@ impl Plugin for TargetPlugin {
|
||||
.init_resource::<PickOcclusion>()
|
||||
.init_resource::<PressPick>()
|
||||
.init_resource::<WorldCursor>()
|
||||
.init_resource::<cursor_mode::AttackFork>()
|
||||
.init_resource::<CombatFlash>()
|
||||
.init_resource::<scan::TabHistory>()
|
||||
.init_resource::<scan::LastEnemy>()
|
||||
@@ -393,6 +399,7 @@ mod tests {
|
||||
world.init_resource::<HoveredObject>();
|
||||
world.init_resource::<PickOcclusion>();
|
||||
world.init_resource::<cursor_mode::WorldCursor>();
|
||||
world.init_resource::<cursor_mode::AttackFork>();
|
||||
world.init_resource::<PressPick>();
|
||||
world.init_resource::<ButtonInput<MouseButton>>();
|
||||
let id = world.register_system(latch_press_pick);
|
||||
@@ -430,6 +437,7 @@ mod tests {
|
||||
world.init_resource::<HoveredObject>();
|
||||
world.init_resource::<PickOcclusion>();
|
||||
world.init_resource::<cursor_mode::WorldCursor>();
|
||||
world.init_resource::<cursor_mode::AttackFork>();
|
||||
world.init_resource::<PressPick>();
|
||||
world.init_resource::<ButtonInput<MouseButton>>();
|
||||
let id = world.register_system(latch_press_pick);
|
||||
|
||||
@@ -789,7 +789,7 @@ pub(super) fn remember_last_enemy(
|
||||
return;
|
||||
};
|
||||
// Conjuncts 2 and 3: not dead or a ghost (a ghost's wire health is 1), and not mounted.
|
||||
if me.0.unit_is_dead() || me.0.player_is_ghost() || me.0.unit_mount_display_id() != 0 {
|
||||
if me.0.is_dead_or_ghost() || me.0.unit_mount_display_id() != 0 {
|
||||
return;
|
||||
}
|
||||
let store = stores.get(entity).ok();
|
||||
|
||||
@@ -186,6 +186,11 @@ pub(super) fn drain_action_uses(
|
||||
};
|
||||
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."
|
||||
if ladder.dead_refusal(SPELL_ATTACK, targeting.self_store.iter().next()) {
|
||||
continue;
|
||||
}
|
||||
// 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(
|
||||
|
||||
@@ -330,9 +330,7 @@ const ATTACK_FLAG_REFUSALS: [(u32, &str); 4] = [
|
||||
|
||||
/// Phase A of the attack-start validator `0x612df0`, the actor's own eligibility: the first
|
||||
/// failing gate raises its `ERR_ATTACK_*` line and returns `true`, and no packet is sent. The
|
||||
/// actor is the player for Attack (`0x6131aa`) and the pet for the pet bar (`0x4bd40d`). The
|
||||
/// reference's dead test `0x605f30` also refuses a player with `PLAYER_FLAGS_GHOST`
|
||||
/// (`[[obj+0xe68]+8]` bit 4); this tests health only.
|
||||
/// actor is the player for Attack (`0x6131aa`) and the pet for the pet bar (`0x4bd40d`).
|
||||
pub(crate) fn attack_actor_refusal(
|
||||
actor: Option<&ObjectStore>,
|
||||
self_guid: Option<u64>,
|
||||
@@ -356,7 +354,8 @@ pub(crate) fn attack_actor_blocked(
|
||||
) -> Option<&'static str> {
|
||||
// An unresolved actor skips the chain, as in the reference (`0x4bd403`).
|
||||
let fields = &actor?.0;
|
||||
let key = if fields.unit_health().is_some_and(|h| h == 0) {
|
||||
// `0x605f30` (`0x612e10`): dead, or a player's ghost flag, since a ghost has health 1.
|
||||
let key = if fields.is_dead_or_ghost() {
|
||||
"ERR_ATTACK_DEAD"
|
||||
} else if fields
|
||||
.unit_charmed_by()
|
||||
@@ -656,13 +655,15 @@ mod attack_actor_tests {
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
const HEALTH: u16 = 22;
|
||||
const MAXHEALTH: u16 = 28;
|
||||
const FLAGS: u16 = 46;
|
||||
const CHARMEDBY: u16 = 10;
|
||||
const MOUNT: u16 = 133;
|
||||
const PLAYER_FLAGS: u16 = 190;
|
||||
|
||||
fn actor(pairs: &[(u16, u32)]) -> ObjectStore {
|
||||
// A live, unowned, unmounted, unimpaired unit unless a case says otherwise.
|
||||
let mut all = vec![(HEALTH, 100)];
|
||||
let mut all = vec![(HEALTH, 100), (MAXHEALTH, 100)];
|
||||
all.extend_from_slice(pairs);
|
||||
ObjectStore(ObjectFields::from_pairs(&all))
|
||||
}
|
||||
@@ -690,6 +691,11 @@ mod attack_actor_tests {
|
||||
refusal(Some(&actor(&[(HEALTH, 0)])), Some(7)),
|
||||
Some("ERR_ATTACK_DEAD")
|
||||
);
|
||||
// A released ghost has health 1; `0x605f30`'s ghost-flag leg refuses it.
|
||||
assert_eq!(
|
||||
refusal(Some(&actor(&[(HEALTH, 1), (PLAYER_FLAGS, 0x10)])), Some(7)),
|
||||
Some("ERR_ATTACK_DEAD")
|
||||
);
|
||||
assert_eq!(
|
||||
refusal(Some(&actor(&[(MOUNT, 1147)])), Some(7)),
|
||||
Some("ERR_ATTACK_MOUNTED")
|
||||
|
||||
@@ -175,8 +175,8 @@ pub(crate) mod net {
|
||||
app.net_handler(SessionEventKind::SummonRequest, on_request);
|
||||
}
|
||||
|
||||
/// `0x605f30` over the self object is both accessors: not `unit_is_dead` alone (a ghost's
|
||||
/// health is 1), nor `unit_reads_dead` (`0x605f90`). An unstreamed self latches (`0x5e6189`).
|
||||
/// `0x605f30` over the self object, not `unit_reads_dead` (`0x605f90`). An unstreamed self
|
||||
/// latches (`0x5e6189`).
|
||||
fn on_request(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut summon: ResMut<SummonState>,
|
||||
@@ -195,7 +195,7 @@ pub(crate) mod net {
|
||||
.0
|
||||
.and_then(|g| index.0.get(&g))
|
||||
.and_then(|e| stores.get(*e).ok())
|
||||
.is_some_and(|s| s.0.unit_is_dead() || s.0.player_is_ghost());
|
||||
.is_some_and(|s| s.0.is_dead_or_ghost());
|
||||
request(
|
||||
summoner,
|
||||
zone,
|
||||
@@ -307,35 +307,6 @@ mod tests {
|
||||
assert!(!summon.ask, "and no second dialog is owed");
|
||||
}
|
||||
|
||||
/// A ghost's health is 1, so `unit_is_dead()` is false; only `0x605f30`'s ghost-bit leg
|
||||
/// refuses it.
|
||||
#[test]
|
||||
fn a_ghost_is_refused_even_though_a_ghost_is_not_dead() {
|
||||
// A released player on the wire: health 1 of 100, the `PLAYER_FLAGS` ghost bit.
|
||||
const HEALTH: u16 = 22;
|
||||
const MAXHEALTH: u16 = 28;
|
||||
const PLAYER_FLAGS: u16 = 190;
|
||||
let ghost = benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(HEALTH, 1),
|
||||
(MAXHEALTH, 100),
|
||||
(PLAYER_FLAGS, 0x10),
|
||||
]);
|
||||
|
||||
assert!(
|
||||
!ghost.unit_is_dead(),
|
||||
"a ghost has health — which is why the first leg alone lets one through"
|
||||
);
|
||||
assert!(ghost.player_is_ghost());
|
||||
assert!(
|
||||
ghost.unit_is_dead() || ghost.player_is_ghost(),
|
||||
"the arm's predicate is the OR of both legs, and it must refuse a ghost"
|
||||
);
|
||||
|
||||
// The corpse case, for the other leg: dead, not yet released, no ghost bit.
|
||||
let corpse = benilla_protocol::ObjectFields::from_pairs(&[(HEALTH, 0), (MAXHEALTH, 100)]);
|
||||
assert!(corpse.unit_is_dead() && !corpse.player_is_ghost());
|
||||
}
|
||||
|
||||
/// No parent-zone hop and no GlobalString tail, unlike [`crate::ui_binder`]'s chain.
|
||||
#[test]
|
||||
fn the_area_name_is_the_bare_row_with_no_fallback_chain() {
|
||||
|
||||
@@ -400,12 +400,11 @@ fn answer_trade_request(
|
||||
return;
|
||||
}
|
||||
|
||||
// Legs 4-7, a silent busy: a dead or ghost initiator (`0x605f30`, raw health 0 or the ghost
|
||||
// flag; `unit_reads_dead`, like `UnitIsDead` `0x517ac0`, would refuse a feigning hunter), a
|
||||
// cinematic (`[0xb4e310]`), lost player control (`[0xb4b3e4]`, which `0x4958e0` writes, not an
|
||||
// in-world flag) or an open auction house (`[0xb725f8]`, its auctioneer, not a pending trade).
|
||||
let dead_or_ghost = initiator_store
|
||||
.is_some_and(|s| s.0.unit_health().is_some_and(|hp| hp == 0) || s.0.player_is_ghost());
|
||||
// Legs 4-7, a silent busy: a dead or ghost initiator (`0x605f30`; `unit_reads_dead`, like
|
||||
// `UnitIsDead` `0x517ac0`, would refuse a feigning hunter), a cinematic (`[0xb4e310]`), lost
|
||||
// player control (`[0xb4b3e4]`, which `0x4958e0` writes, not an in-world flag) or an open
|
||||
// auction house (`[0xb725f8]`, its auctioneer, not a pending trade).
|
||||
let dead_or_ghost = initiator_store.is_some_and(|s| s.0.is_dead_or_ghost());
|
||||
if dead_or_ghost
|
||||
|| cinematic.is_playing()
|
||||
|| player.control_lost
|
||||
@@ -1303,13 +1302,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_dead_or_ghost_initiator_is_refused() {
|
||||
// The `UNIT_FIELD_HEALTH` and `PLAYER_FLAGS` field ids, and `PLAYER_FLAGS_GHOST`.
|
||||
// The `UNIT_FIELD_HEALTH`, `UNIT_FIELD_MAXHEALTH` and `PLAYER_FLAGS` field ids, and
|
||||
// `PLAYER_FLAGS_GHOST`.
|
||||
const HEALTH: u16 = 22;
|
||||
const MAXHEALTH: u16 = 28;
|
||||
const PLAYER_FLAGS: u16 = 190;
|
||||
const GHOST: u32 = 0x10;
|
||||
for (label, fields) in [
|
||||
("dead", vec![(HEALTH, 0u32)]),
|
||||
("ghost", vec![(HEALTH, 1u32), (PLAYER_FLAGS, GHOST)]),
|
||||
("dead", vec![(HEALTH, 0u32), (MAXHEALTH, 100)]),
|
||||
(
|
||||
"ghost",
|
||||
vec![(HEALTH, 1u32), (MAXHEALTH, 100), (PLAYER_FLAGS, GHOST)],
|
||||
),
|
||||
] {
|
||||
let (mut app, rx) = request_app(false, false, true);
|
||||
let entity = app.world().resource::<GuidIndex>().0[&0x7];
|
||||
|
||||
@@ -325,6 +325,11 @@ impl ObjectFields {
|
||||
pub fn player_is_ghost(&self) -> bool {
|
||||
self.player_flags() & 0x10 != 0
|
||||
}
|
||||
/// Dead or a ghost, the reference's `0x605f30`: health 0, or for a player the ghost flag. Not
|
||||
/// feign death, which is `unit_reads_dead`; a creature has no `PLAYER_FLAGS`, so reads clear.
|
||||
pub fn is_dead_or_ghost(&self) -> bool {
|
||||
self.unit_is_dead() || self.player_is_ghost()
|
||||
}
|
||||
/// `PLAYER_FLAGS_HIDE_HELM` (`0x400`, `Player.h:325`), set by `CMSG_TOGGLE_HELM`; public.
|
||||
pub fn player_hides_helm(&self) -> bool {
|
||||
self.player_flags() & 0x400 != 0
|
||||
|
||||
@@ -755,6 +755,33 @@ fn dynamicobject_fields_read_the_live_blizzard_capture() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `0x605f30`: health 0 (`0x605f3b`), or `PLAYER_FLAGS & 0x10` (`0x605f59`); a released ghost has
|
||||
/// health 1, so only the flag catches it, and feign death is neither.
|
||||
#[test]
|
||||
fn dead_or_ghost_is_health_zero_or_the_ghost_flag() {
|
||||
let unit = |pairs: &[(u16, u32)]| ObjectFields::from_pairs(pairs);
|
||||
let alive = unit(&[(FIELD_UNIT_HEALTH, 100), (FIELD_UNIT_MAXHEALTH, 100)]);
|
||||
let corpse = unit(&[(FIELD_UNIT_HEALTH, 0), (FIELD_UNIT_MAXHEALTH, 100)]);
|
||||
let ghost = unit(&[
|
||||
(FIELD_UNIT_HEALTH, 1),
|
||||
(FIELD_UNIT_MAXHEALTH, 100),
|
||||
(FIELD_PLAYER_FLAGS, 0x10),
|
||||
]);
|
||||
let feigning = unit(&[
|
||||
(FIELD_UNIT_HEALTH, 100),
|
||||
(FIELD_UNIT_MAXHEALTH, 100),
|
||||
(FIELD_UNIT_DYNAMIC_FLAGS, 0x20),
|
||||
]);
|
||||
assert!(!alive.is_dead_or_ghost());
|
||||
assert!(corpse.is_dead_or_ghost());
|
||||
assert!(!ghost.unit_is_dead(), "a ghost has health");
|
||||
assert!(ghost.is_dead_or_ghost(), "the ghost-flag leg");
|
||||
assert!(
|
||||
!feigning.is_dead_or_ghost(),
|
||||
"feign death is 0x605f90's, not this"
|
||||
);
|
||||
}
|
||||
|
||||
/// `Unit::SetFeignDeath` sets only `UNIT_DYNFLAG_DEAD`: the raw predicates stay false, the
|
||||
/// reads-dead ones (`0x605f90`, `UnitHealth`, `UnitMana`) flip, and the maxima stay.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user