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:
co-authored by
Claude Opus 5.5
parent
0e04e54fbf
commit
8ef62da6ac
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ mod combat_text;
|
||||
mod console;
|
||||
mod crash;
|
||||
mod creature_anim;
|
||||
mod creature_type;
|
||||
mod cursor;
|
||||
mod cvars;
|
||||
mod death;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user