diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 639cb436..f669ffe1 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -884,6 +884,12 @@ pub(crate) enum ClientCommand { spell_id: u32, target: Option, }, + /// `CMSG_CAST_SPELL` at a corpse: its bit and packed guid, a resurrection on a released player. + CastSpellCorpse { + spell_id: u32, + target: benilla_protocol::messages::CorpseTarget, + corpse_guid: u64, + }, /// `CMSG_CAST_SPELL` with `TARGET_FLAG_DEST_LOCATION`; `dest` in WoW coordinates. CastSpellAtDest { spell_id: u32, diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index d9245ce8..01290709 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -1029,6 +1029,11 @@ fn writer_loop( count, } => w.destroy_item(bag_index, slot, count), ClientCommand::CastSpell { spell_id, target } => w.cast_spell(spell_id, target), + ClientCommand::CastSpellCorpse { + spell_id, + target, + corpse_guid, + } => w.cast_spell_corpse(spell_id, target, corpse_guid), ClientCommand::CastSpellAtDest { spell_id, dest } => { w.cast_spell_at_dest(spell_id, dest) } diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index ef8068c4..9a9dd4b3 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -103,6 +103,8 @@ pub(crate) enum TargetedBind { Object(u64), /// The world click's unit guid. Unit(u64), + /// The world click's corpse guid and the corpse bit `BindTarget`'s corpse arm bound. + Corpse(u64, benilla_protocol::messages::CorpseTarget), } impl CastLadder<'_, '_> { @@ -132,6 +134,11 @@ impl CastLadder<'_, '_> { spell_id, target: Some(unit_guid), }, + TargetedBind::Corpse(corpse_guid, target) => ClientCommand::CastSpellCorpse { + spell_id, + target, + corpse_guid, + }, }, CastCommit::Item { bag_index, @@ -148,6 +155,7 @@ impl CastLadder<'_, '_> { TargetedBind::Item(guid) => UseItemTarget::Item(guid), TargetedBind::Object(guid) => UseItemTarget::Object(guid), TargetedBind::Unit(guid) => UseItemTarget::Unit(guid), + TargetedBind::Corpse(guid, target) => UseItemTarget::Corpse(target, guid), }, }, }; diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index eefb9fec..67cd2a67 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -32,7 +32,9 @@ pub(crate) use inflight::{ }; pub(crate) use mods::{SpellModifiers, OP_COST}; // `TargetingWants` is exported for the ground reticle, which draws for the location word alone. -pub(crate) use targeting::{ground_cast_radius, PicksSelf, SpellTargeting, TargetingWants}; +pub(crate) use targeting::{ + ground_cast_radius, CorpsePick, PicksSelf, SpellTargeting, TargetingWants, +}; /// The local self-cancel's set: a reader of the in-flight state orders `.after(LocalCancel)` so a /// cast ended by a move, jump or Esc drops its cast bar the same frame. @@ -54,6 +56,7 @@ impl Plugin for SpellPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_observer(cast_target::on_cvar) .add_systems( Update, @@ -71,7 +74,11 @@ impl Plugin for SpellPlugin { targeting::feed_targeting_to_vm .in_set(UnitFeed) .after(crate::ui_pet::pet_stop_on_old_target_clear), - targeting::publish_picks_self.in_set(UiFeed), + ( + targeting::publish_picks_self, + targeting::publish_corpse_pick, + ) + .in_set(UiFeed), targeting::drain_stop_targeting.after(UiInput), // The item-target commit (`0x495d60`): after the input pass so a bag click // binds the same frame; outside the target chain, as its clicks never reach diff --git a/crates/benilla-app/src/spell/targeting/corpse.rs b/crates/benilla-app/src/spell/targeting/corpse.rs new file mode 100644 index 00000000..30ca4f64 --- /dev/null +++ b/crates/benilla-app/src/spell/targeting/corpse.rs @@ -0,0 +1,286 @@ +//! The targeting cursor's corpse leg: a released player's corpse under a word carrying a corpse +//! bit, `0x8000` (Resurrection, Redemption, Rebirth) or `0x200`. A dead player who has not released +//! is a unit, and the unit leg takes him. +//! +//! - The pick ([`pick_admits`]): `0x480816` admits a corpse only with pick flag `0x40`, which while +//! targeting only a word in `0x8600` sets (`0x6e6230`), and then only a corpse `0x6e6260` passes. +//! - The cursor ([`super::SpellTargeting::can_target_corpse`]): `0x6e6460`'s corpse leg +//! (`6e6719`–`6e676d`), then its range tail. +//! - The click ([`bind_target_corpse`]): `BindTarget 0x6e5b40`'s corpse arm (`6e5f89`–`6e6020`), +//! then the merge's range test, as for a unit. +//! +//! All three read two facts of the corpse, [`CorpseFacts`]: bones are never a spell target, and the +//! reaction gate `0x6067d0` splits the ally bit from the enemy bit. + +use bevy::prelude::*; + +use benilla_protocol::messages::CorpseTarget; + +use crate::net::ObjectStore; +use crate::spell::cast_send::TargetedBind; +use crate::target::Factions; + +/// `TARGET_FLAG_CORPSE_ALLY` and `TARGET_FLAG_CORPSE_ENEMY`, as the flag word carries them. +const WORD_ALLY: u16 = 0x8000; +const WORD_ENEMY: u16 = 0x0200; + +/// What the corpse legs read of a corpse. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct CorpseFacts { + /// `CORPSE_FIELD_FLAGS & 1`, `CORPSE_FLAG_BONES` (`[desc+0x74]`). + pub(crate) bones: bool, + /// `0x6067d0(caster, corpse)`, [`crate::target::corpse_friendly`]. + pub(crate) friendly: bool, +} + +impl CorpseFacts { + pub(super) fn of( + store: &ObjectStore, + factions: Option<&Factions>, + self_store: Option<&ObjectStore>, + ) -> Self { + Self { + bones: store.0.corpse_is_bones(), + friendly: crate::target::corpse_friendly(store, factions, self_store), + } + } +} + +/// [`pick_admits`] over a corpse's store, for [`crate::target`]'s hover. +pub(crate) fn corpse_pick_admits( + word: u16, + store: &ObjectStore, + factions: Option<&Factions>, + self_store: Option<&ObjectStore>, +) -> bool { + pick_admits(word, CorpseFacts::of(store, factions, self_store)) +} + +/// `0x6e6260`, the pick's corpse filter while targeting: a word with the ally bit takes a corpse +/// that is not bones and that `0x6067d0` passes (`6e6291`–`6e62a7`); only a word without it tries +/// the enemy bit, on a corpse that is not bones and that `0x6067d0` fails (`6e62ad`–`6e62c6`). +/// Both bits are inside `0x8600`, so a corpse this admits was already a candidate (`0x6e6230`). +fn pick_admits(word: u16, f: CorpseFacts) -> bool { + if word & WORD_ALLY != 0 { + !f.bones && f.friendly + } else { + word & WORD_ENEMY != 0 && !f.bones && !f.friendly + } +} + +/// `BindTarget 0x6e5b40`'s corpse arm: the ally arm (`6e5f97`–`6e5fda`) binds a corpse that is not +/// bones and that `0x6067d0` passes, then the enemy arm (`6e5fdc`–`6e6020`) one it fails. Each +/// writes its bit to the wire mask (`6e5fc7`, `6e600c`), parks the corpse's guid and clears its bit +/// from the word. `None` binds nothing, silently, and the cursor stays (`6e6026`). The read-only +/// mirror `0x6e6460` (`6e6719`–`6e676d`) passes exactly the corpses this binds. +fn bind_arm(word: u16, f: CorpseFacts) -> Option { + if f.bones { + None + } else if word & WORD_ALLY != 0 && f.friendly { + Some(CorpseTarget::Ally) + } else if word & WORD_ENEMY != 0 && !f.friendly { + Some(CorpseTarget::Enemy) + } else { + None + } +} + +/// The world pick's corpse admission as the frame began, which [`crate::target`]'s hover reads: +/// `None` outside targeting, where the pick flags carry `0x5c` and so `0x40`, and every corpse is a +/// candidate (`0x480816`); while targeting, the standing word, through [`pick_admits`]. +#[derive(Resource, Default)] +pub(crate) struct CorpsePick(pub(crate) Option); + +/// Publish [`CorpsePick`] before the input pass, beside [`super::PicksSelf`]. +pub(crate) fn publish_corpse_pick( + targeting: Res, + mut pick: ResMut, +) { + let now = targeting.0.as_ref().map(|t| t.word); + if pick.0 != now { + pick.0 = now; + } +} + +impl super::SpellTargeting { + /// `0x6e6460`'s corpse leg with the range flag the hover passes (`48290b`): [`bind_arm`]'s + /// test, then min² ≤ d² ≤ max² (`6e668c`). + pub(crate) fn can_target_corpse(&self, entity: Entity, checks: &super::BindChecks) -> bool { + self.0.as_ref().is_some_and(|t| { + checks + .corpse_facts(entity) + .is_some_and(|f| bind_arm(t.word, f).is_some()) + && checks.corpse_range_refusal(t.spell_id, entity).is_none() + }) + } +} + +/// `BindTarget 0x6e5b40`'s corpse arm for the world click (`0x493540` at `4935d5`): a corpse no +/// arm binds leaves the cursor up; otherwise the merge's range test and the commit, with the +/// corpse's guid and its bit (`0x7e4e10` writes the guid for any mask bit in `0x8a02`). +/// +/// The reference sends only once the word is empty (`6e60c1`), so a word the corpse bit does not +/// empty waits for another click with the corpse parked. No player spell has one (every shipped +/// `0x8000` word with another bit is an NPC or test spell), and as the unit arm does, this binds +/// only a click that empties the word. +pub(super) fn bind_target_corpse( + ladder: &mut crate::spell::CastLadder, + checks: &super::BindChecks, + entity: Entity, + guid: u64, +) { + let Some((spell_id, commit, word)) = ladder.ground.pending() else { + return; + }; + let Some(bit) = checks.corpse_facts(entity).and_then(|f| bind_arm(word, f)) else { + return; + }; + if word & !bit.target_flag() != 0 { + return; + } + let range = checks.corpse_range_refusal(spell_id, entity); + super::merge( + ladder, + spell_id, + commit, + range, + TargetedBind::Corpse(guid, bit), + ); +} + +/// A human caster, and a human corpse and an orc corpse toward him, on the shipped rows of +/// `FactionTemplate.dbc` 1 and 2 and `ChrRaces.dbc` races 1 and 2. +#[cfg(test)] +pub(crate) mod fixture { + use std::collections::HashMap; + + use benilla_formats::{FactionCatalog, FactionTemplate}; + use benilla_protocol::ObjectFields; + + use crate::net::ObjectStore; + use crate::target::Factions; + + pub(crate) const HUMAN: u8 = 1; + pub(crate) const ORC: u8 = 2; + /// Resurrection rank 1: `Targets 0x8000`, range row 4. + pub(crate) const RESURRECTION: u32 = 2006; + /// A corpse guid, `HIGHGUID_CORPSE` 0xF101 (vmangos `ObjectGuid.h:76`). + pub(crate) const CORPSE: u64 = 0xF101_0000_0000_002A; + + /// Templates 1 (Human) and 2 (Orc): group, friend and enemy masks 3/2/12 and 5/4/10. + pub(crate) fn factions() -> Factions { + let tpl = |faction, group_mask, friend_group_mask, enemy_group_mask| FactionTemplate { + faction, + group_mask, + friend_group_mask, + enemy_group_mask, + enemies: [0; 4], + friends: [0; 4], + }; + Factions::from_catalog(FactionCatalog::from_rows( + HashMap::from([(1, tpl(1, 3, 2, 12)), (2, tpl(2, 5, 4, 10))]), + HashMap::from([(HUMAN, 1), (ORC, 2)]), + )) + } + + /// A human caster, template 1, with this `UNIT_FIELD_COMBATREACH` (field 130). + pub(crate) fn caster(reach: f32) -> ObjectStore { + ObjectStore(ObjectFields::from_pairs(&[(35, 1), (130, reach.to_bits())])) + } + + /// A released player's corpse: the race in `CORPSE_FIELD_BYTES_1` byte 1 (field 32) and + /// `CORPSE_FLAG_BONES` in `CORPSE_FIELD_FLAGS` (field 35). + pub(crate) fn corpse(race: u8, bones: bool) -> ObjectStore { + ObjectStore(ObjectFields::from_pairs(&[ + (32, u32::from(race) << 8), + (35, u32::from(bones)), + ])) + } + + /// Resurrection on range row 4, 0 to 30 yd. + pub(crate) fn spells() -> crate::ui_action::Spells { + let mut spells = crate::ui_action::Spells::empty_for_tests(); + spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([( + RESURRECTION, + benilla_formats::SpellDisplay { + range_index: 4, + targets: 0x8000, + ..Default::default() + }, + )])); + spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([( + 4, + benilla_formats::SpellRange { + min: 0.0, + max: 30.0, + flags: 0, + }, + )])); + spells + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BONES: CorpseFacts = CorpseFacts { + bones: true, + friendly: true, + }; + const ALLY: CorpseFacts = CorpseFacts { + bones: false, + friendly: true, + }; + const ENEMY: CorpseFacts = CorpseFacts { + bones: false, + friendly: false, + }; + + /// `0x6e6260`: bones never, the ally bit only a friendly corpse, the enemy bit only an + /// unfriendly one, and only on a word without the ally bit. + #[test] + fn the_pick_admits_a_corpse_as_0x6e6260_does() { + assert!(pick_admits(0x8000, ALLY), "Resurrection over a friend"); + assert!(!pick_admits(0x8000, ENEMY), "never an enemy's corpse"); + assert!(!pick_admits(0x8000, BONES), "never bones"); + assert!(pick_admits(0x0200, ENEMY)); + assert!(!pick_admits(0x0200, ALLY)); + assert!(!pick_admits( + 0x0200, + CorpseFacts { + bones: true, + friendly: false + } + )); + // Both bits: the ally arm decides alone (`6e6291 jns`), so no enemy corpse. + assert!(pick_admits(0x8200, ALLY)); + assert!(!pick_admits(0x8200, ENEMY)); + // A word with no corpse bit admits none, `0x400` inside `0x8600` included. + for word in [0x0002, 0x0100, 0x0400, 0x0040, 0x4800] { + assert!(!pick_admits(word, ALLY), "{word:#06x}"); + assert!(!pick_admits(word, ENEMY), "{word:#06x}"); + } + } + + /// `BindTarget`'s corpse arm, which `0x6e6460`'s corpse leg mirrors: the ally arm then the + /// enemy arm, each on a corpse that is not bones. + #[test] + fn the_bind_takes_the_arm_the_reaction_picks() { + assert_eq!(bind_arm(0x8000, ALLY), Some(CorpseTarget::Ally)); + assert_eq!( + bind_arm(0x8000, ENEMY), + None, + "a hostile corpse under 0x8000" + ); + assert_eq!(bind_arm(0x8000, BONES), None, "bones"); + assert_eq!(bind_arm(0x0200, ENEMY), Some(CorpseTarget::Enemy)); + assert_eq!(bind_arm(0x0200, ALLY), None); + // Both bits: unlike the pick, the enemy arm still runs after the ally arm fails. + assert_eq!(bind_arm(0x8200, ENEMY), Some(CorpseTarget::Enemy)); + assert_eq!(bind_arm(0x8200, ALLY), Some(CorpseTarget::Ally)); + for word in [0x0002, 0x0100, 0x0400, 0x0040] { + assert_eq!(bind_arm(word, ALLY), None, "{word:#06x}"); + } + } +} diff --git a/crates/benilla-app/src/spell/targeting/cursor.rs b/crates/benilla-app/src/spell/targeting/cursor.rs index fbd3cfc9..044bd0f9 100644 --- a/crates/benilla-app/src/spell/targeting/cursor.rs +++ b/crates/benilla-app/src/spell/targeting/cursor.rs @@ -1,6 +1,7 @@ //! The cursor while a cast waits for its click: the ground point's range verdict //! (`CheckGroundPointInRange 0x6e6810`, in `0x4820f0`), the hovered object's validity (`0x6e6460`, -//! in `0x4828d0`) and the reticle's radius (`GetCurrentCastRadius 0x6e6350`). +//! in `0x4828d0`: a GameObject, a unit or a corpse) and the reticle's radius +//! (`GetCurrentCastRadius 0x6e6350`). use bevy::prelude::*; @@ -76,12 +77,13 @@ pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level: /// /// The object arm is `0x6e6460`'s GameObject leg: `word & 0x4800`, the lock predicate `0x5f8260`, /// then the same min/max range test through `GetMinMaxRange 0x6e3480`. Its unit leg is -/// [`SpellTargeting::can_target_unit`]. The world-item and corpse legs are not built. +/// [`SpellTargeting::can_target_unit`] and its corpse leg [`SpellTargeting::can_target_corpse`], +/// over a corpse the pick admitted under a word in `0x8600`. The world-item leg is not built. /// /// Every seam shows the `Cast` kind, so this reads the whole-word [`SpellTargeting::spell`]. pub(crate) fn drive_targeting_cursor( targeting: Res, - unit_checks: super::UnitBindChecks, + checks: super::BindChecks, occlusion: Res, hovered: Res, hovered_object: Res, @@ -104,7 +106,7 @@ pub(crate) fn drive_targeting_cursor( let row = range_row(spells.as_deref(), spell_id); let me = self_tf.single().ok().map(|tf| tf.translation); // The arm the click would take ([`super::world::commit_object_cast_on_click`]): a GameObject - // word's nearest GameObject, else the picked unit, else the terrain. + // word's nearest GameObject, else the picked unit or corpse, else the terrain. let able = if targeting.wants(TargetingWants::GameObject) && crate::target::go_is_nearest(&hovered, &hovered_object) { @@ -121,7 +123,12 @@ pub(crate) fn drive_targeting_cursor( .target .filter(|_| targeting.wants(TargetingWants::Unit)) { - targeting.can_target_unit(unit, &unit_checks) + targeting.can_target_unit(unit, &checks) + } else if let Some(corpse) = hovered + .corpse + .filter(|_| targeting.wants(TargetingWants::Corpse)) + { + targeting.can_target_corpse(corpse, &checks) } else if targeting.wants(TargetingWants::Location) { // `0x4820f0`. No ground hit (sky, mouselook) is state 0, UnableCast. match (occlusion.point, me) { @@ -433,4 +440,77 @@ mod tests { "inside the minimum → UnableCast" ); } + + /// `0x6e6460`'s corpse leg over a hovered corpse: bones, a hostile corpse under the ally bit + /// and a corpse out of range are grey. The range pads a corpse by the caster's reach twice + /// (`6e3605`–`6e361e`): Resurrection's 30 yd with a 3.0 reach is 36. + #[test] + fn the_corpse_leg_is_grey_for_bones_a_hostile_corpse_or_out_of_range() { + use super::super::corpse_fixture as fx; + use bevy::ecs::system::RunSystemOnce; + + let verdict = |word: u16, corpse: crate::net::ObjectStore, distance: f32| { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); + world.init_resource::(); + world.insert_resource(fx::spells()); + world.insert_resource(fx::factions()); + world.spawn(( + SelfPlayer, + Transform::default(), + GlobalTransform::default(), + fx::caster(3.0), + )); + let body = world + .spawn(( + GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)), + corpse, + )) + .id(); + world.insert_resource(crate::target::Hovered { + corpse: Some(body), + corpse_guid: Some(fx::CORPSE), + distance: 5.0, + ..Default::default() + }); + world.resource_mut::().enter( + fx::RESURRECTION, + crate::spell::CastCommit::Spell, + word, + ); + world + .run_system_once(drive_targeting_cursor) + .expect("the targeting cursor drives"); + !world.resource::().unable + }; + let friend = || fx::corpse(fx::HUMAN, false); + + assert!(verdict(0x8000, friend(), 10.0), "a friend's corpse → Cast"); + assert!( + !verdict(0x8000, fx::corpse(fx::HUMAN, true), 10.0), + "bones → UnableCast" + ); + assert!( + !verdict(0x8000, fx::corpse(fx::ORC, false), 10.0), + "a hostile corpse under the ally bit → UnableCast" + ); + assert!( + verdict(0x0200, fx::corpse(fx::ORC, false), 10.0), + "which the enemy bit takes" + ); + // Past the bare 30 and past 30 + 3.0 + 1.5, inside 30 + 2 × 3.0. + assert!(verdict(0x8000, friend(), 35.0), "inside the padded range"); + assert!( + !verdict(0x8000, friend(), 37.0), + "out of range → UnableCast" + ); + // A word outside `0x8600` has no corpse leg: the heal word over a corpse is state 0. + assert!(!verdict(0x0002, friend(), 10.0)); + } } diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index cfb5dafc..5529d2d1 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -3,21 +3,22 @@ //! word and each click seam asks it its own mask test ([`TargetingWants`]). //! //! - [`cursor`]: the hover verdict per seam. Terrain is `0x4820f0`'s `CheckGroundPointInRange -//! 0x6e6810`, a GameObject or a unit is `0x4828d0`'s `0x6e6460` (the spell-vs-lock predicate -//! `0x5f8260` or [`SpellTargeting::can_target_unit`], then range), and a word no seam handles is -//! UnableCast. +//! 0x6e6810`, a GameObject, a unit or a corpse is `0x4828d0`'s `0x6e6460` (the spell-vs-lock +//! predicate `0x5f8260`, [`SpellTargeting::can_target_unit`] or +//! [`SpellTargeting::can_target_corpse`], then range), and a word no seam handles is UnableCast. //! - [`world`]: the world-click dispatcher `0x492ce0`, its terrain leg (`0x492580` → `BindLocation //! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), -//! whose unit arm [`bind_target_unit`] shares with `SpellTargetUnit` ([`drain_spell_target_unit`]). +//! whose unit arm [`bind_target_unit`] shares with `SpellTargetUnit` +//! ([`drain_spell_target_unit`]), and whose corpse arm is [`corpse`]'s. //! - [`item`]: the bag click (`PickupContainerItem 0x4f9b30`) and the paper-doll click //! (`0x4c7300`), both `IsTargeting`, `TargetingWantsItem 0x6e6330`, then `0x495d60`, whose //! confirm popups park the clicked guid (`0xb4e3c0`) with the word still standing. //! //! Every seam commits through [`crate::spell::CastLadder::commit_targeted`] (`SendCast 0x6e54f0`). -//! Only the unit arm has a range gate: the terrain and GameObject clicks send and the server -//! judges range, and `CheckGroundPointInRange` only colours the cursor. While targeting, the pick -//! flags come from the word alone, so a click over a unit with a dest-only word commits on the -//! ground behind it. +//! Only the unit and corpse arms have a range gate ([`merge`]): the terrain and GameObject clicks +//! send and the server judges range, and `CheckGroundPointInRange` only colours the cursor. While +//! targeting, the pick flags come from the word alone, so a click over a unit with a dest-only +//! word commits on the ground behind it. //! //! Cancels: ESC through `UIParent.lua:1490` ([`feed_targeting_to_vm`], [`drain_stop_targeting`]), //! the right-button down edge ([`cancel_targeting_on_right_press`]), a new spell's press, which @@ -25,10 +26,14 @@ //! spell (`UseAction 0x4e5ee0`). A cancel clears the word and sends nothing; movement never //! cancels (`0x515090`). +mod corpse; mod cursor; mod item; mod world; +#[cfg(test)] +pub(crate) use corpse::fixture as corpse_fixture; +pub(crate) use corpse::{corpse_pick_admits, publish_corpse_pick, CorpsePick}; pub(crate) use cursor::{drive_targeting_cursor, ground_cast_radius}; pub(crate) use item::{commit_item_cast_on_pick, EnchantConfirmItem}; pub(crate) use world::{commit_ground_cast_on_click, commit_object_cast_on_click}; @@ -37,12 +42,14 @@ use bevy::prelude::*; use benilla_world::interact::WorldRightPress; -/// The inputs of the cursor's two unit functions, [`SpellTargeting::can_target_unit`] and -/// [`bind_target_unit`]: the relation checks the cast arm's selection bind runs -/// ([`super::cast_target::unit_word_binds`]) and the pre-send range gate's inputs -/// ([`super::cast_target::RangeInputs`]), read as [`super::cast_target::CastTargeting`] reads them. +/// The inputs of `BindTarget 0x6e5b40`'s unit and corpse arms and of their read-only mirror +/// `0x6e6460` ([`SpellTargeting::can_target_unit`], [`bind_target_unit`], +/// [`SpellTargeting::can_target_corpse`], [`corpse::bind_target_corpse`]): the relation checks the +/// cast arm's selection bind runs ([`super::cast_target::unit_word_binds`]), the corpse's two +/// facts, and the pre-send range gate's inputs ([`super::cast_target::RangeInputs`]), read as +/// [`super::cast_target::CastTargeting`] reads them. #[derive(bevy::ecs::system::SystemParam)] -pub(crate) struct UnitBindChecks<'w, 's> { +pub(crate) struct BindChecks<'w, 's> { stores: Query<'w, 's, &'static crate::net::ObjectStore>, index: Option>, self_q: Query< @@ -60,7 +67,7 @@ pub(crate) struct UnitBindChecks<'w, 's> { spells: Option>, } -impl UnitBindChecks<'_, '_> { +impl BindChecks<'_, '_> { fn is_self(&self, entity: Entity) -> bool { self.self_q .iter() @@ -93,8 +100,27 @@ impl UnitBindChecks<'_, '_> { /// asks ([`super::cast_target::RangeInputs::refusal`]); our own body is distance 0. An /// unknown spell or a missing row passes. fn range_refusal(&self, spell_id: u32, entity: Entity) -> Option { - let spells = self.spells.as_deref()?; - let def = spells.catalog.get(spell_id)?; + let mut range = self.range_inputs(entity); + range.target_reach = self + .stores + .get(entity) + .ok() + .map(|s| s.0.unit_combat_reach()); + self.refusal(spell_id, range) + } + + /// The same compare against a corpse. `GetMinMaxRange 0x6e3480` pads a corpse's bounds as a + /// unit's (`6e35fe`), but with no unit to read the second reach from it reads the caster's + /// again (`6e3605`–`6e361e`). + fn corpse_range_refusal(&self, spell_id: u32, entity: Entity) -> Option { + let mut range = self.range_inputs(entity); + range.target_reach = Some(range.self_reach); + self.refusal(spell_id, range) + } + + /// Our position and reach and the candidate's position, each position the pose the hover + /// picks against. + fn range_inputs(&self, entity: Entity) -> super::cast_target::RangeInputs { let me = self.self_q.iter().next(); let mut range = super::cast_target::RangeInputs { self_pos: me @@ -105,19 +131,30 @@ impl UnitBindChecks<'_, '_> { .get(entity) .ok() .map(GlobalTransform::translation), - target_reach: self - .stores - .get(entity) - .ok() - .map(|s| s.0.unit_combat_reach()), ..Default::default() }; if let Some((_, Some(store))) = me { range.self_reach = store.0.unit_combat_reach(); } + range + } + + fn refusal(&self, spell_id: u32, range: super::cast_target::RangeInputs) -> Option { + let spells = self.spells.as_deref()?; + let def = spells.catalog.get(spell_id)?; range.refusal(def, spells.ranges.get(def.range_index)) } + /// What the corpse legs read of this corpse: `CORPSE_FLAG_BONES` and the reaction gate + /// `0x6067d0`. A corpse whose store has not streamed binds nothing. + fn corpse_facts(&self, entity: Entity) -> Option { + Some(corpse::CorpseFacts::of( + self.stores.get(entity).ok()?, + self.factions.as_deref(), + self.self_q.iter().next().and_then(|(_, s)| s), + )) + } + /// The caster under a spell with `AttributesEx & 0x80000` (`6e6507`). fn excluded_caster(&self, spell_id: u32, entity: Entity) -> bool { self.is_self(entity) @@ -135,6 +172,7 @@ impl UnitBindChecks<'_, '_> { /// - `Item`: `TargetingWantsItem 0x6e6330`, `word & 0x4010`, the bag and paper-doll clicks. /// - `GameObject`: `TargetingWantsGameObject 0x6e62d0`, `word & 0x4800`, the world object click. /// - `Unit`: `SpellCanTargetUnit 0x6e6460`, the unit arm of the world click. +/// - `Corpse`: `0x6e6230`, `word & 0x8600`, the pick's corpse flag `0x40` ([`corpse`]). /// /// The masks overlap on `TARGET_FLAG_LOCKED`, so a lock spell answers both the item and the /// GameObject seam; the reference settles it only at the click, where `BindTarget 0x6e5b40` picks @@ -145,6 +183,7 @@ pub(crate) enum TargetingWants { Item, GameObject, Unit, + Corpse, } /// The unit-shaped bits of the flag_word, the first gate `SpellCanTargetUnit` tests before it runs @@ -158,6 +197,7 @@ impl TargetingWants { Self::Item => 0x4010, Self::GameObject => 0x4800, Self::Unit => UNIT_WORD_BITS, + Self::Corpse => 0x8600, }; word & mask != 0 } @@ -203,7 +243,7 @@ impl SpellTargeting { /// `BindTarget`'s relation checks, the caster refused under `AttributesEx & 0x80000` /// (`6e6507`), then min² ≤ d² ≤ max² (`6e677c`–`6e6802`), so a unit inside the minimum is out /// too. There is no line-of-sight test. - pub(crate) fn can_target_unit(&self, entity: Entity, checks: &UnitBindChecks) -> bool { + pub(crate) fn can_target_unit(&self, entity: Entity, checks: &BindChecks) -> bool { self.0.as_ref().is_some_and(|t| { checks.relations_clear(t.word, entity) && !checks.excluded_caster(t.spell_id, entity) @@ -321,7 +361,7 @@ pub(crate) fn publish_picks_self( /// and re-arm in one frame still counts. pub(crate) fn feed_targeting_to_vm( targeting: Res, - checks: UnitBindChecks, + checks: BindChecks, tokens: crate::ui_unit::UnitTokens, selection: Res, mut last: Local>>, @@ -361,12 +401,11 @@ pub(crate) fn drain_stop_targeting( /// `BindTarget 0x6e5b40`'s unit arm, for the world click (`0x493540` at `4935d5`) and /// `SpellTargetUnit` (`0x6e5b10`): the caster under `AttributesEx & 0x80000` (`6e5bf7`) or a unit -/// failing the relation checks binds nothing and the cursor stays up; one out of the spell's range -/// raises "Out of range." or "Target too close" (`6e6063`); otherwise the cast commits at it. The +/// failing the relation checks binds nothing and the cursor stays up; otherwise the merge. The /// player's selection never moves. fn bind_target_unit( ladder: &mut crate::spell::CastLadder, - checks: &UnitBindChecks, + checks: &BindChecks, entity: Entity, guid: u64, ) { @@ -376,15 +415,36 @@ fn bind_target_unit( if checks.excluded_caster(spell_id, entity) || !checks.relations_clear(word, entity) { return; } - if let Some(reason) = checks.range_refusal(spell_id, entity) { - // The reference's abort here (`6e6089`) also sends CMSG_CANCEL_CAST and strands the - // cursor (`6e5b93`) until a cancel: a bug, not copied; targeting stays armed and working. - debug!("ui_action: cast {spell_id} not bound at {guid:#x} — range ({reason:#x})"); + let range = checks.range_refusal(spell_id, entity); + merge( + ladder, + spell_id, + commit, + range, + super::cast_send::TargetedBind::Unit(guid), + ); +} + +/// `BindTarget`'s merge (`6e602c`–`6e60d7`): a bind whose mask is in `0x8202`, a unit or a corpse, +/// runs the range test `0x6e47b0` (`6e6063`). Out of range raises "Out of range." or "Target too +/// close" and binds nothing; otherwise the emptied word commits (`SendCast 0x6e54f0`). The +/// reference's range failure also aborts the cast (`6e6089`), which sends CMSG_CANCEL_CAST and +/// strands the cursor (`6e5b93`) until a cancel: a bug, not copied; targeting stays armed and +/// working. +fn merge( + ladder: &mut crate::spell::CastLadder, + spell_id: u32, + commit: super::cast_send::CastCommit, + range: Option, + bound: super::cast_send::TargetedBind, +) { + if let Some(reason) = range { + debug!("ui_action: cast {spell_id} not bound at {bound:x?} — range ({reason:#x})"); ladder.cast_errors.push_local(spell_id, reason); return; } - debug!("ui_action: cast {spell_id} committed at unit {guid:#x}"); - ladder.commit_targeted(spell_id, commit, super::cast_send::TargetedBind::Unit(guid)); + debug!("ui_action: cast {spell_id} committed at {bound:x?}"); + ladder.commit_targeted(spell_id, commit, bound); } /// Drain `SpellTargetUnit(unit)` (`0x6e6d90`) after the UI pass. The binding already raised the @@ -395,7 +455,7 @@ pub(crate) fn drain_spell_target_unit( script: Option>, tokens: crate::ui_unit::UnitTokens, selection: Res, - checks: UnitBindChecks, + checks: BindChecks, mut ladder: crate::spell::CastLadder, ) { let Some(mut script) = script else { diff --git a/crates/benilla-app/src/spell/targeting/world.rs b/crates/benilla-app/src/spell/targeting/world.rs index 47cbe35b..cef9281a 100644 --- a/crates/benilla-app/src/spell/targeting/world.rs +++ b/crates/benilla-app/src/spell/targeting/world.rs @@ -2,13 +2,14 @@ //! the pick flags (`0x481050`) come only from the word `0xcecac0`, so the word picks the leg: //! //! - terrain (`0x492c90` → `0x492580` → `BindLocation 0x6e60f0`): [`commit_ground_cast_on_click`] -//! - object (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), whose GameObject or -//! unit arm the picked object's type chooses: [`commit_object_cast_on_click`] +//! - object (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), whose GameObject, +//! unit or corpse arm the picked object's type chooses: [`commit_object_cast_on_click`] //! //! A location-only word yields pick flags 3, whose `& 0x7c == 0` disables the object pick, so an //! AoE reticle clicks through a chest. The terrain and GameObject arms gate on nothing, not range, -//! validity or the lock: the server judges and its `SMSG_CAST_RESULT` is the refusal. The unit arm -//! runs `BindTarget`'s relation and range checks ([`super::bind_target_unit`]). +//! validity or the lock: the server judges and its `SMSG_CAST_RESULT` is the refusal. The unit and +//! corpse arms run `BindTarget`'s relation and range checks ([`super::bind_target_unit`], +//! [`super::corpse::bind_target_corpse`]). use bevy::prelude::*; @@ -75,15 +76,16 @@ pub(crate) fn commit_ground_cast_on_click( /// (`0xceac60`), and the zero word lets it call `SendCast 0x6e54f0`, with no gate before the send, /// not range nor the lock: the refusal is the server's `SMSG_CAST_RESULT`. The right-click path /// ([`crate::target::click`], `0x5f33e0`) does resolve the lock and can refuse locally. Otherwise -/// the picked unit goes to the unit arm ([`super::bind_target_unit`]); a unit click under a lock -/// word binds nothing there, since every unit arm tests a bit the word lacks. +/// the picked unit goes to the unit arm ([`super::bind_target_unit`]) and the picked corpse to the +/// corpse arm ([`super::corpse::bind_target_corpse`]); a unit click under a lock word binds nothing +/// there, since every unit arm tests a bit the word lacks. /// /// Runs after `select_on_click`, as the terrain commit does. pub(crate) fn commit_object_cast_on_click( mut clicks: MessageReader, // The press's pick: the object the gesture started on. press: Res, - checks: super::UnitBindChecks, + checks: super::BindChecks, mut ladder: crate::spell::CastLadder, ) { let (hovered, hovered_object) = (press.hovered, press.object); @@ -101,6 +103,8 @@ pub(crate) fn commit_object_cast_on_click( { if let Some((entity, guid)) = hovered.target.zip(hovered.guid) { super::bind_target_unit(&mut ladder, &checks, entity, guid); + } else if let Some((entity, guid)) = hovered.corpse.zip(hovered.corpse_guid) { + super::corpse::bind_target_corpse(&mut ladder, &checks, entity, guid); } return; } @@ -424,6 +428,119 @@ mod tests { assert!(!world.resource::().active()); } + /// `BindTarget`'s corpse arm from a world click on a released player's corpse: the ally bit on + /// a friend's corpse commits with `0x8000` and the corpse guid, the enemy bit on a hostile one + /// with `0x200`; bones, a hostile corpse under the ally bit and a word the bit does not empty + /// wait silently; out of range raises "Out of range." and waits. An item's corpse cast (Goblin + /// Jumper Cables) commits as `CMSG_USE_ITEM` with the same block. + #[test] + fn a_click_on_a_released_corpse_binds_the_corpse_arm() { + use super::super::corpse_fixture as fx; + use benilla_protocol::messages::{CorpseTarget, UseItemTarget}; + let commit = crate::spell::cast_send::CastCommit::Spell; + + let clicked = |word: u16, + commit: crate::spell::cast_send::CastCommit, + corpse: crate::net::ObjectStore, + distance: f32| { + let (mut world, rx, id) = fixture(); + world.insert_resource(fx::spells()); + world.insert_resource(fx::factions()); + world.spawn(( + crate::net::SelfPlayer, + GlobalTransform::default(), + fx::caster(1.5), + )); + let body = world + .spawn(( + GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)), + corpse, + )) + .id(); + world.resource_mut::().hovered = Hovered { + corpse: Some(body), + corpse_guid: Some(fx::CORPSE), + distance: 5.0, + ..Hovered::default() + }; + world.resource_mut::().enter( + fx::RESURRECTION, + commit, + word, + ); + click(&mut world, id); + let sent = rx.try_recv().ok(); + let errors = + std::mem::take(&mut world.resource_mut::().0); + let armed = world.resource::().active(); + (sent, errors, armed) + }; + let friend = || fx::corpse(fx::HUMAN, false); + let foe = || fx::corpse(fx::ORC, false); + + let (sent, errors, armed) = clicked(0x8000, commit, friend(), 10.0); + assert!( + matches!( + sent, + Some(ClientCommand::CastSpellCorpse { + spell_id: fx::RESURRECTION, + target: CorpseTarget::Ally, + corpse_guid: fx::CORPSE, + }) + ), + "{sent:?}" + ); + assert!(errors.is_empty() && !armed, "the bind ends the cursor"); + + let (sent, _, _) = clicked(0x0200, commit, foe(), 10.0); + assert!(matches!( + sent, + Some(ClientCommand::CastSpellCorpse { + target: CorpseTarget::Enemy, + .. + }) + )); + + // Silent refusals: nothing sent, no error, the cursor still up (`6e6026`). + for (word, corpse, what) in [ + (0x8000, fx::corpse(fx::HUMAN, true), "bones"), + (0x8000, foe(), "a hostile corpse under the ally bit"), + (0x8002, friend(), "a word the corpse bit does not empty"), + (0x0002, friend(), "a word with no corpse bit"), + ] { + let (sent, errors, armed) = clicked(word, commit, corpse, 10.0); + assert!(sent.is_none() && errors.is_empty() && armed, "{what}"); + } + + // Out of range (30 + 2 × 1.5 = 33): "Out of range." and the cursor stays. + let (sent, errors, armed) = clicked(0x8000, commit, friend(), 40.0); + assert!(sent.is_none() && armed); + assert_eq!( + errors, + vec![crate::ui_action::CastFail::local(fx::RESURRECTION, 0x59)] + ); + + // An item's cast: the same corpse block under `CMSG_USE_ITEM`. + let cables = crate::spell::cast_send::CastCommit::Item { + bag_index: 255, + slot: 24, + entry: 7148, + spell_index: 0, + on_object: None, + }; + let (sent, _, _) = clicked(0x8000, cables, friend(), 10.0); + assert!( + matches!( + sent, + Some(ClientCommand::UseItem { + target: UseItemTarget::Corpse(CorpseTarget::Ally, fx::CORPSE), + .. + }) + ), + "{sent:?}" + ); + } + /// Every way the object click must not commit. #[test] fn the_object_commit_holds_its_fire() { diff --git a/crates/benilla-app/src/target/hover.rs b/crates/benilla-app/src/target/hover.rs index 4f890139..d5d2dd94 100644 --- a/crates/benilla-app/src/target/hover.rs +++ b/crates/benilla-app/src/target/hover.rs @@ -83,14 +83,45 @@ pub(super) struct PickPose<'w, 's> { rigs: Query<'w, 's, &'static benilla_world::rig_palette::RigSkin>, } -/// The local identity and whether the pick takes it, one [`SystemParam`] under the picker's param -/// limit. +/// The local identity and what of it and of the corpses the pick takes, one [`SystemParam`] under +/// the picker's param limit. #[derive(bevy::ecs::system::SystemParam)] -pub(super) struct TargetHoverState<'w> { +pub(super) struct TargetHoverState<'w, 's> { /// For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause. self_guid: Res<'w, crate::net::SelfGuid>, /// `0x6e61a0`'s pick flag `0x20`, as the frame began. picks_self: Res<'w, crate::spell::PicksSelf>, + /// The corpse leg's word, as the frame began. + corpse_pick: Res<'w, crate::spell::CorpsePick>, + /// The corpse reaction gate's inputs (`0x6067d0`). + factions: Option>, + self_store: Query<'w, 's, &'static ObjectStore, With>, +} + +impl TargetHoverState<'_, '_> { + /// Whether an object of this kind is a candidate of this pick, the posed pass and the box + /// fallback alike: every unit and player, and a corpse (`0x480816`) always outside targeting, + /// whose pick flags carry `0x40`, and while targeting only one `0x6e6260` passes. A corpse + /// whose store has not streamed is refused then. + fn is_candidate(&self, kind: EntityKind, store: Option<&ObjectStore>) -> bool { + match kind { + EntityKind::Unit | EntityKind::Player => true, + EntityKind::Corpse => { + let Some(word) = self.corpse_pick.0 else { + return true; + }; + store.is_some_and(|store| { + crate::spell::targeting::corpse_pick_admits( + word, + store, + self.factions.as_deref(), + self.self_store.iter().next(), + ) + }) + } + _ => false, + } + } } /// The posed pick's geometry under one model root: its skinned parts. Ours are dropped while @@ -222,10 +253,7 @@ pub(super) fn update_hover( } // Units, players and corpses: the reference picks every CGObject in one trace and // switches on type at the end, below. - if !matches!( - net.kind, - EntityKind::Unit | EntityKind::Player | EntityKind::Corpse - ) { + if !target_state.is_candidate(net.kind, store) { continue; } // Not drawn, not clickable. Checked before `faithful.insert`, so an undrawn unit is out by @@ -324,15 +352,13 @@ pub(super) fn update_hover( if !picks_self && roots.get(parent).is_ok_and(|root| root.1) { continue; } - // The same kinds as the posed pick. A bone pile lands here: its corpse model has no + // The same candidates as the posed pick. A bone pile lands here: its corpse model has no // skeleton, so only this box test can pick it. let Ok((_, parent_net)) = units.get(parent) else { continue; }; - if !matches!( - parent_net.kind, - EntityKind::Unit | EntityKind::Player | EntityKind::Corpse - ) { + let store = roots.get(parent).ok().and_then(|root| root.6); + if !target_state.is_candidate(parent_net.kind, store) { continue; } if let Some(t) = ray_mesh_bounds(origin, dir, aabb, gt) { @@ -854,10 +880,9 @@ mod tests { ); } - /// Our own body joins the unit pick only while [`crate::spell::PicksSelf`] says the word takes - /// it (`0x480610` at `480638`), here through the box fallback of a body with no skinned parts. - #[test] - fn our_body_joins_the_pick_only_while_the_word_takes_it() { + /// The picker's inputs with a camera ten yards up the Z axis, looking down it through a + /// 100-pixel square with the cursor at its centre, over whatever stands at the origin. + fn pick_world() -> World { use bevy::camera::RenderTargetInfo; use bevy::window::WindowResolution; @@ -874,7 +899,7 @@ mod tests { world.init_resource::(); world.insert_resource(crate::net::SelfGuid(Some(1))); world.init_resource::(); - // Ten yards up the Z axis, looking down it through a 100-pixel square. + world.init_resource::(); let mut camera = Camera::default(); camera.computed.clip_from_view = Mat4::perspective_infinite_reverse_rh(1.0, 1.0, 0.1); camera.computed.target_info = Some(RenderTargetInfo { @@ -892,6 +917,25 @@ mod tests { }; window.set_cursor_position(Some(Vec2::splat(50.0))); world.spawn((window, PrimaryWindow)); + world + } + + /// One drawn pick box around the origin, the box fallback's geometry for a partless body. + fn pick_box(world: &mut World, body: Entity) { + world.spawn(( + ChildOf(body), + CreaturePickPart, + Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0)), + GlobalTransform::default(), + InheritedVisibility::VISIBLE, + )); + } + + /// Our own body joins the unit pick only while [`crate::spell::PicksSelf`] says the word takes + /// it (`0x480610` at `480638`), here through the box fallback of a body with no skinned parts. + #[test] + fn our_body_joins_the_pick_only_while_the_word_takes_it() { + let mut world = pick_world(); // Us, at the origin under the cursor, with one drawn pick box. let me = world .spawn(( @@ -906,13 +950,7 @@ mod tests { InheritedVisibility::VISIBLE, )) .id(); - world.spawn(( - ChildOf(me), - CreaturePickPart, - Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0)), - GlobalTransform::default(), - InheritedVisibility::VISIBLE, - )); + pick_box(&mut world, me); let hover = |world: &mut World, picks: bool| { world.resource_mut::().0 = picks; world @@ -926,6 +964,76 @@ mod tests { assert_eq!(hover(&mut world, false), None, "and not once it ends"); } + /// A corpse is a candidate outside targeting (`0x5c` carries flag `0x40`); while targeting, + /// only under a word in `0x8600` and only one `0x6e6260` passes, as + /// [`crate::spell::CorpsePick`] carries the standing word to the picker. + #[test] + fn a_corpse_joins_the_pick_as_the_word_admits_it() { + use crate::spell::targeting::corpse_fixture as fx; + + let hover = |corpse: ObjectStore, word: Option| { + let mut world = pick_world(); + world.insert_resource(fx::factions()); + world.init_resource::(); + // Us, a human, away from the ray: the reaction gate's other side. + world.spawn(( + SelfPlayer, + Guid(1), + GlobalTransform::from_translation(Vec3::new(50.0, 0.0, 0.0)), + fx::caster(1.5), + )); + let body = world + .spawn(( + Guid(fx::CORPSE), + NetEntity { + kind: EntityKind::Corpse, + display_id: None, + scale: 1.0, + }, + GlobalTransform::default(), + InheritedVisibility::VISIBLE, + corpse, + )) + .id(); + pick_box(&mut world, body); + if let Some(word) = word { + world.resource_mut::().enter( + fx::RESURRECTION, + crate::spell::CastCommit::Spell, + word, + ); + } + world + .run_system_cached(crate::spell::targeting::publish_corpse_pick) + .expect("the word publishes"); + world + .run_system_cached(update_hover) + .expect("the pick runs"); + let hovered = *world.resource::(); + assert_eq!(hovered.target, None, "a corpse is never the unit slot"); + hovered.corpse.inspect(|&e| assert_eq!(e, body)).is_some() + }; + let friend = || fx::corpse(fx::HUMAN, false); + let foe = || fx::corpse(fx::ORC, false); + let bones = || fx::corpse(fx::HUMAN, true); + + // Not targeting: every corpse, bones and foes included. + for (corpse, what) in [(friend(), "friend"), (foe(), "foe"), (bones(), "bones")] { + assert!(hover(corpse, None), "{what} outside targeting"); + } + // Resurrection's word: a friend's corpse, never a foe's or bones. + assert!(hover(friend(), Some(0x8000))); + assert!(!hover(foe(), Some(0x8000)), "a hostile corpse"); + assert!(!hover(bones(), Some(0x8000)), "bones"); + // The enemy bit takes the other side. + assert!(hover(foe(), Some(0x0200))); + assert!(!hover(friend(), Some(0x0200))); + // A heal word sets no flag `0x40`: the corpse is out of the pick, so a unit behind it + // is reachable. + assert!(!hover(friend(), Some(0x0002))); + assert!(!hover(friend(), Some(0x0040)), "a ground word"); + } + /// Naxxramas's "Unholy Axe" is an `InvisibleStalker` body with no vertices holding a real axe, /// which is its whole pick surface. #[test] diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 9ffe778d..9e0460e5 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -62,7 +62,7 @@ pub(crate) use price_discount::vendor_price_discount; pub(crate) use price_discount::{stormwind_fixture, HUMAN_WARRIOR}; // The attack-with-no-target request, and the same nearest-enemy core called synchronously for the // pet bar's Attack, whose order must leave in the frame it was pressed. -pub(crate) use relations::{can_assist, can_attack, can_interact}; +pub(crate) use relations::{can_assist, can_attack, can_interact, corpse_friendly}; pub(crate) use scan::{attack_order_target, AttackNearestRequest, TargetScan}; // The chat layer's by-name asks (`/target`, `/assist`). pub(crate) use by_name::{AssistRequest, TargetByNameRequest}; diff --git a/crates/benilla-app/src/target/relations.rs b/crates/benilla-app/src/target/relations.rs index ab47a8d5..5e69e49e 100644 --- a/crates/benilla-app/src/target/relations.rs +++ b/crates/benilla-app/src/target/relations.rs @@ -8,6 +8,8 @@ //! The first two live in [`super::ring`] beside the reaction directions they turn on: //! `0x6061e0(this = player)` answers a reputation faction with the at-war bit, //! `0x6061e0(this = unit)` with the standing, and the two often disagree. +//! +//! A corpse has its own reaction gate, `0x6067d0` ([`corpse_friendly`]). use benilla_protocol::messages::{ObjectType, OwnerFallback}; @@ -84,6 +86,25 @@ pub(crate) fn is_selectable(store: Option<&ObjectStore>, self_guid: Option) || (self_guid.is_some() && store.0.unit_created_by() == self_guid) } +/// `0x6067d0(player, corpse)`, the corpse's reaction gate: `0x6064c0` compares the player's +/// template (`UNIT_FIELD_FACTIONTEMPLATE`) toward the dead player's race's (`0x5d7120`: the race +/// byte of `CORPSE_FIELD_BYTES_1`, then `ChrRaces.dbc` column 2) with the template comparator +/// `0x606640` alone, no reputation, and passes at friendly (`setge` 4). A missing row on either +/// side is neutral (`0x60651b`), so it fails, as does a missing catalog or player. +pub(crate) fn corpse_friendly( + corpse: &ObjectStore, + factions: Option<&Factions>, + self_store: Option<&ObjectStore>, +) -> bool { + use benilla_formats::Reaction; + let reaction = (|| { + let catalog = factions?.catalog(); + let own = catalog.template(self_store?.0.unit_faction_template()?)?; + Some(own.reaction_toward(catalog.race_template(corpse.0.corpse_race())?)) + })(); + reaction.unwrap_or(Reaction::Neutral) >= Reaction::Friendly +} + /// `UNIT_FLAG_PVP`, bit 12 (vmangos `UnitDefines.h:557`), what `IsPvP 0x605ff0` tests on the /// unit's owner. const UNIT_FLAG_PVP: u32 = 0x1000; @@ -128,3 +149,56 @@ pub(crate) fn can_assist( let pvp_flags = owned.as_ref().map_or(flags, |o| o.0.unit_flags()); pvp_flags & UNIT_FLAG_PVP != 0 } + +#[cfg(test)] +mod tests { + use super::*; + use crate::spell::targeting::corpse_fixture as fx; + + /// `0x6067d0`: the caster's template toward the corpse's race's, friendly or better; every gap + /// is neutral (`0x60651b`) and fails. + #[test] + fn the_corpse_gate_reads_the_race_template() { + let factions = fx::factions(); + let me = fx::caster(1.5); + let gate = |corpse: &ObjectStore| corpse_friendly(corpse, Some(&factions), Some(&me)); + assert!( + gate(&fx::corpse(fx::HUMAN, false)), + "a human toward a human" + ); + assert!(!gate(&fx::corpse(fx::ORC, false)), "a human toward an orc"); + assert!(!gate(&fx::corpse(0, false)), "race 0 has no ChrRaces row"); + assert!( + !gate(&ObjectStore(Default::default())), + "no BYTES_1 reads race 0" + ); + let human = fx::corpse(fx::HUMAN, false); + assert!(!corpse_friendly(&human, None, Some(&me)), "no catalog"); + assert!(!corpse_friendly(&human, Some(&factions), None), "no player"); + let no_template = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(35, 999)])); + assert!( + !corpse_friendly(&human, Some(&factions), Some(&no_template)), + "a template the table lacks" + ); + } + + /// The same gate on the shipped `ChrRaces.dbc` and `FactionTemplate.dbc`: a human (template 1) + /// stands friendly toward every Alliance race's corpse and not toward a Horde one's. + #[test] + fn the_corpse_gate_splits_the_sides_on_the_shipped_tables() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let factions = Factions::from_catalog( + benilla_formats::load_faction_catalog(&mut chain).expect("faction tables"), + ); + let me = fx::caster(1.5); + let gate = |race| corpse_friendly(&fx::corpse(race, false), Some(&factions), Some(&me)); + for race in [1, 3, 4, 7] { + assert!(gate(race), "Alliance race {race}"); + } + for race in [2, 5, 6, 8] { + assert!(!gate(race), "Horde race {race}"); + } + assert!(!gate(0) && !gate(10), "a race with no row"); + } +} diff --git a/crates/benilla-formats/src/factions.rs b/crates/benilla-formats/src/factions.rs index ef8f18eb..e3e1b78a 100644 --- a/crates/benilla-formats/src/factions.rs +++ b/crates/benilla-formats/src/factions.rs @@ -19,6 +19,7 @@ use crate::Chain; const FACTION_TEMPLATE: &str = "DBFilesClient\\FactionTemplate.dbc"; const FACTION: &str = "DBFilesClient\\Faction.dbc"; const FACTION_GROUP: &str = "DBFilesClient\\FactionGroup.dbc"; +const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc"; /// One `FactionTemplate.dbc` row: the fields the reaction comparator reads. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -204,9 +205,13 @@ impl Reaction { } } -/// `FactionTemplate.dbc`, `Faction.dbc` and `FactionGroup.dbc` as id-to-row maps. +/// `FactionTemplate.dbc`, `Faction.dbc` and `FactionGroup.dbc` as id-to-row maps, and each +/// `ChrRaces.dbc` race's template. pub struct FactionCatalog { templates: HashMap, + /// Race id to `ChrRaces.dbc` column 2, `FactionID`, a template id: what a corpse's reaction + /// (`0x5d7120`) and a unit's PvP team (`0x5efe00`) read for a race. + race_templates: HashMap, factions: HashMap, /// `1 << MaskID` to the localized group name, `GetZonePVPInfo`'s territory line (`0x48d540`). group_names: HashMap, @@ -220,11 +225,39 @@ pub struct FactionCatalog { } impl FactionCatalog { + /// A catalog of these template rows and race templates alone. Fixture constructor for tests; + /// the live path is [`load_faction_catalog`]. + pub fn from_rows( + templates: HashMap, + race_templates: HashMap, + ) -> Self { + Self { + templates, + race_templates, + factions: HashMap::new(), + group_names: HashMap::new(), + group_internal_names: HashMap::new(), + names: HashMap::new(), + descriptions: HashMap::new(), + } + } + /// The template row for a `UNIT_FIELD_FACTIONTEMPLATE` id. pub fn template(&self, id: u32) -> Option<&FactionTemplate> { self.templates.get(&id) } + /// Every race with a `ChrRaces.dbc` row, and its template id. + pub fn race_templates(&self) -> impl Iterator + '_ { + self.race_templates.iter().map(|(&race, &id)| (race, id)) + } + + /// A race's template row: its `ChrRaces.dbc` `FactionID`, then `FactionTemplate.dbc`. `None` + /// for a race with no row or a template that is not in the table, both the reference's null. + pub fn race_template(&self, race: u8) -> Option<&FactionTemplate> { + self.template(*self.race_templates.get(&race)?) + } + /// A `Faction.dbc` id's reputation identity, `Some` only with a reputation slot: the /// reference's `FactionHasReputation` (`0x605fc0`), tested before the comparator (`0x606530`). pub fn reputation_faction(&self, faction_id: u32) -> Option<&FactionInfo> { @@ -344,7 +377,21 @@ fn faction_group_schema() -> Schema { s } -/// Load the three faction tables off the patch chain. +/// `ChrRaces.dbc`: 29 fields in build 5875. Only the id and `FactionID` (column 2) are read; the +/// string columns are declared so the field count matches. +fn chr_races_schema() -> Schema { + let mut s = Schema::new("ChrRaces"); + for i in 0..29 { + let ty = match i { + 15 | 26 | 27 | 28 => FieldType::String, + _ => FieldType::UInt32, + }; + s.add_field(SchemaField::new(format!("f{i}"), ty)); + } + s +} + +/// Load the three faction tables and the race templates off the patch chain. pub fn load_faction_catalog(chain: &mut Chain) -> Result { let bytes = chain .read_file(FACTION_TEMPLATE) @@ -418,8 +465,19 @@ pub fn load_faction_catalog(chain: &mut Chain) -> Result { group_names.insert(1u32 << mask_id, name); } + let bytes = chain + .read_file(CHR_RACES) + .with_context(|| format!("reading {CHR_RACES}"))?; + let rs = parse(&bytes, chr_races_schema(), "ChrRaces")?; + let race_templates = rs + .records() + .iter() + .filter_map(|r| Some((u8::try_from(u32_at(r, 0)?).ok()?, u32_at(r, 2)?))) + .collect(); + Ok(FactionCatalog { templates, + race_templates, factions, group_names, group_internal_names, diff --git a/crates/benilla-formats/src/race_pvp_team.rs b/crates/benilla-formats/src/race_pvp_team.rs index d3e3614d..dc3ceb58 100644 --- a/crates/benilla-formats/src/race_pvp_team.rs +++ b/crates/benilla-formats/src/race_pvp_team.rs @@ -6,45 +6,23 @@ use std::collections::HashMap; -use anyhow::{Context, Result}; -use benilla_dbc::{FieldType, Schema, SchemaField}; +use anyhow::Result; -use crate::dbc::{parse, u32_at}; use crate::Chain; -const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc"; - /// Each `ChrRaces.dbc` row's team digit by race id; a race without a row (-1) is absent. pub fn load_race_pvp_teams(chain: &mut Chain) -> Result> { - let bytes = chain - .read_file(CHR_RACES) - .with_context(|| format!("reading {CHR_RACES}"))?; - // The string columns are declared only so the field count matches; none is read. - let mut schema = Schema::new("ChrRaces"); - for i in 0..29 { - let ty = match i { - 15 | 26 | 27 | 28 => FieldType::String, - _ => FieldType::UInt32, - }; - schema.add_field(SchemaField::new(format!("f{i}"), ty)); - } - let rs = parse(&bytes, schema, "ChrRaces")?; let factions = crate::load_faction_catalog(chain)?; - let mut out = HashMap::new(); - for r in rs.records() { - let (Some(race), Some(template)) = (u32_at(r, 0), u32_at(r, 2)) else { - continue; - }; - let Ok(race) = u8::try_from(race) else { - continue; - }; - // The Horde bit is tested first (`0x5efe42`), so a row with both bits reads Horde. - let team = match factions.template(template).map(|t| t.group_mask) { - Some(m) if m & 4 != 0 => 0, - Some(m) if m & 2 != 0 => 1, - _ => -1, - }; - out.insert(race, team); - } - Ok(out) + Ok(factions + .race_templates() + .map(|(race, template)| { + // The Horde bit is tested first (`0x5efe42`), so a row with both bits reads Horde. + let team = match factions.template(template).map(|t| t.group_mask) { + Some(m) if m & 4 != 0 => 0, + Some(m) if m & 2 != 0 => 1, + _ => -1, + }; + (race, team) + }) + .collect()) } diff --git a/crates/benilla-protocol/src/messages/items.rs b/crates/benilla-protocol/src/messages/items.rs index 4e192780..e1fea725 100644 --- a/crates/benilla-protocol/src/messages/items.rs +++ b/crates/benilla-protocol/src/messages/items.rs @@ -461,6 +461,9 @@ pub enum UseItemTarget { /// `TARGET_FLAG_SOURCE_LOCATION` and three `f32` coords (`BindLocation`, `0x6e60f0`); only /// items carrying spell 265, such as Martin Fury, use it. vmangos reads it before the dest. Source([f32; 3]), + /// A corpse bit and the corpse's packed guid: Goblin Jumper Cables' Defibrillate + /// (`Targets 0x8000`) on a released player's corpse. + Corpse(super::spells::CorpseTarget, u64), } /// Body of `CMSG_USE_ITEM` (opcode 171): bag index (a bag's player-array slot 19-22, or @@ -487,6 +490,10 @@ pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, target: UseItemTarget) body.extend_from_slice(&TARGET_FLAG_ITEM.to_le_bytes()); guid } + UseItemTarget::Corpse(corpse, guid) => { + body.extend_from_slice(&corpse.target_flag().to_le_bytes()); + guid + } UseItemTarget::Dest(dest) => { body.extend_from_slice(&TARGET_FLAG_DEST_LOCATION.to_le_bytes()); for c in dest { diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 5cffe85e..e0814a6a 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -223,8 +223,9 @@ pub use social::{ }; pub use spellbook::SpellCooldown; pub use spells::{ - cancel_aura, cast_spell, cast_spell_at_dest, cast_spell_at_source, cast_spell_gameobject, - cast_spell_item, CastOutcome, SpellCastTargets, SpellChainTargets, SpellGo, SpellStart, + cancel_aura, cast_spell, cast_spell_at_dest, cast_spell_at_source, cast_spell_corpse, + cast_spell_gameobject, cast_spell_item, CastOutcome, CorpseTarget, SpellCastTargets, + SpellChainTargets, SpellGo, SpellStart, }; pub use stable::{ buy_stable_slot, list_stabled_pets, stable_pet, stable_result, stable_swap_pet, unstable_pet, diff --git a/crates/benilla-protocol/src/messages/spells.rs b/crates/benilla-protocol/src/messages/spells.rs index 9db976ab..181f30f6 100644 --- a/crates/benilla-protocol/src/messages/spells.rs +++ b/crates/benilla-protocol/src/messages/spells.rs @@ -304,6 +304,38 @@ pub fn cast_spell_gameobject(spell_id: u32, go_guid: u64) -> Vec { body } +/// The corpse bit a corpse cast carries: `BindTarget 0x6e5b40`'s corpse arm writes `0x8000` for a +/// corpse its caster's reaction toward is friendly (`0x6e5fc7`), `0x200` for one it is not +/// (`0x6e600c`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CorpseTarget { + /// `TARGET_FLAG_CORPSE_ALLY`, a resurrection's. + Ally, + /// `TARGET_FLAG_CORPSE_ENEMY`. + Enemy, +} + +impl CorpseTarget { + /// The wire mask bit, which is also the flag-word bit the bind clears. + pub const fn target_flag(self) -> u16 { + match self { + Self::Ally => TARGET_FLAG_CORPSE_ALLY, + Self::Enemy => TARGET_FLAG_CORPSE_ENEMY, + } + } +} + +/// Body of `CMSG_CAST_SPELL` at a corpse: the corpse bit and the corpse's packed guid. The client's +/// targets writer `0x7e4e10` puts the guid for any mask bit in `0x8a02`; vmangos reads it for +/// either corpse bit (`SpellCastTargetsInfo.cpp:156-157`). +pub fn cast_spell_corpse(spell_id: u32, target: CorpseTarget, corpse_guid: u64) -> Vec { + let mut body = Vec::with_capacity(16); + body.extend_from_slice(&spell_id.to_le_bytes()); + body.extend_from_slice(&target.target_flag().to_le_bytes()); + crate::wire::write_packed_guid(corpse_guid, &mut body).expect("vec write"); + body +} + /// Body of `CMSG_CAST_SPELL` at an item, such as an enchant or poison: `TARGET_FLAG_ITEM` and the /// item's packed guid (`SpellCastTargetsInfo.cpp:159-160`). pub fn cast_spell_item(spell_id: u32, item_guid: u64) -> Vec { @@ -363,6 +395,28 @@ mod tests { assert_eq!(cast_spell(1, None), [0x01, 0x00, 0x00, 0x00, 0x00, 0x00]); } + /// Resurrection (2006) at a released player's corpse, `HIGHGUID_CORPSE` 0xF101 + /// (vmangos `ObjectGuid.h:76`), counter 0x2A. + #[test] + fn cast_spell_corpse_body_golden() { + const CORPSE: u64 = 0xF101_0000_0000_002A; + assert_eq!( + cast_spell_corpse(2006, CorpseTarget::Ally, CORPSE), + [ + 0xD6, 0x07, 0x00, 0x00, // spell id 2006 + 0x00, 0x80, // TARGET_FLAG_CORPSE_ALLY + 0xC1, 0x2A, 0x01, 0xF1, // packed guid: bytes 0, 6 and 7 + ], + "CMSG_CAST_SPELL (ally corpse) body" + ); + // The enemy arm differs only in the mask. + assert_eq!( + cast_spell_corpse(2006, CorpseTarget::Enemy, CORPSE), + [0xD6, 0x07, 0x00, 0x00, 0x00, 0x02, 0xC1, 0x2A, 0x01, 0xF1], + "CMSG_CAST_SPELL (enemy corpse) body" + ); + } + #[test] fn cast_spell_at_dest_body_golden() { // Spell 10 is Blizzard. diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index 6d2846cd..a9d5a74c 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -437,6 +437,11 @@ impl ObjectFields { fn corpse_bytes_2(&self) -> Option { self.get_u32(33) } + /// The dead player's race, `CORPSE_FIELD_BYTES_1` byte 1, the one corpse field the reaction + /// gate reads (`0x5d7120`); 0, which no `ChrRaces` row names, when absent. + pub fn corpse_race(&self) -> u8 { + self.corpse_bytes_1().map_or(0, |b1| (b1 >> 8) as u8) + } /// The corpse's appearance, `None` in a delta that lacks either BYTES word. pub fn corpse_look(&self) -> Option { let (b1, b2) = (self.corpse_bytes_1()?, self.corpse_bytes_2()?); diff --git a/crates/benilla-protocol/src/world/writer/spells.rs b/crates/benilla-protocol/src/world/writer/spells.rs index 84e36c11..f7de7ae4 100644 --- a/crates/benilla-protocol/src/world/writer/spells.rs +++ b/crates/benilla-protocol/src/world/writer/spells.rs @@ -32,6 +32,20 @@ impl WorldWriter { ) } + /// `CMSG_CAST_SPELL` at a corpse: a corpse bit and the corpse's packed guid, a resurrection on + /// a released player. The server resurrects the corpse's owner (`Spell.cpp:3055-3064`). + pub fn cast_spell_corpse( + &mut self, + spell_id: u32, + target: messages::CorpseTarget, + corpse_guid: u64, + ) -> Result<()> { + self.send( + opcode::CMSG_CAST_SPELL, + &messages::cast_spell_corpse(spell_id, target, corpse_guid), + ) + } + /// `CMSG_CAST_SPELL` with `TARGET_FLAG_ITEM` and the item's packed guid: an enchant on the item /// picked in the craft frame. pub fn cast_spell_item(&mut self, spell_id: u32, item_guid: u64) -> Result<()> { diff --git a/crates/benilla-protocol/tests/items.rs b/crates/benilla-protocol/tests/items.rs index 1927e352..b0ad68df 100644 --- a/crates/benilla-protocol/tests/items.rs +++ b/crates/benilla-protocol/tests/items.rs @@ -78,6 +78,19 @@ fn item_query_wire() { hx("ff18001000c3cdab50f1") ); + // A corpse target (Goblin Jumper Cables' Defibrillate, `Targets 0x8000`): the corpse bit and + // the packed guid, the block `cast_spell_corpse` writes; vmangos reads the guid for either + // corpse bit (`SpellCastTargetsInfo.cpp:156-157`). 0xF101_0000_0000_002A packs to mask 0xC1. + assert_eq!( + messages::use_item( + 255, + 24, + 0, + messages::UseItemTarget::Corpse(messages::CorpseTarget::Ally, 0xF101_0000_0000_002A) + ), + hx("ff18000080c12a01f1") + ); + // CMSG_AUTOEQUIP_ITEM (vmangos AutoEquipItem::ReadFromWorldPacket): bagIndex, slot. assert_eq!(messages::auto_equip_item(255, 25), hx("ff19"));