diff --git a/crates/benilla-app/src/items.rs b/crates/benilla-app/src/items.rs index e4c51bfa..663d0acf 100644 --- a/crates/benilla-app/src/items.rs +++ b/crates/benilla-app/src/items.rs @@ -564,6 +564,12 @@ fn equipped_class( Some(items.template(entry, guid, commands)?.class as u8) } +/// Whether the disarm flag is up (`UNIT_FIELD_FLAGS & UNIT_FLAG_DISARMED`), which hides one hand +/// from the equipped-item search. +pub(crate) fn is_disarmed(store: &ObjectStore) -> bool { + store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED != 0 +} + /// The equipment slot this character's disarm hides ([`crate::creature_anim::disarmed_hand`] over /// the raw inventory), for the action bar's equipped-item requirement (`0x5f0c50`) and the /// item-use refusal (`CGItem::Use` rung 15). `None` while the flag is down or neither hand holds @@ -574,7 +580,7 @@ pub(crate) fn disarmed_equipment_slot( items: &Items, commands: &NetCommands, ) -> Option { - if store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED == 0 { + if !is_disarmed(store) { return None; } let main = equipped_class(store, objects, items, commands, EQUIPMENT_SLOT_MAINHAND); @@ -588,7 +594,7 @@ pub(crate) fn disarmed_equipment_slot_cached( objects: &Objects, items: &Items, ) -> Option { - if store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED == 0 { + if !is_disarmed(store) { return None; } let class_of = |slot: u8| -> Option { diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index 125a9138..c076a901 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -33,7 +33,7 @@ pub(crate) use inflight::{ }; #[cfg(test)] pub(crate) use mods::OP_COOLDOWN; -pub(crate) use mods::{SpellModifiers, OP_CAST_TIME, OP_COST, OP_GCD, OP_RADIUS}; +pub(crate) use mods::{ModsDiff, SpellModifiers, OP_CAST_TIME, OP_COST, OP_GCD, OP_RADIUS}; // `TargetingWants` is exported for the ground reticle, which draws for the location word alone. pub(crate) use targeting::{ ground_cast_radius, CorpsePick, PicksSelf, ScriptCursor, SpellTargeting, TargetingWants, @@ -113,6 +113,12 @@ pub(crate) fn spell_skill_value( let Some(line) = skill_lines.and_then(|c| c.spell_to_line(spell_id)) else { return 0; }; + skill_line_value(me, line) +} + +/// The player's skill in `line`, [`spell_skill_value`]'s second half: 0 without a player or a +/// slot for it. +pub(crate) fn skill_line_value(me: Option<&crate::net::ObjectStore>, line: u32) -> u32 { let Some(store) = me else { return 0 }; line_skill_value( (0..benilla_protocol::messages::PLAYER_SKILL_SLOTS) @@ -158,8 +164,31 @@ pub(crate) fn skill_snapshot(me: Option<&crate::net::ObjectStore>) -> SkillSnaps }) } +/// The skill lines whose value differs between two snapshots: a line with no slot reads 0 +/// ([`skill_line_value`]), and the first slot of a line is the one read. +pub(crate) fn changed_skill_lines(prev: &SkillSnapshot, now: &SkillSnapshot) -> Vec { + let by_line = |snapshot: &SkillSnapshot| { + let mut lines = std::collections::HashMap::new(); + for &(line, value) in snapshot.iter().filter(|(line, _)| *line != 0) { + lines.entry(u32::from(line)).or_insert(value); + } + lines + }; + let (prev, now) = (by_line(prev), by_line(now)); + let value = + |lines: &std::collections::HashMap, line| lines.get(&line).copied().unwrap_or(0); + prev.keys() + .chain(now.keys()) + .copied() + .filter(|&line| value(&prev, line) != value(&now, line)) + .collect::>() + .into_iter() + .collect() +} + #[cfg(test)] mod tests { + use super::*; use benilla_protocol::messages::PlayerSkillSlot; fn slot(value: u16, temp_bonus: i16, perm_bonus: i16) -> PlayerSkillSlot { @@ -173,6 +202,37 @@ mod tests { } } + /// A line's change is its value's, wherever its slot sits: a moved slot is no change, a line + /// gained or lost is one, and a slot at 0 is a line at 0. + #[test] + fn the_changed_lines_are_those_whose_value_moved() { + let snapshot = |slots: &[(u16, u32)]| -> SkillSnapshot { + let mut s = [(0, 0); benilla_protocol::messages::PLAYER_SKILL_SLOTS as usize]; + s[..slots.len()].copy_from_slice(slots); + s + }; + let base = snapshot(&[(43, 100), (44, 50), (54, 0)]); + assert!(changed_skill_lines(&base, &base).is_empty()); + assert!( + changed_skill_lines(&base, &snapshot(&[(54, 0), (44, 50), (43, 100)])).is_empty(), + "a reordered slot table changes no line" + ); + assert_eq!( + changed_skill_lines(&base, &snapshot(&[(43, 101), (44, 50)])), + [43] + ); + assert_eq!( + changed_skill_lines(&base, &snapshot(&[(43, 100), (44, 50), (95, 1)])), + [95], + "a gained line, and the slot at 0 that went away is no change" + ); + assert_eq!( + changed_skill_lines(&base, &snapshot(&[(43, 100)])), + [44], + "a lost line reads 0" + ); + } + /// The high bonus half counts only over a positive value, and unsigned; the low half is /// signed and always counts; the sum floors at 0. #[test] diff --git a/crates/benilla-app/src/spell/mods.rs b/crates/benilla-app/src/spell/mods.rs index 15b0b6f1..7208ee5f 100644 --- a/crates/benilla-app/src/spell/mods.rs +++ b/crates/benilla-app/src/spell/mods.rs @@ -83,7 +83,7 @@ impl SpellMod { /// The two tables and the class family the gate compares against (`0xcead60`, `0xcecb30`, /// `0xcecaac`). Written only by the wire, cleared only at world-enter, read live. -#[derive(Resource)] +#[derive(Clone, Resource)] pub(crate) struct SpellModifiers { flat: [i32; CELLS], pct: [i32; CELLS], @@ -135,15 +135,45 @@ impl SpellModifiers { self.class_family = 0; } + /// The two family gates of `GetSpellModifiers 0x6e6b30` (`SpellFamilyName` nonzero and the + /// class's, `AttributesEx3` bit 29 clear). + fn gates_pass(&self, d: &SpellDisplay) -> bool { + d.spell_family != 0 + && d.spell_family == self.class_family + && d.attributes_ex3 & ATTR_EX3_IGNORE_CASTER_MODIFIERS == 0 + } + + /// The `SpellFamilyFlags` bits whose cells any read for `d` can sum, whatever the op: its + /// whole mask past the gates, none before them. A change to another bit's cells cannot move + /// a value modified for `d`; a change of the class family can, whatever the bit. + pub(crate) fn read_bits(&self, d: &SpellDisplay) -> u64 { + if self.gates_pass(d) { + d.spell_family_flags + } else { + 0 + } + } + + /// What differs from `prev`: the `SpellFamilyFlags` bits with any changed cell, in either + /// table and at any op, and whether the class family moved. + pub(crate) fn diff(&self, prev: &SpellModifiers) -> ModsDiff { + let bits = (0..BITS).fold(0u64, |bits, bit| { + let row = bit * OPS..(bit + 1) * OPS; + let same = self.flat[row.clone()] == prev.flat[row.clone()] + && self.pct[row.clone()] == prev.pct[row]; + bits | u64::from(!same) << bit + }); + ModsDiff { + bits, + class_family: self.class_family != prev.class_family, + } + } + /// `GetSpellModifiers 0x6e6b30` for one spell and op. `None` covers all four of its false /// exits: the three gates and both sums zero (`6e6ba8`/`6e6bad`). The op range check is ours; /// the reference's call sites all pass a literal. fn modifiers(&self, d: &SpellDisplay, op: u8) -> Option { - if d.spell_family == 0 - || d.spell_family != self.class_family - || d.attributes_ex3 & ATTR_EX3_IGNORE_CASTER_MODIFIERS != 0 - || usize::from(op) >= OPS - { + if !self.gates_pass(d) || usize::from(op) >= OPS { return None; } // All 64 bits, no early break (`6e6b8f`/`6e6b97`); the accumulators wrap, as the @@ -218,6 +248,14 @@ impl SpellModifiers { } } +/// How one snapshot of the tables differs from an earlier one ([`SpellModifiers::diff`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct ModsDiff { + /// Bit `b` set: a cell of family-flag bit `b` changed, in either table at any op. + pub(crate) bits: u64, + pub(crate) class_family: bool, +} + /// The description expander's view of the tables. impl benilla_formats::SpellMods for SpellModifiers { fn apply_int(&self, d: &SpellDisplay, op: u8, value: i32) -> i32 { @@ -251,7 +289,7 @@ pub(super) fn track_class_family( return; }; let family = classes.0.spell_family(u32::from(class)); - // Guarded: the tooltip feed rebuilds whenever the resource is marked changed. + // Guarded: the tooltip feed diffs the tables whenever the resource is marked changed. if mods.class_family != family { mods.set_class_family(family); } diff --git a/crates/benilla-app/src/spell/usable.rs b/crates/benilla-app/src/spell/usable.rs index 31713961..8b276ff0 100644 --- a/crates/benilla-app/src/spell/usable.rs +++ b/crates/benilla-app/src/spell/usable.rs @@ -50,7 +50,7 @@ pub(crate) struct UsableCtx<'a> { /// The search covers equipment indices `0..=22` (`0x5f0c50`'s `cmp ebx,0x17; jl`): the 19 worn /// slots and the four equipped bags. -const EQUIPMENT_SLOTS: u8 = 23; +pub(crate) const EQUIPMENT_SLOTS: u8 = 23; /// `AttributesEx3`'s hand restrictions, the source of the search's slot mask (`0x5f0c50`'s /// callers): `0x400` main hand only (mask `0x8000`), `0x1000000` off hand only (mask `0x10000`). @@ -77,6 +77,33 @@ fn hand_mask(d: &SpellDisplay) -> u32 { } } +/// Whether the equipped-item search runs at all for `d`: the reference's short-circuits to "fits" +/// (`0x6e40e0` at `6e4103`..`6e4130`) are `EquippedItemClass < 0`, a zero subclass mask (no item +/// required, not a wildcard), and an item-targeting spell (`0x495d60` checks those). +fn needs_equipped_item(d: &SpellDisplay) -> bool { + d.equipped_item_class >= 0 + && d.equipped_item_subclass_mask != 0 + && d.targets & TARGET_FLAG_ITEM == 0 +} + +/// The equipment slots [`equipped_item_fits`] reads for `d`, a bit each: none unless it searches, +/// else the slots its hand restriction leaves and both hands, which the disarm ladder reads +/// whatever the restriction (`0x5f0c69`, `0x5f0c91`). +pub(crate) fn worn_slots_read(d: &SpellDisplay) -> u32 { + if needs_equipped_item(d) { + (hand_mask(d) | HANDS) & EQUIPMENT_MASK + } else { + 0 + } +} + +/// The main and off hand, the two slots the disarm ladder tells apart. +const HANDS: u32 = + 1 << crate::items::EQUIPMENT_SLOT_MAINHAND | 1 << crate::items::EQUIPMENT_SLOT_OFFHAND; + +/// Every equipment index the search covers, as a mask: the 19 worn slots and the four bags. +pub(crate) const EQUIPMENT_MASK: u32 = (1 << EQUIPMENT_SLOTS) - 1; + /// [`equipped_item_fits`] for the cast ladder's rung 7: the same search, never querying a /// missing template; an uncached one counts as a match. pub(crate) fn equipped_item_fits_cached( @@ -85,10 +112,7 @@ pub(crate) fn equipped_item_fits_cached( objects: &Objects, items: &Items, ) -> bool { - if d.equipped_item_class < 0 - || d.equipped_item_subclass_mask == 0 - || d.targets & TARGET_FLAG_ITEM != 0 - { + if !needs_equipped_item(d) { return true; } let mut mask = hand_mask(d); @@ -100,20 +124,91 @@ pub(crate) fn equipped_item_fits_cached( mask, d.equipped_item_class as u32, d.equipped_item_subclass_mask, - |guid| { - let obj = objects.object(guid)?; - let t = items.template_cached(obj.object_entry()?)?; - Some(WornItem { - class: t.class, - subclass: t.subclass, - flags: t.flags, - durability: obj.item_durability(), - max_durability: obj.item_max_durability(), - }) - }, + |guid| worn_item_cached(guid, objects, items), ) } +/// The worn item `guid` names from the cached records alone: `None` while its object or its +/// template has not streamed. +fn worn_item_cached(guid: u64, objects: &Objects, items: &Items) -> Option { + let obj = objects.object(guid)?; + let t = items.template_cached(obj.object_entry()?)?; + Some(WornItem { + class: t.class, + subclass: t.subclass, + flags: t.flags, + durability: obj.item_durability(), + max_durability: obj.item_max_durability(), + }) +} + +/// What the equipped-item search learns of one equipment slot, and all it can: the feed compares +/// these frame to frame, so a view built on the search is rebuilt when a slot's answer can move, +/// and not on a durability tick that leaves an item unbroken. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub(crate) enum SlotItem { + /// No guid: the search passes over the slot. + Empty, + /// A guid whose item object or template has not streamed: the search gives the benefit of the + /// doubt. + Unresolved, + Item { + class: u32, + subclass: u32, + /// `ITEM_FLAG_DEPRECATED`. + deprecated: bool, + /// `MaxDurability > 0 && Durability == 0`. + broken: bool, + }, +} + +impl SlotItem { + fn of(guid: u64, worn: impl FnOnce(u64) -> Option) -> SlotItem { + if guid == 0 { + return SlotItem::Empty; + } + let Some(it) = worn(guid) else { + return SlotItem::Unresolved; + }; + SlotItem::Item { + class: it.class, + subclass: it.subclass, + deprecated: it.flags & ITEM_FLAG_DEPRECATED != 0, + broken: it.max_durability.is_some_and(|m| m > 0) && it.durability == Some(0), + } + } + + /// The search's verdict on the slot for a requirement. + fn fits(self, class: u32, subclass_mask: u32) -> bool { + match self { + SlotItem::Empty => false, + SlotItem::Unresolved => true, // unresolved template: benefit of the doubt + // The reference's two rejects before the class match: deprecated, and broken. + SlotItem::Item { + deprecated: true, .. + } + | SlotItem::Item { broken: true, .. } => false, + SlotItem::Item { + class: c, + subclass: sub, + .. + } => c == class && subclass_mask & (1 << sub) != 0, + } + } +} + +/// Equipment slot `slot`'s [`SlotItem`] from the cached records, without an ask. +pub(crate) fn slot_item_cached( + store: &ObjectStore, + slot: u8, + objects: &Objects, + items: &Items, +) -> SlotItem { + SlotItem::of(store.0.player_inv_slot(slot).unwrap_or(0), |guid| { + worn_item_cached(guid, objects, items) + }) +} + /// The equipped-item refusal reason, keyed on `AttributesEx3` alone (`0x6e40e0` at /// `6e416e`..`6e4180`): main-hand-only `0x1a`, off-hand-only `0x1b`, else `0x19`. All three render /// "Must have a %s equipped" with the item subclass name. @@ -137,13 +232,9 @@ pub(crate) fn equipped_item_fits( items: &Items, commands: &NetCommands, ) -> bool { - // The reference's short-circuits to "fits" (`0x6e40e0` at `6e4103`..`6e4130`): a caster that - // is not the active player (always ours here), `EquippedItemClass < 0`, a zero subclass mask - // (no item required, not a wildcard), and an item-targeting spell (`0x495d60` checks those). - if d.equipped_item_class < 0 - || d.equipped_item_subclass_mask == 0 - || d.targets & TARGET_FLAG_ITEM != 0 - { + // The short-circuits to "fits" (`0x6e40e0` at `6e4103`..`6e4130`): a caster that is not the + // active player (always ours here), and those of [`needs_equipped_item`]. + if !needs_equipped_item(d) { return true; } let class = d.equipped_item_class as u32; @@ -194,21 +285,8 @@ fn equipped_slots_match( (0..EQUIPMENT_SLOTS) .filter(|slot| mask & (1u32 << slot) != 0) .any(|slot| { - let Some(guid) = store.0.player_inv_slot(slot).filter(|&g| g != 0) else { - return false; - }; - let Some(it) = worn(guid) else { - return true; // unresolved template: benefit of the doubt - }; - // The reference's two rejects before the class match: deprecated, and broken - // (`MaxDurability > 0 && Durability == 0`). - if it.flags & ITEM_FLAG_DEPRECATED != 0 { - return false; - } - if it.max_durability.is_some_and(|m| m > 0) && it.durability == Some(0) { - return false; - } - it.class == class && subclass_mask & (1 << it.subclass) != 0 + let guid = store.0.player_inv_slot(slot).unwrap_or(0); + SlotItem::of(guid, &mut worn).fits(class, subclass_mask) }) } @@ -393,15 +471,11 @@ pub(crate) fn power_cost_at( level: u32, mods: &SpellModifiers, ) -> u32 { - let power_type = d.power_type as i32; - let base = if d.mana_cost_pct == 0 { - 0 - } else if d.power_type == 0 { - unit.unit_base_mana().unwrap_or(0) - } else if power_type < 0 { - unit.unit_max_health().unwrap_or(0) - } else { - unit.unit_max_power(power_type as u8).unwrap_or(0) + let base = match cost_basis(d) { + CostBasis::None => 0, + CostBasis::BaseMana => unit.unit_base_mana().unwrap_or(0), + CostBasis::MaxHealth => unit.unit_max_health().unwrap_or(0), + CostBasis::MaxPower(ty) => unit.unit_max_power(ty).unwrap_or(0), }; let level_delta = i64::from(level) - i64::from(d.base_level); let cost = i64::from(d.mana_cost) @@ -412,6 +486,37 @@ pub(crate) fn power_cost_at( u32::try_from(mods.apply(d, OP_COST, cost)).unwrap_or(0) } +/// The unit field [`power_cost_at`] scales `d`'s percentage cost by, the reference's `0x612c50` +/// basis: base mana for a mana spell, else the max pool of the power type, or base health for a +/// negative one. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum CostBasis { + /// No percentage: the cost reads no unit field. + None, + BaseMana, + MaxHealth, + /// `UNIT_FIELD_MAXPOWER` of the type; a type past the five reads nothing. + MaxPower(u8), +} + +pub(crate) fn cost_basis(d: &SpellDisplay) -> CostBasis { + let power_type = d.power_type as i32; + if d.mana_cost_pct == 0 { + CostBasis::None + } else if d.power_type == 0 { + CostBasis::BaseMana + } else if power_type < 0 { + CostBasis::MaxHealth + } else { + CostBasis::MaxPower(power_type as u8) + } +} + +/// Whether [`power_cost_at`]'s level term moves the cost: only a spell with a per-level cost. +pub(crate) fn cost_reads_level(d: &SpellDisplay) -> bool { + d.mana_cost_per_level != 0 +} + /// Whether the caster can afford `d`, for leg 12 (`0x6e3fba`) and the press-path gate /// (`0x60962c`): `UNIT_FIELD_POWER[type]`, or `UNIT_FIELD_HEALTH` for any negative PowerType /// (`0x609631`; Bloodrage's -2), against [`power_cost`]. diff --git a/crates/benilla-app/src/ui_craft.rs b/crates/benilla-app/src/ui_craft.rs index fb55bcf2..4b9fc387 100644 --- a/crates/benilla-app/src/ui_craft.rs +++ b/crates/benilla-app/src/ui_craft.rs @@ -135,7 +135,7 @@ fn feed_craft( lookup: &|id| spells.catalog.get(id), mods: Some(&*spell_mods), unmodified_points: false, - home_area: None, + home_area: &|| None, global: &global, printf: &crate::ui_script::token_printf, }; diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index e36d7777..e66bd879 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -77,7 +77,7 @@ fn spell_desc_text( lookup: &|i| sp.catalog.get(i), mods: mods.map(|m| m as &dyn benilla_formats::SpellMods), unmodified_points: false, - home_area, + home_area: &|| home_area, global, printf: &crate::ui_script::token_printf, }; diff --git a/crates/benilla-app/src/ui_tooltip/bench.rs b/crates/benilla-app/src/ui_tooltip/bench.rs new file mode 100644 index 00000000..9845bd98 --- /dev/null +++ b/crates/benilla-app/src/ui_tooltip/bench.rs @@ -0,0 +1,215 @@ +//! The feed's cost per invalidation on the real data, run by hand: +//! `cargo test --release -p benilla-app --lib ui_tooltip::bench -- --ignored --nocapture`. +//! `BENCH_N` sets how many spells the feed has pushed (2000 by default). + +use std::time::{Duration, Instant}; + +use benilla_protocol::ObjectFields; +use benilla_ui::script::UiScript; +use bevy::prelude::*; + +use super::feed_spell_tooltips; +use crate::items::Items; +use crate::net::{GuidIndex, NetCommands, ObjectStore, SelfPlayer}; +use crate::ui_action::{PlayerActions, Spells}; + +/// The mage's family in `Spell.dbc`. +const MAGE: u32 = 3; + +fn median(mut v: Vec) -> Duration { + v.sort(); + v[v.len() / 2] +} + +#[test] +#[ignore = "a measurement, not a check"] +fn feed_cost_per_invalidation() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let spells = Spells { + catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"), + forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("forms"), + ranges: benilla_formats::load_spell_ranges(&mut chain).expect("ranges"), + cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("cast times"), + durations: benilla_formats::load_spell_durations(&mut chain).expect("durations"), + radii: benilla_formats::load_spell_radii(&mut chain).expect("radii"), + }; + let skill_lines = benilla_formats::load_skill_line_catalog(&mut chain).expect("skill lines"); + let n: usize = std::env::var("BENCH_N") + .ok() + .and_then(|n| n.parse().ok()) + .unwrap_or(2000); + + // What a mage has pushed: its own family's spells, then an even sample of every other kind, + // as the auras of other units and the trainer's services fill the set. + let mut described: Vec = spells + .catalog + .iter() + .filter(|(_, d)| d.description.is_some() && !d.name.is_empty()) + .map(|(id, _)| id) + .collect(); + described.sort_unstable(); + let (mut ids, others): (Vec, Vec) = described.into_iter().partition(|&id| { + spells + .catalog + .get(id) + .is_some_and(|d| d.spell_family == MAGE) + }); + let class = ids.len().min(n); + ids.truncate(class); + let stride = (others.len() / n.saturating_sub(class).max(1)).max(1); + ids.extend(others.iter().step_by(stride).take(n - class)); + let mut lines: Vec = ids + .iter() + .filter_map(|&id| skill_lines.spell_to_line(id)) + .collect(); + lines.sort_unstable(); + lines.dedup(); + + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.insert_resource(spells) + .insert_resource(NetCommands(tx)) + .insert_resource(crate::ui_spellbook::SkillLines { + catalog: skill_lines, + }) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, feed_spell_tooltips); + app.world_mut() + .resource_mut::() + .spells + .extend(ids.iter().copied()); + app.world_mut() + .resource_mut::() + .set_class_family(MAGE); + + // A level-60 mage: unit fields, the four percentages, the skills of its lines. + let player_fields = |crit: f32, dodge: f32, reach: f32, form: u32, head: u32, skill: u32| { + let mut pairs: Vec<(u16, u32)> = vec![ + (22, 3500), + (28, 4000), + (34, 60), + (162, 1373), + (130, reach.to_bits()), + (138, form << 16), + (486, head), + (1106, 5.0f32.to_bits()), + (1107, dodge.to_bits()), + (1108, 4.0f32.to_bits()), + (1109, crit.to_bits()), + ]; + for (slot, &line) in lines.iter().take(128).enumerate() { + let base = 718 + 3 * slot as u16; + pairs.push((base, line)); + pairs.push((base + 1, if slot == 0 { skill } else { 300 } | 300 << 16)); + } + ObjectStore(ObjectFields::from_pairs(&pairs)) + }; + let player = app + .world_mut() + .spawn((SelfPlayer, player_fields(5.0, 4.0, 1.5, 0, 0, 300))) + .id(); + // An auto-attack target, whose reach a swap moves. + const TARGET: u64 = 0xF130_0000_0000_0042; + let target_fields = + |reach: f32| ObjectStore(ObjectFields::from_pairs(&[(130, reach.to_bits())])); + let target = app.world_mut().spawn(target_fields(1.5)).id(); + app.world_mut() + .resource_mut::() + .0 + .insert(TARGET, target); + + let script = UiScript::new().expect("VM"); + crate::ui_script::load_ui_for_test(&script, "Interface\\FrameXML\\GlobalStrings.lua"); + app.insert_non_send_resource(script); + + let timed = |app: &mut App| { + let start = Instant::now(); + app.update(); + start.elapsed() + }; + let first = timed(&mut app); + let steady = median((0..50).map(|_| timed(&mut app)).collect()); + println!( + "pushed {} spells ({} of them the mage's): first frame {:?}, a quiet frame {:?}", + ids.len(), + class, + first, + steady + ); + + // Each cause moves, alternating between two values, and the frame after it is timed. + let run = |name: &str, app: &mut App, step: &mut dyn FnMut(&mut App, bool)| { + let times: Vec = (0..40) + .map(|i| { + step(app, i % 2 == 0); + timed(app) + }) + .collect(); + let mean = times.iter().sum::() / times.len() as u32; + println!( + "{name:<28} median {:>9.1?} mean {:>9.1?}", + median(times), + mean + ); + }; + // Engage the target first: the reach cause is the attack target's. + let set_player = move |app: &mut App, s: ObjectStore| { + app.world_mut().entity_mut(player).insert(s); + }; + run("crit percentage", &mut app, &mut |app, a| { + let crit = if a { 5.0 } else { 6.5 }; + set_player(app, player_fields(crit, 4.0, 1.5, 0, 0, 300)); + }); + run("dodge percentage", &mut app, &mut |app, a| { + let dodge = if a { 4.0 } else { 5.5 }; + set_player(app, player_fields(5.0, dodge, 1.5, 0, 0, 300)); + }); + run("caster combat reach", &mut app, &mut |app, a| { + let reach = if a { 1.5 } else { 2.0 }; + set_player(app, player_fields(5.0, 4.0, reach, 0, 0, 300)); + }); + app.world_mut() + .entity_mut(player) + .insert(crate::creature_anim::Engaged(TARGET)); + run("attack target's reach", &mut app, &mut |app, a| { + let reach = if a { 1.5 } else { 3.0 }; + app.world_mut() + .entity_mut(target) + .insert(target_fields(reach)); + }); + run("shapeshift form", &mut app, &mut |app, a| { + set_player(app, player_fields(5.0, 4.0, 1.5, u32::from(!a), 0, 300)); + }); + run("one worn slot", &mut app, &mut |app, a| { + set_player(app, player_fields(5.0, 4.0, 1.5, 0, u32::from(!a), 300)); + }); + run("one skill line's value", &mut app, &mut |app, a| { + let skill = if a { 300 } else { 299 }; + set_player(app, player_fields(5.0, 4.0, 1.5, 0, 0, skill)); + }); + run("one modifier cell (narrow)", &mut app, &mut |app, a| { + let mut mods = app + .world_mut() + .resource_mut::(); + mods.set( + false, + 5, + crate::spell::OP_CAST_TIME, + if a { -10 } else { -20 }, + ); + }); + // A proc that changes the cost of everything the class casts (Clearcasting's shape): one + // packet per family-flag bit. + run("all modifier bits (proc)", &mut app, &mut |app, a| { + let mut mods = app + .world_mut() + .resource_mut::(); + for bit in 0..64 { + mods.set(false, bit, crate::spell::OP_COST, if a { -100 } else { 0 }); + } + }); +} diff --git a/crates/benilla-app/src/ui_tooltip/deps_tests.rs b/crates/benilla-app/src/ui_tooltip/deps_tests.rs new file mode 100644 index 00000000..803f4790 --- /dev/null +++ b/crates/benilla-app/src/ui_tooltip/deps_tests.rs @@ -0,0 +1,1321 @@ +//! The feed's dependency classification against the real `Spell.dbc`. +//! +//! A pushed view is re-queued when [`Deps::hits`] finds an input it read among the [`Changes`]. A +//! spell missing a dependency would keep a stale tooltip, so the safety net builds every spell's +//! view, moves inputs, builds it again, and holds that a view which moved was re-queued. Two kinds +//! of move: each cause alone, through the real diff ([`Seen::changes_since`], the modifier and +//! reagent diffs), and, per view, every input the view's recorded reads leave out, all at once, +//! which must leave it exactly as it was. + +use std::collections::BTreeSet; + +use benilla_protocol::ObjectFields; +use benilla_ui::script::SpellTooltipView; + +use super::spell_deps::{Changes, Deps, Reagents, Seen, UnitField}; +use super::spell_feed::{build_view, reagent_state, PetInputs, ViewCaster, ViewCtx}; +use super::tests::{real_spells, TestCtx}; +use crate::items::Items; +use crate::net::{NetCommands, ObjectStore, Objects}; +use crate::spell::usable::{ + slot_item_cached, worn_slots_read, SlotItem, EQUIPMENT_MASK, EQUIPMENT_SLOTS, +}; +use crate::spell::{ModsDiff, SpellModifiers}; +use crate::ui_action::Spells; +use crate::ui_items::TestObjects; + +/// One side of each input group, so that moving an input is an xor: `false` is the empty side (no +/// bind point, form 0, no percentages, default reach, nothing worn, no skills, zero cells), `true` +/// the populated one. +#[derive(Clone, Copy, Default, PartialEq, Eq, Hash, Debug)] +struct Inputs { + home: bool, + form: bool, + /// A `Chance` bit each: block, dodge, parry, crit. + avoid: u8, + reach: bool, + /// The equipment slots holding an item that meets the view's requirement. + worn: u32, + /// The disarm flag. + disarm: bool, + /// A [`UnitField`] bit each: level, base mana, max health, max power of each type, ranged + /// attack time. + unit: u16, + /// Bit `i`: the skill line `lines[i]` at 300. + lines: u128, + /// The `SpellFamilyFlags` bits whose cells hold a modifier. + bits: u64, + /// A run of the player's fields, `(first, count)`, all set to [`POKE`], whatever they hold. + poke: Option<(u16, u16)>, +} + +/// What a poked field is set to: 2.5 as a float, and a large number as an integer. +const POKE: u32 = 0x4020_0000; + +impl Inputs { + const ALL: Inputs = Inputs { + home: true, + form: true, + avoid: 0xf, + reach: true, + worn: EQUIPMENT_MASK, + disarm: true, + unit: 0x1ff, + lines: u128::MAX, + bits: u64::MAX, + poke: None, + }; + + fn xor(self, o: Inputs) -> Inputs { + Inputs { + home: self.home ^ o.home, + form: self.form ^ o.form, + avoid: self.avoid ^ o.avoid, + reach: self.reach ^ o.reach, + worn: self.worn ^ o.worn, + disarm: self.disarm ^ o.disarm, + unit: self.unit ^ o.unit, + lines: self.lines ^ o.lines, + bits: self.bits ^ o.bits, + poke: self.poke.xor(o.poke), + } + } + + /// Each input in `self` alone, named: what to move to find which one moved a view. + fn parts(self, layout: &Layout) -> Vec<(String, Inputs)> { + let alone = Inputs::default(); + let mut parts = Vec::new(); + if self.home { + parts.push(( + "the bind point".to_string(), + Inputs { + home: true, + ..alone + }, + )); + } + if self.form { + parts.push(( + "the form".to_string(), + Inputs { + form: true, + ..alone + }, + )); + } + for (i, which) in ["block", "dodge", "parry", "crit"].iter().enumerate() { + if self.avoid >> i & 1 == 1 { + let only = Inputs { + avoid: 1 << i, + ..alone + }; + parts.push((format!("the {which} percentage"), only)); + } + } + if self.reach { + parts.push(( + "a combat reach".to_string(), + Inputs { + reach: true, + ..alone + }, + )); + } + for slot in (0..EQUIPMENT_SLOTS).filter(|s| self.worn >> s & 1 == 1) { + let only = Inputs { + worn: 1 << slot, + ..alone + }; + parts.push((format!("worn slot {slot}"), only)); + } + if self.disarm { + parts.push(( + "the disarm flag".to_string(), + Inputs { + disarm: true, + ..alone + }, + )); + } + for (bit, name) in UNIT_NAMES.iter().enumerate() { + if self.unit >> bit & 1 == 1 { + let only = Inputs { + unit: 1 << bit, + ..alone + }; + parts.push((format!("the player's {name}"), only)); + } + } + if let Some((first, count)) = self.poke { + for field in first..first + count { + let only = Inputs { + poke: Some((field, 1)), + ..alone + }; + parts.push((format!("the player's field {field}"), only)); + } + } + for (i, line) in layout.lines.iter().enumerate() { + if self.lines >> i & 1 == 1 { + parts.push(( + format!("skill line {line}"), + Inputs { + lines: 1 << i, + ..alone + }, + )); + } + } + for bit in (0..64).filter(|b| self.bits >> b & 1 == 1) { + let only = Inputs { + bits: 1 << bit, + ..alone + }; + parts.push((format!("the modifier cells of family-flag bit {bit}"), only)); + } + parts + } + + /// The inputs a view's recorded reads leave out. + fn outside(deps: &Deps, layout: &Layout) -> Inputs { + Inputs { + home: !deps.home, + form: !deps.form, + avoid: !deps.avoidance & 0xf, + reach: !deps.reach, + worn: EQUIPMENT_MASK & !deps.worn, + disarm: !deps.disarm, + unit: !deps.unit & 0x1ff, + lines: layout + .lines + .iter() + .enumerate() + .filter(|(_, line)| !deps.skill_lines.contains(line)) + .fold(0, |mask, (i, _)| mask | 1 << i), + bits: !deps.mod_bits, + poke: None, + } + } +} + +/// The [`UnitField`] names, by bit. +const UNIT_NAMES: [&str; 9] = [ + "level", + "base mana", + "max health", + "max mana", + "max rage", + "max focus", + "max energy", + "max happiness", + "ranged attack time", +]; + +/// What a sweep holds fixed for a view: the form the form input puts on, the item kind worn +/// items are of, and the class family the tables gate on. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)] +struct Params { + form: u8, + kind: usize, + family: u32, +} + +/// The fixed shape of the scenarios. +struct Layout { + /// The skill lines any spell belongs to, ascending; bit `i` of [`Inputs::lines`]. + lines: Vec, + /// The `(class, subclass)` an item is of, per kind: what some spell's equipped-item search + /// asks for. + kinds: Vec<(u32, u32)>, +} + +impl Layout { + /// The item a slot holds in a scenario, one object per kind and slot. + fn guid(kind: usize, slot: u32) -> u64 { + 0x1_0000 + ((kind as u64) << 8) + u64::from(slot) + } + + fn entry(kind: usize) -> u32 { + 90_000 + kind as u32 + } + + /// The kind that meets `d`'s requirement: the lowest subclass its mask names. + fn kind_of(&self, d: &benilla_formats::SpellDisplay) -> usize { + let want = ( + d.equipped_item_class as u32, + d.equipped_item_subclass_mask.trailing_zeros(), + ); + self.kinds.iter().position(|&k| k == want).unwrap_or(0) + } + + /// The parameters that put each input where it moves `d`'s view the most: the lowest form `d` + /// names, the item kind it needs, its own family. + fn params_of(&self, d: &benilla_formats::SpellDisplay) -> Params { + Params { + form: d.stances.trailing_zeros().min(31) as u8 + 1, + kind: self.kind_of(d), + family: d.spell_family.max(1), + } + } +} + +/// A scenario made into the inputs a view is built from. +struct Mat { + store: ObjectStore, + mods: SpellModifiers, + home: Option<&'static str>, + target_reach: Option, + form: u8, +} + +fn mat(layout: &Layout, inputs: Inputs, p: Params) -> Mat { + let unit = |field: UnitField| inputs.unit & field.bit() != 0; + // Health, and the unit fields at one value on the empty side and another on the populated one. + let mut pairs: Vec<(u16, u32)> = vec![ + (22, 3500), + (34, if unit(UnitField::Level) { 60 } else { 20 }), + ( + 28, + if unit(UnitField::MaxHealth) { + 9000 + } else { + 4000 + }, + ), + ( + 162, + if unit(UnitField::BaseMana) { + 2100 + } else { + 1000 + }, + ), + ( + 128, + if unit(UnitField::RangedTime) { + 3000 + } else { + 2000 + }, + ), + ]; + for ty in 0..5u8 { + let max_power = if unit(UnitField::MaxPower(ty)) { + 1500 + } else { + 500 + }; + pairs.push((29 + u16::from(ty), max_power)); + } + if inputs.disarm { + pairs.push((46, 0x0020_0000)); + } + let form = if inputs.form { p.form } else { 0 }; + if form != 0 { + pairs.push((138, u32::from(form) << 16)); + } + // Block, dodge, parry and crit percent. + for (i, pct) in [7.5f32, 6.25, 5.5, 3.75].into_iter().enumerate() { + if inputs.avoid >> i & 1 == 1 { + pairs.push((1106 + i as u16, pct.to_bits())); + } + } + if inputs.reach { + pairs.push((130, 4.0f32.to_bits())); + } + let mut slot = 0; + for (i, &line) in layout.lines.iter().enumerate() { + if inputs.lines >> i & 1 == 1 { + let base = 718 + 3 * slot; + pairs.push((base, line)); + pairs.push((base + 1, 300 | 300 << 16)); + slot += 1; + } + } + for s in (0..EQUIPMENT_SLOTS.into()).filter(|s| inputs.worn >> s & 1 == 1) { + let guid = Layout::guid(p.kind, s); + pairs.push((486 + 2 * s as u16, guid as u32)); + pairs.push((487 + 2 * s as u16, (guid >> 32) as u32)); + } + if let Some((first, count)) = inputs.poke { + pairs.extend((first..first + count).map(|field| (field, POKE))); + } + let mut mods = SpellModifiers::default(); + mods.set_class_family(p.family); + for bit in (0..64u8).filter(|b| inputs.bits >> b & 1 == 1) { + for op in 0..29 { + mods.set(true, bit, op, -7); + mods.set(false, bit, op, 35); + } + } + Mat { + store: ObjectStore(ObjectFields::from_pairs(&pairs)), + mods, + home: inputs.home.then_some("Zzz"), + // The two reaches move together: the caster's 1.5 to 4.0, and an auto-attack target at 3.0. + target_reach: inputs.reach.then_some(3.0), + form, + } +} + +/// What differs between two scenarios, through the feed's own diffs. +fn changes(before: &Mat, now: &Mat, objects: &Objects, items: &Items) -> Changes { + let seen = |m: &Mat| Seen::of(Some(&m.store), m.home, m.target_reach, objects, items); + seen(now) + .changes_since(&seen(before)) + .with_mods(now.mods.diff(&before.mods)) +} + +/// The real data and the objects the scenarios put in the world. +struct Rig { + spells: Spells, + t: TestCtx, + objs: TestObjects, + ids: Vec, + layout: Layout, + /// The reagent entries any spell lists, ascending. + reagents: Vec, + families: Vec, +} + +impl Rig { + fn new() -> Option { + let spells = real_spells()?; + let data = benilla_formats::wow_data_or_skip!(None); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let skill_lines = + benilla_formats::load_skill_line_catalog(&mut chain).expect("skill lines"); + let mut ids: Vec = spells.catalog.iter().map(|(id, _)| id).collect(); + ids.sort_unstable(); + let displays = || ids.iter().filter_map(|&id| spells.catalog.get(id)); + let lines: BTreeSet = ids + .iter() + .filter_map(|&id| skill_lines.spell_to_line(id)) + .collect(); + assert!( + lines.len() <= 128, + "the player's skill table holds 128 lines" + ); + let mut layout = Layout { + lines: lines.into_iter().collect(), + kinds: vec![(2, 0)], + }; + for d in displays().filter(|d| worn_slots_read(d) != 0) { + let kind = ( + d.equipped_item_class as u32, + d.equipped_item_subclass_mask.trailing_zeros(), + ); + if !layout.kinds.contains(&kind) { + layout.kinds.push(kind); + } + } + let reagents: BTreeSet = displays() + .flat_map(|d| d.reagents.iter().map(|&(entry, _)| entry)) + .filter(|&entry| entry != 0) + .collect(); + let families: BTreeSet = displays() + .map(|d| d.spell_family) + .filter(|&f| f != 0) + .collect(); + let mut t = TestCtx::new(); + t.skill_lines = Some(skill_lines); + let mut objs = TestObjects::new(); + // One worn item per kind and slot, and each kind's template. + for (kind, &(class, subclass)) in layout.kinds.iter().enumerate() { + t.items.insert_template( + Layout::entry(kind), + Some(benilla_protocol::messages::ItemInfo { + class, + subclass, + ..crate::items::test_template("Worn") + }), + ); + for slot in 0..u32::from(EQUIPMENT_SLOTS) { + objs.spawn( + Layout::guid(kind, slot), + ObjectFields::from_pairs(&[(3, Layout::entry(kind))]), + ); + } + } + // One carried stack of five per reagent entry, for the scenarios that carry it. + for (i, &entry) in reagents.iter().enumerate() { + objs.spawn( + 0x2_0000 + i as u64, + ObjectFields::from_pairs(&[(3, entry), (14, 5)]), + ); + } + Some(Rig { + spells, + t, + objs, + ids, + layout, + reagents: reagents.into_iter().collect(), + families: families.into_iter().collect(), + }) + } +} + +fn build( + t: &mut TestCtx, + objects: &Objects, + spells: &Spells, + id: u32, + m: &Mat, +) -> Option<(SpellTooltipView, Deps)> { + t.spell_mods = m.mods.clone(); + let mut ctx = t.ctx_for(objects, m.form, None, Some(&m.store)); + ctx.home_area = m.home; + ctx.attack_target_reach = m.target_reach; + build_view(id, spells, &mut ctx) +} + +/// A spell whose text names another spell's number (`$1234s1`), whose modifiers then answer to +/// that spell's family. +fn cross_references(d: &benilla_formats::SpellDisplay) -> bool { + [d.description.as_deref(), d.aura_description.as_deref()] + .into_iter() + .flatten() + .flat_map(|text| text.split('$').skip(1)) + .any(|token| { + let after_scale = match token.split_once(';') { + Some((scale, rest)) if scale.starts_with(['/', '*']) => rest, + _ => token, + }; + after_scale.starts_with(|c: char| c.is_ascii_digit()) + }) +} + +/// What a sweep found. +#[derive(Default)] +struct Sweep { + /// The views that moved. + moved: usize, + /// The views the changes re-queue, moved or not. + requeued: usize, + /// One line per moved view its reads do not cover. + misses: Vec, +} + +type Only<'a> = &'a dyn Fn(&benilla_formats::SpellDisplay) -> bool; +type ParamsOf<'a> = &'a dyn Fn(&Layout, &benilla_formats::SpellDisplay) -> Params; +type FlipOf<'a> = &'a dyn Fn(&Layout, &Deps) -> Inputs; + +/// Build every view `only` names at `base`, move the inputs `flip` names for it, build it again, +/// and hold that a view which moved is one the changes re-queue. +fn sweep(rig: &mut Rig, base: Inputs, only: Only, params: ParamsOf, flip: FlipOf) -> Sweep { + let Rig { + spells, + t, + objs, + ids, + layout, + .. + } = rig; + let objects = objs.get(); + let mut out = Sweep::default(); + let mut before: Option<(Params, Mat)> = None; + let mut after: Option<((Params, Inputs), Mat, Changes)> = None; + // Views with the same parameters run together, so a scenario is made once for them. + let mut order: Vec<(Params, u32)> = ids + .iter() + .filter_map(|&id| { + let d = spells.catalog.get(id).expect("listed spell"); + only(d).then(|| (params(layout, d), id)) + }) + .collect(); + order.sort_unstable_by_key(|&(p, id)| (p, id)); + for (p, id) in order { + let d = spells.catalog.get(id).expect("listed spell"); + if before.as_ref().map(|(k, _)| k) != Some(&p) { + before = Some((p, mat(layout, base, p))); + } + let (_, before) = before.as_ref().expect("set above"); + let Some((view, deps)) = build(t, &objects, spells, id, before) else { + continue; + }; + let moved = flip(layout, &deps); + if after.as_ref().map(|(k, ..)| k) != Some(&(p, moved)) { + let now = mat(layout, base.xor(moved), p); + let c = changes(before, &now, &objects, &t.items); + after = Some(((p, moved), now, c)); + } + let (_, now, changes) = after.as_ref().expect("set above"); + let (moved_view, _) = build(t, &objects, spells, id, now).expect("built before"); + let hit = deps.hits(changes); + out.requeued += usize::from(hit); + if moved_view == view { + continue; + } + out.moved += 1; + if hit { + continue; + } + // Name what moved it: each input alone. + let culprits: Vec = moved + .parts(layout) + .into_iter() + .filter(|(_, part)| { + let alone = mat(layout, base.xor(*part), p); + build(t, &objects, spells, id, &alone).is_some_and(|(v, _)| v != view) + }) + .map(|(name, _)| name) + .collect(); + out.misses.push(format!( + "spell {id} {:?} moved with {}, which its reads {deps:?} do not cover", + d.name, + if culprits.is_empty() { + "these inputs together".to_string() + } else { + culprits.join(", ") + } + )); + } + out +} + +fn assert_covered(what: &str, sweep: &Sweep) { + assert!( + sweep.misses.is_empty(), + "{what}: {} of {} moved views are not re-queued; first:\n{}", + sweep.misses.len(), + sweep.moved, + sweep + .misses + .iter() + .take(8) + .cloned() + .collect::>() + .join("\n") + ); +} + +fn any(_: &benilla_formats::SpellDisplay) -> bool { + true +} + +fn own(layout: &Layout, d: &benilla_formats::SpellDisplay) -> Params { + layout.params_of(d) +} + +/// Every view that moves with an input is one the feed re-queues for that input: each cause alone +/// against the real spell set, and, per view, everything its reads leave out. +#[test] +fn every_view_that_moves_with_an_input_is_requeued_by_that_input() { + let Some(mut rig) = Rig::new() else { return }; + let total = rig.ids.len(); + let mut report = Vec::new(); + let mut row = |what: &str, s: &Sweep| { + report.push(format!( + "{what:<28} moves {:>5} re-queues {:>5} of {total}", + s.moved, s.requeued + )); + }; + + // Each cause alone, from the empty side to the populated one. + let empty = Inputs::default(); + let cause = |inputs: Inputs| move |_: &Layout, _: &Deps| inputs; + let s = sweep( + &mut rig, + empty, + &any, + &own, + &cause(Inputs { + home: true, + ..empty + }), + ); + assert_covered("the bind point", &s); + assert!(s.moved >= 1, "no view reads the bind point"); + row("home bind ($z)", &s); + let s = sweep( + &mut rig, + empty, + &any, + &own, + &cause(Inputs { + form: true, + ..empty + }), + ); + assert_covered("the form", &s); + assert!(s.moved >= 100, "only {} views read the form", s.moved); + row("form", &s); + for (i, name) in ["block", "dodge", "parry", "crit"].into_iter().enumerate() { + let inputs = Inputs { + avoid: 1 << i, + ..empty + }; + let s = sweep(&mut rig, empty, &any, &own, &cause(inputs)); + assert_covered(name, &s); + assert!(s.moved >= 1, "no view reads the {name} percentage"); + row(name, &s); + } + let s = sweep( + &mut rig, + empty, + &any, + &own, + &cause(Inputs { + reach: true, + ..empty + }), + ); + assert_covered("the combat reaches", &s); + assert!(s.moved >= 100, "only {} views read a reach", s.moved); + row("combat reach", &s); + let all_worn = Inputs { + worn: EQUIPMENT_MASK, + ..empty + }; + let s = sweep(&mut rig, empty, &any, &own, &cause(all_worn)); + assert_covered("the worn set", &s); + assert!(s.moved >= 20, "only {} views read the worn set", s.moved); + row("worn set (all slots)", &s); + for slot in 0..EQUIPMENT_SLOTS { + let inputs = Inputs { + worn: 1 << slot, + ..empty + }; + let searching = |d: &benilla_formats::SpellDisplay| worn_slots_read(d) != 0; + let s = sweep(&mut rig, empty, &searching, &own, &cause(inputs)); + assert_covered(&format!("worn slot {slot}"), &s); + } + // The player's unit fields, one at a time, and the disarm flag with weapons in both hands. + for (bit, name) in UNIT_NAMES.iter().enumerate() { + let inputs = Inputs { + unit: 1 << bit, + ..empty + }; + let s = sweep(&mut rig, empty, &any, &own, &cause(inputs)); + assert_covered(&format!("the player's {name}"), &s); + // Mana costs scale by the base, and no 1.12 spell costs a percentage of a rage, focus, + // energy or happiness pool: those are watched all the same. + if !name.starts_with("max ") || name == &"max health" { + assert!(s.moved >= 1, "no view reads the player's {name}"); + } + row(name, &s); + } + let armed = Inputs { + worn: EQUIPMENT_MASK, + ..empty + }; + let disarm = Inputs { + disarm: true, + ..empty + }; + let s = sweep(&mut rig, armed, &any, &own, &cause(disarm)); + assert_covered("the disarm flag", &s); + assert!(s.moved >= 20, "only {} views read the disarm flag", s.moved); + row("disarm flag (armed)", &s); + let all_lines = Inputs { + lines: u128::MAX, + ..empty + }; + let s = sweep(&mut rig, empty, &any, &own, &cause(all_lines)); + assert_covered("the skills", &s); + assert!(s.moved >= 100, "only {} views read a skill", s.moved); + row("skills (all lines)", &s); + // The tables, per class family: the family gates which spells they reach. + let families = rig.families.clone(); + let mut mods = Sweep::default(); + for &family in &families { + let params = move |_: &Layout, _: &benilla_formats::SpellDisplay| Params { + form: 1, + kind: 0, + family, + }; + let inputs = Inputs { + bits: u64::MAX, + ..empty + }; + let s = sweep(&mut rig, empty, &any, ¶ms, &cause(inputs)); + assert_covered(&format!("the modifiers of family {family}"), &s); + mods.moved += s.moved; + mods.requeued += s.requeued; + } + assert!( + mods.moved >= 500, + "only {} views read the tables", + mods.moved + ); + row("modifier tables (all bits)", &mods); + + // Everything a view's reads leave out at once, from either side: it must not move. Moving the + // inputs it does read instead moves it, so the moves are not inert. + let outside = |layout: &Layout, deps: &Deps| Inputs::outside(deps, layout); + let inside = |layout: &Layout, deps: &Deps| Inputs::ALL.xor(Inputs::outside(deps, layout)); + let s = sweep(&mut rig, empty, &any, &own, &inside); + assert_covered("everything a view reads", &s); + assert!( + s.moved >= 3000, + "only {} views moved with what they read", + s.moved + ); + row("everything a view reads", &s); + for (name, base) in [("from empty", empty), ("from populated", Inputs::ALL)] { + let s = sweep(&mut rig, base, &any, &own, &outside); + assert_covered(&format!("everything a view does not read, {name}"), &s); + // A cross-referenced spell's family owns its modifiers: try every family. + for &family in &families { + let params = move |layout: &Layout, d: &benilla_formats::SpellDisplay| Params { + family, + ..layout.params_of(d) + }; + let s = sweep(&mut rig, base, &cross_references, ¶ms, &outside); + let what = format!("everything a view does not read, {name}, family {family}"); + assert_covered(&what, &s); + } + } + eprintln!("{}", report.join("\n")); +} + +/// The reagents line: a name streaming in re-queues every view that lists a reagent, and a count +/// moving re-queues only the spells that list the entry. +#[test] +fn a_reagent_moving_requeues_the_views_that_list_it() { + let Some(mut rig) = Rig::new() else { return }; + let Rig { + spells, + t, + objs, + ids, + reagents, + .. + } = &mut rig; + let listing: Vec = ids + .iter() + .copied() + .filter(|&id| { + spells + .catalog + .get(id) + .is_some_and(|d| d.reagents.iter().any(|&(entry, _)| entry != 0)) + }) + .collect(); + assert!( + listing.len() >= 100, + "{} spells list a reagent", + listing.len() + ); + let objects = objs.get(); + let states = |t: &mut TestCtx, store: &ObjectStore| -> Reagents { + reagents + .iter() + .map(|&e| { + let state = reagent_state(e, Some(store), &objects, &mut t.items, &t.commands); + (e, state) + }) + .collect() + }; + let mods = SpellModifiers::default(); + let mat_of = |store: ObjectStore| Mat { + store, + mods: mods.clone(), + home: None, + target_reach: None, + form: 0, + }; + + // The names stream in: every reagent line appears, and each view that has one is due. + let empty = mat_of(ObjectStore(ObjectFields::from_pairs(&[(22, 100)]))); + let unnamed = states(t, &empty.store); + let before: Vec<_> = listing + .iter() + .map(|&id| build(t, &objects, spells, id, &empty).expect("built")) + .collect(); + for (i, &entry) in reagents.iter().enumerate() { + let template = crate::items::test_template(&format!("Reagent {i}")); + t.items.insert_template(entry, Some(template)); + } + let named = states(t, &empty.store); + let changes = Changes::default().with_reagents(&unnamed, &named); + let mut moved = 0; + for (&id, (view, deps)) in listing.iter().zip(&before) { + let (now, _) = build(t, &objects, spells, id, &empty).expect("built"); + if now != *view { + moved += 1; + assert!( + deps.hits(&changes), + "spell {id} gained its reagent names unheeded: {deps:?}" + ); + } + } + assert!(moved >= 100, "only {moved} views showed a reagent name"); + + // A stack of each entry moves in, sixteen at a time: only the spells listing one are due. + let plain: Vec<_> = listing + .iter() + .map(|&id| build(t, &objects, spells, id, &empty).expect("built")) + .collect(); + let mut moved = 0; + for (group, entries) in reagents.chunks(16).enumerate() { + let mut pairs: Vec<(u16, u32)> = vec![(22, 100)]; + for slot in 0..entries.len() { + let guid = 0x2_0000 + (group * 16 + slot) as u64; + pairs.push((532 + 2 * slot as u16, guid as u32)); + pairs.push((533 + 2 * slot as u16, (guid >> 32) as u32)); + } + let carrying = mat_of(ObjectStore(ObjectFields::from_pairs(&pairs))); + let changes = Changes::default().with_reagents(&named, &states(t, &carrying.store)); + assert!(!changes.is_empty()); + for (&id, (view, deps)) in listing.iter().zip(&plain) { + let (now, _) = build(t, &objects, spells, id, &carrying).expect("built"); + let lists = deps.reagents().iter().any(|e| entries.contains(e)); + assert_eq!(deps.hits(&changes), lists, "spell {id}: {deps:?}"); + if now != *view { + moved += 1; + assert!(lists, "spell {id} moved with a reagent it does not list"); + } + } + } + assert!(moved >= 100, "only {moved} views showed a carried reagent"); + // Each view records exactly the entries its spell lists. + for (&id, (_, deps)) in listing.iter().zip(&plain) { + let d = spells.catalog.get(id).expect("listed"); + let mut want: Vec = d.reagents.iter().map(|r| r.0).filter(|&e| e != 0).collect(); + want.sort_unstable(); + want.dedup(); + let mut got = deps.reagents().to_vec(); + got.sort_unstable(); + assert_eq!(got, want, "spell {id}"); + } +} + +/// A pet view reads the player's side only where the builder does: of the unit fields, only the +/// ranged attack time. Its form, level, cost bases and reaches are the pet's own, and it never +/// searches the player's equipment. +#[test] +fn a_pet_view_reads_no_player_form_equipment_reach_or_unit_field_but_the_ranged_time() { + let Some(mut rig) = Rig::new() else { return }; + let Rig { + spells, + t, + objs, + ids, + layout, + .. + } = &mut rig; + let objects = objs.get(); + let pet = ObjectFields::from_pairs(&[(34, 30), (162, 900)]); + let m = mat( + layout, + Inputs::default(), + Params { + form: 1, + kind: 0, + family: 1, + }, + ); + let (mut expands_home, mut ranged) = (0, 0); + for &id in ids.iter() { + t.spell_mods = m.mods.clone(); + let mut ctx = t.ctx_for(&objects, 0, None, Some(&m.store)); + ctx.caster = ViewCaster::Pet(Some(&pet)); + let Some((_, deps)) = build_view(id, spells, &mut ctx) else { + continue; + }; + assert!( + !deps.form + && !deps.reach + && deps.worn == 0 + && !deps.disarm + && deps.unit & !UnitField::RangedTime.bit() == 0, + "the pet view of spell {id} reads the player's side: {deps:?}" + ); + expands_home += usize::from(deps.home); + ranged += deps.unit; + } + assert!(expands_home >= 1, "no pet view expands `$z`"); + assert!(ranged >= 1, "no pet view reads the ranged attack time"); +} + +/// Every field of the pet's unit that moves a pet view is one of the pet inputs the feed rebuilds +/// the pet views on. The fields go 48 at a time; a view that moves without a pet input moving is +/// then narrowed to the fields that move it. +#[test] +fn no_pet_field_moves_a_pet_view_unwatched() { + let Some(mut rig) = Rig::new() else { return }; + let Rig { + spells, + t, + objs, + ids, + layout, + .. + } = &mut rig; + let objects = objs.get(); + let player = mat( + layout, + Inputs::ALL, + Params { + form: 1, + kind: 0, + family: 1, + }, + ); + // Level, max health, max power of each type, reach, base mana. + let base_pairs: Vec<(u16, u32)> = vec![ + (34, 30), + (28, 600), + (29, 700), + (30, 1000), + (31, 100), + (32, 100), + (33, 10), + (130, 2.0f32.to_bits()), + (162, 900), + ]; + let pet_with = |poke: std::ops::Range| { + let mut pairs = base_pairs.clone(); + pairs.extend(poke.map(|field| (field, POKE))); + ObjectFields::from_pairs(&pairs) + }; + let build_one = |t: &mut TestCtx, id: u32, pet: &ObjectFields| { + t.spell_mods = player.mods.clone(); + let mut ctx = t.ctx_for( + &objects, + pet.unit_shapeshift_form(), + None, + Some(&player.store), + ); + ctx.caster = ViewCaster::Pet(Some(pet)); + ctx.combat_reach = pet.unit_combat_reach(); + build_view(id, spells, &mut ctx).map(|(view, _)| view) + }; + let base = pet_with(0..0); + let before: Vec<_> = ids.iter().map(|&id| build_one(t, id, &base)).collect(); + let (mut moved, mut misses) = (0, Vec::new()); + for first in (0..192u16).step_by(48) { + let pet = pet_with(first..first + 48); + for (&id, before) in ids.iter().zip(&before) { + if *before == build_one(t, id, &pet) { + continue; + } + moved += 1; + if PetInputs::of(None, Some(&pet), None) != PetInputs::of(None, Some(&base), None) { + continue; + } + let culprits: Vec = (first..first + 48) + .filter(|&field| *before != build_one(t, id, &pet_with(field..field + 1))) + .collect(); + let name = spells.catalog.get(id).map(|d| d.name.as_str()); + misses.push(format!( + "pet view of spell {id} {name:?} moved with its unit field(s) {culprits:?}" + )); + } + } + assert!( + misses.is_empty(), + "{} unwatched:\n{}", + misses.len(), + misses.join("\n") + ); + assert!(moved >= 100, "only {moved} pet views moved with their unit"); +} + +/// Every field of the player's unit and player blocks that moves a view is one the feed watches: +/// the fields go 64 at a time, from a populated player with and without the disarm flag up, and a +/// view that moves without a change hitting it is narrowed to the fields that move it. +#[test] +fn no_player_field_moves_a_view_unwatched() { + let Some(mut rig) = Rig::new() else { return }; + let mut moved = 0; + for base in [ + Inputs::ALL, + Inputs { + disarm: false, + ..Inputs::ALL + }, + ] { + for first in (0..1400u16).step_by(64) { + let poke = Some((first, 64.min(1400 - first))); + let flip = move |_: &Layout, _: &Deps| Inputs { + poke, + ..Inputs::default() + }; + let s = sweep(&mut rig, base, &any, &own, &flip); + assert_covered(&format!("the player's fields {first}.."), &s); + moved += s.moved; + } + } + assert!( + moved >= 2000, + "only {moved} views moved with a player field" + ); +} + +/// The equipped-item search's answer is a function of what the feed watches: for spells of each +/// hand restriction and armor, across every state of the main hand, the off hand and the chest +/// (empty, a guid with no object, an object with and without a template of each kind, whole, +/// worn, broken or without durability) and the disarm flag, two configurations that the watched +/// state does not tell apart give the view the same `item_met`. +#[test] +fn the_search_answers_the_same_wherever_the_watched_state_is_the_same() { + use std::collections::HashMap; + + // (class, subclass, flags): a sword, a mace's class with a shield's, a sword by another + // subclass, a deprecated sword, a shield. + const TEMPLATES: [(u32, u32, u32); 5] = + [(2, 7, 0), (4, 7, 0), (2, 3, 0), (2, 7, 0x10), (4, 6, 0)]; + // (durability, max durability): none, whole, worn but not broken, broken, no maximum. + const DURABILITY: [Option<(u32, u32)>; 5] = [ + None, + Some((100, 100)), + Some((50, 100)), + Some((0, 100)), + Some((0, 0)), + ]; + #[derive(Clone, Copy, Debug)] + enum Held { + Empty, + GuidOnly, + Object { + template: Option, + durability: usize, + }, + } + let all: Vec = [Held::Empty, Held::GuidOnly] + .into_iter() + .chain((0..5).flat_map(|d| { + (0..=TEMPLATES.len()).map(move |t| Held::Object { + template: t.checked_sub(1), + durability: d, + }) + })) + .collect(); + let chest: Vec = [Held::Empty, Held::GuidOnly] + .into_iter() + .chain([None, Some(0), Some(3), Some(4)].into_iter().flat_map(|t| { + [1, 3].into_iter().map(move |durability| Held::Object { + template: t, + durability, + }) + })) + .collect(); + let guid = |slot: u64, i: usize| 0x9_0000 + slot * 0x100 + i as u64; + let entry = |template: Option| 1000 + template.map_or(0, |t| 1 + t as u32); + + let mut objs = TestObjects::new(); + let mut items = Items::default(); + for (t, &(class, subclass, flags)) in TEMPLATES.iter().enumerate() { + items.insert_template( + entry(Some(t)), + Some(benilla_protocol::messages::ItemInfo { + class, + subclass, + flags, + ..crate::items::test_template("Item") + }), + ); + } + for (slot, states) in [(15u64, &all), (16, &all), (3, &chest)] { + for (i, held) in states.iter().enumerate() { + if let Held::Object { + template, + durability, + } = *held + { + let mut pairs = vec![(3u16, entry(template))]; + if let Some((now, max)) = DURABILITY[durability] { + pairs.extend([(46, now), (47, max)]); + } + objs.spawn(guid(slot, i), ObjectFields::from_pairs(&pairs)); + } + } + } + let objects = objs.get(); + + let spec = |id: u32, class: i32, mask: u32, ex3: u32| { + ( + id, + benilla_formats::SpellDisplay { + id, + name: format!("Spec {id}"), + equipped_item_class: class, + equipped_item_subclass_mask: mask, + attributes_ex3: ex3, + ..Default::default() + }, + ) + }; + let ids = [1u32, 2, 3, 4, 5]; + let specs = [ + spec(1, 2, 1 << 7, 0), + spec(2, 2, 1 << 7, 0x400), + spec(3, 2, 1 << 7, 0x0100_0000), + spec(4, 4, 1 << 6, 0), + spec(5, 2, 1 << 7 | 1 << 3, 0), + ]; + let spells = Spells { + catalog: benilla_formats::SpellCatalog::from_displays(specs.into_iter().collect()), + ..Spells::empty_for_tests() + }; + let (tx, _rx) = crossbeam_channel::unbounded(); + let commands = NetCommands(tx); + let mods = SpellModifiers::default(); + let get: &dyn Fn(&str) -> Option = &|_| None; + + // (spell, the watched state of the slots it reads, the disarm flag if it reads it) to its + // `item_met`, and the verdicts each spell reached. + let mut answers: HashMap<(u32, Vec, bool), bool> = HashMap::new(); + let mut verdicts: HashMap = HashMap::new(); + for (i15, h15) in all.iter().enumerate() { + for (i16, h16) in all.iter().enumerate() { + for (i3, h3) in chest.iter().enumerate() { + for disarmed in [false, true] { + let mut pairs: Vec<(u16, u32)> = Vec::new(); + for (slot, i, held) in [(15u64, i15, h15), (16, i16, h16), (3, i3, h3)] { + let g = match held { + Held::Empty => continue, + Held::GuidOnly => 0x8_0000 + slot, + Held::Object { .. } => guid(slot, i), + }; + pairs.push((486 + 2 * slot as u16, g as u32)); + pairs.push((487 + 2 * slot as u16, (g >> 32) as u32)); + } + if disarmed { + pairs.push((46, 0x0020_0000)); + } + let store = ObjectStore(ObjectFields::from_pairs(&pairs)); + for id in ids { + let mut vctx = ViewCtx { + home_area: None, + form: 0, + store: Some(&store), + caster: ViewCaster::Player, + combat_reach: 1.5, + attack_target_reach: None, + objects: &objects, + items: &mut items, + commands: &commands, + sub_classes: None, + skill_lines: None, + spell_mods: &mods, + get, + }; + let (view, deps) = build_view(id, &spells, &mut vctx).expect("a spec"); + assert!( + deps.disarm && deps.worn != 0, + "spec {id} searches: {deps:?}" + ); + let slots = (0..EQUIPMENT_SLOTS) + .filter(|&slot| deps.worn >> slot & 1 == 1) + .map(|slot| slot_item_cached(&store, slot, &objects, &items)) + .collect(); + let key = (id, slots, deps.disarm && crate::items::is_disarmed(&store)); + let seen = *answers.entry(key).or_insert(view.item_met); + assert_eq!( + seen, view.item_met, + "spec {id}: main {h15:?}, off {h16:?}, chest {h3:?}, disarmed {disarmed}" + ); + verdicts.entry(id).or_default()[usize::from(view.item_met)] += 1; + } + } + } + } + } + for (id, [without, with]) in verdicts { + assert!( + without > 100 && with > 100, + "spec {id} met {with} times, unmet {without}" + ); + } +} + +/// [`ModsDiff`] names the row of the cell that moved, whichever table and op. +#[test] +fn the_modifier_diff_names_the_family_flag_bit_of_a_changed_cell() { + let base = SpellModifiers::default(); + for (bit, op) in [(0u8, 0u8), (5, 14), (33, 28), (63, 28)] { + for flat in [true, false] { + let mut now = base.clone(); + now.set(flat, bit, op, 9); + assert_eq!( + now.diff(&base), + ModsDiff { + bits: 1 << bit, + class_family: false + }, + "bit {bit} op {op} flat {flat}" + ); + } + } + let mut now = base.clone(); + now.set_class_family(3); + assert_eq!( + now.diff(&base), + ModsDiff { + bits: 0, + class_family: true + } + ); + assert_eq!(base.diff(&base), ModsDiff::default()); +} + +/// A percentage's own bit, a worn slot's own bit, a skill line by its id, each unit field by its +/// own bit and a player appearing, through the diff the feed runs each frame. +#[test] +fn the_seen_diff_names_each_input_it_saw_move() { + let mut objs = TestObjects::new(); + let objects = objs.get(); + let items = Items::default(); + let player = |pairs: &[(u16, u32)]| ObjectStore(ObjectFields::from_pairs(pairs)); + let seen = |s: &ObjectStore| Seen::of(Some(s), None, None, &objects, &items); + let base = player(&[(22, 100)]); + for (i, field) in [1106u16, 1107, 1108, 1109].into_iter().enumerate() { + let now = player(&[(22, 100), (field, 4.5f32.to_bits())]); + let c = seen(&now).changes_since(&seen(&base)); + assert_eq!(c.avoidance, 1 << i, "percentage field {field}"); + assert_eq!((c.worn, c.form, c.reach, c.home), (0, false, false, false)); + assert_eq!((c.unit, c.disarm), (0, false)); + } + // A guid the object index does not hold is an unresolved item: the slot moved. + let now = player(&[(22, 100), (486 + 2 * 15, 7)]); + assert_eq!(seen(&now).changes_since(&seen(&base)).worn, 1 << 15); + let now = player(&[(22, 100), (486 + 2 * 22, 7)]); + assert_eq!( + seen(&now).changes_since(&seen(&base)).worn, + 1 << 22, + "a bag" + ); + let now = player(&[(22, 100), (138, 1 << 16)]); + assert!(seen(&now).changes_since(&seen(&base)).form); + let now = player(&[(22, 100), (130, 3.0f32.to_bits())]); + assert!(seen(&now).changes_since(&seen(&base)).reach); + let engaged = Seen::of(Some(&base), None, Some(2.0), &objects, &items); + assert!(engaged.changes_since(&seen(&base)).reach); + let bound = Seen::of(Some(&base), Some("Ironforge"), None, &objects, &items); + assert!(bound.changes_since(&seen(&base)).home); + let gone = Seen::of(None, None, None, &objects, &items); + assert_eq!(gone.changes_since(&seen(&base)).worn, EQUIPMENT_MASK); + assert!(seen(&base).changes_since(&seen(&base)).is_empty()); + // The disarm flag, and no other flag. + let now = player(&[(22, 100), (46, 0x0020_0000)]); + let c = seen(&now).changes_since(&seen(&base)); + assert!(c.disarm); + let now = player(&[(22, 100), (46, 0x0000_0008)]); + assert!(seen(&now).changes_since(&seen(&base)).is_empty()); + // Each unit field on its own bit: level 34, max health 28, max power 29-33, base mana 162, + // ranged attack time 128. + let unit = [ + (34u16, UnitField::Level), + (162, UnitField::BaseMana), + (28, UnitField::MaxHealth), + (29, UnitField::MaxPower(0)), + (31, UnitField::MaxPower(2)), + (33, UnitField::MaxPower(4)), + (128, UnitField::RangedTime), + ]; + for (field, which) in unit { + let now = player(&[(22, 100), (field, 77)]); + let c = seen(&now).changes_since(&seen(&base)); + assert_eq!(c.unit, which.bit(), "unit field {field}"); + } + // A line's value moving names the line. + let line = |value: u32| player(&[(22, 100), (718, 43), (719, value | 300 << 16)]); + assert_eq!( + seen(&line(40)).changes_since(&seen(&line(39))).skill_lines, + [43] + ); +} diff --git a/crates/benilla-app/src/ui_tooltip/feed_tests.rs b/crates/benilla-app/src/ui_tooltip/feed_tests.rs new file mode 100644 index 00000000..9737d1d8 --- /dev/null +++ b/crates/benilla-app/src/ui_tooltip/feed_tests.rs @@ -0,0 +1,239 @@ +//! The feed re-pushing only the views a change reaches, through [`feed_spell_tooltips`] itself. + +use benilla_protocol::ObjectFields; +use benilla_ui::script::UiScript; + +use super::spell_feed::feed_spell_tooltips; +use crate::items::Items; +use crate::net::{NetCommands, ObjectStore, SelfPlayer}; +use crate::ui_action::{PlayerActions, Spells}; +use bevy::prelude::*; + +/// A feed over a bare app: the book holds `make(1)`'s spells, the player is `fields`, and each +/// description names the generation of the catalog it was built from. The feed re-pushes a view +/// only when it must, so which generation a tooltip shows says which views were rebuilt. +struct FeedApp { + app: App, + player: Entity, + ids: Vec, + make: Box Vec<(u32, benilla_formats::SpellDisplay)>>, +} + +impl FeedApp { + fn new( + make: impl Fn(u32) -> Vec<(u32, benilla_formats::SpellDisplay)> + 'static, + fields: &[(u16, u32)], + ) -> Self { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.insert_resource(NetCommands(tx)) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, feed_spell_tooltips); + let player = app + .world_mut() + .spawn((SelfPlayer, ObjectStore(ObjectFields::from_pairs(fields)))) + .id(); + let ids: Vec = make(1).iter().map(|&(id, _)| id).collect(); + app.world_mut().resource_mut::().spells = ids.iter().copied().collect(); + let mut script = UiScript::new().unwrap(); + script.set_spellbook(benilla_ui::script::SpellBookState { + tabs: Vec::new(), + slots: ids + .iter() + .map(|&id| benilla_ui::script::SpellSlotView { + spell_id: id, + name: format!("Spell {id}"), + ..Default::default() + }) + .collect(), + }); + script + .run( + r#" + local a = CreateFrame("Button", "B"); a:SetPoint("CENTER", 0, 0) + a:SetWidth(10); a:SetHeight(10) + CreateFrame("GameTooltip", "TT") + function TEXT(i) + TT:SetOwner(B, "ANCHOR_RIGHT"); TT:SetSpell(i, "spell") + local t = {} + for k = 1, TT:NumLines() do t[k] = getglobal("TTTextLeft" .. k):GetText() end + return table.concat(t, " | ") + end + "#, + ) + .unwrap(); + app.insert_non_send_resource(script); + let mut feed = FeedApp { + app, + player, + ids, + make: Box::new(make), + }; + feed.catalog(1); + feed.app.update(); + feed + } + + /// The catalog as generation `generation`. + fn catalog(&mut self, generation: u32) { + let catalog = (self.make)(generation).into_iter().collect(); + self.app.insert_resource(Spells { + catalog: benilla_formats::SpellCatalog::from_displays(catalog), + ..Spells::empty_for_tests() + }); + } + + /// Set the player's fields. + fn set_player(&mut self, fields: &[(u16, u32)]) { + self.app + .world_mut() + .entity_mut(self.player) + .insert(ObjectStore(ObjectFields::from_pairs(fields))); + } + + /// The generation each spell's tooltip shows, in book order. + fn generations(&mut self) -> Vec { + let script = self.app.world().non_send_resource::(); + (1..=self.ids.len()) + .map(|i| { + let text: String = script.eval(&format!("return TEXT({i})")).unwrap(); + text.split(" | ") + .find_map(|line| line.strip_prefix("gen")) + .and_then(|g| g.parse().ok()) + .unwrap_or_else(|| panic!("no generation in {text:?}")) + }) + .collect() + } +} + +/// A dodge percentage moving rebuilds the dodge spell's view and no other: the parry and crit +/// spells read other percentages, the plain spell none. A change no view read rebuilds nothing. +#[test] +fn a_dodge_change_rebuilds_only_the_dodge_chance_view() { + let spell = |effect: u32, passive: bool, generation: u32| benilla_formats::SpellDisplay { + name: "Spell".into(), + effects: [effect, 0, 0], + passive, + description: Some(format!("gen{generation}")), + ..Default::default() + }; + let spells = move |g: u32| { + vec![ + (1, spell(20, true, g)), // chance to dodge + (2, spell(22, true, g)), // chance to parry + (3, spell(78, false, g)), // chance to crit + (4, spell(0, false, g)), // no chance line + (5, spell(20, false, g)), // the dodge effect without the passive gate: no line + ] + }; + let player = |dodge: f32, health: u32| { + [ + (22, health), + (1107, dodge.to_bits()), + (1108, 6.0f32.to_bits()), + (1109, 7.0f32.to_bits()), + ] + }; + let mut feed = FeedApp::new(spells, &player(5.0, 100)); + assert_eq!(feed.generations(), [1, 1, 1, 1, 1], "every view is pushed"); + + feed.catalog(2); + feed.set_player(&player(9.5, 100)); + feed.app.update(); + assert_eq!( + feed.generations(), + [2, 1, 1, 1, 1], + "only the dodge view read the dodge percentage" + ); + + // Health is nothing a view reads. + feed.catalog(3); + feed.set_player(&player(9.5, 60)); + feed.app.update(); + assert_eq!(feed.generations(), [2, 1, 1, 1, 1], "nothing was rebuilt"); + + // The other two percentages, one frame each. + feed.set_player(&[ + (22, 60), + (1107, 9.5f32.to_bits()), + (1108, 8.0f32.to_bits()), + (1109, 7.0f32.to_bits()), + ]); + feed.app.update(); + assert_eq!(feed.generations(), [2, 3, 1, 1, 1], "parry"); + feed.catalog(4); + feed.set_player(&[ + (22, 60), + (1107, 9.5f32.to_bits()), + (1108, 8.0f32.to_bits()), + (1109, 1.0f32.to_bits()), + ]); + feed.app.update(); + assert_eq!(feed.generations(), [2, 3, 4, 1, 1], "crit"); +} + +/// A modifier packet rebuilds the views of the spells whose family bit it names, for the class the +/// tables gate on, and no other; a new class family rebuilds them all. +#[test] +fn a_modifier_change_rebuilds_only_the_matching_familys_views() { + let spell = |family: u32, bits: &[u32], generation: u32| benilla_formats::SpellDisplay { + name: "Spell".into(), + spell_family: family, + spell_family_flags: bits.iter().fold(0u64, |mask, b| mask | 1 << b), + description: Some(format!("gen{generation}")), + ..Default::default() + }; + let spells = move |g: u32| { + vec![ + (10, spell(3, &[5], g)), // a mage spell on bit 5 + (11, spell(3, &[6], g)), // a mage spell on bit 6 + (12, spell(8, &[5], g)), // a rogue spell on the same bit + (13, spell(0, &[5], g)), // no family: never modified + (14, spell(3, &[5, 6], g)), // a mage spell on both + ] + }; + let mut feed = FeedApp::new(spells, &[(22, 100)]); + let mods = |feed: &mut FeedApp, f: &dyn Fn(&mut crate::spell::SpellModifiers)| { + f(&mut feed + .app + .world_mut() + .resource_mut::()); + feed.app.update(); + }; + // The class family arrives after the first push. + feed.catalog(2); + mods(&mut feed, &|m| m.set_class_family(3)); + assert_eq!( + feed.generations(), + [2, 2, 2, 2, 2], + "a new class family rebuilds every view" + ); + + feed.catalog(3); + mods(&mut feed, &|m| m.set(true, 5, crate::spell::OP_COST, -10)); + assert_eq!( + feed.generations(), + [3, 2, 2, 2, 3], + "the mage spells with bit 5" + ); + + // The other table, another op, the other bit. + feed.catalog(4); + mods(&mut feed, &|m| { + m.set(false, 6, crate::spell::OP_CAST_TIME, 20) + }); + assert_eq!(feed.generations(), [3, 4, 2, 2, 4], "bit 6"); + + // A bit no spell carries, and a packet that restates a cell, rebuild nothing. + feed.catalog(5); + mods(&mut feed, &|m| m.set(true, 40, crate::spell::OP_COST, 5)); + mods(&mut feed, &|m| m.set(true, 5, crate::spell::OP_COST, -10)); + assert_eq!( + feed.generations(), + [3, 4, 2, 2, 4], + "the mage spells with bit 40" + ); +} diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index d2d967d7..b050c06b 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -1,23 +1,25 @@ //! The world-mouseover tooltip and the spell tooltip views. The world tooltip rebuilds once per //! hover-target change and a lost hover arms a fade; the pick is [`Hovered`] or -//! [`HoveredObject`], arbitrated by [`go_is_nearest`] as the click router does. +//! [`HoveredObject`], arbitrated by [`go_is_nearest`] as the click router does. The spell views +//! are built and pushed ahead of a hover by [`spell_feed`]. -use crate::ui_items::{count_of, InventoryScope}; use bevy::prelude::*; use bevy::window::PrimaryWindow; use benilla_ui::script::{TooltipTint, UiScript, UnitState}; use benilla_ui::strings::Arg; -use crate::items::Items; use crate::names::NameCache; -use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer}; +use crate::net::{NetCommands, ObjectStore, SelfPlayer}; use crate::target::{go_is_nearest, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC}; -use crate::ui_action::{PlayerActions, Spells}; use crate::ui_script::UiFeed; -use crate::ui_trainer::{TrainerFeed, TrainerTooltipSubjects}; +use crate::ui_trainer::TrainerFeed; use crate::ui_unit::{enrich_unit, snapshot, unit_reaction, UnitFeed}; +mod spell_deps; +mod spell_feed; +use spell_feed::feed_spell_tooltips; + pub struct UiTooltipPlugin; impl Plugin for UiTooltipPlugin { @@ -39,679 +41,12 @@ impl Plugin for UiTooltipPlugin { } } -/// The unit a view's caster cells are computed against, the spell builder `0x52e610`'s fourth -/// argument: the level the `$`-tokens, the cost and the cast time scale by, and the cost's basis. -#[derive(Clone, Copy)] -enum ViewCaster<'a> { - /// The active player ([`ViewCtx::store`]), selector 0. - Player, - /// Selector 1: the player's charm, else its summon (`0x6e3159`-`0x6e317f`, `0x6e31fe`- - /// `0x6e3228`), `None` when neither is streamed. - Pet(Option<&'a benilla_protocol::ObjectFields>), -} - -impl ViewCaster<'_> { - /// The pet's `[vtbl+0xa8]`, `CGUnit`'s `0x60cd80`: `UNIT_FIELD_LEVEL × 5`, which - /// [`benilla_formats::SpellDisplay::skill_level`] caps at `maxLevel × 5` and divides by 5 as - /// `0x60cdb2`-`0x60cdb9` and `0x6e3195` do. No unit reads 0 (`0x6e31a4`). - fn pet_skill_value(pet: Option<&benilla_protocol::ObjectFields>) -> u32 { - pet.and_then(|p| p.unit_level()) - .unwrap_or(0) - .saturating_mul(5) - } -} - -/// The caster-dependent inputs of a spell view: the worn set (`0x5f0c50`), the bags, the form and -/// the bind point `$z` names. -struct ViewCtx<'a, 'w, 's> { - home_area: Option<&'a str>, - /// The caster's shapeshift form, which the required-form line's colour reads (`0x52f1f2`). - form: u8, - /// The active player. - store: Option<&'a ObjectStore>, - /// Whom the level terms and the cost read: the player or its pet. The form and the reaches - /// are that same unit's. - caster: ViewCaster<'a>, - /// The caster's `UNIT_FIELD_COMBATREACH`; 1.5 is the descriptor default. - combat_reach: f32, - /// The caster's auto-attack target's reach, which `0x6e3480` reads itself (`[caster+0xc48]`); - /// with none, the caster's reach counts twice. - attack_target_reach: Option, - /// The object index the worn-item search and each reagent's carried count resolve through. - objects: &'a Objects<'w, 's>, - items: &'a mut Items, - commands: &'a NetCommands, - sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>, - /// The spell-to-line hop the `$`-tokens' skill level reads ([`crate::spell::spell_skill_value`]). - skill_lines: Option<&'a benilla_formats::SkillLineCatalog>, - /// The talent spell-modifier tables: the cost cell shows the modified cost (`power_cost`). - spell_mods: &'a crate::spell::SpellModifiers, - /// The VM's `GlobalStrings.lua`, where the keyed cells and the `$` tokens' templates resolve. - get: &'a dyn Fn(&str) -> Option, -} - /// Fill a key's template from the VM's `GlobalStrings.lua`; no string shows nothing. fn keyed(get: &dyn Fn(&str) -> Option, key: &str, args: &[Arg<'_>]) -> Option { let text = benilla_ui::strings::fill(&get(key)?, args); (!text.is_empty()).then_some(text) } -/// Build one spell's tooltip view, every cell resolved here where the catalogs live. The range, -/// cast, cooldown, form and item cells fill their `GlobalStrings.lua` keys; the cost, reagents and -/// chance cells are composed in English, where the reference fills `MANA_COST` and its kin, -/// `SPELL_REAGENTS` and `CHANCE_TO_*`. -fn spell_tooltip_view( - spell_id: u32, - spells: &Spells, - vctx: &mut ViewCtx, -) -> Option { - let d = spells.catalog.get(spell_id)?; - let home_area = vctx.home_area; - let form = vctx.form; - let caster = vctx.caster; - // The `$`-tokens' level (`0x5075f0` hands the selector to `0x6e3130`, `507645`-`50764a`). - let skill = |id| match caster { - ViewCaster::Player => crate::spell::spell_skill_value(vctx.store, vctx.skill_lines, id), - ViewCaster::Pet(pet) => ViewCaster::pet_skill_value(pet), - }; - // `0x6e3130` for this spell: the level the cost's and the cast time's per-level terms read. - let pet_level = |pet| d.skill_level(ViewCaster::pet_skill_value(pet)); - let ctx = benilla_formats::TokenContext { - durations: &spells.durations, - radii: &spells.radii, - ranges: Some(&spells.ranges), - skill: &skill, - lookup: &|id| spells.catalog.get(id), - mods: Some(vctx.spell_mods), - unmodified_points: false, - home_area, - global: vctx.get, - printf: &crate::ui_script::token_printf, - }; - // The aura tooltip `0x52f880` expands the aura text with the points' modifiers off (`52f940`). - let aura_ctx = benilla_formats::TokenContext { - unmodified_points: true, - ..ctx - }; - // The cost cell (`0x52e8ad`): the resolved `power_cost`, named by power type (keys at - // `0x85416c`) and Health for a type outside 0..=4 (`0x52e8fc`: Bloodrage's -2), never a - // percentage. Rage shows wire ÷ 10, a per-second column adds `_PER_TIME`, and no cost at all - // leaves the cell empty. A pet with no unit costs -1 (`0x6e3233`), which shows as none. - let resolved_cost = match caster { - ViewCaster::Player => vctx.store.map_or(d.mana_cost, |s| { - crate::spell::usable::power_cost(d, s, vctx.spell_mods) - }), - ViewCaster::Pet(pet) => pet.map_or(0, |p| { - crate::spell::usable::power_cost_at(d, p, pet_level(Some(p)), vctx.spell_mods) - }), - }; - let cost = { - // The shared per-type divisor (`0x6e7130`), never a local `power_type == 1` test. - let div = benilla_protocol::messages::power_display_scale(d.power_type); - let unit = match d.power_type { - 0 => "Mana", - 1 => "Rage", - 2 => "Focus", - 3 => "Energy", - 4 => "Happiness", - _ => "Health", - }; - if resolved_cost == 0 && d.mana_per_second == 0 { - None - } else if d.mana_per_second > 0 { - Some(format!( - "{} {unit}, plus {} per sec", - resolved_cost / div, - d.mana_per_second / div - )) - } else { - Some(format!("{} {unit}", resolved_cost / div)) - } - }; - // The range cell: two attribute gates, `GetMinMaxRange` (`0x6e3480`, `0x52e9c2`), then - // `max <= 0`. There is no melee wording: the melee row (flags bit 0, row 2 only) resolves to - // `max(reach + casterReach + 1.3333334, 5.0)` and prints like any other, usually "5 yd range". - let range = (!d.tooltip_omits_range_line()) - .then(|| { - let (min, max) = vctx.spell_mods.min_max_range( - d, - spells.ranges.get(d.range_index), - vctx.combat_reach, - vctx.attack_target_reach, - )?; - if max <= 0.0 { - return None; - } - // `SPELL_RANGE`'s hole is a string: the number is composed first, `"%d"` or `"%d-%d"` - // (`0x854fb4`, Charge's 8-25), each a `fistp` conversion that rounds to nearest. - let yd = |v: f32| f64::from(v).round_ties_even() as i64; - let yards = if min > 0.0 { - format!("{}-{}", yd(min), yd(max)) - } else { - format!("{}", yd(max)) - }; - keyed(vctx.get, "SPELL_RANGE", &[Arg::S(&yards)]) - }) - .flatten(); - // The cast line: gate `0x52eb15` also omits it for a TRADE_SKILL or ATTACK `Effect[0]`; then - // the ladder `0x52eb45`-`0x52ec90` over `GetCastTime(1)` (`52eb4d`), unclamped, where a - // negative time is "Instant cast" (`0x52ebce`) and a zero one is "Instant cast" only for a - // mana spell with a cost (`0x52ec4b`). - let cast_time = if d.tooltip_omits_cast_line() { - None - } else { - // `GetCastTime` takes the selector too (`52eb45`), and its level term is `0x6e3130`'s. - let level = match caster { - ViewCaster::Player => vctx.store.and_then(|s| s.0.unit_level()).unwrap_or(0), - ViewCaster::Pet(pet) => pet_level(pet), - }; - let base = spells.cast_time_unclamped_ms(d, level, vctx.spell_mods); - // `%.3g` templates: the seconds go over as a real. - if base > 0 { - let (key, v) = if base >= 60_000 { - ("SPELL_CAST_TIME_MIN", f64::from(base) / 60_000.0) - } else { - ("SPELL_CAST_TIME_SEC", f64::from(base) / 1000.0) - }; - keyed(vctx.get, key, &[Arg::F(v)]) - } else { - let key = if base < 0 { - "SPELL_CAST_TIME_INSTANT" - } else if d.on_next_swing() { - "SPELL_ON_NEXT_SWING" - } else if d.tooltip_on_next_ranged() { - "SPELL_ON_NEXT_RANGED" - } else if d.tooltip_channeled() { - "SPELL_CAST_CHANNELED" - } else if d.power_type == 0 && resolved_cost > 0 { - "SPELL_CAST_TIME_INSTANT" - } else { - "SPELL_CAST_TIME_INSTANT_NO_MANA" - }; - keyed(vctx.get, key, &[]) - } - }; - // The larger recovery column (`0x52eada`): op 11 follows the ranged-speed category pad. - let ranged_ms = if d.ranged_speed_cooldown() { - vctx.store - .and_then(|s| s.0.unit_ranged_attack_time()) - .unwrap_or(0) - } else { - 0 - }; - let (own_recovery, category_recovery) = vctx.spell_mods.spell_cooldowns(d, ranged_ms); - let recovery_ms = own_recovery.max(category_recovery); - let cooldown = (recovery_ms > 0) - .then(|| { - let secs = f64::from(recovery_ms) / 1000.0; - let (key, v) = if secs >= 60.0 { - ("SPELL_RECAST_TIME_MIN", secs / 60.0) - } else { - ("SPELL_RECAST_TIME_SEC", secs) - }; - keyed(vctx.get, key, &[Arg::F(v)]) - }) - .flatten(); - // The required-form line (`0x52f10a`-`0x52f2ae`). With `AttributesEx2` bit 19 set the mask is - // permissive (Inner Fire in Shadowform), and the line is skipped (`0x52f115`). - let requires_form = (d.stances != 0 && !d.form_mask_is_permissive()) - .then(|| { - let names: Vec<&str> = (0..32u32) - .filter(|b| d.stances & (1 << b) != 0) - .filter_map(|b| spells.forms.get(&(b + 1)).map(|f| f.name.as_str())) - .filter(|n| !n.is_empty()) - .collect(); - (!names.is_empty()) - .then(|| { - keyed( - vctx.get, - "SPELL_REQUIRED_FORM", - &[Arg::S(&names.join(", "))], - ) - }) - .flatten() - }) - .flatten(); - // No caster unit passes the form test (`0x52f1e8`). - let form_met = matches!(caster, ViewCaster::Pet(None)) - || (form != 0 && d.stances & (1u32 << (u32::from(form) - 1)) != 0); - // The required-item line (`0x52eea7`-`0x52f10a`): a mask is named whole by - // `ItemSubClassMask.dbc` before its subclasses are joined; an empty mask or class < 0 skips it. - let requires_item = (d.targets & TARGET_ITEM == 0 && d.equipped_item_class >= 0) - .then(|| { - vctx.sub_classes? - .requirement_name(d.equipped_item_class as u32, d.equipped_item_subclass_mask) - }) - .flatten() - .and_then(|name| keyed(vctx.get, "SPELL_EQUIPPED_ITEM", &[Arg::S(&name)])); - let chance = chance_line(d, vctx.store); - // The unit selector passes the item test outright (`0x52f07f`). - let item_met = matches!(caster, ViewCaster::Pet(_)) - || vctx.store.is_none_or(|s| { - crate::spell::usable::equipped_item_fits(d, s, vctx.objects, vctx.items, vctx.commands) - }); - // Reagents (`SPELL_REAGENTS`, `0x854e54`), red when short; one whose template has not - // streamed is absent until `feed_spell_tooltips` re-pushes on its arrival. - let reagents = { - let mut parts: Vec = Vec::new(); - for (entry, count) in d.reagents.iter().copied().filter(|&(e, _)| e != 0) { - let Some(name) = vctx - .items - .template(entry, 0, vctx.commands) - .map(|t| t.name.clone()) - else { - continue; - }; - let text = if count > 1 { - format!("{name} ({count})") - } else { - name - }; - let short = vctx.store.is_some_and(|s| { - count_of(&s.0, vctx.objects, entry, InventoryScope::CARRIED) < count - }); - parts.push(if short { - format!("|cffff2020{text}|r") - } else { - text - }); - } - (!parts.is_empty()).then(|| format!("Reagents: {}", parts.join(", "))) - }; - Some(benilla_ui::script::SpellTooltipView { - name: d.name.clone(), - rank: d.rank.clone(), - // The aura variant's right column (`0x52f8e5`), gated on `SpellDispelType.dbc` `[+0x28]`. - dispel_type: spells.catalog.dispel_name(d).map(str::to_string), - cost, - range, - cast_time, - cooldown, - requires_item, - item_met, - requires_form, - form_met, - chance, - reagents, - description: d - .description - .as_deref() - .map(|t| benilla_formats::substitute(t, d, &ctx)) - .unwrap_or_default(), - aura_description: d - .aura_description - .as_deref() - .map(|t| benilla_formats::substitute(t, d, &aura_ctx)) - .unwrap_or_default(), - }) -} - -/// `Targets` bit `0x10` (name inferred): set, the required-item line is skipped (`0x52eeaa`). -const TARGET_ITEM: u32 = 0x10; - -/// The four `Effect[0]` values that select a chance-to-X line (jump table `0x52f7c4`). -const EFFECT_DODGE: u32 = 20; -const EFFECT_PARRY: u32 = 22; -const EFFECT_BLOCK: u32 = 23; -const EFFECT_ATTACK: u32 = 78; - -/// The chance-to-X line (`0x52f5b1`-`0x52f697`): ATTACK skips the passive gate that dodge, parry -/// and block need. The wire's values are already percents. -fn chance_line(d: &benilla_formats::SpellDisplay, store: Option<&ObjectStore>) -> Option { - let (label, percentage) = match d.effects[0] { - EFFECT_ATTACK => ("crit", store?.0.player_crit_percentage()?), - EFFECT_DODGE if d.passive => ("dodge", store?.0.player_dodge_percentage()?), - EFFECT_PARRY if d.passive => ("parry", store?.0.player_parry_percentage()?), - EFFECT_BLOCK if d.passive => ("block", store?.0.player_block_percentage()?), - _ => return None, - }; - // The enUS text of the reference's `CHANCE_TO_*` keys (`%.2f%% chance to crit`). - Some(format!("{percentage:.2}% chance to {label}")) -} - -/// What a pushed view snapshots at build time; a change to any of it re-pushes every view, and a -/// change to the pet's inputs every pet view. The reference rebuilds on every hover and keeps no -/// such snapshot. -#[derive(Default)] -struct SpellFeedMemory { - pushed: std::collections::HashSet, - /// The spells whose view built against the pet is in the VM's pet store. - pet_pushed: std::collections::HashSet, - /// What the pet views read off the pet unit. - pet: Option, - /// The bind point `$z` names. - home: Option, - /// The form the required-form line's colour follows (`0x52f1e3`). - form: Option, - /// The skills the descriptions' per-level terms scale by ([`crate::spell::skill_snapshot`]). - skills: Option, - /// The 19 worn-slot guids the required-item line's colour follows (`0x5f0c50`). - worn: Option<[u64; 19]>, - /// The block, dodge, parry and crit percentages as bit patterns (`0x52f5b1`). - avoidance: Option<[u32; 4]>, - /// The caster's and its auto-attack target's reach as bit patterns. - combat_reach: Option<(Option, Option)>, - /// Per reagent on show, `(owned count, name resolved)` (the inline red, `0x854120`). - reagents: std::collections::BTreeMap, -} - -impl SpellFeedMemory { - /// Queue every pushed view, the player's and the pet's, for a rebuild. - fn invalidate(&mut self, wanted: &mut Vec, wanted_pet: &mut Vec) { - wanted.extend(self.pushed.drain()); - wanted_pet.extend(self.pet_pushed.drain()); - } -} - -/// The pet unit's fields a pet view reads (`0x52e610` with the unit selector set): its level, the -/// cost bases `0x612c50` reads, its form, and its and its melee target's reach, as bit patterns. -#[derive(Clone, Copy, PartialEq, Default)] -struct PetInputs { - guid: Option, - level: u32, - base_mana: u32, - max_health: u32, - max_power: [u32; 5], - form: u8, - combat_reach: u32, - attack_target_reach: Option, -} - -impl PetInputs { - fn of( - guid: Option, - pet: Option<&benilla_protocol::ObjectFields>, - attack_target_reach: Option, - ) -> Self { - let Some(p) = pet else { - return Self { - guid, - ..Self::default() - }; - }; - Self { - guid, - level: p.unit_level().unwrap_or(0), - base_mana: p.unit_base_mana().unwrap_or(0), - max_health: p.unit_max_health().unwrap_or(0), - max_power: std::array::from_fn(|i| p.unit_max_power(i as u8).unwrap_or(0)), - form: p.unit_shapeshift_form(), - combat_reach: p.unit_combat_reach().to_bits(), - attack_target_reach: attack_target_reach.map(f32::to_bits), - } - } -} - -/// The hoverable spell sources [`feed_spell_tooltips`] reads, one parameter under Bevy's 16. -#[derive(bevy::ecs::system::SystemParam)] -struct SpellTooltipSources<'w> { - spells: Option>, - trainer_subjects: Option>, - talents: Option>, -} - -/// Push a view for every spell the UI can hover (the book, the class's talent ranks, the open -/// trainer's services, and what the VM holds: the pet's spells, the quest rewards, the craft's -/// subjects, every unit's auras and the tracking spell) before it is hovered, as the reference -/// reads them all locally; an ask for any other id too. -fn feed_spell_tooltips( - script: Option>, - actions: Option>, - spell_sources: SpellTooltipSources, - self_q: Query<&ObjectStore, With>, - // The player's and the pet's auto-attack target, the melee range cell's second reach. - engaged_q: Query<&crate::creature_anim::Engaged, With>, - engaged_units: Query<&crate::creature_anim::Engaged>, - objects: Objects, - home_bind: Option>, - area_names: Option>, - mut items: ResMut, - // One tuple param, under Bevy's 16-param ceiling. - lookups: ( - Option>, - Res, - Option>, - ), - commands: Res, - mut memory: Local>, -) { - let (sub_classes, spell_mods, skill_lines) = &lookups; - let SpellTooltipSources { - spells, - trainer_subjects, - talents, - } = spell_sources; - let Some(mut script) = script else { - return; - }; - let memory = memory.get(&script); - let Some(spells) = spells.as_deref() else { - return; - }; - let mut wanted: Vec = script.take_spell_tooltip_asks(); - // What the VM holds for a hover: pet spells, quest rewards, craft subjects, auras, tracking. - wanted.extend( - script - .spell_tooltip_subjects() - .into_iter() - .filter(|s| !memory.pushed.contains(s)), - ); - if let Some(actions) = actions.as_deref() { - wanted.extend( - actions - .spells - .iter() - .copied() - .filter(|s| !memory.pushed.contains(s)), - ); - } - // The pet views: the pet's bar and book, and what their setters asked for. - let mut wanted_pet: Vec = script.take_pet_spell_tooltip_asks(); - wanted_pet.extend( - script - .pet_spell_tooltip_subjects() - .into_iter() - .filter(|s| !memory.pet_pushed.contains(s)), - ); - // The open trainer's services, which the detail icon's `SetTrainerService` renders, a - // pet-learn service's against the pet. - if let Some(trainer) = trainer_subjects.as_deref() { - wanted.extend( - trainer - .player - .iter() - .copied() - .filter(|s| !memory.pushed.contains(s)), - ); - wanted_pet.extend( - trainer - .pet - .iter() - .copied() - .filter(|s| !memory.pet_pushed.contains(s)), - ); - } - // Every rank of the class's talents: a talent tooltip reads the current and the next rank. - if let (Some(talents), Ok(store)) = (talents.as_deref(), self_q.single()) { - let race = store.0.unit_race().unwrap_or(0); - let class = store.0.unit_class().unwrap_or(0); - for tab in talents.catalog.tabs_for_class(race, class) { - for t in talents.catalog.talents_in_tab(tab.id) { - wanted.extend( - t.ranks - .iter() - .copied() - .filter(|s| *s != 0 && !memory.pushed.contains(s)), - ); - } - } - } - let home_area: Option = home_bind - .as_deref() - .and_then(|b| b.0) - .and_then(|id| area_names.as_deref()?.0.resolve(id as i32)) - .map(str::to_string); - // A bind-point change re-substitutes every view's `$z` (Astral Recall). - if memory.home != home_area { - memory.home = home_area.clone(); - memory.invalidate(&mut wanted, &mut wanted_pet); - } - // So does a form change: the required-form line's colour follows the form (`0x52f1e3`). - let form = self_q - .single() - .map(|s| s.0.unit_shapeshift_form()) - .unwrap_or(0); - if memory.form != Some(form) { - memory.form = Some(form); - memory.invalidate(&mut wanted, &mut wanted_pet); - } - let self_store = self_q.single().ok(); - // So does a skill change: the `$`-tokens' per-level terms scale by the spell's line. - let skills = crate::spell::skill_snapshot(self_store); - if memory.skills != Some(skills) { - memory.skills = Some(skills); - memory.invalidate(&mut wanted, &mut wanted_pet); - } - // So do the worn set and, below, the reagents on show. - let worn = - self_store.map(|s| std::array::from_fn(|i| s.0.player_inv_slot(i as u8).unwrap_or(0))); - if memory.worn != worn { - memory.worn = worn; - memory.invalidate(&mut wanted, &mut wanted_pet); - } - let avoidance = self_store.map(|s| { - [ - s.0.player_block_percentage(), - s.0.player_dodge_percentage(), - s.0.player_parry_percentage(), - s.0.player_crit_percentage(), - ] - .map(|v| v.unwrap_or(0.0).to_bits()) - }); - if memory.avoidance != avoidance { - memory.avoidance = avoidance; - memory.invalidate(&mut wanted, &mut wanted_pet); - } - let target_reach = - |e: &crate::creature_anim::Engaged| objects.object(e.0).map(|f| f.unit_combat_reach()); - let attack_target_reach = engaged_q.single().ok().and_then(target_reach); - let reaches = ( - self_store.map(|s| s.0.unit_combat_reach().to_bits()), - attack_target_reach.map(f32::to_bits), - ); - if memory.combat_reach != Some(reaches) { - memory.combat_reach = Some(reaches); - memory.invalidate(&mut wanted, &mut wanted_pet); - } - // So does a spell-modifier change: the cost cell resolves through the talent tables. - if spell_mods.is_changed() { - memory.invalidate(&mut wanted, &mut wanted_pet); - } - let watched: Vec = memory.reagents.keys().copied().collect(); - let reagent_state: std::collections::BTreeMap = watched - .into_iter() - .map(|entry| { - let named = items.template(entry, 0, &commands).is_some(); - let owned = self_store.map_or(0, |s| { - count_of(&s.0, &objects, entry, InventoryScope::CARRIED) - }); - (entry, (owned, named)) - }) - .collect(); - if memory.reagents != reagent_state { - memory.reagents = reagent_state; - memory.invalidate(&mut wanted, &mut wanted_pet); - } - // The pet's inputs: a new pet, or its level, bases, form or reach moving, rebuilds the pet - // views alone. - let pet_guid = self_store.and_then(|s| s.0.unit_pet_guid()); - let pet = pet_guid.and_then(|g| objects.object(g)); - let pet_target_reach = pet_guid - .and_then(|g| objects.entity(g)) - .and_then(|e| engaged_units.get(e).ok()) - .and_then(target_reach); - let pet_inputs = PetInputs::of(pet_guid, pet, pet_target_reach); - if memory.pet != Some(pet_inputs) { - memory.pet = Some(pet_inputs); - wanted_pet.extend(memory.pet_pushed.drain()); - } - // The sources overlap (a self-buff is in the book and on the player): build each id once. - wanted.sort_unstable(); - wanted.dedup(); - wanted_pet.sort_unstable(); - wanted_pet.dedup(); - // Build, then push: the build's borrow of the VM's strings ends before the store is written. - let (built, built_pet) = { - let get = |key: &str| benilla_ui::strings::global(script.lua(), key); - let mut vctx = ViewCtx { - home_area: home_area.as_deref(), - form, - store: self_store, - caster: ViewCaster::Player, - combat_reach: self_store.map_or(1.5, |s| s.0.unit_combat_reach()), - attack_target_reach, - objects: &objects, - items: &mut items, - commands: &commands, - sub_classes: sub_classes.as_deref().map(|c| &c.0), - skill_lines: skill_lines.as_deref().map(|s| &s.catalog), - spell_mods, - get: &get, - }; - let built = build_views(wanted, spells, &mut vctx, memory, false); - // The pet views read the pet's form and reach where the player's read the player's. - vctx.caster = ViewCaster::Pet(pet); - vctx.form = pet.map_or(0, |p| p.unit_shapeshift_form()); - vctx.combat_reach = pet.map_or(1.5, |p| p.unit_combat_reach()); - vctx.attack_target_reach = pet_target_reach; - let built_pet = build_views(wanted_pet, spells, &mut vctx, memory, true); - (built, built_pet) - }; - for (id, view) in built { - script.set_spell_tooltip(id, view); - } - for (id, view) in built_pet { - script.set_pet_spell_tooltip(id, view); - } -} - -/// Build the views of `ids` against `vctx`'s caster, recording each in the pushed set `pet` names -/// and watching its reagents, seeded with the state the view was built against. -fn build_views( - ids: Vec, - spells: &Spells, - vctx: &mut ViewCtx, - memory: &mut SpellFeedMemory, - pet: bool, -) -> Vec<(u32, benilla_ui::script::SpellTooltipView)> { - let mut built = Vec::new(); - for id in ids { - let Some(view) = spell_tooltip_view(id, spells, vctx) else { - continue; - }; - if let Some(d) = spells.catalog.get(id) { - for (entry, _) in d.reagents.iter().copied().filter(|&(e, _)| e != 0) { - if let std::collections::btree_map::Entry::Vacant(slot) = - memory.reagents.entry(entry) - { - let named = vctx.items.template(entry, 0, vctx.commands).is_some(); - let owned = vctx.store.map_or(0, |s| { - count_of(&s.0, vctx.objects, entry, InventoryScope::CARRIED) - }); - slot.insert((owned, named)); - } - } - } - built.push((id, view)); - if pet { - memory.pet_pushed.insert(id); - } else { - memory.pushed.insert(id); - } - } - built -} - /// What the world tooltip was last built for; the reference rebuilds once per hover-target change. #[derive(Default, PartialEq, Clone, Copy)] enum LastHover { @@ -1018,5 +353,11 @@ fn drive_mouseover_tooltip( } } +#[cfg(test)] +mod bench; +#[cfg(test)] +mod deps_tests; +#[cfg(test)] +mod feed_tests; #[cfg(test)] mod tests; diff --git a/crates/benilla-app/src/ui_tooltip/spell_deps.rs b/crates/benilla-app/src/ui_tooltip/spell_deps.rs new file mode 100644 index 00000000..b933931b --- /dev/null +++ b/crates/benilla-app/src/ui_tooltip/spell_deps.rs @@ -0,0 +1,343 @@ +//! What each pushed spell view read, and which changes of the player's state move it. The +//! reference builds a spell tooltip at the hover and keeps nothing; the feed pushes views ahead of +//! it, so it must know, per view, the inputs it was built from. A view records them as it is built +//! ([`Deps`]), the feed diffs the player-side inputs frame to frame ([`Seen`], the modifier +//! tables, the watched reagents) into what moved ([`Changes`]), and only the views that read what +//! moved are built again. + +use std::collections::BTreeMap; + +use benilla_protocol::ObjectFields; + +use crate::items::Items; +use crate::net::{ObjectStore, Objects}; +use crate::spell::usable::{slot_item_cached, SlotItem, EQUIPMENT_MASK, EQUIPMENT_SLOTS}; +use crate::spell::{ModsDiff, SkillSnapshot}; + +/// The four percentages a chance-to-X line reads (`0x52f5b1`), in the order [`Seen`] keeps them. +#[derive(Clone, Copy)] +pub(super) enum Chance { + Block, + Dodge, + Parry, + Crit, +} + +impl Chance { + pub(super) const ALL: [Chance; 4] = [Chance::Block, Chance::Dodge, Chance::Parry, Chance::Crit]; + + /// The player's live percentage, already a percent on the wire. + pub(super) fn read(self, player: &ObjectFields) -> Option { + match self { + Chance::Block => player.player_block_percentage(), + Chance::Dodge => player.player_dodge_percentage(), + Chance::Parry => player.player_parry_percentage(), + Chance::Crit => player.player_crit_percentage(), + } + } + + /// The bit a [`Deps::avoidance`] or [`Changes::avoidance`] mask holds it at. + fn bit(self) -> u8 { + 1 << self as u8 + } +} + +/// One of the player's unit fields a cost, cast or cooldown cell reads, a bit of [`Deps::unit`]. +#[derive(Clone, Copy)] +pub(super) enum UnitField { + Level, + BaseMana, + MaxHealth, + /// `UNIT_FIELD_MAXPOWER` of a type; one past the five reads nothing. + MaxPower(u8), + RangedTime, +} + +impl UnitField { + pub(super) fn bit(self) -> u16 { + match self { + UnitField::Level => 1, + UnitField::BaseMana => 1 << 1, + UnitField::MaxHealth => 1 << 2, + UnitField::MaxPower(ty) if ty < 5 => 1 << (3 + ty), + UnitField::MaxPower(_) => 0, + UnitField::RangedTime => 1 << 8, + } + } +} + +/// The player's unit fields those cells read, as the values they read them at (an absent field +/// reads 0). +#[derive(Clone, Copy, Default, PartialEq)] +struct UnitFields { + level: u32, + base_mana: u32, + max_health: u32, + max_power: [u32; 5], + ranged_time: u32, +} + +impl UnitFields { + fn of(player: Option<&ObjectStore>) -> Self { + let Some(p) = player.map(|s| &s.0) else { + return Self::default(); + }; + Self { + level: p.unit_level().unwrap_or(0), + base_mana: p.unit_base_mana().unwrap_or(0), + max_health: p.unit_max_health().unwrap_or(0), + max_power: std::array::from_fn(|ty| p.unit_max_power(ty as u8).unwrap_or(0)), + ranged_time: p.unit_ranged_attack_time().unwrap_or(0), + } + } + + /// The [`UnitField`] bits that differ. + fn diff(&self, prev: &Self) -> u16 { + let moved = |field: UnitField, now: u32, before: u32| { + if now != before { + field.bit() + } else { + 0 + } + }; + let mut bits = moved(UnitField::Level, self.level, prev.level) + | moved(UnitField::BaseMana, self.base_mana, prev.base_mana) + | moved(UnitField::MaxHealth, self.max_health, prev.max_health) + | moved(UnitField::RangedTime, self.ranged_time, prev.ranged_time); + for ty in 0..5u8 { + bits |= moved( + UnitField::MaxPower(ty), + self.max_power[usize::from(ty)], + prev.max_power[usize::from(ty)], + ); + } + bits + } +} + +/// What one built view read of the player-side inputs the feed watches; the pet's own inputs +/// ([`super::spell_feed::PetInputs`]) rebuild every pet view and are not recorded. Written where +/// the builder reads, so a read cannot be added without saying what it depends on. +#[derive(Clone, Debug, Default, PartialEq)] +pub(super) struct Deps { + /// A `$z` expanded: the bind point. + pub(super) home: bool, + /// The required-form test read the shapeshift form. + pub(super) form: bool, + /// The range cell read the caster's or the auto-attack target's combat reach. + pub(super) reach: bool, + /// The chance line read these percentages, a [`Chance`] bit each. + pub(super) avoidance: u8, + /// The equipped-item search read these equipment slots, a bit each. + pub(super) worn: u32, + /// The equipped-item search asked whether the disarm flag hides a hand. + pub(super) disarm: bool, + /// The player's unit fields the cost, cast and cooldown cells read, a [`UnitField`] bit each. + /// A pet view's cost and cast cells read the pet's own fields, which are pet inputs, so of + /// the unit fields only the ranged attack time (read off the player) is recorded for one. + pub(super) unit: u16, + /// The modifier cells read: the `SpellFamilyFlags` bits of the view's spell and of each spell + /// its text cross-references, for the ones the caster's class family owns. + pub(super) mod_bits: u64, + /// The skill lines read, through the view's own spell and each one its text cross-references. + pub(super) skill_lines: Vec, + /// The reagent entries the reagents line lists. + reagents: Vec, +} + +impl Deps { + pub(super) fn chance(&mut self, which: Chance) { + self.avoidance |= which.bit(); + } + + pub(super) fn unit(&mut self, field: UnitField) { + self.unit |= field.bit(); + } + + /// The equipped-item search ran over the equipment slots in `slots`. + pub(super) fn item_search(&mut self, slots: u32) { + self.worn |= slots; + self.disarm |= slots != 0; + } + + pub(super) fn skill_line(&mut self, line: u32) { + if !self.skill_lines.contains(&line) { + self.skill_lines.push(line); + } + } + + pub(super) fn reagent(&mut self, entry: u32) { + if !self.reagents.contains(&entry) { + self.reagents.push(entry); + } + } + + /// The reagent entries the view lists. + pub(super) fn reagents(&self) -> &[u32] { + &self.reagents + } + + /// Whether `changes` moved an input this view read. + pub(super) fn hits(&self, changes: &Changes) -> bool { + changes.everything + || (changes.home && self.home) + || (changes.form && self.form) + || (changes.reach && self.reach) + || changes.avoidance & self.avoidance != 0 + || changes.worn & self.worn != 0 + || (changes.disarm && self.disarm) + || changes.unit & self.unit != 0 + || changes.mod_bits & self.mod_bits != 0 + || self + .skill_lines + .iter() + .any(|line| changes.skill_lines.contains(line)) + || self + .reagents + .iter() + .any(|entry| changes.reagents.contains(entry)) + } +} + +/// What moved since the frame before, in the terms of [`Deps`]. +#[derive(Debug, Default)] +pub(super) struct Changes { + /// An input no view can be ruled out of: every view is due. + pub(super) everything: bool, + pub(super) home: bool, + pub(super) form: bool, + pub(super) reach: bool, + /// A [`Chance`] bit each. + pub(super) avoidance: u8, + /// The equipment slots whose item moved in a way the search can tell, a bit each. + pub(super) worn: u32, + pub(super) disarm: bool, + /// A [`UnitField`] bit each. + pub(super) unit: u16, + /// The `SpellFamilyFlags` bits with a changed modifier cell. + pub(super) mod_bits: u64, + pub(super) skill_lines: Vec, + pub(super) reagents: Vec, +} + +impl Changes { + pub(super) fn is_empty(&self) -> bool { + !self.everything + && !self.home + && !self.form + && !self.reach + && self.avoidance == 0 + && self.worn == 0 + && !self.disarm + && self.unit == 0 + && self.mod_bits == 0 + && self.skill_lines.is_empty() + && self.reagents.is_empty() + } + + /// The modifier tables' change. A new class family changes which spells the tables reach at + /// all, so it is everything. + pub(super) fn with_mods(mut self, diff: ModsDiff) -> Self { + self.mod_bits = diff.bits; + self.everything |= diff.class_family; + self + } + + /// The watched reagents whose count or resolved name moved. + pub(super) fn with_reagents(mut self, prev: &Reagents, now: &Reagents) -> Self { + self.reagents = now + .iter() + .filter(|(entry, state)| prev.get(entry) != Some(state)) + .map(|(&entry, _)| entry) + .collect(); + self + } +} + +/// Per reagent on show, `(owned count, name resolved)`: what the reagents line renders (the +/// inline red, `0x854120`). +pub(super) type Reagents = BTreeMap; + +/// The player-side inputs one frame saw, which the pushed views were built against. +#[derive(Clone, PartialEq)] +pub(super) struct Seen { + /// The bind point `$z` names. + home: Option, + /// The form the required-form line's colour follows (`0x52f1e3`). + form: u8, + /// The skills the descriptions' per-level terms scale by ([`crate::spell::skill_snapshot`]). + skills: SkillSnapshot, + /// What the equipped-item search learns of each of the 23 equipment slots (`0x5f0c50`); `None` + /// without a player. + worn: Option<[SlotItem; EQUIPMENT_SLOTS as usize]>, + /// The disarm flag, which hides a hand from that search. + disarmed: bool, + /// The [`Chance`] percentages as bit patterns. + avoidance: [Option; 4], + /// The caster's and its auto-attack target's combat reach as bit patterns. + reach: (Option, Option), + /// The unit fields the cost, cast and cooldown cells read. + unit: UnitFields, +} + +impl Seen { + pub(super) fn of( + player: Option<&ObjectStore>, + home: Option<&str>, + attack_target_reach: Option, + objects: &Objects, + items: &Items, + ) -> Self { + Seen { + home: home.map(str::to_string), + form: player.map_or(0, |s| s.0.unit_shapeshift_form()), + skills: crate::spell::skill_snapshot(player), + worn: player + .map(|s| std::array::from_fn(|i| slot_item_cached(s, i as u8, objects, items))), + disarmed: player.is_some_and(crate::items::is_disarmed), + avoidance: Chance::ALL + .map(|which| player.and_then(|s| which.read(&s.0)).map(f32::to_bits)), + reach: ( + player.map(|s| s.0.unit_combat_reach().to_bits()), + attack_target_reach.map(f32::to_bits), + ), + unit: UnitFields::of(player), + } + } + + /// What differs from the frame `prev` saw, the modifiers and reagents apart. + pub(super) fn changes_since(&self, prev: &Seen) -> Changes { + let mut changes = Changes { + home: self.home != prev.home, + form: self.form != prev.form, + reach: self.reach != prev.reach, + disarm: self.disarmed != prev.disarmed, + unit: self.unit.diff(&prev.unit), + ..Changes::default() + }; + for (which, (now, before)) in Chance::ALL + .iter() + .zip(self.avoidance.iter().zip(&prev.avoidance)) + { + if now != before { + changes.avoidance |= which.bit(); + } + } + changes.worn = match (&self.worn, &prev.worn) { + (Some(now), Some(before)) => now + .iter() + .zip(before) + .enumerate() + .fold(0, |mask, (slot, (now, before))| { + mask | u32::from(now != before) << slot + }), + (None, None) => 0, + // A player appearing or going: every slot's item is new. + _ => EQUIPMENT_MASK, + }; + if self.skills != prev.skills { + changes.skill_lines = crate::spell::changed_skill_lines(&prev.skills, &self.skills); + } + changes + } +} diff --git a/crates/benilla-app/src/ui_tooltip/spell_feed.rs b/crates/benilla-app/src/ui_tooltip/spell_feed.rs new file mode 100644 index 00000000..928529b1 --- /dev/null +++ b/crates/benilla-app/src/ui_tooltip/spell_feed.rs @@ -0,0 +1,755 @@ +//! The spell tooltip views: the builder `0x52e610`'s cells resolved here where the catalogs live, +//! and the feed that pushes a view for every spell the UI can hover before it is hovered. The +//! reference builds at the hover and keeps nothing; the feed keeps what each pushed view read +//! ([`super::spell_deps`]) and builds again only the views a change of the player's state reaches. + +use std::cell::RefCell; +use std::collections::HashMap; + +use bevy::prelude::*; + +use benilla_ui::script::UiScript; +use benilla_ui::strings::Arg; + +use super::keyed; +use super::spell_deps::{Chance, Changes, Deps, Reagents, Seen, UnitField}; +use crate::items::Items; +use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer}; +use crate::spell::usable::{self, CostBasis}; +use crate::ui_action::{PlayerActions, Spells}; +use crate::ui_items::{count_of, InventoryScope}; +use crate::ui_trainer::TrainerTooltipSubjects; + +/// The unit a view's caster cells are computed against, the spell builder `0x52e610`'s fourth +/// argument: the level the `$`-tokens, the cost and the cast time scale by, and the cost's basis. +#[derive(Clone, Copy)] +pub(super) enum ViewCaster<'a> { + /// The active player ([`ViewCtx::store`]), selector 0. + Player, + /// Selector 1: the player's charm, else its summon (`0x6e3159`-`0x6e317f`, `0x6e31fe`- + /// `0x6e3228`), `None` when neither is streamed. + Pet(Option<&'a benilla_protocol::ObjectFields>), +} + +impl ViewCaster<'_> { + /// The pet's `[vtbl+0xa8]`, `CGUnit`'s `0x60cd80`: `UNIT_FIELD_LEVEL × 5`, which + /// [`benilla_formats::SpellDisplay::skill_level`] caps at `maxLevel × 5` and divides by 5 as + /// `0x60cdb2`-`0x60cdb9` and `0x6e3195` do. No unit reads 0 (`0x6e31a4`). + fn pet_skill_value(pet: Option<&benilla_protocol::ObjectFields>) -> u32 { + pet.and_then(|p| p.unit_level()) + .unwrap_or(0) + .saturating_mul(5) + } +} + +/// The caster-dependent inputs of a spell view: the worn set (`0x5f0c50`), the bags, the form and +/// the bind point `$z` names. +pub(super) struct ViewCtx<'a, 'w, 's> { + pub(super) home_area: Option<&'a str>, + /// The caster's shapeshift form, which the required-form line's colour reads (`0x52f1f2`). + pub(super) form: u8, + /// The active player. + pub(super) store: Option<&'a ObjectStore>, + /// Whom the level terms and the cost read: the player or its pet. The form and the reaches + /// are that same unit's. + pub(super) caster: ViewCaster<'a>, + /// The caster's `UNIT_FIELD_COMBATREACH`; 1.5 is the descriptor default. + pub(super) combat_reach: f32, + /// The caster's auto-attack target's reach, which `0x6e3480` reads itself (`[caster+0xc48]`); + /// with none, the caster's reach counts twice. + pub(super) attack_target_reach: Option, + /// The object index the worn-item search and each reagent's carried count resolve through. + pub(super) objects: &'a Objects<'w, 's>, + pub(super) items: &'a mut Items, + pub(super) commands: &'a NetCommands, + pub(super) sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>, + /// The spell-to-line hop the `$`-tokens' skill level reads ([`crate::spell::spell_skill_value`]). + pub(super) skill_lines: Option<&'a benilla_formats::SkillLineCatalog>, + /// The talent spell-modifier tables: the cost cell shows the modified cost (`power_cost`). + pub(super) spell_mods: &'a crate::spell::SpellModifiers, + /// The VM's `GlobalStrings.lua`, where the keyed cells and the `$` tokens' templates resolve. + pub(super) get: &'a dyn Fn(&str) -> Option, +} + +/// Build one spell's tooltip view, every cell resolved here where the catalogs live, and what it +/// read ([`Deps`]). The range, cast, cooldown, form and item cells fill their `GlobalStrings.lua` +/// keys; the cost, reagents and chance cells are composed in English, where the reference fills +/// `MANA_COST` and its kin, `SPELL_REAGENTS` and `CHANCE_TO_*`. +pub(super) fn build_view( + spell_id: u32, + spells: &Spells, + vctx: &mut ViewCtx, +) -> Option<(benilla_ui::script::SpellTooltipView, Deps)> { + let d = spells.catalog.get(spell_id)?; + let deps = RefCell::new(Deps::default()); + // The cost, cast, cooldown and range cells and the description's tokens all modify through + // the spell's own family mask. + deps.borrow_mut().mod_bits |= vctx.spell_mods.read_bits(d); + let form = vctx.form; + let caster = vctx.caster; + // The `$`-tokens' level (`0x5075f0` hands the selector to `0x6e3130`, `507645`-`50764a`). + let skill = |id| match caster { + ViewCaster::Player => { + let line = vctx.skill_lines.and_then(|c| c.spell_to_line(id)); + if let Some(line) = line { + deps.borrow_mut().skill_line(line); + } + line.map_or(0, |line| crate::spell::skill_line_value(vctx.store, line)) + } + // The pet's level is one of the pet's own inputs. + ViewCaster::Pet(pet) => ViewCaster::pet_skill_value(pet), + }; + // A cross-referenced spell's own family mask decides its modifiers. + let lookup = |id| { + let t = spells.catalog.get(id)?; + deps.borrow_mut().mod_bits |= vctx.spell_mods.read_bits(t); + Some(t) + }; + let home = vctx.home_area; + let home_area = || { + deps.borrow_mut().home = true; + home + }; + // `0x6e3130` for this spell: the level the cost's and the cast time's per-level terms read. + let pet_level = |pet| d.skill_level(ViewCaster::pet_skill_value(pet)); + let ctx = benilla_formats::TokenContext { + durations: &spells.durations, + radii: &spells.radii, + ranges: Some(&spells.ranges), + skill: &skill, + lookup: &lookup, + mods: Some(vctx.spell_mods), + unmodified_points: false, + home_area: &home_area, + global: vctx.get, + printf: &crate::ui_script::token_printf, + }; + // The aura tooltip `0x52f880` expands the aura text with the points' modifiers off (`52f940`). + let aura_ctx = benilla_formats::TokenContext { + unmodified_points: true, + ..ctx + }; + // The cost cell (`0x52e8ad`): the resolved `power_cost`, named by power type (keys at + // `0x85416c`) and Health for a type outside 0..=4 (`0x52e8fc`: Bloodrage's -2), never a + // percentage. Rage shows wire ÷ 10, a per-second column adds `_PER_TIME`, and no cost at all + // leaves the cell empty. A pet with no unit costs -1 (`0x6e3233`), which shows as none. + let resolved_cost = match caster { + ViewCaster::Player => vctx + .store + .map_or(d.mana_cost, |s| usable::power_cost(d, s, vctx.spell_mods)), + ViewCaster::Pet(pet) => pet.map_or(0, |p| { + usable::power_cost_at(d, p, pet_level(Some(p)), vctx.spell_mods) + }), + }; + // The player's cost reads its level, and the field a percentage scales by; the pet's reads the + // pet's own, which are pet inputs. + if matches!(caster, ViewCaster::Player) { + let mut deps = deps.borrow_mut(); + if usable::cost_reads_level(d) { + deps.unit(UnitField::Level); + } + match usable::cost_basis(d) { + CostBasis::None => {} + CostBasis::BaseMana => deps.unit(UnitField::BaseMana), + CostBasis::MaxHealth => deps.unit(UnitField::MaxHealth), + CostBasis::MaxPower(ty) => deps.unit(UnitField::MaxPower(ty)), + } + } + let cost = { + // The shared per-type divisor (`0x6e7130`), never a local `power_type == 1` test. + let div = benilla_protocol::messages::power_display_scale(d.power_type); + let unit = match d.power_type { + 0 => "Mana", + 1 => "Rage", + 2 => "Focus", + 3 => "Energy", + 4 => "Happiness", + _ => "Health", + }; + if resolved_cost == 0 && d.mana_per_second == 0 { + None + } else if d.mana_per_second > 0 { + Some(format!( + "{} {unit}, plus {} per sec", + resolved_cost / div, + d.mana_per_second / div + )) + } else { + Some(format!("{} {unit}", resolved_cost / div)) + } + }; + // The range cell: two attribute gates, `GetMinMaxRange` (`0x6e3480`, `0x52e9c2`), then + // `max <= 0`. There is no melee wording: the melee row (flags bit 0, row 2 only) resolves to + // `max(reach + casterReach + 1.3333334, 5.0)` and prints like any other, usually "5 yd range". + let range = (!d.tooltip_omits_range_line()) + .then(|| { + let row = spells.ranges.get(d.range_index); + // The pet views read the pet's reaches, which are among the pet's own inputs. + if matches!(caster, ViewCaster::Player) + && benilla_formats::min_max_range_reads_reach(d, row) + { + deps.borrow_mut().reach = true; + } + let (min, max) = vctx.spell_mods.min_max_range( + d, + row, + vctx.combat_reach, + vctx.attack_target_reach, + )?; + if max <= 0.0 { + return None; + } + // `SPELL_RANGE`'s hole is a string: the number is composed first, `"%d"` or `"%d-%d"` + // (`0x854fb4`, Charge's 8-25), each a `fistp` conversion that rounds to nearest. + let yd = |v: f32| f64::from(v).round_ties_even() as i64; + let yards = if min > 0.0 { + format!("{}-{}", yd(min), yd(max)) + } else { + format!("{}", yd(max)) + }; + keyed(vctx.get, "SPELL_RANGE", &[Arg::S(&yards)]) + }) + .flatten(); + // The cast line: gate `0x52eb15` also omits it for a TRADE_SKILL or ATTACK `Effect[0]`; then + // the ladder `0x52eb45`-`0x52ec90` over `GetCastTime(1)` (`52eb4d`), unclamped, where a + // negative time is "Instant cast" (`0x52ebce`) and a zero one is "Instant cast" only for a + // mana spell with a cost (`0x52ec4b`). + let cast_time = if d.tooltip_omits_cast_line() { + None + } else { + // `GetCastTime` takes the selector too (`52eb45`), and its level term is `0x6e3130`'s. The + // player's is its level only where the row scales by one. + if matches!(caster, ViewCaster::Player) + && spells + .cast_times + .get(d.casting_time_index) + .is_some_and(|row| row.reads_caster_level()) + { + deps.borrow_mut().unit(UnitField::Level); + } + let level = match caster { + ViewCaster::Player => vctx.store.and_then(|s| s.0.unit_level()).unwrap_or(0), + ViewCaster::Pet(pet) => pet_level(pet), + }; + let base = spells.cast_time_unclamped_ms(d, level, vctx.spell_mods); + // `%.3g` templates: the seconds go over as a real. + if base > 0 { + let (key, v) = if base >= 60_000 { + ("SPELL_CAST_TIME_MIN", f64::from(base) / 60_000.0) + } else { + ("SPELL_CAST_TIME_SEC", f64::from(base) / 1000.0) + }; + keyed(vctx.get, key, &[Arg::F(v)]) + } else { + let key = if base < 0 { + "SPELL_CAST_TIME_INSTANT" + } else if d.on_next_swing() { + "SPELL_ON_NEXT_SWING" + } else if d.tooltip_on_next_ranged() { + "SPELL_ON_NEXT_RANGED" + } else if d.tooltip_channeled() { + "SPELL_CAST_CHANNELED" + } else if d.power_type == 0 && resolved_cost > 0 { + "SPELL_CAST_TIME_INSTANT" + } else { + "SPELL_CAST_TIME_INSTANT_NO_MANA" + }; + keyed(vctx.get, key, &[]) + } + }; + // The larger recovery column (`0x52eada`): op 11 follows the ranged-speed category pad. The + // ranged attack time is the player's, a pet view's too. + let ranged_ms = if d.ranged_speed_cooldown() { + deps.borrow_mut().unit(UnitField::RangedTime); + vctx.store + .and_then(|s| s.0.unit_ranged_attack_time()) + .unwrap_or(0) + } else { + 0 + }; + let (own_recovery, category_recovery) = vctx.spell_mods.spell_cooldowns(d, ranged_ms); + let recovery_ms = own_recovery.max(category_recovery); + let cooldown = (recovery_ms > 0) + .then(|| { + let secs = f64::from(recovery_ms) / 1000.0; + let (key, v) = if secs >= 60.0 { + ("SPELL_RECAST_TIME_MIN", secs / 60.0) + } else { + ("SPELL_RECAST_TIME_SEC", secs) + }; + keyed(vctx.get, key, &[Arg::F(v)]) + }) + .flatten(); + // The required-form line (`0x52f10a`-`0x52f2ae`). With `AttributesEx2` bit 19 set the mask is + // permissive (Inner Fire in Shadowform), and the line is skipped (`0x52f115`). + let requires_form = (d.stances != 0 && !d.form_mask_is_permissive()) + .then(|| { + let names: Vec<&str> = (0..32u32) + .filter(|b| d.stances & (1 << b) != 0) + .filter_map(|b| spells.forms.get(&(b + 1)).map(|f| f.name.as_str())) + .filter(|n| !n.is_empty()) + .collect(); + (!names.is_empty()) + .then(|| { + keyed( + vctx.get, + "SPELL_REQUIRED_FORM", + &[Arg::S(&names.join(", "))], + ) + }) + .flatten() + }) + .flatten(); + // No caster unit passes the form test (`0x52f1e8`). A spell with no stance mask fails it for + // every form, so only a stance mask reads the player's form; a pet's is its own input. + if matches!(caster, ViewCaster::Player) && d.stances != 0 { + deps.borrow_mut().form = true; + } + let form_met = matches!(caster, ViewCaster::Pet(None)) + || (form != 0 && d.stances & (1u32 << (u32::from(form) - 1)) != 0); + // The required-item line (`0x52eea7`-`0x52f10a`): a mask is named whole by + // `ItemSubClassMask.dbc` before its subclasses are joined; an empty mask or class < 0 skips it. + let requires_item = (d.targets & TARGET_ITEM == 0 && d.equipped_item_class >= 0) + .then(|| { + vctx.sub_classes? + .requirement_name(d.equipped_item_class as u32, d.equipped_item_subclass_mask) + }) + .flatten() + .and_then(|name| keyed(vctx.get, "SPELL_EQUIPPED_ITEM", &[Arg::S(&name)])); + let chance = chance_line(d, vctx.store, &mut deps.borrow_mut()); + // The unit selector passes the item test outright (`0x52f07f`). + if matches!(caster, ViewCaster::Player) { + deps.borrow_mut().item_search(usable::worn_slots_read(d)); + } + let item_met = matches!(caster, ViewCaster::Pet(_)) + || vctx.store.is_none_or(|s| { + usable::equipped_item_fits(d, s, vctx.objects, vctx.items, vctx.commands) + }); + // Reagents (`SPELL_REAGENTS`, `0x854e54`), red when short; one whose template has not + // streamed is absent until `feed_spell_tooltips` re-pushes on its arrival. + let reagents = { + let mut parts: Vec = Vec::new(); + for (entry, count) in d.reagents.iter().copied().filter(|&(e, _)| e != 0) { + deps.borrow_mut().reagent(entry); + let Some(name) = vctx + .items + .template(entry, 0, vctx.commands) + .map(|t| t.name.clone()) + else { + continue; + }; + let text = if count > 1 { + format!("{name} ({count})") + } else { + name + }; + let short = vctx.store.is_some_and(|s| { + count_of(&s.0, vctx.objects, entry, InventoryScope::CARRIED) < count + }); + parts.push(if short { + format!("|cffff2020{text}|r") + } else { + text + }); + } + (!parts.is_empty()).then(|| format!("Reagents: {}", parts.join(", "))) + }; + let view = benilla_ui::script::SpellTooltipView { + name: d.name.clone(), + rank: d.rank.clone(), + // The aura variant's right column (`0x52f8e5`), gated on `SpellDispelType.dbc` `[+0x28]`. + dispel_type: spells.catalog.dispel_name(d).map(str::to_string), + cost, + range, + cast_time, + cooldown, + requires_item, + item_met, + requires_form, + form_met, + chance, + reagents, + description: d + .description + .as_deref() + .map(|t| benilla_formats::substitute(t, d, &ctx)) + .unwrap_or_default(), + aura_description: d + .aura_description + .as_deref() + .map(|t| benilla_formats::substitute(t, d, &aura_ctx)) + .unwrap_or_default(), + }; + Some((view, deps.into_inner())) +} + +/// `Targets` bit `0x10` (name inferred): set, the required-item line is skipped (`0x52eeaa`). +const TARGET_ITEM: u32 = 0x10; + +/// The four `Effect[0]` values that select a chance-to-X line (jump table `0x52f7c4`). +const EFFECT_DODGE: u32 = 20; +const EFFECT_PARRY: u32 = 22; +const EFFECT_BLOCK: u32 = 23; +const EFFECT_ATTACK: u32 = 78; + +/// The chance-to-X line (`0x52f5b1`-`0x52f697`): ATTACK skips the passive gate that dodge, parry +/// and block need. The wire's values are already percents. +fn chance_line( + d: &benilla_formats::SpellDisplay, + store: Option<&ObjectStore>, + deps: &mut Deps, +) -> Option { + let (which, label) = match d.effects[0] { + EFFECT_ATTACK => (Chance::Crit, "crit"), + EFFECT_DODGE if d.passive => (Chance::Dodge, "dodge"), + EFFECT_PARRY if d.passive => (Chance::Parry, "parry"), + EFFECT_BLOCK if d.passive => (Chance::Block, "block"), + _ => return None, + }; + deps.chance(which); + // The enUS text of the reference's `CHANCE_TO_*` keys (`%.2f%% chance to crit`). + Some(format!("{:.2}% chance to {label}", which.read(&store?.0)?)) +} + +/// What the feed knows of the views it has pushed: what each was built from and what the inputs +/// were when the last frame looked. The reference rebuilds on every hover and keeps no such +/// snapshot. +#[derive(Default)] +pub(super) struct SpellFeedMemory { + /// The views built against the player, by spell, with what each read. + pub(super) pushed: HashMap, + /// The spells whose view built against the pet is in the VM's pet store, and what each read + /// of the player's side (the pet's own inputs are [`Self::pet`]). + pub(super) pet_pushed: HashMap, + /// What the pet views read off the pet unit. + pet: Option, + /// The player-side inputs the pushed views were checked against last frame. + pub(super) seen: Option, + /// The talent modifier tables as last frame's check found them. + pub(super) mods: Option, + /// Per reagent on show, the state its views were built against. + pub(super) reagents: Reagents, +} + +impl SpellFeedMemory { + /// Queue for a rebuild the pushed views, the player's and the pet's, that read what + /// `changes` moved. + pub(super) fn requeue( + &mut self, + changes: &Changes, + wanted: &mut Vec, + wanted_pet: &mut Vec, + ) { + if changes.is_empty() { + return; + } + for (pushed, queue) in [ + (&mut self.pushed, wanted), + (&mut self.pet_pushed, wanted_pet), + ] { + pushed.retain(|&id, deps| { + let hit = deps.hits(changes); + if hit { + queue.push(id); + } + !hit + }); + } + } +} + +/// What a reagent's line renders for `entry`: the carried count and whether its name has streamed. +pub(super) fn reagent_state( + entry: u32, + store: Option<&ObjectStore>, + objects: &Objects, + items: &mut Items, + commands: &NetCommands, +) -> (u32, bool) { + let named = items.template(entry, 0, commands).is_some(); + let owned = store.map_or(0, |s| { + count_of(&s.0, objects, entry, InventoryScope::CARRIED) + }); + (owned, named) +} + +/// The pet unit's fields a pet view reads (`0x52e610` with the unit selector set): its level, the +/// cost bases `0x612c50` reads, its form, and its and its melee target's reach, as bit patterns. +#[derive(Clone, Copy, PartialEq, Default)] +pub(super) struct PetInputs { + guid: Option, + level: u32, + base_mana: u32, + max_health: u32, + max_power: [u32; 5], + form: u8, + combat_reach: u32, + attack_target_reach: Option, +} + +impl PetInputs { + pub(super) fn of( + guid: Option, + pet: Option<&benilla_protocol::ObjectFields>, + attack_target_reach: Option, + ) -> Self { + let Some(p) = pet else { + return Self { + guid, + ..Self::default() + }; + }; + Self { + guid, + level: p.unit_level().unwrap_or(0), + base_mana: p.unit_base_mana().unwrap_or(0), + max_health: p.unit_max_health().unwrap_or(0), + max_power: std::array::from_fn(|i| p.unit_max_power(i as u8).unwrap_or(0)), + form: p.unit_shapeshift_form(), + combat_reach: p.unit_combat_reach().to_bits(), + attack_target_reach: attack_target_reach.map(f32::to_bits), + } + } +} + +/// The hoverable spell sources [`feed_spell_tooltips`] reads, one parameter under Bevy's 16. +#[derive(bevy::ecs::system::SystemParam)] +pub(super) struct SpellTooltipSources<'w> { + spells: Option>, + trainer_subjects: Option>, + talents: Option>, +} + +/// Push a view for every spell the UI can hover (the book, the class's talent ranks, the open +/// trainer's services, and what the VM holds: the pet's spells, the quest rewards, the craft's +/// subjects, every unit's auras and the tracking spell) before it is hovered, as the reference +/// reads them all locally; an ask for any other id too. +pub(super) fn feed_spell_tooltips( + script: Option>, + actions: Option>, + spell_sources: SpellTooltipSources, + self_q: Query<&ObjectStore, With>, + // The player's and the pet's auto-attack target, the melee range cell's second reach. + engaged_q: Query<&crate::creature_anim::Engaged, With>, + engaged_units: Query<&crate::creature_anim::Engaged>, + objects: Objects, + home_bind: Option>, + area_names: Option>, + mut items: ResMut, + // One tuple param, under Bevy's 16-param ceiling. + lookups: ( + Option>, + Res, + Option>, + ), + commands: Res, + mut memory: Local>, +) { + let (sub_classes, spell_mods, skill_lines) = &lookups; + let SpellTooltipSources { + spells, + trainer_subjects, + talents, + } = spell_sources; + let Some(mut script) = script else { + return; + }; + let memory = memory.get(&script); + let Some(spells) = spells.as_deref() else { + return; + }; + let mut wanted: Vec = script.take_spell_tooltip_asks(); + // What the VM holds for a hover: pet spells, quest rewards, craft subjects, auras, tracking. + wanted.extend( + script + .spell_tooltip_subjects() + .into_iter() + .filter(|s| !memory.pushed.contains_key(s)), + ); + if let Some(actions) = actions.as_deref() { + wanted.extend( + actions + .spells + .iter() + .copied() + .filter(|s| !memory.pushed.contains_key(s)), + ); + } + // The pet views: the pet's bar and book, and what their setters asked for. + let mut wanted_pet: Vec = script.take_pet_spell_tooltip_asks(); + wanted_pet.extend( + script + .pet_spell_tooltip_subjects() + .into_iter() + .filter(|s| !memory.pet_pushed.contains_key(s)), + ); + // The open trainer's services, which the detail icon's `SetTrainerService` renders, a + // pet-learn service's against the pet. + if let Some(trainer) = trainer_subjects.as_deref() { + wanted.extend( + trainer + .player + .iter() + .copied() + .filter(|s| !memory.pushed.contains_key(s)), + ); + wanted_pet.extend( + trainer + .pet + .iter() + .copied() + .filter(|s| !memory.pet_pushed.contains_key(s)), + ); + } + // Every rank of the class's talents: a talent tooltip reads the current and the next rank. + if let (Some(talents), Ok(store)) = (talents.as_deref(), self_q.single()) { + let race = store.0.unit_race().unwrap_or(0); + let class = store.0.unit_class().unwrap_or(0); + for tab in talents.catalog.tabs_for_class(race, class) { + for t in talents.catalog.talents_in_tab(tab.id) { + wanted.extend( + t.ranks + .iter() + .copied() + .filter(|s| *s != 0 && !memory.pushed.contains_key(s)), + ); + } + } + } + let home_area: Option = home_bind + .as_deref() + .and_then(|b| b.0) + .and_then(|id| area_names.as_deref()?.0.resolve(id as i32)) + .map(str::to_string); + let self_store = self_q.single().ok(); + let target_reach = + |e: &crate::creature_anim::Engaged| objects.object(e.0).map(|f| f.unit_combat_reach()); + let attack_target_reach = engaged_q.single().ok().and_then(target_reach); + // What moved since the last frame, which each pushed view is checked against: the bind + // point (Astral Recall's `$z`), the form, the skills, what the equipped-item search learns of + // each equipment slot and the disarm flag, the level, cost bases and ranged attack time, the + // block, dodge, parry and crit percentages, and the two combat reaches. + let seen = Seen::of( + self_store, + home_area.as_deref(), + attack_target_reach, + &objects, + &items, + ); + let mut changes = memory + .seen + .as_ref() + .map(|prev| seen.changes_since(prev)) + .unwrap_or_default(); + memory.seen = Some(seen); + // The talent tables: the cost, cast, cooldown and range cells and the description's tokens + // resolve through them. + if let Some(prev) = memory.mods.as_ref().filter(|_| spell_mods.is_changed()) { + changes = changes.with_mods(spell_mods.diff(prev)); + } + if memory.mods.is_none() || spell_mods.is_changed() { + memory.mods = Some((**spell_mods).clone()); + } + // And the reagents on show: their counts and whether their names have streamed. + let reagent_state: Reagents = memory + .reagents + .keys() + .map(|&entry| { + ( + entry, + reagent_state(entry, self_store, &objects, &mut items, &commands), + ) + }) + .collect(); + changes = changes.with_reagents(&memory.reagents, &reagent_state); + memory.reagents = reagent_state; + memory.requeue(&changes, &mut wanted, &mut wanted_pet); + // The pet's inputs: a new pet, or its level, bases, form or reach moving, rebuilds the pet + // views alone. + let pet_guid = self_store.and_then(|s| s.0.unit_pet_guid()); + let pet = pet_guid.and_then(|g| objects.object(g)); + let pet_target_reach = pet_guid + .and_then(|g| objects.entity(g)) + .and_then(|e| engaged_units.get(e).ok()) + .and_then(target_reach); + let pet_inputs = PetInputs::of(pet_guid, pet, pet_target_reach); + if memory.pet != Some(pet_inputs) { + memory.pet = Some(pet_inputs); + wanted_pet.extend(memory.pet_pushed.drain().map(|(id, _)| id)); + } + // The sources overlap (a self-buff is in the book and on the player): build each id once. + wanted.sort_unstable(); + wanted.dedup(); + wanted_pet.sort_unstable(); + wanted_pet.dedup(); + // Build, then push: the build's borrow of the VM's strings ends before the store is written. + let (built, built_pet) = { + let get = |key: &str| benilla_ui::strings::global(script.lua(), key); + let mut vctx = ViewCtx { + home_area: home_area.as_deref(), + form: self_store.map_or(0, |s| s.0.unit_shapeshift_form()), + store: self_store, + caster: ViewCaster::Player, + combat_reach: self_store.map_or(1.5, |s| s.0.unit_combat_reach()), + attack_target_reach, + objects: &objects, + items: &mut items, + commands: &commands, + sub_classes: sub_classes.as_deref().map(|c| &c.0), + skill_lines: skill_lines.as_deref().map(|s| &s.catalog), + spell_mods, + get: &get, + }; + let built = build_views(wanted, spells, &mut vctx, memory, false); + // The pet views read the pet's form and reach where the player's read the player's. + vctx.caster = ViewCaster::Pet(pet); + vctx.form = pet.map_or(0, |p| p.unit_shapeshift_form()); + vctx.combat_reach = pet.map_or(1.5, |p| p.unit_combat_reach()); + vctx.attack_target_reach = pet_target_reach; + let built_pet = build_views(wanted_pet, spells, &mut vctx, memory, true); + (built, built_pet) + }; + for (id, view) in built { + script.set_spell_tooltip(id, view); + } + for (id, view) in built_pet { + script.set_pet_spell_tooltip(id, view); + } +} + +/// Build the views of `ids` against `vctx`'s caster, recording each with what it read in the +/// pushed set `pet` names and watching its reagents, seeded with the state the view was built +/// against. +fn build_views( + ids: Vec, + spells: &Spells, + vctx: &mut ViewCtx, + memory: &mut SpellFeedMemory, + pet: bool, +) -> Vec<(u32, benilla_ui::script::SpellTooltipView)> { + let mut built = Vec::new(); + for id in ids { + let Some((view, deps)) = build_view(id, spells, vctx) else { + continue; + }; + for &entry in deps.reagents() { + if let std::collections::btree_map::Entry::Vacant(slot) = memory.reagents.entry(entry) { + slot.insert(reagent_state( + entry, + vctx.store, + vctx.objects, + vctx.items, + vctx.commands, + )); + } + } + built.push((id, view)); + let pushed = if pet { + &mut memory.pet_pushed + } else { + &mut memory.pushed + }; + pushed.insert(id, deps); + } + built +} diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index acaa17f7..8f8084d4 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -1,26 +1,38 @@ //! The spell-view cell tests, against the real 5875 data, and the feed's place in the schedule. +use super::spell_feed::{build_view, feed_spell_tooltips, ViewCaster, ViewCtx}; use super::*; -use crate::ui_action::Spells; +use crate::items::Items; +use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer}; +use crate::ui_action::{PlayerActions, Spells}; + +/// The view alone, for the cell tests; the feed keeps what it read beside it. +fn spell_tooltip_view( + spell_id: u32, + spells: &Spells, + vctx: &mut ViewCtx, +) -> Option { + build_view(spell_id, spells, vctx).map(|(view, _)| view) +} /// A view context with no player state, the DBC-only half of the builder. -struct TestCtx { - items: Items, - commands: NetCommands, +pub(super) struct TestCtx { + pub(super) items: Items, + pub(super) commands: NetCommands, _rx: crossbeam_channel::Receiver, /// The builder's lookup over the shipped `GlobalStrings.lua`: a stub would pass on wording /// the client never shows. get: Box, /// Empty by default; modifier tests populate it explicitly. - spell_mods: crate::spell::SpellModifiers, + pub(super) spell_mods: crate::spell::SpellModifiers, /// Absent by default: every spell's skill level reads 0. - skill_lines: Option, + pub(super) skill_lines: Option, } type Getter = dyn Fn(&str) -> Option; impl TestCtx { - fn new() -> Self { + pub(super) fn new() -> Self { let (tx, rx) = crossbeam_channel::unbounded(); let vm = benilla_ui::script::UiScript::new().expect("VM"); crate::ui_script::load_ui_for_test(&vm, "Interface\\FrameXML\\GlobalStrings.lua"); @@ -54,7 +66,7 @@ impl TestCtx { ctx } - fn ctx_for<'a, 'w, 's>( + pub(super) fn ctx_for<'a, 'w, 's>( &'a mut self, objects: &'a Objects<'w, 's>, form: u8, @@ -92,7 +104,7 @@ impl TestCtx { } /// The 5875 spell data the view builder reads; `None` skips where the install is absent. -fn real_spells() -> Option { +pub(super) fn real_spells() -> Option { let data = benilla_formats::wow_data_or_skip!(None); let mut chain = benilla_formats::open_chain(&data).expect("open chain"); Some(Spells { diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index cc091595..b04d3893 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -197,15 +197,16 @@ mod spells; pub use spells::{ cc_exemption, grants_immunity, load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_dispel_types, load_spell_durations, load_spell_radii, - load_spell_ranges, min_max_range, soft_modify, substitute, CcExemption, FormRefusal, - LearnAnnouncement, LearnEffect, OpenLock, ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, - SpellCatalog, SpellDispelTypes, SpellDisplay, SpellDuration, SpellDurationCatalog, SpellMods, - SpellRadius, SpellRadiusCatalog, SpellRange, SpellRangeCatalog, TokenContext, TokenNumber, - ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED, COMBAT_REACH_ADD, - MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, - SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL, - SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_PROSPECTING, SPELL_EFFECT_SKILL_STEP, - SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL, + load_spell_ranges, min_max_range, min_max_range_reads_reach, soft_modify, substitute, + CcExemption, FormRefusal, LearnAnnouncement, LearnEffect, OpenLock, ShapeshiftForm, + SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes, SpellDisplay, + SpellDuration, SpellDurationCatalog, SpellMods, SpellRadius, SpellRadiusCatalog, SpellRange, + SpellRangeCatalog, TokenContext, TokenNumber, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, + ATTR_ONLY_STEALTHED, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE, + SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_ENCHANT_ITEM, + SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL, + SPELL_EFFECT_PROSPECTING, SPELL_EFFECT_SKILL_STEP, SPELL_EFFECT_SKINNING, + SPELL_EFFECT_TRADE_SKILL, }; mod skill_lines; pub use skill_lines::{ diff --git a/crates/benilla-formats/src/spells/cast_times.rs b/crates/benilla-formats/src/spells/cast_times.rs index 598ae550..055ca2b7 100644 --- a/crates/benilla-formats/src/spells/cast_times.rs +++ b/crates/benilla-formats/src/spells/cast_times.rs @@ -38,6 +38,13 @@ impl SpellCastTime { } } +impl SpellCastTime { + /// Whether the caster's level moves [`Self::resolved_ms`]: only through the per-level column. + pub fn reads_caster_level(&self) -> bool { + self.per_level_ms != 0 + } +} + /// `SpellCastTimes.dbc`, by row id ([`crate::spells::SpellDisplay::casting_time_index`]). #[derive(Default)] pub struct SpellCastTimeCatalog { @@ -100,6 +107,50 @@ pub fn load_spell_cast_times(chain: &mut Chain) -> Result mod tests { use super::*; + /// A row reads the caster's level when some level changes its answer, which the per-level + /// column decides for every row shape the data has. + #[test] + fn a_row_reads_the_caster_level_only_through_its_per_level_column() { + for row in [ + SpellCastTime { + base_ms: 0, + per_level_ms: 0, + minimum_ms: 0, + }, + SpellCastTime { + base_ms: 1500, + per_level_ms: 0, + minimum_ms: 1500, + }, + SpellCastTime { + base_ms: -1_000_000, + per_level_ms: 0, + minimum_ms: -1_000_000, + }, + SpellCastTime { + base_ms: 1000, + per_level_ms: -100, + minimum_ms: 500, + }, + SpellCastTime { + base_ms: 1000, + per_level_ms: 50, + minimum_ms: 0, + }, + ] { + for base_level in [0, 10] { + let moves = (0..=60).any(|level| { + row.resolved_ms(level, base_level) != row.resolved_ms(level + 1, base_level) + }); + assert_eq!( + row.reads_caster_level(), + moves, + "{row:?} at base {base_level}" + ); + } + } + } + #[test] fn resolved_ms_scales_and_floors() { let instant = SpellCastTime { diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index 4fa2cbc0..2bf479bf 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -32,8 +32,8 @@ mod immunity; pub use immunity::{cc_exemption, grants_immunity, CcExemption}; pub use radius::{load_spell_radii, SpellRadius, SpellRadiusCatalog}; pub use ranges::{ - load_spell_ranges, min_max_range, SpellRange, SpellRangeCatalog, COMBAT_REACH_ADD, - MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE, + load_spell_ranges, min_max_range, min_max_range_reads_reach, SpellRange, SpellRangeCatalog, + COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE, }; pub use soft_float::modify as soft_modify; pub use tokens::{substitute, SpellMods, TokenContext, TokenNumber}; diff --git a/crates/benilla-formats/src/spells/ranges.rs b/crates/benilla-formats/src/spells/ranges.rs index d8eec939..7eca08ae 100644 --- a/crates/benilla-formats/src/spells/ranges.rs +++ b/crates/benilla-formats/src/spells/ranges.rs @@ -81,6 +81,20 @@ pub fn min_max_range( Some((min, (f64::from(row.max) + pad) as f32)) } +/// Whether [`min_max_range`] reads either reach for this spell and row: every arm but the +/// on-next-swing short-circuit, the missing row and the self row `{0, 0}`. The melee arm sums both; +/// the ranged arm pads by both, or by neither when there is no target, so a change of either reach +/// can move the result. +pub fn min_max_range_reads_reach( + spell: &crate::spells::SpellDisplay, + row: Option<&SpellRange>, +) -> bool { + if spell.on_next_swing() { + return false; + } + row.is_some_and(|row| row.is_melee() || row.min != 0.0 || row.max != 0.0) +} + /// `SpellRange.dbc`, by row id ([`crate::spells::SpellDisplay::range_index`]). #[derive(Default)] pub struct SpellRangeCatalog { @@ -207,6 +221,46 @@ mod tests { assert_eq!(min_max_range(&d, Some(&self_row), 1.5, None), None); } + /// The predicate is exact over every arm: it is true for a spell and row exactly when some + /// change of either reach, with or without a target, moves [`min_max_range`]'s answer. + #[test] + fn the_reach_predicate_matches_what_min_max_range_reads() { + let row = |min, max, flags| Some(SpellRange { min, max, flags }); + let rows = [ + None, + row(0.0, 0.0, 0), + row(0.0, 0.0, 1), + row(0.0, 35.0, 0), + row(8.0, 25.0, 0), + row(0.0, 5.0, 1), + ]; + for attributes in [0, 0x4, 0x400, 0x404] { + let spell = crate::spells::SpellDisplay { + attributes, + ..Default::default() + }; + for row in &rows { + let moves = [ + (1.5, None, 4.0, None), + (1.5, Some(1.5), 4.0, Some(1.5)), + (1.5, Some(1.5), 1.5, Some(4.0)), + (1.5, None, 1.5, Some(4.0)), + (1.5, Some(1.5), 1.5, None), + ] + .iter() + .any(|&(a, at, b, bt)| { + min_max_range(&spell, row.as_ref(), a, at) + != min_max_range(&spell, row.as_ref(), b, bt) + }); + assert_eq!( + min_max_range_reads_reach(&spell, row.as_ref()), + moves, + "attributes {attributes:#x}, row {row:?}" + ); + } + } + } + /// Row 2 is the melee family, 114 Auto Shot's 8-35, 95 Charge's 8-25. #[test] fn real_spell_ranges_read_the_byte_laws_rows() { diff --git a/crates/benilla-formats/src/spells/tokens.rs b/crates/benilla-formats/src/spells/tokens.rs index 04c77d01..73808b5b 100644 --- a/crates/benilla-formats/src/spells/tokens.rs +++ b/crates/benilla-formats/src/spells/tokens.rs @@ -40,8 +40,9 @@ pub struct TokenContext<'a> { /// (`0x6ea000`'s flag, pushed at `507ccd` and `507917`). pub unmodified_points: bool, /// The `$z` token: the home-bind area's name, from `SMSG_BINDPOINTUPDATE`'s area id through - /// `AreaTable.dbc`; `None` leaves the token raw. - pub home_area: Option<&'a str>, + /// `AreaTable.dbc`; `None` leaves the token raw. Asked only where a `$z` expands, so a caller + /// can tell which texts read the bind point. + pub home_area: &'a dyn Fn() -> Option<&'a str>, /// `GetText 0x703bf0`: a `GlobalStrings` template by key. `None` leaves the token raw. pub global: &'a dyn Fn(&str) -> Option, /// `SStrPrintf 0x64a7f0`: a template's `%` holes filled, as the CRT formats them. @@ -344,7 +345,7 @@ fn token_value( } 'z' => { // Player state, not spell data: the home-bind area name. - let name = ctx.home_area?; + let name = (ctx.home_area)()?; Some((name.to_string(), 0.0)) } 'r' => { @@ -611,7 +612,7 @@ mod tests { lookup: &'a dyn Fn(u32) -> Option<&'a SpellDisplay>, ) -> TokenContext<'a> { TokenContext { - home_area: None, + home_area: &|| None, durations, radii, ranges: None, @@ -1225,7 +1226,7 @@ mod tests { let lookup = |id: u32| -> Option<&SpellDisplay> { (id == 1234).then_some(&other) }; let d = SpellDisplay::default(); let c = TokenContext { - home_area: Some("Goldshire"), + home_area: &|| Some("Goldshire"), ..ctx(&durations, &radii, &lookup) }; assert_eq!(