diff --git a/crates/benilla-app/src/creature_type.rs b/crates/benilla-app/src/creature_type.rs new file mode 100644 index 00000000..b130b9b2 --- /dev/null +++ b/crates/benilla-app/src/creature_type.rs @@ -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>, +} + +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 { + 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}" + ); + } + } +} diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index e5c687e5..2cb91ff8 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -42,6 +42,7 @@ mod combat_text; mod console; mod crash; mod creature_anim; +mod creature_type; mod cursor; mod cvars; mod death; diff --git a/crates/benilla-app/src/minimap/blips/dots.rs b/crates/benilla-app/src/minimap/blips/dots.rs index fb1afb07..3f976cdb 100644 --- a/crates/benilla-app/src/minimap/blips/dots.rs +++ b/crates/benilla-app/src/minimap/blips/dots.rs @@ -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, - names: &NameCache, - forms: Option<&HashMap>, -) -> Option { - 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 = [ - ( - 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)); diff --git a/crates/benilla-app/src/spell/bind_gates.rs b/crates/benilla-app/src/spell/bind_gates.rs new file mode 100644 index 00000000..0b138f03 --- /dev/null +++ b/crates/benilla-app/src/spell/bind_gates.rs @@ -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) + ); + } + } +} diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index e93bdf11..1e0b8661 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -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, diff --git a/crates/benilla-app/src/spell/cast_target.rs b/crates/benilla-app/src/spell/cast_target.rs index 58c37f43..e66c52d9 100644 --- a/crates/benilla-app/src/spell/cast_target.rs +++ b/crates/benilla-app/src/spell/cast_target.rs @@ -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, } -/// 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>, reputations: Res<'w, Reputations>, + /// The creature-type gate's template cache and form table; a harness without them resolves + /// no type. + names: Option>, + spells: Option>, self_transform: Query<'w, 's, &'static Transform, With>, 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(), } } } diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index 3a006376..125a9138 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -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; diff --git a/crates/benilla-app/src/spell/targeting/cursor.rs b/crates/benilla-app/src/spell/targeting/cursor.rs index 789ee832..636bc3ff 100644 --- a/crates/benilla-app/src/spell/targeting/cursor.rs +++ b/crates/benilla-app/src/spell/targeting/cursor.rs @@ -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 { diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index cd737379..16a61fba 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -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>, reputations: Res<'w, crate::net::Reputations>, + names: Option>, /// 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::>() + .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::(); + 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::() + .eval::(&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::().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] diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index f9112aec..cc091595 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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; diff --git a/crates/benilla-formats/src/race_creature_type.rs b/crates/benilla-formats/src/race_creature_type.rs new file mode 100644 index 00000000..328ed745 --- /dev/null +++ b/crates/benilla-formats/src/race_creature_type.rs @@ -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> { + 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"); + } + } +} diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 8f96c991..58777411 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -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"); +} diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index fd7e2761..1dae9ed9 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -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 { diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index 6dccea1f..4fa2cbc0 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -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 { 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) diff --git a/docs/MAP.md b/docs/MAP.md index 8386cb73..51bbc776 100644 --- a/docs/MAP.md +++ b/docs/MAP.md @@ -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