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:
Sam Sung
2026-09-26 07:23:28 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 459a9a5697
commit b8a32a06e7
14 changed files with 369 additions and 73 deletions
+1 -1
View File
@@ -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)
+4 -2
View File
@@ -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) {
+145 -6
View File
@@ -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]
+12 -8
View File
@@ -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 {
+78 -3
View File
@@ -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]
+56 -6
View File
@@ -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;
+8
View File
@@ -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);
+1 -1
View File
@@ -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(
+11 -5
View File
@@ -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")
+3 -32
View File
@@ -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() {
+13 -9
View File
@@ -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]