The pet bar's, pet book's and pet-learn trainer's spell tooltips are built against the pet: its level scales the per-level terms, cost and cast time, and its base mana is the percentage cost's basis (#606)

`SetPetAction` (`0x532888`), `SetSpell` on the pet book (`0x532e23`) and a pet-learn
`SetTrainerService` (`0x533a7f`) pass the spell builder `0x52e610` its unit selector. With it set,
the level lookup `0x6e3130` asks the player's charm, else its summon (`6e3159`-`6e317f`), for
`UNIT_FIELD_LEVEL × 5` capped at `maxLevel × 5` (`0x60cd80`), and `GetPowerCost` `0x6e31b0` costs
against that unit: its level term and `0x612c50` on it, base mana for a mana spell (`6e327d`).
`GetCastTime` takes the same level (`6e3397`). No such unit reads level 0 and no cost.

The VM keeps a second view store for the pet, which the selector's setters read; the app builds
those views against the pet's fields and rebuilds only them when the pet, its level or its bases
change.

Verified: real-catalog tests (Sacrifice rank 1 at pet level 22 reads 318, Blood Pact rank 1 at 14
reads 3, Seduction costs 449 from the pet's base mana 1874), each failing with the change undone;
the player's views of the same spells are unchanged. Live, a level-60 warlock's succubus shows
Seduction at "449 Mana" (main computes 329 from the warlock's base mana 1373) and its voidwalker's
Sacrifice rank 1 absorbs 414, the capped level-22 value with the warlock's 30% talent mod, where
main reads 396. Built on macOS only; no platform seam.

Fixes #600
Fixes #601

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-29 16:31:47 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e4b06c5c2f
commit f000aa0128
9 changed files with 763 additions and 134 deletions
+19 -6
View File
@@ -378,19 +378,32 @@ pub(crate) fn spell_usable(
/// callers. The press-path gate refuses an unaffordable cast without sending anything
/// (`0x609657`), so an unmodified cost refuses casts a talented character can afford.
pub(crate) fn power_cost(d: &SpellDisplay, store: &ObjectStore, mods: &SpellModifiers) -> u32 {
// The level term reads `baseLevel` (`+0x70`), as `0x6e31b0` does, but the reference scales a
// caster value / 5 there (vmangos: the level / 5); only creature spells have a per-level cost.
power_cost_at(d, &store.0, store.0.unit_level().unwrap_or(0), mods)
}
/// [`power_cost`] against `unit`'s fields at the level term `level`, the value `0x6e31b0` reads
/// through the caster's `[vtbl+0xa8]` over 5 (`6e3242`-`6e3254`). With the unit selector set, the
/// tooltip's caster is the player's charm, else its summon (`6e31fe`-`6e3228`), whose own basis
/// `0x612c50` is called (`6e327d`).
pub(crate) fn power_cost_at(
d: &SpellDisplay,
unit: &benilla_protocol::ObjectFields,
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 {
store.0.unit_base_mana().unwrap_or(0)
unit.unit_base_mana().unwrap_or(0)
} else if power_type < 0 {
store.0.unit_max_health().unwrap_or(0)
unit.unit_max_health().unwrap_or(0)
} else {
store.0.unit_max_power(power_type as u8).unwrap_or(0)
unit.unit_max_power(power_type as u8).unwrap_or(0)
};
// The level term reads `baseLevel` (`+0x70`), as `0x6e31b0` does, but the reference scales a
// caster value / 5 there (vmangos: the level / 5); only creature spells have a per-level cost.
let level_delta = i64::from(store.0.unit_level().unwrap_or(0)) - i64::from(d.base_level);
let level_delta = i64::from(level) - i64::from(d.base_level);
let cost = i64::from(d.mana_cost)
+ level_delta * i64::from(d.mana_cost_per_level)
+ i64::from(base) * i64::from(d.mana_cost_pct) / 100;
+212 -54
View File
@@ -39,16 +39,43 @@ impl Plugin for UiTooltipPlugin {
}
}
/// The player-dependent inputs of a spell view: the worn set (`0x5f0c50`), the bags, the form and
/// 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 auto-attack target's reach, which `0x6e3480` reads itself (`[caster+0xc48]`); with
/// none, the caster's reach counts twice.
/// 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>,
@@ -81,11 +108,19 @@ fn spell_tooltip_view(
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: &|id| crate::spell::spell_skill_value(vctx.store, vctx.skill_lines, id),
skill: &skill,
lookup: &|id| spells.catalog.get(id),
mods: Some(vctx.spell_mods),
unmodified_points: false,
@@ -101,10 +136,15 @@ fn spell_tooltip_view(
// 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.
let resolved_cost = vctx.store.map_or(d.mana_cost, |s| {
crate::spell::usable::power_cost(d, s, vctx.spell_mods)
});
// 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);
@@ -160,7 +200,11 @@ fn spell_tooltip_view(
let cast_time = if d.tooltip_omits_cast_line() {
None
} else {
let level = vctx.store.and_then(|s| s.0.unit_level()).unwrap_or(0);
// `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 {
@@ -228,7 +272,9 @@ fn spell_tooltip_view(
.flatten()
})
.flatten();
let form_met = form != 0 && d.stances & (1u32 << (u32::from(form) - 1)) != 0;
// 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)
@@ -239,9 +285,11 @@ fn spell_tooltip_view(
.flatten()
.and_then(|name| keyed(vctx.get, "SPELL_EQUIPPED_ITEM", &[Arg::S(&name)]));
let chance = chance_line(d, vctx.store);
let item_met = vctx.store.is_none_or(|s| {
crate::spell::usable::equipped_item_fits(d, s, vctx.objects, vctx.items, vctx.commands)
});
// 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 = {
@@ -321,11 +369,16 @@ fn chance_line(d: &benilla_formats::SpellDisplay, store: Option<&ObjectStore>) -
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. The
/// reference rebuilds on every hover and keeps no such snapshot.
/// 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`).
@@ -342,6 +395,53 @@ struct SpellFeedMemory {
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> {
@@ -359,8 +459,9 @@ fn feed_spell_tooltips(
actions: Option<Res<PlayerActions>>,
spell_sources: SpellTooltipSources,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
// The auto-attack target, the melee range cell's second reach.
// 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>>,
@@ -404,15 +505,31 @@ fn feed_spell_tooltips(
.filter(|s| !memory.pushed.contains(s)),
);
}
// The open trainer's services, which the detail icon's `SetTrainerService` renders.
// 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
.0
.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()) {
@@ -437,7 +554,7 @@ fn feed_spell_tooltips(
// A bind-point change re-substitutes every view's `$z` (Astral Recall).
if memory.home != home_area {
memory.home = home_area.clone();
wanted.extend(memory.pushed.drain());
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
@@ -446,21 +563,21 @@ fn feed_spell_tooltips(
.unwrap_or(0);
if memory.form != Some(form) {
memory.form = Some(form);
wanted.extend(memory.pushed.drain());
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);
wanted.extend(memory.pushed.drain());
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;
wanted.extend(memory.pushed.drain());
memory.invalidate(&mut wanted, &mut wanted_pet);
}
let avoidance = self_store.map(|s| {
[
@@ -473,24 +590,22 @@ fn feed_spell_tooltips(
});
if memory.avoidance != avoidance {
memory.avoidance = avoidance;
wanted.extend(memory.pushed.drain());
memory.invalidate(&mut wanted, &mut wanted_pet);
}
let attack_target_reach = engaged_q
.single()
.ok()
.and_then(|e| objects.object(e.0))
.map(|f| f.unit_combat_reach());
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);
wanted.extend(memory.pushed.drain());
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() {
wanted.extend(memory.pushed.drain());
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
@@ -505,19 +620,34 @@ fn feed_spell_tooltips(
.collect();
if memory.reagents != reagent_state {
memory.reagents = reagent_state;
wanted.extend(memory.pushed.drain());
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 mut built: Vec<(u32, benilla_ui::script::SpellTooltipView)> = Vec::new();
{
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,
@@ -528,30 +658,58 @@ fn feed_spell_tooltips(
spell_mods,
get: &get,
};
for id in wanted {
if let Some(view) = spell_tooltip_view(id, spells, &mut vctx) {
// Watch this spell's reagents, seeded with the state the view was built against.
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));
memory.pushed.insert(id);
}
}
}
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.
+252
View File
@@ -65,6 +65,7 @@ impl TestCtx {
home_area: None,
form,
store,
caster: ViewCaster::Player,
combat_reach: store.map_or(1.5, |s| s.0.unit_combat_reach()),
attack_target_reach: None,
objects,
@@ -76,6 +77,133 @@ impl TestCtx {
get: self.get.as_ref(),
}
}
/// The pet view's context: the player `store`, and `pet` the unit the selector reads.
fn pet_ctx<'a, 'w, 's>(
&'a mut self,
objects: &'a Objects<'w, 's>,
store: Option<&'a ObjectStore>,
pet: Option<&'a benilla_protocol::ObjectFields>,
) -> ViewCtx<'a, 'w, 's> {
let mut ctx = self.ctx_for(objects, 0, None, store);
ctx.caster = ViewCaster::Pet(pet);
ctx
}
}
/// The 5875 spell data the view builder reads; `None` skips where the install is absent.
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 {
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"),
})
}
/// A unit's fields: level (34) and base mana (162).
fn unit(level: u32, base_mana: u32) -> benilla_protocol::ObjectFields {
benilla_protocol::ObjectFields::from_pairs(&[(22u16, 100u32), (34, level), (162, base_mana)])
}
/// The pet bar's views scale by the pet's level (`0x6e3130` with the selector: the charm or
/// summon's `[vtbl+0xa8]`, `0x60cd80`, level × 5 capped at `maxLevel × 5`): Voidwalker Sacrifice
/// rank 1 (7812: 305, 2.3 a level from 16, cap 22) reads 318 at 22 and at 30, and the player's own
/// view of it, in no line of the player's, reads the floor.
#[test]
fn a_pet_view_scales_sacrifice_by_the_pets_level() {
let Some(spells) = real_spells() else { return };
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
let player = ObjectStore(unit(60, 1373));
let absorb = |v: benilla_ui::script::SpellTooltipView, n: u32| {
assert!(
v.description.contains(&format!("absorb {n} damage")),
"{n}: {}",
v.description
);
};
for (level, n) in [(16, 305), (22, 318), (30, 318)] {
let pet = unit(level, 0);
let v = spell_tooltip_view(
7812,
&spells,
&mut t.pet_ctx(&objects, Some(&player), Some(&pet)),
)
.expect("Sacrifice view");
absorb(v, n);
}
let v = spell_tooltip_view(
7812,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&player)),
)
.expect("Sacrifice view");
absorb(v, 305);
// No pet unit reads level 0 (`0x6e31a4`), which floors at the rank's base.
let v = spell_tooltip_view(7812, &spells, &mut t.pet_ctx(&objects, Some(&player), None))
.expect("Sacrifice view");
absorb(v, 305);
}
/// Imp Blood Pact rank 1 (6307: 0.1 a level from 4, cap 14) reads 3 on a level-14 imp's bar.
#[test]
fn a_pet_view_scales_blood_pact_by_the_pets_level() {
let Some(spells) = real_spells() else { return };
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
let player = ObjectStore(unit(60, 1373));
let pet = unit(14, 0);
let v = spell_tooltip_view(
6307,
&spells,
&mut t.pet_ctx(&objects, Some(&player), Some(&pet)),
)
.expect("Blood Pact view");
assert!(v.description.contains("Stamina by 3."), "{}", v.description);
let v = spell_tooltip_view(
6307,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&player)),
)
.expect("Blood Pact view");
assert!(v.description.contains("Stamina by 2."), "{}", v.description);
}
/// Seduction (6358, 24% of base mana) on the pet bar costs 24% of the succubus's
/// `UNIT_FIELD_BASE_MANA`, as `0x6e31b0` calls `0x612c50` on the pet (`6e327d`): 449 of 1874, not
/// 329 of the warlock's 1373. No pet unit is `GetPowerCost`'s -1 (`0x6e3233`), no cost cell.
#[test]
fn a_pet_view_costs_seduction_from_the_pets_base_mana() {
let Some(spells) = real_spells() else { return };
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
let player = ObjectStore(unit(60, 1373));
let pet = unit(60, 1874);
let v = spell_tooltip_view(
6358,
&spells,
&mut t.pet_ctx(&objects, Some(&player), Some(&pet)),
)
.expect("Seduction view");
assert_eq!(v.cost.as_deref(), Some("449 Mana"));
let v = spell_tooltip_view(
6358,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&player)),
)
.expect("Seduction view");
assert_eq!(v.cost.as_deref(), Some("329 Mana"), "the player's own view");
let v = spell_tooltip_view(6358, &spells, &mut t.pet_ctx(&objects, Some(&player), None))
.expect("Seduction view");
assert_eq!(v.cost, None);
}
/// An object index with nothing streamed: no worn item, and every reagent count 0.
@@ -933,3 +1061,127 @@ fn the_feed_pushes_the_spells_the_vm_holds_before_a_hover() {
"Shadow Word: Pain | Shadow damage over time."
);
}
/// The feed builds the pet's bar and book against the pet (the charm, else the summon), keeps the
/// player's book on the player, and rebuilds only the pet views when the pet's level moves.
#[test]
fn the_feed_builds_the_pet_views_against_the_pet_and_rebuilds_them_on_its_level() {
use benilla_protocol::ObjectFields;
use benilla_ui::script::{PetActionView, PetBookState, SpellBookState, SpellSlotView};
const ME: u64 = 0x10;
const IMP: u64 = 0xF140_0000_0000_0077;
// `$s1` is 10 plus 1 a level, uncapped: the player, in no line of its own, reads 10.
let pact = benilla_formats::SpellDisplay {
id: 6307,
name: "Blood Pact".into(),
description: Some("Stamina by $s1.".into()),
effect_base_points: [9, 0, 0],
effect_base_dice: [1, 0, 0],
effect_die_sides: [1, 0, 0],
effect_real_points_per_level: [1.0, 0.0, 0.0],
..Default::default()
};
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(Spells {
catalog: benilla_formats::SpellCatalog::from_displays([(6307, pact)].into()),
..Spells::empty_for_tests()
})
.insert_resource(NetCommands(tx))
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::spell::SpellModifiers>()
.add_systems(Update, feed_spell_tooltips);
// The player summons the imp (`UNIT_FIELD_SUMMON`, 8-9).
let me = app
.world_mut()
.spawn((
SelfPlayer,
ObjectStore(ObjectFields::from_pairs(&[
(8, IMP as u32),
(9, (IMP >> 32) as u32),
(34, 60),
])),
))
.id();
let imp = app
.world_mut()
.spawn(ObjectStore(ObjectFields::from_pairs(&[(34, 14)])))
.id();
let mut index = app.world_mut().resource_mut::<crate::net::GuidIndex>();
index.0.insert(ME, me);
index.0.insert(IMP, imp);
let mut script = UiScript::new().unwrap();
script.set_pet_actions(
true,
true,
true,
vec![PetActionView {
name: Some("Blood Pact".into()),
spell_id: Some(6307),
..Default::default()
}],
);
let slot = SpellSlotView {
spell_id: 6307,
name: "Blood Pact".into(),
..Default::default()
};
script.set_pet_book(PetBookState {
token: Some("DEMON".into()),
slots: vec![slot.clone()],
});
script.set_spellbook(SpellBookState {
tabs: Vec::new(),
slots: vec![slot],
});
script
.run(
r#"
local a = CreateFrame("Button", "B"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
CreateFrame("GameTooltip", "TT")
function DESC(set) TT:SetOwner(B, "ANCHOR_RIGHT"); set(); return TTTextLeft2:GetText() end
"#,
)
.unwrap();
app.insert_non_send_resource(script);
// The feed runs over the pet bar and book; the player's book's view is an ask on its hover.
app.update();
let descs = |app: &mut App| {
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
let d = script
.eval::<(String, String, Option<String>)>(
r#"return DESC(function() TT:SetPetAction(1) end),
DESC(function() TT:SetSpell(1, "pet") end),
DESC(function() TT:SetSpell(1, "spell") end)"#,
)
.unwrap();
assert!(script.take_errors().is_empty());
d
};
let _ = descs(&mut app);
app.update();
assert_eq!(
descs(&mut app),
(
"Stamina by 24.".to_string(),
"Stamina by 24.".to_string(),
Some("Stamina by 10.".to_string())
),
"a level-14 imp's bar and book, and the player's own book"
);
// The imp dings: its views rebuild; the player's does not move.
app.world_mut()
.entity_mut(imp)
.insert(ObjectStore(ObjectFields::from_pairs(&[(34, 15)])));
app.update();
assert_eq!(
descs(&mut app),
(
"Stamina by 25.".to_string(),
"Stamina by 25.".to_string(),
Some("Stamina by 10.".to_string())
)
);
}
+31 -15
View File
@@ -99,9 +99,13 @@ impl TrainerOpen {
#[derive(Resource, Default)]
pub(crate) struct TrainerErrors(pub Vec<u32>);
/// The spells the open trainer's services show as tooltips, which the spell-tooltip feed pushes.
#[derive(Resource, Default)]
pub(crate) struct TrainerTooltipSubjects(pub(crate) Vec<u32>);
/// The spells the open trainer's services show as tooltips, which the spell-tooltip feed pushes:
/// a pet-learn service's view is built against the pet (the builder's unit selector, `0x533a7f`).
#[derive(Resource, Default, PartialEq)]
pub(crate) struct TrainerTooltipSubjects {
pub(crate) player: Vec<u32>,
pub(crate) pet: Vec<u32>,
}
/// The trainer feed, which the spell-tooltip feed runs after.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -430,18 +434,30 @@ pub(crate) fn feed_trainer(
},
);
// A spell subject's view must be in the store before the detail icon is hovered.
let mut fresh_tooltip_subjects: Vec<u32> = fresh
.iter()
.flat_map(|state| &state.services)
.filter_map(|service| match &service.tooltip {
TrainerTooltip::Spell { spell_id, .. } => Some(*spell_id),
TrainerTooltip::Item(_) => None,
})
.collect();
fresh_tooltip_subjects.sort_unstable();
fresh_tooltip_subjects.dedup();
if tooltip_subjects.0 != fresh_tooltip_subjects {
tooltip_subjects.0 = fresh_tooltip_subjects;
let mut fresh_tooltip_subjects = TrainerTooltipSubjects::default();
for service in fresh.iter().flat_map(|state| &state.services) {
if let TrainerTooltip::Spell {
spell_id,
alt_caster,
} = service.tooltip
{
let subjects = if alt_caster {
&mut fresh_tooltip_subjects.pet
} else {
&mut fresh_tooltip_subjects.player
};
subjects.push(spell_id);
}
}
for subjects in [
&mut fresh_tooltip_subjects.player,
&mut fresh_tooltip_subjects.pet,
] {
subjects.sort_unstable();
subjects.dedup();
}
if *tooltip_subjects != fresh_tooltip_subjects {
*tooltip_subjects = fresh_tooltip_subjects;
}
// A name-only change re-fires `TRAINER_UPDATE`, so the title's `UnitName("npc")` repaints.
// The name rides as arg1, which the 1.12 trainer events do not carry.
+6
View File
@@ -796,6 +796,10 @@ pub(crate) struct Model {
/// Spell id to its tooltip view, and the misses asked for.
pub(crate) spell_tooltips: HashMap<u32, super::SpellTooltipView>,
pub(crate) spell_tooltip_asks: HashSet<u32>,
/// The same for the views built against the player's pet, which the spell builder's unit
/// selector asks for (`0x52e610`'s fourth argument).
pub(crate) pet_spell_tooltips: HashMap<u32, super::SpellTooltipView>,
pub(crate) pet_spell_tooltip_asks: HashSet<u32>,
/// The tooltips whose spell render missed its view, re-rendered when the app answers.
pub(crate) spell_tooltip_waits: HashMap<FrameHandle, super::tooltip_spell::SpellWait>,
/// `CollapseQuestHeader`/`ExpandQuestHeader` as `(1-based entry, collapse)`, entry 0 for all.
@@ -1357,6 +1361,8 @@ impl Model {
player_req: PlayerReqState::default(),
spell_tooltips: HashMap::new(),
spell_tooltip_asks: HashSet::new(),
pet_spell_tooltips: HashMap::new(),
pet_spell_tooltip_asks: HashSet::new(),
spell_tooltip_waits: HashMap::new(),
quest_log_collapses: Vec::new(),
quest_log_watched: Vec::new(),
+1 -1
View File
@@ -161,7 +161,7 @@ impl super::UiScript {
/// Whether `bookType` names the pet book: a case-insensitive compare with `"pet"` alone
/// (`0x4b3f27`), so any other string, `"spell"` included, is the player's book.
fn is_pet_book(book_type: &str) -> bool {
pub(super) fn is_pet_book(book_type: &str) -> bool {
book_type.eq_ignore_ascii_case(BOOKTYPE_PET)
}
+1 -1
View File
@@ -293,6 +293,6 @@ pub(super) fn install_tooltip_method(lua: &Lua, m: &Table) -> mlua::Result<()> {
/// Ask the spell store for the next rank's description; a miss queues it as a primary view's does.
pub(super) fn ask_next_rank(lua: &Lua, next_spell: u32) {
if next_spell != 0 {
let _ = spell_view_of(lua, next_spell); // a miss records the ask as a side effect
let _ = spell_view_of(lua, next_spell, false); // a miss records the ask as a side effect
}
}
@@ -68,7 +68,8 @@ fn a_pet_book_hover_reads_the_pets_book_not_the_players() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_spell_tooltip(133, fireball());
s.set_spell_tooltip(
// The pet book's flag is the builder's unit selector, so its hover reads the pet's view.
s.set_pet_spell_tooltip(
3110,
SpellTooltipView {
name: "Firebolt".into(),
@@ -667,16 +668,16 @@ fn set_trainer_service_selects_the_builder_and_never_renders_its_own_line() {
..Default::default()
},
);
s.set_spell_tooltip(
200,
SpellTooltipView {
name: "Heroic Strike".into(),
cost: Some("15 Rage".into()),
reagents: Some("Reagents: Linen Cloth".into()),
description: "A strong attack.".into(),
..Default::default()
},
);
let heroic_strike = SpellTooltipView {
name: "Heroic Strike".into(),
cost: Some("15 Rage".into()),
reagents: Some("Reagents: Linen Cloth".into()),
description: "A strong attack.".into(),
..Default::default()
};
s.set_spell_tooltip(200, heroic_strike.clone());
// altCaster is the builder's unit selector, which reads the pet's view.
s.set_pet_spell_tooltip(200, heroic_strike);
s.set_trainer(Some(TrainerState {
services: vec![
trainer_service(2756, "Copper Shortsword", TrainerTooltip::Item(2847)),
@@ -1149,10 +1150,131 @@ fn the_spell_subjects_are_what_the_setters_read_from_the_vm() {
let mut subjects = s.spell_tooltip_subjects();
subjects.sort_unstable();
assert_eq!(
subjects,
vec![116, 133, 172, 589, 1459, 2580, 3110, 6307, 8921, 17253]
assert_eq!(subjects, vec![116, 133, 172, 589, 1459, 2580, 8921, 17253]);
// The pet's bar and book are read through the builder's unit selector: the pet's views.
let mut pet_subjects = s.pet_spell_tooltip_subjects();
pet_subjects.sort_unstable();
assert_eq!(pet_subjects, vec![3110, 6307]);
}
/// The builder's unit selector picks the view built against the pet: `SetPetAction` passes it
/// (`0x532888`, `0x5328d0`), `SetSpell` on the pet book (`0x532e23`) and a pet-learn
/// `SetTrainerService` (`0x533a7f`); the player's book, a class service and a craft pass 0.
#[test]
fn the_unit_selector_setters_read_the_pet_view() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
let view = |description: &str| SpellTooltipView {
name: "Sacrifice".into(),
description: description.into(),
..Default::default()
};
s.set_spell_tooltip(7812, view("absorb 305 damage"));
s.set_pet_spell_tooltip(7812, view("absorb 318 damage"));
s.set_pet_actions(
true,
true,
true,
vec![PetActionView {
name: Some("Sacrifice".into()),
spell_id: Some(7812),
..Default::default()
}],
);
let slot = SpellSlotView {
spell_id: 7812,
name: "Sacrifice".into(),
..Default::default()
};
s.set_pet_book(PetBookState {
token: Some("DEMON".into()),
slots: vec![slot.clone()],
});
s.set_spellbook(SpellBookState {
tabs: Vec::new(),
slots: vec![slot],
});
let service = |alt_caster| {
trainer_service(
100,
"Sacrifice",
TrainerTooltip::Spell {
spell_id: 7812,
alt_caster,
},
)
};
s.set_trainer(Some(TrainerState {
services: vec![service(true), service(false)],
..Default::default()
}));
s.set_craft(Some(CraftState {
name: "Beast Training".into(),
rank: 0,
max_rank: 0,
craft_type: 1,
recipes: vec![craft_recipe(24599, "Sacrifice", CraftTooltip::Spell(7812))],
}));
s.run(
r#"
local a = CreateFrame("Button", "PB1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
CreateFrame("GameTooltip", "TT")
local function desc(set)
TT:SetOwner(PB1, "ANCHOR_RIGHT"); set(); return TTTextLeft2:GetText()
end
PET_BAR = desc(function() TT:SetPetAction(1) end)
PET_BOOK = desc(function() TT:SetSpell(1, "pet") end)
BOOK = desc(function() TT:SetSpell(1, "spell") end)
-- Row 1 is the group header.
PET_SERVICE = desc(function() TT:SetTrainerService(2) end)
SERVICE = desc(function() TT:SetTrainerService(3) end)
CRAFT = desc(function() TT:SetCraftSpell(1) end)
"#,
)
.unwrap();
let got = |name: &str| s.eval::<String>(&format!("return {name}")).unwrap();
assert_eq!(got("PET_BAR"), "absorb 318 damage");
assert_eq!(got("PET_BOOK"), "absorb 318 damage");
assert_eq!(got("PET_SERVICE"), "absorb 318 damage");
assert_eq!(got("BOOK"), "absorb 305 damage");
assert_eq!(got("SERVICE"), "absorb 305 damage");
assert_eq!(got("CRAFT"), "absorb 305 damage");
assert!(s.take_spell_tooltip_asks().is_empty());
assert!(s.take_pet_spell_tooltip_asks().is_empty());
assert!(s.take_errors().is_empty());
}
/// A pet view's miss asks the pet's store, and only the pet view's answer re-renders it.
#[test]
fn a_pet_view_miss_waits_on_the_pet_store() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_pet_actions(true, true, true, vec![firebolt_slot()]);
s.run(
r#"
local a = CreateFrame("Button", "PB1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
CreateFrame("GameTooltip", "TT")
TT:SetOwner(PB1, "ANCHOR_RIGHT")
TT:SetPetAction(1)
"#,
)
.unwrap();
assert!(s.take_spell_tooltip_asks().is_empty());
assert_eq!(s.take_pet_spell_tooltip_asks(), vec![3110]);
// The player's view of the same id is not the one the selector renders.
s.set_spell_tooltip(3110, firebolt());
assert_eq!(left_lines(&mut s), vec!["Firebolt"]);
s.set_pet_spell_tooltip(3110, firebolt());
assert_eq!(
left_lines(&mut s),
vec![
"Firebolt",
"10 Mana",
"1 sec cast",
"Deals 7 to 10 Fire damage to a target."
]
);
assert!(s.take_errors().is_empty());
}
/// No second hover and no second `OnTooltipCleared`: the reference builds the tooltip at the call.
@@ -1173,9 +1295,9 @@ fn a_missed_view_re_renders_the_tooltip_when_the_app_answers() {
)
.unwrap();
assert_eq!(left_lines(&mut s), vec!["Firebolt"]);
assert_eq!(s.take_spell_tooltip_asks(), vec![3110]);
assert_eq!(s.take_pet_spell_tooltip_asks(), vec![3110]);
s.set_spell_tooltip(3110, firebolt());
s.set_pet_spell_tooltip(3110, firebolt());
assert_eq!(
left_lines(&mut s),
vec![
@@ -1187,7 +1309,7 @@ fn a_missed_view_re_renders_the_tooltip_when_the_app_answers() {
);
assert!(s.eval::<bool>("return TT:IsShown() == 1").unwrap());
assert!(
s.take_spell_tooltip_asks().is_empty(),
s.take_pet_spell_tooltip_asks().is_empty(),
"the re-render found the view"
);
assert_eq!(
@@ -1290,7 +1412,7 @@ fn new_content_or_an_added_line_ends_the_wait() {
"#,
)
.unwrap();
s.set_spell_tooltip(3110, firebolt());
s.set_pet_spell_tooltip(3110, firebolt());
assert_eq!(left_lines(&mut s), vec!["Attack"]);
assert_eq!(s.eval::<i64>("return HID:NumLines()").unwrap(), 0);
assert!(!s.eval::<bool>("return HID:IsShown() == 1").unwrap());
@@ -1329,7 +1451,7 @@ fn a_line_rewritten_in_place_ends_the_wait() {
"#,
)
.unwrap();
s.set_spell_tooltip(3110, firebolt());
s.set_pet_spell_tooltip(3110, firebolt());
assert_eq!(
s.eval::<(i64, String)>("return APP:NumLines(), APPTextLeft1:GetText()")
.unwrap(),
@@ -1351,7 +1473,6 @@ fn a_line_rewritten_in_place_ends_the_wait() {
fn a_wait_replaced_by_an_earlier_re_render_is_not_replayed() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_pet_actions(true, true, true, vec![firebolt_slot()]);
s.set_craft(Some(CraftState {
name: "Beast Training".into(),
rank: 0,
@@ -1367,7 +1488,7 @@ fn a_wait_replaced_by_an_earlier_re_render_is_not_replayed() {
A:SetOwner(PB1, "ANCHOR_RIGHT")
A:SetCraftSpell(1)
B:SetOwner(PB1, "ANCHOR_RIGHT")
B:SetPetAction(1)
B:SetCraftSpell(1)
A:SetScript("OnShow", function()
B:SetOwner(PB1, "ANCHOR_RIGHT")
B:SetHyperlink("|cffffd000|Henchant:20034|h[Enchant Weapon - Crusader]|h|r")
+99 -36
View File
@@ -5,11 +5,13 @@
//! Only `SetPlayerBuff` adds the gold time-remaining line.
//!
//! The app resolves each spell into a [`SpellTooltipView`] (`$`-tokens, cast, range and duration
//! text), kept in an ask-once store by spell id and pushed ahead of a hover. A miss records the
//! id; a `set_spell_by_id` miss also waits, and the app's answer re-renders that tooltip a frame
//! after the hover. That frame is a known difference from the reference, which builds every spell
//! tooltip at the call (`0x52e610`); a spell no pushed set names, such as an `enchant:` link's,
//! always meets it. A talent or tracking miss shows its view on the next hover.
//! text), kept in an ask-once store by spell id and pushed ahead of a hover: one store for the
//! views built against the player and one for those built against its pet, which the builder's
//! unit selector asks for. A miss records the id; a `set_spell_by_id` miss also waits, and the
//! app's answer re-renders that tooltip a frame after the hover. That frame is a known difference
//! from the reference, which builds every spell tooltip at the call (`0x52e610`); a spell no
//! pushed set names, such as an `enchant:` link's, always meets it. A talent or tracking miss shows
//! its view on the next hover.
use mlua::{Lua, Table, Value};
@@ -66,37 +68,53 @@ pub struct SpellTooltipView {
}
impl super::UiScript {
/// Store or replace a spell's view, answering its ask: a tooltip waiting on it re-renders now,
/// a frame after its hover, where the reference builds it whole at the call (`0x52e610`).
/// Store or replace a spell's view built against the player, answering its ask: a tooltip
/// waiting on it re-renders now, a frame after its hover, where the reference builds it whole
/// at the call (`0x52e610`).
pub fn set_spell_tooltip(&mut self, spell_id: u32, view: SpellTooltipView) {
self.store_spell_view(false, spell_id, view);
}
/// Store or replace a spell's view built against the player's pet, the one the builder's unit
/// selector renders (`SetPetAction`, `SetSpell` on the pet book, a pet-learn trainer service).
pub fn set_pet_spell_tooltip(&mut self, spell_id: u32, view: SpellTooltipView) {
self.store_spell_view(true, spell_id, view);
}
fn store_spell_view(&mut self, alt_caster: bool, spell_id: u32, view: SpellTooltipView) {
let waiting: Vec<FrameHandle> = {
let mut model = self.model_mut();
model.spell_tooltip_asks.remove(&spell_id);
model.spell_tooltips.insert(spell_id, view);
let (views, asks) = spell_store(&mut model, alt_caster);
asks.remove(&spell_id);
views.insert(spell_id, view);
model
.spell_tooltip_waits
.iter()
.filter(|(_, w)| w.spell_id == spell_id)
.filter(|(_, w)| w.spell_id == spell_id && w.opts.alt_caster == alt_caster)
.map(|(h, _)| *h)
.collect()
};
for h in waiting {
if let Err(e) = answer_wait(&self.lua, h, spell_id) {
if let Err(e) = answer_wait(&self.lua, h, spell_id, alt_caster) {
self.push_error(e);
}
}
}
/// Drain the spell ids the renderers asked for and the store lacked.
/// Drain the spell ids the renderers asked the player's store for and it lacked.
pub fn take_spell_tooltip_asks(&mut self) -> Vec<u32> {
self.model_mut().spell_tooltip_asks.drain().collect()
}
/// The spells a hover reads from the VM's own state rather than the player's book: the pet's
/// bar and book, the open quest's and each log entry's reward spell, the open craft's spell
/// subjects, the player's and every other unit's auras, and the tracking spell. The app pushes
/// Drain the spell ids the renderers asked the pet's store for and it lacked.
pub fn take_pet_spell_tooltip_asks(&mut self) -> Vec<u32> {
self.model_mut().pet_spell_tooltip_asks.drain().collect()
}
/// The spells whose pet view a hover reads from the VM's own state: the pet's bar and book,
/// whose setters pass the builder's unit selector (`0x532888`, `0x532e23`). The app pushes
/// their views ahead of a hover.
pub fn spell_tooltip_subjects(&self) -> Vec<u32> {
pub fn pet_spell_tooltip_subjects(&self) -> Vec<u32> {
let model = self.model_ref();
let pet_bar = model
.pet_bar
@@ -105,6 +123,15 @@ impl super::UiScript {
.filter(|s| !s.view.is_token)
.filter_map(|s| s.view.spell_id);
let pet_book = model.pet_book.slots.iter().map(|s| s.spell_id);
pet_bar.chain(pet_book).filter(|&id| id != 0).collect()
}
/// The spells whose player view a hover reads from the VM's own state rather than the
/// player's book: the open quest's and each log entry's reward spell, the open craft's spell
/// subjects, the player's and every other unit's auras, and the tracking spell. The app pushes
/// their views ahead of a hover.
pub fn spell_tooltip_subjects(&self) -> Vec<u32> {
let model = self.model_ref();
let rewards = model
.quest
.iter()
@@ -131,9 +158,7 @@ impl super::UiScript {
.chain(model.unit_auras.values().flatten())
.map(|a| a.spell_id);
let tracking = model.tracking.iter().map(|t| t.spell_id);
pet_bar
.chain(pet_book)
.chain(rewards)
rewards
.chain(craft)
.chain(auras)
.chain(tracking)
@@ -178,16 +203,17 @@ fn drawn_lines(model: &mut Model, h: FrameHandle) -> mlua::Result<Drawn> {
.collect())
}
/// Re-run the render `h` still waits with on `spell_id`, read afresh: an earlier re-render's Lua
/// may have replaced it. Lines Lua added or rewrote since, or a fade under way, keep the tooltip as
/// it is. The clear is silent: Lua made one setter call and saw its one `OnTooltipCleared`.
fn answer_wait(lua: &Lua, h: FrameHandle, spell_id: u32) -> mlua::Result<()> {
/// Re-run the render `h` still waits with on `spell_id` in the store `alt_caster` names, read
/// afresh: an earlier re-render's Lua may have replaced it. Lines Lua added or rewrote since, or a
/// fade under way, keep the tooltip as it is. The clear is silent: Lua made one setter call and saw
/// its one `OnTooltipCleared`.
fn answer_wait(lua: &Lua, h: FrameHandle, spell_id: u32, alt_caster: bool) -> mlua::Result<()> {
let (id, wait) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(wait) = model
.spell_tooltip_waits
.get(&h)
.filter(|w| w.spell_id == spell_id)
.filter(|w| w.spell_id == spell_id && w.opts.alt_caster == alt_caster)
.cloned()
else {
return Ok(());
@@ -212,12 +238,37 @@ fn answer_wait(lua: &Lua, h: FrameHandle, spell_id: u32) -> mlua::Result<()> {
)
}
/// Look up a spell's view; a miss records the ask.
pub(super) fn spell_view_of(lua: &Lua, spell_id: u32) -> Option<SpellTooltipView> {
/// The view store and its asks for the unit a render is built against: the pet's when the
/// builder's unit selector is set (`alt_caster`), else the player's.
fn spell_store(
model: &mut Model,
alt_caster: bool,
) -> (
&mut std::collections::HashMap<u32, SpellTooltipView>,
&mut std::collections::HashSet<u32>,
) {
if alt_caster {
(
&mut model.pet_spell_tooltips,
&mut model.pet_spell_tooltip_asks,
)
} else {
(&mut model.spell_tooltips, &mut model.spell_tooltip_asks)
}
}
/// Look up a spell's view built against the player, or with `alt_caster` against its pet; a miss
/// records the ask.
pub(super) fn spell_view_of(
lua: &Lua,
spell_id: u32,
alt_caster: bool,
) -> Option<SpellTooltipView> {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let v = model.spell_tooltips.get(&spell_id).cloned();
let (views, asks) = spell_store(&mut model, alt_caster);
let v = views.get(&spell_id).cloned();
if v.is_none() && spell_id != 0 {
model.spell_tooltip_asks.insert(spell_id);
asks.insert(spell_id);
}
v
}
@@ -253,8 +304,10 @@ pub(super) struct SpellRenderOpts {
pub(super) aura: bool,
/// `param6` showRank, the gray "Rank N" column: `SetSpell` passes 0, `SetAction` 1.
pub(super) show_rank: bool,
/// `param5` altCaster, which drops the totem and reagent lines (`0x52ed43`, `0x52f393`); only
/// `SetTrainerService` sets it, for a `LEARN_PET_SPELL` service.
/// `param5` altCaster, the unit selector: the view is the one built against the player's
/// charm, else its summon (`0x6e3130`, `0x6e31b0`, `0x6e3340`), and the totem and reagent
/// lines drop (`0x52ed43`, `0x52f393`). `SetPetAction` sets it (`0x532888`), `SetSpell` on the
/// pet book (`0x532e23`) and `SetTrainerService` for a `LEARN_PET_SPELL` service (`0x533a7f`).
pub(super) alt_caster: bool,
}
@@ -391,7 +444,7 @@ pub(super) fn set_spell_with_talent(
if talent.next_desc.is_none() {
super::talent::ask_next_rank(lua, talent.next_spell);
}
match spell_view_of(lua, spell_id) {
match spell_view_of(lua, spell_id, false) {
Some(v) => render_spell(
lua,
this,
@@ -458,7 +511,7 @@ fn fill_spell(
opts: SpellRenderOpts,
remaining: Option<String>,
) -> mlua::Result<()> {
match spell_view_of(lua, spell_id) {
match spell_view_of(lua, spell_id, opts.alt_caster) {
Some(v) => render_spell(lua, this, &v, opts, remaining, None)?,
None => {
if let Some(name) = fallback_name.clone() {
@@ -505,7 +558,12 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
None => return Ok(()),
}
};
set_spell_by_id(lua, &this, spell_id, name, SpellRenderOpts::default(), None)
// The pet book's flag is also the builder's unit selector (`0x532e70`).
let opts = SpellRenderOpts {
alt_caster: super::spellbook::is_pet_book(&book_type),
..Default::default()
};
set_spell_by_id(lua, &this, spell_id, name, opts, None)
})?,
)?;
// GameTooltip:SetShapeshift(index): the stance-bar hover, the form's spell tooltip.
@@ -522,7 +580,8 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
set_spell_by_id(lua, &this, spell_id, name, SpellRenderOpts::default(), None)
})?,
)?;
// GameTooltip:SetPetAction(index): the pet-bar hover for a spell slot; the stock
// GameTooltip:SetPetAction(index): the pet-bar hover for a spell slot, built against the pet
// (the unit selector, pushed at `0x532888` and `0x5328d0`); the stock
// `PetActionButton_OnEnter` builds a token's tooltip itself (`PetActionBarFrame.lua:290`). A
// slot with no spell is a no-op.
m.set(
@@ -543,7 +602,11 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
None => return Ok(()),
}
};
set_spell_by_id(lua, &this, spell_id, name, SpellRenderOpts::default(), None)
let opts = SpellRenderOpts {
alt_caster: true,
..Default::default()
};
set_spell_by_id(lua, &this, spell_id, name, opts, None)
})?,
)?;
// GameTooltip:SetPlayerBuff(buffIndex): the aura variant plus the time-remaining line only
@@ -663,7 +726,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
clear_content(&mut model, h);
}
fire_cleared(lua, h);
match spell_view_of(lua, spell_id) {
match spell_view_of(lua, spell_id, false) {
Some(v) => {
append_line(lua, &this, (v.name.clone(), GOLD), None, false)?;
let desc = if !v.aura_description.is_empty() {