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:
co-authored by
Claude Opus 5.5
parent
a2c3b006ec
commit
1ef11b5bb6
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user