diff --git a/crates/benilla-app/src/death/mod.rs b/crates/benilla-app/src/death/mod.rs index 96cf8e26..11e94c55 100644 --- a/crates/benilla-app/src/death/mod.rs +++ b/crates/benilla-app/src/death/mod.rs @@ -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) diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 5aab507b..5a196a92 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -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) { diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index c28329f4..1b1c8c5f 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -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::().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::().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::().0, + vec![PROFESSION] + ); + assert!(world.resource::().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::().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] diff --git a/crates/benilla-app/src/spell/usable.rs b/crates/benilla-app/src/spell/usable.rs index 7467d987..a714d116 100644 --- a/crates/benilla-app/src/spell/usable.rs +++ b/crates/benilla-app/src/spell/usable.rs @@ -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 { diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 16a14165..53942be4 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -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::(); + let (mut world, _boar) = right_click_world(); + world.insert_resource(NetCommands(tx)); + let me = world + .query_filtered::>() + .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 { + 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::>() + .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::() + .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] diff --git a/crates/benilla-app/src/target/cursor_mode.rs b/crates/benilla-app/src/target/cursor_mode.rs index 819678a1..37f41648 100644 --- a/crates/benilla-app/src/target/cursor_mode.rs +++ b/crates/benilla-app/src/target/cursor_mode.rs @@ -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, factions: Option>, reputations: Res, - mut cursor: ResMut, + (mut cursor, mut fork): (ResMut, ResMut), + // `control_lost` is the attack leg's `[0xb4b3e4]` clear. + player: Res, 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; diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 778b9097..279b5427 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -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, occlusion: Res, cursor: Res, + attack_fork: Res, mut press: ResMut, ) { // 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::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -393,6 +399,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::>(); let id = world.register_system(latch_press_pick); @@ -430,6 +437,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::>(); let id = world.register_system(latch_press_pick); diff --git a/crates/benilla-app/src/target/scan.rs b/crates/benilla-app/src/target/scan.rs index 3dff5700..0712343c 100644 --- a/crates/benilla-app/src/target/scan.rs +++ b/crates/benilla-app/src/target/scan.rs @@ -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(); diff --git a/crates/benilla-app/src/ui_action/drain.rs b/crates/benilla-app/src/ui_action/drain.rs index 3f655b1b..afa1526a 100644 --- a/crates/benilla-app/src/ui_action/drain.rs +++ b/crates/benilla-app/src/ui_action/drain.rs @@ -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( diff --git a/crates/benilla-app/src/ui_action/errors.rs b/crates/benilla-app/src/ui_action/errors.rs index 8784af01..0847a3e6 100644 --- a/crates/benilla-app/src/ui_action/errors.rs +++ b/crates/benilla-app/src/ui_action/errors.rs @@ -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, @@ -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") diff --git a/crates/benilla-app/src/ui_summon.rs b/crates/benilla-app/src/ui_summon.rs index 34ef45c7..e1550e6e 100644 --- a/crates/benilla-app/src/ui_summon.rs +++ b/crates/benilla-app/src/ui_summon.rs @@ -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, mut summon: ResMut, @@ -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() { diff --git a/crates/benilla-app/src/ui_trade/mod.rs b/crates/benilla-app/src/ui_trade/mod.rs index aa5a9ab4..3950e8e0 100644 --- a/crates/benilla-app/src/ui_trade/mod.rs +++ b/crates/benilla-app/src/ui_trade/mod.rs @@ -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::().0[&0x7]; diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index c2c47173..8b768369 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs index 265be6a9..74984735 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -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]