A released player's corpse takes a resurrection: the targeting cursor, the pick and the click bind it as 1.12.1 does (#462)

With Resurrection, Redemption or Rebirth armed and nothing selected, the targeting cursor takes a released party member's corpse: it lights over the corpse in range, and a click casts on it. Before, the cursor stayed grey over a corpse and a click bound nothing.

- **The pick** (`0x480816`, `0x6e6260`). While targeting, a corpse is a candidate only under a word in `0x8600`: under `0x8000` a corpse that is not bones and passes `0x6067d0`, and under `0x200`, tried only without `0x8000`, one that is not bones and fails it. Outside targeting every corpse stays a candidate, as before.
- **The reaction gate `0x6067d0`**: the caster's faction template toward the corpse owner's race template (`ChrRaces`, as `0x5d7120` reads it), friendly at 4 and up. The faction catalog now carries each race's template, and the PvP team loader reads it from there.
- **The cursor**: `0x6e6460`'s corpse leg (`0x6e6719`-`0x6e676d`) with its range tail. `GetMinMaxRange 0x6e3480` pads a corpse like a unit, reading the caster's own reach twice (`0x6e3605`-`0x6e361e`).
- **The click**: `BindTarget`'s corpse arm (`0x6e5f89`-`0x6e6020`), the `0x8000` arm then the `0x200` arm, through the same range merge as a unit (`0x6e602c`). Out of range raises the error and keeps the cursor armed, as the unit arm already does.
- **The wire**: `CMSG_CAST_SPELL` with the corpse bit and the corpse's packed guid, which the client writes for any mask bit in `0x8a02` (`0x7e4e10`) and vmangos reads for either corpse bit (`SpellCastTargetsInfo.cpp:156-157`); `CMSG_USE_ITEM` the same, for Goblin Jumper Cables.

Checked: byte-exact goldens for both packets; unit tests for the pick filter, the cursor verdict (bones, a hostile corpse, out of range, the reach pad), the bind arms from a world click to the sent command, and the reaction gate on the shipped DBCs. Each fails with its piece undone. There is no live run: the probes cannot aim or click in the 3D world. Gates green on macOS; no platform seam touched.

Fixes #450

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-27 14:01:33 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a2c3b006ec
commit 1ef11b5bb6
19 changed files with 992 additions and 111 deletions
+6
View File
@@ -884,6 +884,12 @@ pub(crate) enum ClientCommand {
spell_id: u32,
target: Option<u64>,
},
/// `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,
+5
View File
@@ -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)
}
@@ -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),
},
},
};
+9 -2
View File
@@ -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::<SpellTargeting>()
.init_resource::<targeting::EnchantConfirmItem>()
.init_resource::<targeting::PicksSelf>()
.init_resource::<targeting::CorpsePick>()
.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
@@ -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<CorpseTarget> {
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<u16>);
/// Publish [`CorpsePick`] before the input pass, beside [`super::PicksSelf`].
pub(crate) fn publish_corpse_pick(
targeting: Res<super::SpellTargeting>,
mut pick: ResMut<CorpsePick>,
) {
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}");
}
}
}
@@ -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<SpellTargeting>,
unit_checks: super::UnitBindChecks,
checks: super::BindChecks,
occlusion: Res<PickOcclusion>,
hovered: Res<crate::target::Hovered>,
hovered_object: Res<crate::target::HoveredObject>,
@@ -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::<WorldCursor>();
world.init_resource::<SpellTargeting>();
world.init_resource::<crate::target::HoveredObject>();
world.init_resource::<crate::go_templates::GameObjectTemplates>();
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::net::GuidIndex>();
world.insert_resource(crate::net::Reputations(Vec::new()));
world.init_resource::<PickOcclusion>();
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::<SpellTargeting>().enter(
fx::RESURRECTION,
crate::spell::CastCommit::Spell,
word,
);
world
.run_system_once(drive_targeting_cursor)
.expect("the targeting cursor drives");
!world.resource::<WorldCursor>().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));
}
}
+93 -33
View File
@@ -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<Res<'w, crate::net::GuidIndex>>,
self_q: Query<
@@ -60,7 +67,7 @@ pub(crate) struct UnitBindChecks<'w, 's> {
spells: Option<Res<'w, crate::ui_action::Spells>>,
}
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<u8> {
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<u8> {
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<u8> {
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<corpse::CorpseFacts> {
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<SpellTargeting>,
checks: UnitBindChecks,
checks: BindChecks,
tokens: crate::ui_unit::UnitTokens,
selection: Res<crate::target::Selection>,
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
@@ -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<u8>,
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<NonSendMut<benilla_ui::script::UiScript>>,
tokens: crate::ui_unit::UnitTokens,
selection: Res<crate::target::Selection>,
checks: UnitBindChecks,
checks: BindChecks,
mut ladder: crate::spell::CastLadder,
) {
let Some(mut script) = script else {
+124 -7
View File
@@ -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<WorldClick>,
// The press's pick: the object the gesture started on.
press: Res<crate::target::PressPick>,
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::<super::super::SpellTargeting>().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::<crate::target::PressPick>().hovered = Hovered {
corpse: Some(body),
corpse_guid: Some(fx::CORPSE),
distance: 5.0,
..Hovered::default()
};
world.resource_mut::<super::super::SpellTargeting>().enter(
fx::RESURRECTION,
commit,
word,
);
click(&mut world, id);
let sent = rx.try_recv().ok();
let errors =
std::mem::take(&mut world.resource_mut::<crate::ui_action::CastErrors>().0);
let armed = world.resource::<super::super::SpellTargeting>().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() {
+132 -24
View File
@@ -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<Res<'w, super::Factions>>,
self_store: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
}
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::<benilla_world::rig_palette::RigPalettes>();
world.insert_resource(crate::net::SelfGuid(Some(1)));
world.init_resource::<crate::spell::PicksSelf>();
// Ten yards up the Z axis, looking down it through a 100-pixel square.
world.init_resource::<crate::spell::CorpsePick>();
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::<crate::spell::PicksSelf>().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<u16>| {
let mut world = pick_world();
world.insert_resource(fx::factions());
world.init_resource::<crate::spell::SpellTargeting>();
// 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::<crate::spell::SpellTargeting>().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::<Hovered>();
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]
+1 -1
View File
@@ -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};
@@ -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<u64>)
|| (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");
}
}
+60 -2
View File
@@ -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<u32, FactionTemplate>,
/// 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<u8, u32>,
factions: HashMap<u32, FactionInfo>,
/// `1 << MaskID` to the localized group name, `GetZonePVPInfo`'s territory line (`0x48d540`).
group_names: HashMap<u32, String>,
@@ -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<u32, FactionTemplate>,
race_templates: HashMap<u8, u32>,
) -> 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<Item = (u8, u32)> + '_ {
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<FactionCatalog> {
let bytes = chain
.read_file(FACTION_TEMPLATE)
@@ -418,8 +465,19 @@ pub fn load_faction_catalog(chain: &mut Chain) -> Result<FactionCatalog> {
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,
+13 -35
View File
@@ -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<HashMap<u8, i8>> {
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())
}
@@ -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 {
+3 -2
View File
@@ -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,
@@ -304,6 +304,38 @@ pub fn cast_spell_gameobject(spell_id: u32, go_guid: u64) -> Vec<u8> {
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<u8> {
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<u8> {
@@ -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.
@@ -437,6 +437,11 @@ impl ObjectFields {
fn corpse_bytes_2(&self) -> Option<u32> {
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<CorpseLook> {
let (b1, b2) = (self.corpse_bytes_1()?, self.corpse_bytes_2()?);
@@ -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<()> {
+13
View File
@@ -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"));