From 44fb6e5e435e4d128dcc3816d919e1dd3f7ad584 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:22:18 +0700 Subject: [PATCH] spell tooltip feed: each pushed view records what it read, and a change of the player's state rebuilds only the views that read it (#650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every spell-tooltip invalidation rebuilt every view the feed had ever pushed. The remembered set only grows: the book, every talent rank and trainer service, and every aura ever seen. So in a long session each of these frame-hitched for 1.5 to 4 ms: - a crit or dodge change; - a worn-slot change; - a target swap while auto-attacking; - a spell-modifier packet; - a reagent cast. The reference builds a tooltip at the hover (`0x52e610`) and keeps nothing. benilla pushes views ahead of the hover, so it has to know what each view was built from. Each pushed view now records, as it is built, the inputs it read: the `$z` home area, the form line, the worn slots the equipped-item search reads, and so on. Each record sits at the line that reads the input. The inputs: - the `$z` home area; - the form line (a spell with `Stances`); - the worn slots the equipped-item search reads, each slot's item, template and broken state, and the disarm flag; - the chance line's percentage (`Effect[0]` 20, 22, 23, 78); - the combat reach the range cell reads; - the skill lines the `$`-tokens scale by, the spell's own and those of each spell it cross-references; - the modifier family-flag bits of the spell and of those it cross-references; - the reagents it lists; - the player's level, base mana, max health and max power where the cost or cast cell reads them; - the ranged attack time the cooldown cell pads by. The feed diffs those inputs frame to frame and rebuilds only the views whose recorded reads intersect what moved. A class-family change still rebuilds everything, and the pet's own inputs still rebuild the pet views. The feed moves out of `ui_tooltip/mod.rs` into `spell_feed.rs` (the builder and the system) and `spell_deps.rs` (the records and the diff). `TokenContext::home_area` becomes a closure, so a `$z` read is recorded exactly. Measured in release on the real data with 2,000 remembered ids, per forced change: - crit or dodge: 3.3 ms → 26-38 µs; - one worn slot: 3.5 ms → ~100 µs; - one modifier cell: 3.5 ms → ~60 µs; - all 64 modifier bits: 3.5 ms → ~0.6 ms; - reach: 3.3 ms → 1.3 ms, still high because the range cell also pads ranged rows by reach (#643); - a quiet frame: unchanged. The bench is `ui_tooltip/bench.rs`, `#[ignore]`d. Tests: - `deps_tests.rs`, the safety net, builds all 22,357 real views. For each input it moves the input alone and fails on any view that changed without having recorded it, naming the spell and the input. - It also moves everything each view did not record at once, from both sides. - It pokes every player-store field (and every pet-store field for the pet views) to discover reads mechanically. - It enumerates the equipment slot states the item search distinguishes. - `feed_tests.rs` drives `feed_spell_tooltips` itself. A dodge change re-pushes only the dodge chance-line view, and a modifier cell only its family's views. Fixes #535 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/items.rs | 10 +- crates/benilla-app/src/spell/mod.rs | 62 +- crates/benilla-app/src/spell/mods.rs | 52 +- crates/benilla-app/src/spell/usable.rs | 199 ++- crates/benilla-app/src/ui_craft.rs | 2 +- crates/benilla-app/src/ui_items/feed.rs | 2 +- crates/benilla-app/src/ui_tooltip/bench.rs | 215 +++ .../benilla-app/src/ui_tooltip/deps_tests.rs | 1321 +++++++++++++++++ .../benilla-app/src/ui_tooltip/feed_tests.rs | 239 +++ crates/benilla-app/src/ui_tooltip/mod.rs | 687 +-------- .../benilla-app/src/ui_tooltip/spell_deps.rs | 343 +++++ .../benilla-app/src/ui_tooltip/spell_feed.rs | 755 ++++++++++ crates/benilla-app/src/ui_tooltip/tests.rs | 30 +- crates/benilla-formats/src/lib.rs | 19 +- .../benilla-formats/src/spells/cast_times.rs | 51 + crates/benilla-formats/src/spells/mod.rs | 4 +- crates/benilla-formats/src/spells/ranges.rs | 54 + crates/benilla-formats/src/spells/tokens.rs | 11 +- 18 files changed, 3299 insertions(+), 757 deletions(-) create mode 100644 crates/benilla-app/src/ui_tooltip/bench.rs create mode 100644 crates/benilla-app/src/ui_tooltip/deps_tests.rs create mode 100644 crates/benilla-app/src/ui_tooltip/feed_tests.rs create mode 100644 crates/benilla-app/src/ui_tooltip/spell_deps.rs create mode 100644 crates/benilla-app/src/ui_tooltip/spell_feed.rs 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!(