spell bind: BindTarget's unit gates run ahead of the relation arms at the press, the click and the hover, through one creature-type resolver (#640)

The spell target binder went straight to the relation checks and tested none of `BindTarget`'s unit gates. So:
- a heal with a dead friendly player selected was sent at the corpse and refused by the server;
- Hibernate, Banish or Turn Undead at the wrong creature type was sent and refused, instead of refusing locally with "Invalid target";
- a self-excluding spell with yourself selected bound you.

`BindTarget 0x6e5b40` refuses a unit silently, before any relation arm, when any of these holds:
- the unit is the caster and the spell has `AttributesEx & 0x80000` (`6e5bd8`-`6e5bf7`);
- the unit has `UNIT_FIELD_FLAGS & 0x10000` (`6e5c1d`);
- the spell's `TargetCreatureType` mask excludes the unit's type (`6e5c26`-`6e5c53`). A zero mask admits every unit. Otherwise a unit of type 0 is refused and a typed one needs bit `type - 1`;
- the unit is dead (signed `UNIT_FIELD_HEALTH` not above 0), unless `UNIT_DYNAMIC_FLAGS & 0x20`, a word in `0x8600` or `AttributesEx2 & 1` admits it (`6e5c5f`-`6e5c8b`);
- the unit is alive and the word has `0x400` (`6e5ca2`).

A refused selection falls to the player behind `autoSelfCast`, then to the enemy-word refusal or the cursor. `SpellCanTargetUnit 0x6e6460` runs the same five gates (`6e6507`-`6e65ab`).

- `spell/bind_gates.rs` holds the five gates. `cast_target::unit_binds` is the one predicate for the selection, the `autoSelfCast` fallback, the cursor's click, `SpellTargetUnit` and the hover verdict. The cursor's separate `excludes_caster` checks fold into it.
- The dead test now matches the reference, which catches a corpse whose create block sent no health. The `0x400` bit is now cleared by a dead unit's bind (`6e5d89`, `6e5ddb`), where before any non-self candidate cleared it.
- `creature_type.rs` is the one `0x605570`, reading these in order:
  - the form's `SpellShapeshiftForm` creature type;
  - the creature template's type, by `OBJECT_FIELD_ENTRY`;
  - `ChrRaces`'s type.
  The minimap's tracking dots used their own copy, keyed by the guid's entry bits and missing the fall-through past the form. They now call the shared resolver.
- `SpellDisplay` reads `TargetCreatureType` (column 14).

Tests:
- `bind_gates.rs` has a truth table per gate. Its `ladder` module drives `resolve_cast_target` through:
  - a dead heal target, with `autoSelfCast` on and off;
  - Hibernate at the wrong type;
  - a self-excluding spell;
  - the `0x10000` flag;
  - the `0x402` word.
- `the_press_the_click_and_the_hover_bind_the_same_units` runs one table through all three paths.
- `real_spell_catalog_reads_the_bind_gate_columns` pins the real rows for Hibernate, Banish, Turn Undead, Mind Control, Bloodlust and Revive.

Fixes #449

🤖 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-29 23:31:30 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0e04e54fbf
commit 8ef62da6ac
15 changed files with 1229 additions and 152 deletions
+177
View File
@@ -0,0 +1,177 @@
//! A unit's creature type as the reference resolves it (`0x605570`), which the spell bind's
//! creature-type gate and the tracking dots read: the shapeshift form's type when above 0
//! (`SpellShapeshiftForm.dbc` column 12, `0x60559a`), else the cached creature template's
//! (`[unit+0xb30] + 0x18`, `0x6055a8`), else the race's (`ChrRaces.dbc` column 9, `0x6055ca`).
//! 0 is a unit with no type, which a creature-type-limited spell refuses.
use std::collections::HashMap;
use benilla_formats::ShapeshiftForm;
use crate::names::NameCache;
use crate::net::ObjectStore;
/// What the resolver reads beside the unit's own fields: the creature template cache and the
/// form table. A source that is absent resolves nothing at its stage.
#[derive(Clone, Copy, Default)]
pub(crate) struct CreatureTypeSources<'a> {
pub(crate) names: Option<&'a NameCache>,
pub(crate) forms: Option<&'a HashMap<u32, ShapeshiftForm>>,
}
impl CreatureTypeSources<'_> {
/// The unit's creature type, 0 for none. The template stage answers whatever the record
/// holds, 0 included, and is keyed by `OBJECT_FIELD_ENTRY` as the reference's cache is.
pub(crate) fn of(&self, store: &ObjectStore) -> u32 {
let fields = &store.0;
let form = self
.forms
.and_then(|forms| forms.get(&u32::from(fields.unit_shapeshift_form())))
.map(|row| row.creature_type)
.filter(|&ty| ty > 0);
if let Some(ty) = form {
return ty as u32;
}
let record = self
.names
.zip(fields.object_entry())
.and_then(|(names, entry)| names.creature_type(entry));
if let Some(ty) = record {
return ty;
}
// The race byte of `UNIT_FIELD_BYTES_0`: a player's, or 0 on a creature whose template
// has not come back, which has no row.
race_creature_type((fields.unit_bytes_0().unwrap_or(0) & 0xff) as u8)
}
}
/// `ChrRaces.dbc` column 9 by race id: Humanoid on all nine shipped rows, and 0 where the table
/// has no row (`0x6055b3`-`0x6055d1`).
fn race_creature_type(race: u8) -> u32 {
if (1..=9).contains(&race) {
CREATURE_TYPE_HUMANOID
} else {
0
}
}
/// `CreatureType.dbc` id 7.
const CREATURE_TYPE_HUMANOID: u32 = 7;
#[cfg(test)]
mod tests {
use super::*;
use crate::names::CreatureRecord;
use benilla_protocol::ObjectFields;
/// `UNIT_FIELD_BYTES_1` and `UNIT_FIELD_BYTES_0`, absolute descriptor indices.
const BYTES_1: u16 = 138;
const BYTES_0: u16 = 36;
const OBJECT_FIELD_ENTRY: u16 = 3;
fn store(entry: u32, race: u32, form: u32) -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(&[
(OBJECT_FIELD_ENTRY, entry),
(BYTES_0, race),
(BYTES_1, form << 16),
]))
}
fn names_with(entry: u32, creature_type: u32) -> NameCache {
let mut names = NameCache::default();
names.insert_creature(
entry,
Some(CreatureRecord {
name: "Test".into(),
subname: None,
creature_type,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
racial_leader: false,
display_id: 0,
}),
);
names
}
fn forms() -> HashMap<u32, ShapeshiftForm> {
let row = |creature_type| ShapeshiftForm {
creature_type,
..Default::default()
};
// Cat form is a Beast; a warrior stance carries type 0.
HashMap::from([(1, row(1)), (17, row(0))])
}
/// The three stages in the reference's order: form above 0, then the template, then the
/// race, and 0 for a unit none of them names.
#[test]
fn stages_resolve_in_the_references_order() {
let names = names_with(69, 1);
let forms = forms();
let sources = CreatureTypeSources {
names: Some(&names),
forms: Some(&forms),
};
// A cat-form player is a Beast; unshifted, a Humanoid by race.
assert_eq!(sources.of(&store(0, 1, 1)), 1);
assert_eq!(sources.of(&store(0, 1, 0)), 7);
// A stance row of type 0 falls through to the race, not to a fixed answer.
assert_eq!(sources.of(&store(0, 5, 17)), 7);
// A cached creature is its template's type; the form still wins over it.
assert_eq!(sources.of(&store(69, 0, 0)), 1);
let humanoid = names_with(69, 7);
let sources = CreatureTypeSources {
names: Some(&humanoid),
forms: Some(&forms),
};
assert_eq!(sources.of(&store(69, 0, 0)), 7);
assert_eq!(
sources.of(&store(69, 0, 1)),
1,
"a form outranks the template"
);
// The template answers whatever it holds: type 0 does not fall through to the race.
let untyped = names_with(69, 0);
let sources = CreatureTypeSources {
names: Some(&untyped),
forms: None,
};
assert_eq!(sources.of(&store(69, 1, 0)), 0);
}
/// A creature whose template has not answered has race 0, which has no row: type 0. A form
/// id with no row, or no form table at all, resolves nothing at that stage.
#[test]
fn an_uncached_creature_and_a_missing_row_read_type_zero() {
let names = NameCache::default();
let forms = forms();
let sources = CreatureTypeSources {
names: Some(&names),
forms: Some(&forms),
};
assert_eq!(sources.of(&store(69, 0, 0)), 0, "no template, race 0");
assert_eq!(sources.of(&store(69, 0, 99)), 0, "no form row either");
assert_eq!(sources.of(&store(0, 10, 0)), 0, "race 10 has no row");
assert_eq!(CreatureTypeSources::default().of(&store(0, 1, 1)), 7);
assert_eq!(CreatureTypeSources::default().of(&store(69, 0, 1)), 0);
}
/// The frozen race table against the shipped `ChrRaces.dbc`.
#[test]
fn race_creature_type_matches_the_shipped_table() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let rows = benilla_formats::load_race_creature_types(&mut chain).expect("ChrRaces walk");
assert_eq!(rows.len(), 9, "ChrRaces.dbc row count");
for race in 0..=u8::MAX {
assert_eq!(
race_creature_type(race),
rows.get(&race).copied().unwrap_or(0),
"race {race}"
);
}
}
}
+1
View File
@@ -42,6 +42,7 @@ mod combat_text;
mod console;
mod crash;
mod creature_anim;
mod creature_type;
mod cursor;
mod cvars;
mod death;
+23 -98
View File
@@ -10,6 +10,7 @@ use benilla_assets::coords::bevy_to_wow;
use benilla_formats::{LockCatalog, ShapeshiftForm, LOCK_KEY_SKILL};
use benilla_protocol::EntityKind;
use crate::creature_type::CreatureTypeSources;
use crate::go_templates::GameObjectTemplates;
use crate::names::NameCache;
use crate::net::{GuidIndex, NetEntity, ObjectStore};
@@ -31,10 +32,6 @@ const TRACKED_UNIT_CELL: [f32; 4] = [0.25, 0.5, 0.0, 0.25];
/// `UNIT_DYNAMIC_FLAGS` bit 0x2, set per viewer on a Hunter's Mark victim for its caster; one of
/// `0x5ed210`'s two always-show clauses (`+0x224 & 0x2`).
const UNIT_DYNFLAG_TRACK_UNIT: u32 = 0x2;
/// Creature type 7, Humanoid: a player's type (`ChrRaces.dbc` col 9 is 7 for all nine playable
/// races, read by `0x605570`) and the `<= 0` fallback of the shapeshift override.
const CREATURE_TYPE_HUMANOID: u32 = 7;
/// Only DIALOG_STATUS 7 draws a quest dot, the gold cell 3 (`cmp [obj+0xcb8],7` at `0x4eac31`).
fn quest_dot_cell(status: u32) -> Option<[f32; 4]> {
(status == 7).then_some([0.75, 1.0, 0.0, 0.25])
@@ -162,33 +159,6 @@ fn tracked_creature(
.is_some_and(|t| (1..=32).contains(&t) && tracking.creatures & (1u32 << (t - 1)) != 0)
}
/// The creature-type resolver `0x605570`: a shapeshift form's `SpellShapeshiftForm.dbc` type
/// first (`<= 0` reads Humanoid), else an NPC's cached template, else Humanoid for a player.
fn creature_type_of(
kind: EntityKind,
shapeshift_form: u8,
entry: Option<u32>,
names: &NameCache,
forms: Option<&HashMap<u32, ShapeshiftForm>>,
) -> Option<u32> {
if shapeshift_form != 0 {
if let Some(t) =
forms.and_then(|f| f.get(&u32::from(shapeshift_form)).map(|r| r.creature_type))
{
return Some(if t >= 1 {
t as u32
} else {
CREATURE_TYPE_HUMANOID
});
}
}
match kind {
EntityKind::Unit => entry.and_then(|e| names.creature_type(e)),
EntityKind::Player => Some(CREATURE_TYPE_HUMANOID),
_ => None,
}
}
/// Draw the tracking dots, gold cell 0 per GameObject then red cell 1 per unit not at quest
/// status 7 (`0x4eac31` tests that first). Drawn before the quest and party dots, as the cell
/// lists draw in order.
@@ -257,6 +227,10 @@ pub(in crate::minimap) fn emit_tracking_dots(
}
}
// Cell 1, units.
let types = CreatureTypeSources {
names: Some(names),
forms,
};
for (guid, net, tf, store) in candidates.iter() {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
@@ -267,22 +241,10 @@ pub(in crate::minimap) fn emit_tracking_dots(
if statuses.get(&guid.0).copied() == Some(7) {
continue; // the ==7 branch already drew the quest dot
}
let (dyn_flags, form, creeping) = store
.map(|s| {
(
s.0.unit_dynamic_flags(),
s.0.unit_shapeshift_form(),
s.0.unit_is_stealthed(),
)
})
.unwrap_or((0, 0, false));
let creature_type = creature_type_of(
net.kind,
form,
benilla_protocol::guid::entry(guid.0),
names,
forms,
);
let (dyn_flags, creeping) = store
.map(|s| (s.0.unit_dynamic_flags(), s.0.unit_is_stealthed()))
.unwrap_or((0, false));
let creature_type = store.map(|s| types.of(s));
if tracked_creature(tracking, creature_type, dyn_flags, creeping) {
dot(guid.0, tf, TRACKED_UNIT_CELL, DotName::Guid);
}
@@ -332,6 +294,9 @@ pub(in crate::minimap) fn emit_party_dots(
mod tests {
use super::*;
/// Creature type 7, Humanoid: a player's, by race (`ChrRaces.dbc` column 9).
const CREATURE_TYPE_HUMANOID: u32 = 7;
/// The first row is the control: an ordinary live creature still passes.
#[test]
fn the_dead_our_own_minions_and_the_untrackable_draw_no_dot_of_either_kind() {
@@ -495,51 +460,6 @@ mod tests {
);
}
#[test]
fn creature_type_resolver_prefers_the_shapeshift_override() {
use benilla_formats::ShapeshiftForm;
let names = NameCache::default();
let forms: HashMap<u32, ShapeshiftForm> = [
(
1,
ShapeshiftForm {
creature_type: 1, // Cat → Beast
..Default::default()
},
),
(
16,
ShapeshiftForm {
creature_type: 0, // a <=0 row reads Humanoid (the resolver's fallback)
..Default::default()
},
),
]
.into();
// A cat-form player is a Beast; unshifted, a Humanoid.
assert_eq!(
creature_type_of(EntityKind::Player, 1, None, &names, Some(&forms)),
Some(1)
);
assert_eq!(
creature_type_of(EntityKind::Player, 0, None, &names, Some(&forms)),
Some(CREATURE_TYPE_HUMANOID)
);
assert_eq!(
creature_type_of(EntityKind::Player, 16, None, &names, Some(&forms)),
Some(CREATURE_TYPE_HUMANOID)
);
// An NPC with no cached template yet resolves nothing.
assert_eq!(
creature_type_of(EntityKind::Unit, 0, Some(69), &names, Some(&forms)),
None
);
assert_eq!(
creature_type_of(EntityKind::GameObject, 0, None, &names, Some(&forms)),
None
);
}
/// The tracking spell's `EffectMiscValue`, the server's mask bit and the node's `Lock.dbc`
/// skill slot, on the install's data.
#[test]
@@ -586,12 +506,17 @@ mod tests {
0,
false
));
let cat = creature_type_of(
EntityKind::Player,
1,
None,
&NameCache::default(),
Some(&forms),
// A cat-form player (`UNIT_FIELD_BYTES_1` byte 2 is form 1).
let cat_druid = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
138,
1 << 16,
)]));
let cat = Some(
CreatureTypeSources {
names: Some(&NameCache::default()),
forms: Some(&forms),
}
.of(&cat_druid),
);
assert_eq!(cat, Some(1), "cat form resolves Beast (DBC col 12)");
assert!(tracked_creature(beasts, cat, 0, false));
+537
View File
@@ -0,0 +1,537 @@
//! `BindTarget 0x6e5b40`'s gates: the five silent exits a unit meets before any relation arm
//! (`6e5bd8`-`6e5ca2`). A refused unit binds nothing, so `ArmCast` falls to its next candidate,
//! the player behind `autoSelfCast`, and then to the enemy-word refusal or the targeting cursor.
//! The read-only mirror `SpellCanTargetUnit 0x6e6460` runs the same five (`6e6507`-`6e65ab`), so
//! the press, the cursor's click, `SpellTargetUnit` and the hover verdict share this one gate
//! through [`super::cast_target::unit_binds`].
use benilla_formats::SpellDisplay;
use benilla_protocol::ObjectFields;
use super::cast_target::{TargetRelations, CORPSE_WORD_BITS, TF_EXPLICIT_GATE};
/// `UNIT_FIELD_FLAGS & 0x10000` (vmangos `UNIT_FLAG_NON_ATTACKABLE_2`, `UnitDefines.h:561`), tested
/// at `6e5c16`-`6e5c1d`.
const UNIT_FLAG_NON_ATTACKABLE_2: u32 = 0x0001_0000;
/// The bind's alive test (`6e5c5f`, `6e5e55`): signed `UNIT_FIELD_HEALTH` above 0. An absent field
/// reads 0, as the reference's descriptor does, so a dead unit whose create sent no health is dead.
pub(super) fn unit_alive(fields: &ObjectFields) -> bool {
fields.unit_health().unwrap_or(0) as i32 > 0
}
/// Whether `unit` passes the gates for `word`. `is_caster` is the unit being the cast's caster
/// (`6e5bd8`-`6e5bee`); the unit's own fields come from `rel.target_store`, and a candidate with
/// no streamed store has none to read, so the relation arms alone decide it. Refused when:
///
/// - the unit is the caster and the spell has `AttributesEx & 0x80000` (`6e5bf7`);
/// - the unit has `UNIT_FIELD_FLAGS & 0x10000` (`6e5c1d`);
/// - the spell's `TargetCreatureType` mask excludes the unit's creature type (`6e5c36`, `6e5c53`);
/// - the unit is dead and neither `UNIT_DYNAMIC_FLAGS & 0x20`, a word in `0x8600` nor
/// `AttributesEx2 & 1` admits it (`6e5c8b`);
/// - the unit is alive and the word has `0x400` (`6e5ca2`).
pub(super) fn bind_gates(
def: &SpellDisplay,
word: u16,
is_caster: bool,
rel: &TargetRelations,
) -> bool {
if is_caster && def.excludes_caster() {
return false;
}
let Some(store) = rel.target_store else {
return true;
};
let fields = &store.0;
if fields.unit_flags() & UNIT_FLAG_NON_ATTACKABLE_2 != 0 {
return false;
}
if !def.admits_creature_type(rel.types.of(store)) {
return false;
}
if unit_alive(fields) {
word & TF_EXPLICIT_GATE == 0
} else {
fields.unit_dynflag_dead() || word & CORPSE_WORD_BITS != 0 || def.allows_dead_target()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creature_type::CreatureTypeSources;
use crate::names::{CreatureRecord, NameCache};
use crate::net::{ObjectStore, Reputations};
use benilla_protocol::ObjectFields;
/// Absolute descriptor indices: `OBJECT_FIELD_ENTRY`, `UNIT_FIELD_BYTES_0` (the race byte),
/// `UNIT_FIELD_HEALTH`, `UNIT_FIELD_FLAGS` and `UNIT_DYNAMIC_FLAGS`.
const ENTRY: u16 = 3;
const BYTES_0: u16 = 36;
const HEALTH: u16 = 22;
const FLAGS: u16 = 46;
const DYNAMIC_FLAGS: u16 = 143;
const WORD_UNIT: u16 = 0x0002;
const WORD_ASSIST: u16 = 0x0100;
const WORD_DEAD_ONLY: u16 = 0x0402;
const WORD_CORPSE_ALLY: u16 = 0x8000;
/// A store with the fields a case sets, health 100 unless it says otherwise.
fn unit(extra: &[(u16, u32)]) -> ObjectStore {
let mut pairs = extra.to_vec();
if !pairs.iter().any(|&(index, _)| index == HEALTH) {
pairs.push((HEALTH, 100));
}
ObjectStore(ObjectFields::from_pairs(&pairs))
}
fn passes(def: &SpellDisplay, word: u16, is_caster: bool, store: &ObjectStore) -> bool {
passes_with(def, word, is_caster, store, CreatureTypeSources::default())
}
fn passes_with(
def: &SpellDisplay,
word: u16,
is_caster: bool,
store: &ObjectStore,
types: CreatureTypeSources,
) -> bool {
static EMPTY: Reputations = Reputations(Vec::new());
let rel = TargetRelations {
target_store: Some(store),
target_owner_store: None,
self_store: None,
factions: None,
reputations: &EMPTY,
types,
};
bind_gates(def, word, is_caster, &rel)
}
fn spell() -> SpellDisplay {
SpellDisplay::default()
}
#[test]
fn an_ordinary_live_unit_passes() {
assert!(passes(&spell(), WORD_UNIT, false, &unit(&[])));
assert!(passes(&spell(), WORD_ASSIST, true, &unit(&[])));
}
/// `6e5bf7`: the caster, and only the caster, under `AttributesEx & 0x80000`.
#[test]
fn the_caster_is_refused_under_a_self_excluding_spell() {
let excluding = SpellDisplay {
attributes_ex: 0x0008_0000,
..spell()
};
assert!(!passes(&excluding, WORD_ASSIST, true, &unit(&[])));
assert!(passes(&excluding, WORD_ASSIST, false, &unit(&[])));
assert!(passes(&spell(), WORD_ASSIST, true, &unit(&[])));
// Before any store is read: an unstreamed caster is still the caster.
let rel = TargetRelations {
target_store: None,
target_owner_store: None,
self_store: None,
factions: None,
reputations: &Reputations(Vec::new()),
types: CreatureTypeSources::default(),
};
assert!(!bind_gates(&excluding, WORD_ASSIST, true, &rel));
assert!(bind_gates(&excluding, WORD_ASSIST, false, &rel));
}
/// `6e5c1d`: bit 16 of `UNIT_FIELD_FLAGS`, and only that bit.
#[test]
fn a_unit_flagged_0x10000_is_refused() {
assert!(!passes(
&spell(),
WORD_ASSIST,
false,
&unit(&[(FLAGS, 0x0001_0000)])
));
assert!(!passes(
&spell(),
WORD_ASSIST,
true,
&unit(&[(FLAGS, 0x0001_1008)])
));
for other in [0x0000_ffff, 0x0002_0000, 0xfffe_ffff] {
assert!(
passes(&spell(), WORD_ASSIST, false, &unit(&[(FLAGS, other)])),
"flags {other:#x}"
);
}
}
/// `6e5c26`-`6e5c53`: a mask excludes a unit of another type, a unit of no type, and lets
/// the right type through, the type read the reference's way.
#[test]
fn a_creature_type_mask_refuses_the_wrong_type() {
let hibernate = SpellDisplay {
target_creature_type: 0x3,
..spell()
};
let mut names = NameCache::default();
for (entry, creature_type) in [(69u32, 1u32), (70, 7)] {
names.insert_creature(
entry,
Some(CreatureRecord {
name: String::new(),
subname: None,
creature_type,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
racial_leader: false,
display_id: 0,
}),
);
}
let types = CreatureTypeSources {
names: Some(&names),
forms: None,
};
let check = |def: &SpellDisplay, store: &ObjectStore| {
passes_with(def, WORD_UNIT, false, store, types)
};
assert!(check(&hibernate, &unit(&[(ENTRY, 69)])), "a Beast");
assert!(!check(&hibernate, &unit(&[(ENTRY, 70)])), "a Humanoid");
assert!(
!check(&hibernate, &unit(&[(ENTRY, 71)])),
"a creature no template has answered for is type 0"
);
assert!(check(&spell(), &unit(&[(ENTRY, 70)])), "no mask, any type");
assert!(check(&spell(), &unit(&[(ENTRY, 71)])), "no mask, no type");
// A player is Humanoid by race, so Hibernate refuses it and Mind Control (Humanoid) takes it.
let player = unit(&[(BYTES_0, 1)]);
let mind_control = SpellDisplay {
target_creature_type: 0x40,
..spell()
};
assert!(!check(&hibernate, &player));
assert!(check(&mind_control, &player));
}
/// `6e5c6a`-`6e5c8b`: a dead unit is refused unless the dynamic flag, a corpse word or
/// `AttributesEx2 & 1` admits it; health is the signed field, and absent reads 0.
#[test]
fn a_dead_unit_is_refused_unless_something_admits_it() {
let dead = unit(&[(HEALTH, 0)]);
assert!(!passes(&spell(), WORD_ASSIST, false, &dead));
assert!(!passes(&spell(), WORD_ASSIST, true, &dead));
assert!(!passes(&spell(), WORD_UNIT, false, &dead));
let absent = ObjectStore(ObjectFields::default());
assert!(
!passes(&spell(), WORD_ASSIST, false, &absent),
"a dead unit's create sends no health"
);
let negative = unit(&[(HEALTH, 0x8000_0000)]);
assert!(!passes(&spell(), WORD_ASSIST, false, &negative), "signed");
assert!(
passes(
&spell(),
WORD_ASSIST,
false,
&unit(&[(HEALTH, 0), (DYNAMIC_FLAGS, 0x20)])
),
"UNIT_DYNAMIC_FLAGS bit 5"
);
for word in [WORD_CORPSE_ALLY, 0x0200, WORD_DEAD_ONLY] {
assert!(passes(&spell(), word, false, &dead), "word {word:#06x}");
}
let raise_dead = SpellDisplay {
attributes_ex2: 1,
..spell()
};
assert!(passes(&raise_dead, WORD_ASSIST, false, &dead));
let other_ex2 = SpellDisplay {
attributes_ex2: 0xffff_fffe,
..spell()
};
assert!(!passes(&other_ex2, WORD_ASSIST, false, &dead));
assert!(!passes(
&spell(),
WORD_ASSIST,
false,
&unit(&[(HEALTH, 0), (DYNAMIC_FLAGS, 0xffff_ffdf)])
));
}
/// `6e5c9a`-`6e5ca2`: a live unit is refused when the word has `0x400`, whatever else it has.
#[test]
fn a_living_unit_is_refused_under_the_0x400_word() {
let alive = unit(&[]);
assert!(!passes(&spell(), WORD_DEAD_ONLY, false, &alive));
assert!(!passes(&spell(), 0x0400, false, &alive));
assert!(!passes(&spell(), WORD_DEAD_ONLY, true, &alive));
assert!(passes(&spell(), WORD_UNIT, false, &alive));
// AttributesEx2 & 1 and a corpse bit other than 0x400 exempt only the dead.
let raise_dead = SpellDisplay {
attributes_ex2: 1,
..spell()
};
assert!(!passes(&raise_dead, WORD_DEAD_ONLY, false, &alive));
assert!(passes(&spell(), WORD_CORPSE_ALLY, false, &alive));
assert!(
passes(&spell(), WORD_DEAD_ONLY, false, &unit(&[(HEALTH, 0)])),
"the same word at a corpse"
);
}
/// The gates at the press, through `resolve_cast_target`: a unit they refuse binds nothing,
/// so the selection falls to the player behind `autoSelfCast`, and with nothing bound an
/// enemy word refuses "Invalid target" and any other word raises the cursor. Each case's
/// control is the same unit passing the gate, which binds.
mod ladder {
use super::*;
use crate::spell::cast_target::{
cast_target_mask, resolve_cast_target, CastCandidates, CastWireTarget,
ERR_INVALID_TARGET,
};
use crate::spell::targeting::corpse_fixture as fx;
const ME: u64 = 1;
const THEM: u64 = 42;
const TEMPLATE: u16 = 35;
/// A creature entry whose cached template is a Humanoid (type 7) and one that is a
/// Beast (type 1).
const HUMANOID_ENTRY: u32 = 69;
const BEAST_ENTRY: u32 = 70;
fn spell_at(targets: u32, implicit: u32) -> SpellDisplay {
SpellDisplay {
targets,
implicit_target_a1: implicit,
..Default::default()
}
}
/// A heal: the assist word, `Targets 0` with implicit target 21.
fn heal() -> SpellDisplay {
spell_at(0, 21)
}
/// A player-controlled unit of faction template 1 (`0x8`), so [`fx::factions`]' Human pair
/// is friendly to itself and `CanAssist` passes on its player-controlled arm. `extra`
/// overrides a default; health is 100 unless it says otherwise.
fn player(extra: &[(u16, u32)]) -> ObjectStore {
let mut fields =
std::collections::BTreeMap::from([(TEMPLATE, 1), (FLAGS, 0x8), (HEALTH, 100)]);
fields.extend(extra.iter().copied());
let pairs: Vec<_> = fields.into_iter().collect();
ObjectStore(ObjectFields::from_pairs(&pairs))
}
fn cached(creature_type: u32) -> CreatureRecord {
CreatureRecord {
name: String::new(),
subname: None,
creature_type,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
racial_leader: false,
display_id: 0,
}
}
fn names() -> NameCache {
let mut names = NameCache::default();
names.insert_creature(HUMANOID_ENTRY, Some(cached(7)));
names.insert_creature(BEAST_ENTRY, Some(cached(1)));
names
}
/// The wire result for `def` with `target` selected (`selection_is_self`: the player is
/// the selection), the player being `me`.
fn press_as(
me: &ObjectStore,
def: &SpellDisplay,
target: &ObjectStore,
selection_is_self: bool,
auto_self_cast: bool,
) -> CastWireTarget {
let factions = fx::factions();
let names = names();
let rel = TargetRelations {
target_store: Some(target),
target_owner_store: None,
self_store: Some(me),
factions: Some(&factions),
reputations: &Reputations(Vec::new()),
types: CreatureTypeSources {
names: Some(&names),
forms: None,
},
};
let candidates = CastCandidates {
selection: Some(if selection_is_self { ME } else { THEM }),
caster: Some(ME),
main_hand_item: None,
};
resolve_cast_target(Some(def), &candidates, auto_self_cast, &rel)
}
/// [`press_as`] with a living, unflagged player.
fn press(
def: &SpellDisplay,
target: &ObjectStore,
selection_is_self: bool,
auto_self_cast: bool,
) -> CastWireTarget {
press_as(&player(&[]), def, target, selection_is_self, auto_self_cast)
}
/// A dead friendly player selected with a heal: the selection binds nothing. With
/// `autoSelfCast` on the player is bound instead; with it off the assist word is left for
/// the cursor, as for any relation failure.
#[test]
fn a_dead_friendly_selection_falls_to_you_or_the_cursor() {
let alive = player(&[]);
let dead = player(&[(HEALTH, 0)]);
for auto in [false, true] {
assert_eq!(
press(&heal(), &alive, false, auto),
CastWireTarget::Unit(THEM),
"the living friendly binds (autoSelfCast {auto})"
);
}
assert_eq!(
press(&heal(), &dead, false, true),
CastWireTarget::Unit(ME),
"the corpse is skipped and the player takes the heal"
);
assert_eq!(
press(&heal(), &dead, false, false),
CastWireTarget::Targeting(WORD_ASSIST),
"with autoSelfCast off the cursor comes up, as for a relation failure"
);
// A store that never streamed a health field is dead in the reference's descriptor.
let unstreamed = ObjectStore(ObjectFields::from_pairs(&[(TEMPLATE, 1), (FLAGS, 0x8)]));
assert_eq!(
press(&heal(), &unstreamed, false, false),
CastWireTarget::Targeting(WORD_ASSIST)
);
// `AttributesEx2 & 1` and `UNIT_DYNAMIC_FLAGS & 0x20` keep a corpse a candidate.
let raise = SpellDisplay {
attributes_ex2: 1,
..heal()
};
assert_eq!(
press(&raise, &dead, false, false),
CastWireTarget::Unit(THEM)
);
let feigning = player(&[(HEALTH, 0), (DYNAMIC_FLAGS, 0x20)]);
assert_eq!(
press(&heal(), &feigning, false, false),
CastWireTarget::Unit(THEM)
);
}
/// Hibernate at a unit of another type binds nothing, so the enemy word refuses locally
/// with "Invalid target"; a Beast binds.
#[test]
fn a_creature_type_mask_refuses_the_wrong_type_locally() {
let hibernate = SpellDisplay {
target_creature_type: 0x3,
..spell_at(0, 6)
};
let creature = |entry| {
ObjectStore(ObjectFields::from_pairs(&[
(ENTRY, entry),
(HEALTH, 100),
(TEMPLATE, 0),
]))
};
assert_eq!(
press(&hibernate, &creature(BEAST_ENTRY), false, true),
CastWireTarget::Unit(THEM)
);
for entry in [HUMANOID_ENTRY, 999] {
assert_eq!(
press(&hibernate, &creature(entry), false, true),
CastWireTarget::Refused(ERR_INVALID_TARGET),
"entry {entry}: a Humanoid, and a creature with no cached template"
);
}
assert_eq!(
press(&spell_at(0, 6), &creature(HUMANOID_ENTRY), false, true),
CastWireTarget::Unit(THEM),
"the same Humanoid without a mask"
);
}
/// A self-excluding spell with yourself selected: you are not bound, not even by
/// `autoSelfCast`, so the assist word raises the cursor.
#[test]
fn a_self_excluding_spell_never_binds_the_caster() {
let excluding = SpellDisplay {
attributes_ex: 0x0008_0000,
..heal()
};
let me = player(&[]);
for auto in [false, true] {
assert_eq!(
press(&excluding, &me, true, auto),
CastWireTarget::Targeting(WORD_ASSIST),
"autoSelfCast {auto}"
);
}
assert_eq!(
press(&excluding, &player(&[]), false, false),
CastWireTarget::Unit(THEM),
"another friendly still binds"
);
assert_eq!(
press(&heal(), &me, true, false),
CastWireTarget::Unit(ME),
"without the attribute the caster is the selection"
);
}
/// `UNIT_FIELD_FLAGS & 0x10000` binds nothing: a flagged selection falls to the player
/// behind `autoSelfCast`, and a flagged player has no fallback left.
#[test]
fn a_unit_flagged_0x10000_binds_nothing() {
let flagged = || player(&[(FLAGS, 0x0001_0008)]);
assert_eq!(
press(&heal(), &flagged(), false, false),
CastWireTarget::Targeting(WORD_ASSIST)
);
assert_eq!(
press(&heal(), &flagged(), false, true),
CastWireTarget::Unit(ME),
"the selection is skipped, the player binds"
);
assert_eq!(
press_as(&flagged(), &heal(), &flagged(), true, true),
CastWireTarget::Targeting(WORD_ASSIST),
"neither the selection nor the fallback"
);
}
/// The `0x400` word (Skinning, `Targets 0x402`): a living unit is refused, a dead one
/// binds and loses the bit.
#[test]
fn the_0x400_word_binds_only_the_dead() {
let skinning = spell_at(0x402, 0);
assert_eq!(cast_target_mask(&skinning), WORD_DEAD_ONLY);
let creature =
|health| ObjectStore(ObjectFields::from_pairs(&[(HEALTH, health), (TEMPLATE, 0)]));
assert_eq!(
press(&skinning, &creature(100), false, false),
CastWireTarget::Targeting(WORD_DEAD_ONLY),
"a living unit binds nothing; the word is left for the cursor"
);
assert_eq!(
press(&skinning, &creature(0), false, false),
CastWireTarget::Unit(THEM)
);
}
}
}
@@ -806,6 +806,7 @@ mod tests {
self_store: None,
factions: None,
reputations: &EMPTY_REPUTATIONS,
types: Default::default(),
},
range: cast_target::RangeInputs::default(),
main_hand_item: None,
+78 -20
View File
@@ -6,8 +6,9 @@
//! target from the spell's implicit targeting.
//! - `Attributes & 0x200`: the candidate is the equipped main hand (`0x6e5361`), bound as an item
//! with no cursor; an empty hand refuses with "Your weapon hand is empty" (`0x6e504b`).
//! - otherwise each bit is cleared against the selection by its relation check, then against the
//! player behind `autoSelfCast` (`0x6e53d7`); only a fully cleared word commits, as a unit guid.
//! - otherwise the selection meets `BindTarget`'s gates ([`bind_gates`]), then each bit is cleared
//! by its relation check; then the player behind `autoSelfCast` (`0x6e53d7`) does the same. Only a
//! fully cleared word commits, as a unit guid.
//! - a word with item, lock, GameObject or location bits enters the targeting cursor carrying
//! the whole word: its location (`0x6e6320`, `& 0x60`), item (`0x6e6330`, `& 0x4010`),
//! GameObject (`0x6e62d0`, `& 0x4800`) and unit (`0x6e6460`) predicates can hold at once, and
@@ -21,6 +22,9 @@ use benilla_formats::SpellDisplay;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use super::bind_gates::{bind_gates, unit_alive};
use crate::creature_type::CreatureTypeSources;
use crate::names::NameCache;
use crate::net::{GuidIndex, ObjectStore, Reputations, SelfGuid, SelfPlayer};
use crate::target::{can_assist, can_attack, Factions, Selection};
@@ -31,8 +35,11 @@ const TF_UNIT_PARTY: u16 = 0x0008;
const TF_UNIT_ENEMY: u16 = 0x0080;
const TF_UNIT_ASSIST: u16 = 0x0100;
const TF_CORPSE_ENEMY: u16 = 0x0200;
const TF_EXPLICIT_GATE: u16 = 0x0400;
pub(super) const TF_EXPLICIT_GATE: u16 = 0x0400;
const TF_CORPSE_ALLY: u16 = 0x8000;
/// The corpse words, `0x8600`: `BindTarget`'s dead-unit gate lets a dead unit through for any of
/// them (`6e5c7a`).
pub(super) const CORPSE_WORD_BITS: u16 = TF_CORPSE_ENEMY | TF_EXPLICIT_GATE | TF_CORPSE_ALLY;
/// The unit-shaped bits a selected unit (alive or dead) can satisfy.
const UNIT_BITS: u16 = TF_UNIT
| TF_UNIT_RAID
@@ -104,7 +111,7 @@ pub(crate) struct CastCandidates {
pub(crate) main_hand_item: Option<u64>,
}
/// What the binder's relation checks read: the selected unit's store and the player's.
/// What the binder's gates and relation checks read: the selected unit's store and the player's.
#[derive(Clone, Copy)]
pub(crate) struct TargetRelations<'a> {
pub(crate) target_store: Option<&'a ObjectStore>,
@@ -113,6 +120,8 @@ pub(crate) struct TargetRelations<'a> {
pub(crate) self_store: Option<&'a ObjectStore>,
pub(crate) factions: Option<&'a Factions>,
pub(crate) reputations: &'a Reputations,
/// The creature-type gate's inputs, for the candidate and for the player alike.
pub(crate) types: CreatureTypeSources<'a>,
}
/// The targeting inputs [`super::send_spell_cast`] resolves with, built by both cast callers.
@@ -205,6 +214,10 @@ pub(crate) struct CastTargeting<'w, 's> {
auto_self_cast: Res<'w, AutoSelfCast>,
factions: Option<Res<'w, Factions>>,
reputations: Res<'w, Reputations>,
/// The creature-type gate's template cache and form table; a harness without them resolves
/// no type.
names: Option<Res<'w, NameCache>>,
spells: Option<Res<'w, crate::ui_action::Spells>>,
self_transform: Query<'w, 's, &'static Transform, With<SelfPlayer>>,
transforms: Query<'w, 's, &'static Transform>,
/// `Option`: the body exists only in world, and the cast-result handler must fetch this param
@@ -234,6 +247,10 @@ impl CastTargeting<'_, '_> {
self_store: self.self_store.iter().next(),
factions: self.factions.as_deref(),
reputations: &self.reputations,
types: CreatureTypeSources {
names: self.names.as_deref(),
forms: self.spells.as_deref().map(|s| &s.forms),
},
},
range: RangeInputs {
self_pos: self.self_transform.iter().next().map(|t| t.translation),
@@ -295,7 +312,8 @@ pub(crate) fn cast_target_mask(def: &SpellDisplay) -> u16 {
word
}
/// `BindTarget 0x6e5b40`'s unit branch: clear every flag-word bit the candidate satisfies.
/// `BindTarget 0x6e5b40`'s unit branch past the gates: clear every flag-word bit the candidate
/// satisfies.
///
/// The assist bit asks `CanAssist 0x6066f0`: selectable, friendly or better, and an NPC's owner
/// (or the NPC itself) PvP-enabled. This keeps friendly ambient NPCs and critters unbindable.
@@ -311,9 +329,7 @@ fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16
rel.self_store,
|_| rel.target_owner_store.cloned(),
);
let dead = rel
.target_store
.is_some_and(|s| s.0.unit_health() == Some(0));
let dead = rel.target_store.is_some_and(|s| !unit_alive(&s.0));
if word & TF_UNIT_PARTY != 0 && is_self {
word &= !TF_UNIT_PARTY;
}
@@ -334,11 +350,12 @@ fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16
{
word &= !TF_UNIT_ENEMY;
}
// Bit 1 has no relation check: any unit binds. Bit 10 is cleared by any candidate but self.
// Bit 1 has no relation check: any unit binds. A dead unit's bind clears bit 10 (`6e5d89`,
// `6e5ddb`); the gate refuses a living unit under that word before it gets here.
if word & TF_UNIT != 0 {
word &= !TF_UNIT;
}
if word & TF_EXPLICIT_GATE != 0 && !is_self {
if word & TF_EXPLICIT_GATE != 0 && dead {
word &= !TF_EXPLICIT_GATE;
}
if word & TF_CORPSE_ALLY != 0 && assist && dead {
@@ -350,12 +367,24 @@ fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16
word
}
/// Whether `BindTarget 0x6e5b40`'s unit arm clears the whole standing word for this unit.
/// The initial selection, a world click and `SpellTargetUnit` all use this one predicate.
pub(super) fn unit_word_binds(word: u16, is_self: bool, rel: &TargetRelations) -> bool {
/// Whether the unit arm's relation checks clear the whole standing word for this unit.
fn unit_word_binds(word: u16, is_self: bool, rel: &TargetRelations) -> bool {
word & UNIT_BITS != 0 && clear_satisfied_bits(word, is_self, rel) == 0
}
/// Whether `BindTarget 0x6e5b40`'s unit branch binds this unit under the standing word: the gates
/// ([`bind_gates`]), then the relation checks. The initial selection, the `autoSelfCast` fallback,
/// a world click, `SpellTargetUnit` and the hover verdict all ask this one predicate. An unknown
/// spell has no row to gate on and leaves the relation checks alone.
pub(super) fn unit_binds(
def: Option<&SpellDisplay>,
word: u16,
is_self: bool,
rel: &TargetRelations,
) -> bool {
def.is_none_or(|def| bind_gates(def, word, is_self, rel)) && unit_word_binds(word, is_self, rel)
}
/// The wire target for casting `def`. An unknown spell sends the selection as is, or no target
/// without one, and the server validates.
pub(crate) fn resolve_cast_target(
@@ -410,14 +439,15 @@ pub(crate) fn resolve_cast_target(
}
return CastWireTarget::Refused(ERR_INVALID_TARGET);
}
// The selection (`0xb4e2d8`, `0x6e539f`).
// The selection (`0xb4e2d8`, `0x6e539f`). A unit the gates or the relation checks refuse binds
// nothing, so the fallback below gets its turn.
if let Some(guid) = cand.selection {
let is_self = cand.caster == Some(guid);
if unit_word_binds(word, is_self, rel) {
if unit_binds(Some(def), word, is_self, rel) {
return CastWireTarget::Unit(guid);
}
}
// The fallback: the active player (`0x6e53d7`), behind autoSelfCast.
// The fallback: the active player (`0x6e53d7`), behind autoSelfCast, through the same gates.
if auto_self_cast {
if let Some(guid) = cand.caster {
let self_rel = TargetRelations {
@@ -425,7 +455,7 @@ pub(crate) fn resolve_cast_target(
target_owner_store: None,
..*rel
};
if unit_word_binds(word, true, &self_rel) {
if unit_binds(Some(def), word, true, &self_rel) {
return CastWireTarget::Unit(guid);
}
}
@@ -503,15 +533,17 @@ mod tests {
fn resolution_wire_shapes() {
use benilla_protocol::ObjectFields;
// `UNIT_FIELD_FLAGS` bit 3 (player-controlled) on us alone selects `CanAttack`'s mixed
// arm; with no catalog the reaction resolves neutral, so the enemy bit clears.
let me = crate::net::ObjectStore(ObjectFields::from_pairs(&[(46, 1 << 3)]));
let it = crate::net::ObjectStore(ObjectFields::from_pairs(&[(35, 0)]));
// arm; with no catalog the reaction resolves neutral, so the enemy bit clears. Both carry
// `UNIT_FIELD_HEALTH` (22): a store with none reads dead to the gates.
let me = crate::net::ObjectStore(ObjectFields::from_pairs(&[(22, 100), (46, 1 << 3)]));
let it = crate::net::ObjectStore(ObjectFields::from_pairs(&[(22, 100), (35, 0)]));
let rel = TargetRelations {
target_store: Some(&it),
target_owner_store: None,
self_store: Some(&me),
factions: None,
reputations: &Reputations(Vec::new()),
types: Default::default(),
};
let ice_armor = spell(0, 1);
assert_eq!(
@@ -582,6 +614,7 @@ mod tests {
self_store: Some(&me),
factions: Some(&factions),
reputations: &reputations,
types: Default::default(),
};
assert!(
@@ -760,6 +793,30 @@ mod tests {
);
}
/// A dead unit's bind clears the `0x400` bit whoever it is (`6e5d89`, `6e5ddb`); a living
/// unit's never does, and the gate refuses it before the relation checks are asked.
#[test]
fn a_dead_units_bind_clears_the_0x400_bit() {
use benilla_protocol::ObjectFields;
let health = |hp| ObjectStore(ObjectFields::from_pairs(&[(22, hp)]));
let (corpse, live) = (health(0), health(100));
let rel = |target| TargetRelations {
target_store: Some(target),
..rel_none()
};
for is_self in [false, true] {
assert!(
unit_word_binds(0x402, is_self, &rel(&corpse)),
"a corpse, self {is_self}"
);
assert!(
!unit_word_binds(0x402, is_self, &rel(&live)),
"a living unit keeps the bit, self {is_self}"
);
}
}
fn rel_none() -> TargetRelations<'static> {
static EMPTY: Reputations = Reputations(Vec::new());
TargetRelations {
@@ -768,6 +825,7 @@ mod tests {
self_store: None,
factions: None,
reputations: &EMPTY,
types: Default::default(),
}
}
}
+1
View File
@@ -11,6 +11,7 @@ use crate::ui_script::{UiFeed, UiInput};
use crate::ui_unit::UnitFeed;
use benilla_world::schedule::WorldStage;
mod bind_gates;
mod cast_send;
pub(crate) mod cast_target;
pub(crate) mod cooldowns;
@@ -406,17 +406,20 @@ mod tests {
(114, row(8.0, 35.0)),
]));
world.insert_resource(spells);
let empty = || crate::net::ObjectStore(benilla_protocol::ObjectFields::default());
// `UNIT_FIELD_HEALTH` 100: a store with no health reads dead to `BindTarget`'s gates.
let live = || {
crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(22, 100)]))
};
world.spawn((
SelfPlayer,
Transform::default(),
GlobalTransform::default(),
empty(),
live(),
));
let unit = world
.spawn((
GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)),
empty(),
live(),
))
.id();
world.insert_resource(crate::target::Hovered {
+249 -27
View File
@@ -44,8 +44,8 @@ use benilla_world::interact::WorldRightPress;
/// 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
/// [`SpellTargeting::can_target_corpse`], [`corpse::bind_target_corpse`]): the gates and relation
/// checks the cast arm's selection bind runs ([`super::cast_target::unit_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)]
@@ -60,6 +60,7 @@ pub(crate) struct BindChecks<'w, 's> {
>,
factions: Option<Res<'w, crate::target::Factions>>,
reputations: Res<'w, crate::net::Reputations>,
names: Option<Res<'w, crate::names::NameCache>>,
/// The range leg's positions: the pose the hover picks against, as last frame propagated it,
/// so the VM feed, the cursor and both binds read one pose. Never the camera's, which
/// `publish_camera_pose` rewrites mid-frame.
@@ -76,8 +77,10 @@ impl BindChecks<'_, '_> {
.is_some_and(|(me, _)| me == entity)
}
/// `BindTarget 0x6e5b40`'s relation checks: whether this unit clears the whole word.
fn relations_clear(&self, word: u16, entity: Entity) -> bool {
/// `BindTarget 0x6e5b40`'s unit branch: whether this unit binds the word under this spell,
/// its gates and then its relation checks ([`super::cast_target::unit_binds`]). An unknown
/// spell has no row to gate on.
fn unit_binds(&self, spell_id: u32, word: u16, entity: Entity) -> bool {
let target_store = self.stores.get(entity).ok();
let target_owner_store = target_store
.and_then(|store| {
@@ -93,8 +96,13 @@ impl BindChecks<'_, '_> {
self_store: self.self_q.iter().next().and_then(|(_, store)| store),
factions: self.factions.as_deref(),
reputations: &self.reputations,
types: crate::creature_type::CreatureTypeSources {
names: self.names.as_deref(),
forms: self.spells.as_deref().map(|s| &s.forms),
},
};
super::cast_target::unit_word_binds(word, self.is_self(entity), &rel)
let def = self.spells.as_deref().and_then(|s| s.catalog.get(spell_id));
super::cast_target::unit_binds(def, word, self.is_self(entity), &rel)
}
/// The spell's min/max range against this unit, through the one compare the pre-send gate
@@ -155,16 +163,6 @@ impl BindChecks<'_, '_> {
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)
&& self
.spells
.as_deref()
.and_then(|s| s.catalog.get(spell_id))
.is_some_and(|d| d.excludes_caster())
}
}
/// A click seam's mask test on the flag_word `0xcecac0`, one per reference predicate:
@@ -241,13 +239,12 @@ impl SpellTargeting {
/// `SpellCanTargetUnit`'s predicate `0x6e6460`, its unit leg, with the range flag both callers
/// pass (the world hover `0x4828d0` at `48290b`, the Lua `SpellCanTargetUnit 0x6e6d00`):
/// `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.
/// `BindTarget`'s gates and relation checks (`6e6507`–`6e65ab`), 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: &BindChecks) -> bool {
self.0.as_ref().is_some_and(|t| {
checks.relations_clear(t.word, entity)
&& !checks.excluded_caster(t.spell_id, entity)
checks.unit_binds(t.spell_id, t.word, entity)
&& checks.range_refusal(t.spell_id, entity).is_none()
})
}
@@ -386,9 +383,8 @@ pub(crate) fn feed_targeting_to_vm(
}
/// `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; otherwise the merge. The
/// player's selection never moves.
/// `SpellTargetUnit` (`0x6e5b10`): a unit the gates or the relation checks refuse 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: &BindChecks,
@@ -398,7 +394,7 @@ fn bind_target_unit(
let Some((spell_id, commit, word)) = ladder.ground.pending() else {
return;
};
if checks.excluded_caster(spell_id, entity) || !checks.relations_clear(word, entity) {
if !checks.unit_binds(spell_id, word, entity) {
return;
}
let range = checks.range_refusal(spell_id, entity);
@@ -532,13 +528,14 @@ mod tests {
(114, row(8.0, 35.0)),
]));
world.insert_resource(spells);
let empty = || ObjectStore(benilla_protocol::ObjectFields::default());
world.spawn((SelfPlayer, Guid(ME), GlobalTransform::default(), empty()));
// `UNIT_FIELD_HEALTH` 100: a store with no health reads dead to `BindTarget`'s gates.
let live = || ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(22, 100)]));
world.spawn((SelfPlayer, Guid(ME), GlobalTransform::default(), live()));
let ally = world
.spawn((
Guid(ALLY),
GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)),
empty(),
live(),
))
.id();
world.insert_resource(crate::target::Selection {
@@ -650,6 +647,231 @@ mod tests {
assert_eq!((selection.target, selection.guid), (Some(ally), Some(ALLY)));
}
/// The press (`resolve_cast_target`), the click and `SpellTargetUnit` ([`bind_target_unit`]) and
/// the hover verdict (`SpellCanTargetUnit`) all ask [`crate::spell::cast_target::unit_binds`],
/// so one table of units decides all three: `BindTarget`'s gates refuse the same units at
/// each, and the control row beside each refusal binds at each.
#[test]
fn the_press_the_click_and_the_hover_bind_the_same_units() {
use crate::names::{CreatureRecord, NameCache};
use crate::spell::cast_target::{
cast_target_mask, resolve_cast_target, CastCandidates, CastWireTarget, TargetRelations,
};
use benilla_formats::SpellDisplay;
use benilla_protocol::ObjectFields;
const SPELL: u32 = 3000;
/// Absolute descriptor indices: `OBJECT_FIELD_ENTRY`, `UNIT_FIELD_HEALTH`,
/// `UNIT_FIELD_FLAGS` and `UNIT_DYNAMIC_FLAGS`.
const ENTRY: u16 = 3;
const HEALTH: u16 = 22;
const FLAGS: u16 = 46;
const DYNAMIC_FLAGS: u16 = 143;
const HUMANOID: u32 = 69;
const BEAST: u32 = 70;
let make_names = || {
let mut names = NameCache::default();
for (entry, creature_type) in [(HUMANOID, 7), (BEAST, 1)] {
names.insert_creature(
entry,
Some(CreatureRecord {
name: String::new(),
subname: None,
creature_type,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
racial_leader: false,
display_id: 0,
}),
);
}
names
};
fn plain() -> SpellDisplay {
SpellDisplay {
targets: 0x2,
..Default::default()
}
}
fn hibernate() -> SpellDisplay {
SpellDisplay {
target_creature_type: 0x3,
..plain()
}
}
fn excluding() -> SpellDisplay {
SpellDisplay {
attributes_ex: 0x0008_0000,
..plain()
}
}
fn skinning() -> SpellDisplay {
SpellDisplay {
targets: 0x402,
..Default::default()
}
}
fn raise() -> SpellDisplay {
SpellDisplay {
attributes_ex2: 1,
..plain()
}
}
// (label, spell, the caster is the candidate, the candidate's fields, binds)
#[allow(clippy::type_complexity)]
let cases: Vec<(&str, fn() -> SpellDisplay, bool, Vec<(u16, u32)>, bool)> = vec![
("a live unit", plain, false, vec![(HEALTH, 100)], true),
("a dead unit", plain, false, vec![(HEALTH, 0)], false),
("no health streamed", plain, false, vec![], false),
(
"a dead unit, AttributesEx2 & 1",
raise,
false,
vec![(HEALTH, 0)],
true,
),
(
"a dead unit, the dynamic dead flag",
plain,
false,
vec![(HEALTH, 0), (DYNAMIC_FLAGS, 0x20)],
true,
),
(
"UNIT_FIELD_FLAGS & 0x10000",
plain,
false,
vec![(HEALTH, 100), (FLAGS, 0x1_0000)],
false,
),
(
"a Humanoid under Hibernate's mask",
hibernate,
false,
vec![(HEALTH, 100), (ENTRY, HUMANOID)],
false,
),
(
"a Beast under Hibernate's mask",
hibernate,
false,
vec![(HEALTH, 100), (ENTRY, BEAST)],
true,
),
(
"the caster, AttributesEx & 0x80000",
excluding,
true,
vec![(HEALTH, 100)],
false,
),
(
"another unit, AttributesEx & 0x80000",
excluding,
false,
vec![(HEALTH, 100)],
true,
),
(
"a living unit under the 0x400 word",
skinning,
false,
vec![(HEALTH, 100)],
false,
),
(
"a dead unit under the 0x400 word",
skinning,
false,
vec![(HEALTH, 0)],
true,
),
];
for (label, make_def, on_self, fields, binds) in cases {
let (mut world, rx, ally) = unit_world(10.0);
let me = world
.query_filtered::<Entity, With<SelfPlayer>>()
.single(&world)
.expect("the player");
let (entity, guid, token) = if on_self {
(me, ME, "player")
} else {
(ally, ALLY, "target")
};
let store = ObjectStore(ObjectFields::from_pairs(&fields));
world.entity_mut(entity).insert(store.clone());
let word = cast_target_mask(&make_def());
let mut spells = world.resource_mut::<crate::ui_action::Spells>();
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([(
SPELL,
SpellDisplay {
range_index: 5,
..make_def()
},
)]));
world.insert_resource(make_names());
arm(&mut world, SPELL, word);
// The hover verdict.
world
.run_system_cached(feed_targeting_to_vm)
.expect("the feed runs");
let hover = world
.non_send_resource::<UiScript>()
.eval::<bool>(&format!("return SpellCanTargetUnit({token:?}) == true"))
.expect("a boolean");
assert_eq!(hover, binds, "{label}: the hover verdict");
// The click, through `SpellTargetUnit`.
spell_target_unit(&mut world, token);
let clicked = matches!(
rx.try_recv(),
Ok(ClientCommand::CastSpell {
spell_id: SPELL,
target: Some(sent),
}) if sent == guid
);
assert_eq!(clicked, binds, "{label}: the cursor's click");
assert_eq!(
world.resource::<SpellTargeting>().active(),
!binds,
"{label}: a bind ends the cursor, a refusal leaves it up"
);
// The press, from the same fields.
let (def, names) = (make_def(), make_names());
let pressed = resolve_cast_target(
Some(&def),
&CastCandidates {
selection: Some(guid),
caster: Some(ME),
main_hand_item: None,
},
false,
&TargetRelations {
target_store: Some(&store),
target_owner_store: None,
self_store: Some(&store),
factions: None,
reputations: &crate::net::Reputations(Vec::new()),
types: crate::creature_type::CreatureTypeSources {
names: Some(&names),
forms: None,
},
},
);
assert_eq!(
pressed == CastWireTarget::Unit(guid),
binds,
"{label}: the press ({pressed:?})"
);
}
}
/// `SpellCanTargetUnit` asks `0x6e6460`'s unit leg per token: nil out of range, inside the
/// minimum included, and nil for ourselves under a spell that excludes its caster.
#[test]
+2
View File
@@ -308,6 +308,8 @@ pub use text_filter_lists::{load_chat_profanity, load_spam_messages, FilterPatte
mod race_sound;
pub use race_sound::{load_exploration_sound_catalog, ExplorationSoundCatalog};
mod race_creature_type;
pub use race_creature_type::load_race_creature_types;
mod race_pvp_team;
pub use race_pvp_team::load_race_pvp_teams;
mod zone_map;
@@ -0,0 +1,55 @@
//! `ChrRaces.dbc` column 9: the creature type a player's race reads as, the last stage of the
//! creature-type resolver (`0x605570`: race byte → row `[0xc0dee0][race]`, offset `0x24`, at
//! `0x6055ca`). The runtime uses a frozen copy, `spell::unit_type::race_creature_type`; this
//! loader backs the test that pins it.
use std::collections::HashMap;
use crate::Chain;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, u32_at};
const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc";
/// Each `ChrRaces.dbc` row's creature type by race id; a race without a row is absent.
pub fn load_race_creature_types(chain: &mut Chain) -> Result<HashMap<u8, u32>> {
let bytes = chain
.read_file(CHR_RACES)
.with_context(|| format!("reading {CHR_RACES}"))?;
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")?;
Ok(rs
.records()
.iter()
.filter_map(|r| Some((u8::try_from(u32_at(r, 0)?).ok()?, u32_at(r, 9)?)))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
/// Nine rows, every one Humanoid (7), the rows `race_creature_type` freezes.
#[test]
fn every_shipped_race_reads_humanoid() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let rows = load_race_creature_types(&mut chain).expect("load ChrRaces");
assert_eq!(rows.len(), 9, "ChrRaces.dbc row count");
for race in 1..=9u8 {
assert_eq!(rows.get(&race), Some(&7), "race {race}");
}
for race in [0u8, 10, 255] {
assert!(!rows.contains_key(&race), "race {race} has no row");
}
}
}
@@ -1493,3 +1493,71 @@ fn real_pet_spells_carry_the_gcd_pair_and_ex4_reads_column_10() {
assert!(d.allows_client_targeting());
}
}
/// `TargetCreatureType` (column 14), read as the bind gate does: bit `type - 1` of the mask.
/// Beast is type 1, Dragonkin 2, Demon 3, Elemental 4, Undead 6, Humanoid 7.
#[test]
fn real_spell_catalog_reads_the_bind_gate_columns() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon");
for (id, mask, admits, refuses) in [
// Hibernate: Beast and Dragonkin.
(2637u32, 0x3u32, [1u32, 2], [7u32, 6]),
// Banish: Demon and Elemental.
(710, 0xc, [3, 4], [1, 7]),
// Turn Undead: Undead alone.
(2878, 0x20, [6, 6], [7, 1]),
// Mind Control: Humanoid alone, so a player (Humanoid) is a candidate.
(605, 0x40, [7, 7], [1, 6]),
] {
let d = cat.get(id).expect("spell row");
assert_eq!(d.target_creature_type, mask, "{} ({id})", d.name);
for t in admits {
assert!(d.admits_creature_type(t), "{} admits type {t}", d.name);
}
for t in refuses {
assert!(!d.admits_creature_type(t), "{} refuses type {t}", d.name);
}
}
// Fireball has no mask: any type, and a unit with none too.
let fireball = cat.get(133).expect("Fireball");
assert_eq!(fireball.target_creature_type, 0);
assert!(fireball.admits_creature_type(0));
// Bloodlust (24185, a frenzy that never takes its own caster) and Revive (24341, which
// `AttributesEx2 & 1` lets reach a corpse): `AttributesEx` bit 19 and `AttributesEx2` bit 0.
assert!(cat.get(24185).expect("Bloodlust").excludes_caster());
assert!(!fireball.excludes_caster());
assert!(cat.get(24341).expect("Revive").allows_dead_target());
assert!(!fireball.allows_dead_target());
}
/// The mask rule alone: no mask admits everything, a masked spell refuses a unit of type 0, and
/// the bit is `type - 1`, wrapping at 32 like the hardware's shift.
#[test]
fn creature_type_mask_rule() {
let masked = |mask| SpellDisplay {
target_creature_type: mask,
..Default::default()
};
let open = masked(0);
for t in [0u32, 1, 7, 32, 33, u32::MAX] {
assert!(open.admits_creature_type(t), "no mask admits type {t}");
}
let beast = masked(1);
assert!(beast.admits_creature_type(1));
assert!(
!beast.admits_creature_type(0),
"type 0 is refused under a mask"
);
assert!(!beast.admits_creature_type(2));
let top = masked(1 << 31);
assert!(top.admits_creature_type(32), "type 32 is bit 31");
assert!(
!top.admits_creature_type(1),
"type 1 is bit 0, not the wrapped bit 32"
);
assert!(masked(1).admits_creature_type(33), "bit 32 wraps to bit 0");
}
+22 -2
View File
@@ -106,6 +106,9 @@ pub struct SpellDisplay {
pub range_index: u32,
/// `Targets` (column 13): the `TARGET_FLAG_*` seed of the cast arm's targeting word.
pub targets: u32,
/// `TargetCreatureType` (column 14): the creature types the spell may target, bit `type - 1`
/// each; 0 is any. Hibernate takes Beast and Dragonkin, Banish Demon and Elemental.
pub target_creature_type: u32,
/// `EffectImplicitTargetA[0]` (column 82): the cast arm's switch adjusts the targeting word
/// by it, and the usable walk's target-aura-state leg forks on it (6 enemy, 21 friend).
pub implicit_target_a1: u32,
@@ -238,6 +241,7 @@ impl Default for SpellDisplay {
mana_per_second: 0,
range_index: 0,
targets: 0,
target_creature_type: 0,
implicit_target_a1: 0,
stances: 0,
stances_not: 0,
@@ -394,12 +398,28 @@ impl SpellDisplay {
self.attributes_ex3 & ATTR_EX3_NO_CHANNEL_BAR != 0
}
/// `AttributesEx & 0x80000`: the targeting cursor never takes the caster, neither the world
/// pick (`0x6e61a0` at `6e61cf`) nor `SpellCanTargetUnit`'s unit leg (`0x6e6460` at `6e6507`).
/// `AttributesEx & 0x80000`: the caster is never a bind candidate, not in `BindTarget`
/// (`0x6e5b40` at `6e5bf7`), not for `SpellCanTargetUnit`'s unit leg (`0x6e6460` at `6e6507`)
/// and not for the world pick (`0x6e61a0` at `6e61cf`).
pub fn excludes_caster(&self) -> bool {
self.attributes_ex & ATTR_EX_EXCLUDE_CASTER != 0
}
/// `AttributesEx2 & 0x1` (vmangos `SPELL_ATTR_EX2_ALLOW_DEAD_TARGET`, `SpellDefines.h:868`):
/// a dead unit stays a bind candidate (`BindTarget` at `6e5c85`).
pub fn allows_dead_target(&self) -> bool {
self.attributes_ex2 & ATTR_EX2_ALLOW_DEAD_TARGET != 0
}
/// The `TargetCreatureType` gate of `BindTarget` (`6e5c23`-`6e5c53`) and its mirror
/// (`6e6544`-`6e656c`): no mask admits any unit, else a unit of type 0 is refused and type `t`
/// needs bit `t - 1`. The shift wraps at 32 as the hardware's does.
pub fn admits_creature_type(&self, creature_type: u32) -> bool {
self.target_creature_type == 0
|| (creature_type != 0
&& self.target_creature_type & 1u32.wrapping_shl(creature_type - 1) != 0)
}
/// `AttributesEx & 0x2000_0000` (`0x6e759a`): the channel bar shows the spell's own name
/// (`0x6e75a9`), else `CHANNELING` (`0x6e75bc`); nine channels set it, Fishing among them.
pub fn channel_bar_own_name(&self) -> bool {
+7
View File
@@ -79,6 +79,9 @@ const COL_SPELL_FAMILY_NAME: usize = 160;
const COL_SPELL_FAMILY_FLAGS_LOW: usize = 161;
/// `Targets` (`+0x34`): the seed the cast arm loads into its targeting word (`0x6e525a`).
const COL_TARGETS: usize = 13;
/// `TargetCreatureType` (`+0x38`): the creature-type mask `BindTarget` (`0x6e5c26`) and
/// `SpellCanTargetUnit`'s mirror (`0x6e6544`) test the unit's type against, bit `type - 1`.
const COL_TARGET_CREATURE_TYPE: usize = 14;
/// `EffectImplicitTargetA[0]` (`+0x148`), the key of the cast arm's 62-case switch (`0x6e5484`).
const COL_IMPLICIT_TARGET_A1: usize = 82;
/// `EffectImplicitTargetB[0]` (`+0x154`), walked beside A by the classifier `0x6ea280`.
@@ -196,6 +199,9 @@ const ATTR_RANGED: u32 = 0x2;
const ATTR_TARGET_MAIN_HAND_ITEM: u32 = 0x200;
/// `SPELL_ATTR_EX2_AUTO_REPEAT`: Auto Shot and wand Shoot.
const ATTR_EX2_AUTO_REPEAT: u32 = 0x20;
/// `SPELL_ATTR_EX2_ALLOW_DEAD_TARGET` (vmangos `SpellDefines.h:868`): the bind's dead-unit gate
/// tests it at `0x6e5c85`.
const ATTR_EX2_ALLOW_DEAD_TARGET: u32 = 0x1;
const ATTR_EX2_DO_NOT_RESET_COMBAT_TIMERS: u32 = 0x20000;
/// `SPELL_ATTR_PASSIVE`: the spellbook grays the spell (`SpellBookFrame.lua:379-390`).
const ATTR_PASSIVE: u32 = 0x40;
@@ -536,6 +542,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
range_index: u32_at(r, COL_RANGE_INDEX).unwrap_or(0),
modal_next_spell: u32_at(r, COL_MODAL_NEXT_SPELL).unwrap_or(0),
targets: u32_at(r, COL_TARGETS).unwrap_or(0),
target_creature_type: u32_at(r, COL_TARGET_CREATURE_TYPE).unwrap_or(0),
implicit_target_a1: u32_at(r, COL_IMPLICIT_TARGET_A1).unwrap_or(0),
effect_implicit_target_a: std::array::from_fn(|i| {
u32_at(r, COL_IMPLICIT_TARGET_A1 + i).unwrap_or(0)
+2 -2
View File
@@ -102,9 +102,9 @@
## Modules (top-level, per crate)
- **benilla-adt** (lib.rs): combined_alpha
- **benilla-app** (lib.rs): addon_harness area area_poi area_trigger asset_churn aura_visual bindings blob_shadow bowstring camera_shake capture char_create char_select chat_bubble chr_classes cinematic combat_log combat_text console crash creature_anim cursor cvars death debug_panel dev doodad_events entities fishing_line footprints game_plugins game_tip glue glue_strings go_anim go_templates hover_log items loading_screen local_state login minimap name_persist nameplates names net npc_text opaque2d pending_item_ops perf pipe_warm player poi_marker portrait preflight probe_shield query_cache quest_markers raid_marks ranged_flex realm_select realmlist run_mode screen_fade screenshot script_calls shaders shutdown smart_rect sound spell swing_refusal target test_support text_filter text_reshape textinput transport tutorial ui_action ui_auction ui_aura ui_bank ui_battlefield ui_battlefield_positions ui_battlefield_score ui_bind_confirm ui_binder ui_cast ui_char ui_chat ui_craft ui_dialog_verbs ui_dressup ui_duel ui_follow ui_gamma ui_gm_ticket ui_gossip ui_guild ui_honor ui_inspect ui_instance ui_item_text ui_items ui_layout ui_logout ui_loot ui_loot_roll ui_macro ui_mail ui_merchant ui_mirror ui_models ui_net ui_party ui_pass ui_pet ui_pet_book ui_pet_doll ui_pet_stats ui_petition ui_quest ui_quest_log ui_quest_share ui_reputation ui_saved ui_script ui_session ui_shapeshift ui_social ui_spellbook ui_stable ui_summon ui_tabard ui_talent ui_talent_wipe ui_taxi ui_text ui_tooltip ui_trade ui_tradeskill ui_trainer ui_unit ui_world_map video vplates weapon_trail world_backdrop world_state world_state_ui
- **benilla-app** (lib.rs): addon_harness area area_poi area_trigger asset_churn aura_visual bindings blob_shadow bowstring camera_shake capture char_create char_select chat_bubble chr_classes cinematic combat_log combat_text console crash creature_anim creature_type cursor cvars death debug_panel dev doodad_events entities fishing_line footprints game_plugins game_tip glue glue_strings go_anim go_templates hover_log items loading_screen local_state login minimap name_persist nameplates names net npc_text opaque2d pending_item_ops perf pipe_warm player poi_marker portrait preflight probe_shield query_cache quest_markers raid_marks ranged_flex realm_select realmlist run_mode screen_fade screenshot script_calls shaders shutdown smart_rect sound spell swing_refusal target test_support text_filter text_reshape textinput transport tutorial ui_action ui_auction ui_aura ui_bank ui_battlefield ui_battlefield_positions ui_battlefield_score ui_bind_confirm ui_binder ui_cast ui_char ui_chat ui_craft ui_dialog_verbs ui_dressup ui_duel ui_follow ui_gamma ui_gm_ticket ui_gossip ui_guild ui_honor ui_inspect ui_instance ui_item_text ui_items ui_layout ui_logout ui_loot ui_loot_roll ui_macro ui_mail ui_merchant ui_mirror ui_models ui_net ui_party ui_pass ui_pet ui_pet_book ui_pet_doll ui_pet_stats ui_petition ui_quest ui_quest_log ui_quest_share ui_reputation ui_saved ui_script ui_session ui_shapeshift ui_social ui_spellbook ui_stable ui_summon ui_tabard ui_talent ui_talent_wipe ui_taxi ui_text ui_tooltip ui_trade ui_tradeskill ui_trainer ui_unit ui_world_map video vplates weapon_trail world_backdrop world_state world_state_ui
- **benilla-assets** (lib.rs): adt anim_rng blp column_grid coords gpu_blp m2 materials minimap_grid model spatial_cache terrain tex_filter trace wdt wmo world_assets
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_creature_type race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
- **benilla-m2** (lib.rs): error model skin tests track
- **benilla-mpq** (lib.rs): crypto
- **benilla-protocol** (lib.rs): auth events guid messages wire world