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)

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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-30 01:22:18 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e071b8ded3
commit 44fb6e5e43
18 changed files with 3299 additions and 757 deletions
+8 -2
View File
@@ -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<u8> {
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<u8> {
if store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED == 0 {
if !is_disarmed(store) {
return None;
}
let class_of = |slot: u8| -> Option<u8> {
+61 -1
View File
@@ -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<u32> {
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<u32, u32>, line| lines.get(&line).copied().unwrap_or(0);
prev.keys()
.chain(now.keys())
.copied()
.filter(|&line| value(&prev, line) != value(&now, line))
.collect::<std::collections::BTreeSet<_>>()
.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]
+45 -7
View File
@@ -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<SpellMod> {
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);
}
+152 -47
View File
@@ -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<WornItem> {
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<WornItem>) -> 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`].
+1 -1
View File
@@ -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,
};
+1 -1
View File
@@ -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,
};
+215
View File
@@ -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>) -> 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<u32> = 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<u32>, Vec<u32>) = 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<u32> = 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::<Items>()
.init_resource::<GuidIndex>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<PlayerActions>()
.add_systems(Update, feed_spell_tooltips);
app.world_mut()
.resource_mut::<PlayerActions>()
.spells
.extend(ids.iter().copied());
app.world_mut()
.resource_mut::<crate::spell::SpellModifiers>()
.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::<GuidIndex>()
.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<Duration> = (0..40)
.map(|i| {
step(app, i % 2 == 0);
timed(app)
})
.collect();
let mean = times.iter().sum::<Duration>() / 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::<crate::spell::SpellModifiers>();
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::<crate::spell::SpellModifiers>();
for bit in 0..64 {
mods.set(false, bit, crate::spell::OP_COST, if a { -100 } else { 0 });
}
});
}
File diff suppressed because it is too large Load Diff
@@ -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<u32>,
make: Box<dyn Fn(u32) -> 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::<Items>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<PlayerActions>()
.add_systems(Update, feed_spell_tooltips);
let player = app
.world_mut()
.spawn((SelfPlayer, ObjectStore(ObjectFields::from_pairs(fields))))
.id();
let ids: Vec<u32> = make(1).iter().map(|&(id, _)| id).collect();
app.world_mut().resource_mut::<PlayerActions>().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<u32> {
let script = self.app.world().non_send_resource::<UiScript>();
(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::<crate::spell::SpellModifiers>());
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"
);
}
+14 -673
View File
@@ -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<f32>,
/// 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<String>,
}
/// Fill a key's template from the VM's `GlobalStrings.lua`; no string shows nothing.
fn keyed(get: &dyn Fn(&str) -> Option<String>, key: &str, args: &[Arg<'_>]) -> Option<String> {
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<benilla_ui::script::SpellTooltipView> {
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<String> = 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<String> {
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<u32>,
/// The spells whose view built against the pet is in the VM's pet store.
pet_pushed: std::collections::HashSet<u32>,
/// What the pet views read off the pet unit.
pet: Option<PetInputs>,
/// The bind point `$z` names.
home: Option<String>,
/// The form the required-form line's colour follows (`0x52f1e3`).
form: Option<u8>,
/// The skills the descriptions' per-level terms scale by ([`crate::spell::skill_snapshot`]).
skills: Option<crate::spell::SkillSnapshot>,
/// 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<u32>, Option<u32>)>,
/// Per reagent on show, `(owned count, name resolved)` (the inline red, `0x854120`).
reagents: std::collections::BTreeMap<u32, (u32, bool)>,
}
impl SpellFeedMemory {
/// Queue every pushed view, the player's and the pet's, for a rebuild.
fn invalidate(&mut self, wanted: &mut Vec<u32>, wanted_pet: &mut Vec<u32>) {
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<u64>,
level: u32,
base_mana: u32,
max_health: u32,
max_power: [u32; 5],
form: u8,
combat_reach: u32,
attack_target_reach: Option<u32>,
}
impl PetInputs {
fn of(
guid: Option<u64>,
pet: Option<&benilla_protocol::ObjectFields>,
attack_target_reach: Option<f32>,
) -> 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<Res<'w, Spells>>,
trainer_subjects: Option<Res<'w, TrainerTooltipSubjects>>,
talents: Option<Res<'w, crate::ui_talent::Talents>>,
}
/// 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<NonSendMut<UiScript>>,
actions: Option<Res<PlayerActions>>,
spell_sources: SpellTooltipSources,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
// 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<SelfPlayer>>,
engaged_units: Query<&crate::creature_anim::Engaged>,
objects: Objects,
home_bind: Option<Res<crate::net::HomeBind>>,
area_names: Option<Res<crate::ui_quest_log::QuestHeaderNamesRes>>,
mut items: ResMut<Items>,
// One tuple param, under Bevy's 16-param ceiling.
lookups: (
Option<Res<crate::ui_items::ItemSubClasses>>,
Res<crate::spell::SpellModifiers>,
Option<Res<crate::ui_spellbook::SkillLines>>,
),
commands: Res<NetCommands>,
mut memory: Local<crate::ui_script::VmMemo<SpellFeedMemory>>,
) {
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<u32> = 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<u32> = 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<String> = 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<u32> = memory.reagents.keys().copied().collect();
let reagent_state: std::collections::BTreeMap<u32, (u32, bool)> = 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<u32>,
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;
@@ -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<f32> {
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<u32>,
/// The reagent entries the reagents line lists.
reagents: Vec<u32>,
}
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<u32>,
pub(super) reagents: Vec<u32>,
}
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<u32, (u32, bool)>;
/// 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<String>,
/// 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<u32>; 4],
/// The caster's and its auto-attack target's combat reach as bit patterns.
reach: (Option<u32>, Option<u32>),
/// 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<f32>,
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
}
}
@@ -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<f32>,
/// 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<String>,
}
/// 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<String> = 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<String> {
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<u32, Deps>,
/// 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<u32, Deps>,
/// What the pet views read off the pet unit.
pet: Option<PetInputs>,
/// The player-side inputs the pushed views were checked against last frame.
pub(super) seen: Option<Seen>,
/// The talent modifier tables as last frame's check found them.
pub(super) mods: Option<crate::spell::SpellModifiers>,
/// 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<u32>,
wanted_pet: &mut Vec<u32>,
) {
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<u64>,
level: u32,
base_mana: u32,
max_health: u32,
max_power: [u32; 5],
form: u8,
combat_reach: u32,
attack_target_reach: Option<u32>,
}
impl PetInputs {
pub(super) fn of(
guid: Option<u64>,
pet: Option<&benilla_protocol::ObjectFields>,
attack_target_reach: Option<f32>,
) -> 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<Res<'w, Spells>>,
trainer_subjects: Option<Res<'w, TrainerTooltipSubjects>>,
talents: Option<Res<'w, crate::ui_talent::Talents>>,
}
/// 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<NonSendMut<UiScript>>,
actions: Option<Res<PlayerActions>>,
spell_sources: SpellTooltipSources,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
// 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<SelfPlayer>>,
engaged_units: Query<&crate::creature_anim::Engaged>,
objects: Objects,
home_bind: Option<Res<crate::net::HomeBind>>,
area_names: Option<Res<crate::ui_quest_log::QuestHeaderNamesRes>>,
mut items: ResMut<Items>,
// One tuple param, under Bevy's 16-param ceiling.
lookups: (
Option<Res<crate::ui_items::ItemSubClasses>>,
Res<crate::spell::SpellModifiers>,
Option<Res<crate::ui_spellbook::SkillLines>>,
),
commands: Res<NetCommands>,
mut memory: Local<crate::ui_script::VmMemo<SpellFeedMemory>>,
) {
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<u32> = 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<u32> = 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<String> = 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<u32>,
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
}
+21 -9
View File
@@ -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<benilla_ui::script::SpellTooltipView> {
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<crate::net::ClientCommand>,
/// The builder's lookup over the shipped `GlobalStrings.lua`: a stub would pass on wording
/// the client never shows.
get: Box<Getter>,
/// 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<benilla_formats::SkillLineCatalog>,
pub(super) skill_lines: Option<benilla_formats::SkillLineCatalog>,
}
type Getter = dyn Fn(&str) -> Option<String>;
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<Spells> {
pub(super) fn real_spells() -> Option<Spells> {
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
Some(Spells {
+10 -9
View File
@@ -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::{
@@ -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<SpellCastTimeCatalog>
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 {
+2 -2
View File
@@ -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};
@@ -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() {
+6 -5
View File
@@ -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<String>,
/// `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!(