From f000aa01282eac35a99370c680250d50adc67970 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:31:47 +0700 Subject: [PATCH] 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) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- crates/benilla-app/src/spell/usable.rs | 25 +- crates/benilla-app/src/ui_tooltip/mod.rs | 266 ++++++++++++++---- crates/benilla-app/src/ui_tooltip/tests.rs | 252 +++++++++++++++++ crates/benilla-app/src/ui_trainer/mod.rs | 46 ++- crates/benilla-ui/src/script/model.rs | 6 + crates/benilla-ui/src/script/spellbook.rs | 2 +- crates/benilla-ui/src/script/talent.rs | 2 +- .../src/script/tests/tooltip_spell.rs | 163 +++++++++-- crates/benilla-ui/src/script/tooltip_spell.rs | 135 ++++++--- 9 files changed, 763 insertions(+), 134 deletions(-) diff --git a/crates/benilla-app/src/spell/usable.rs b/crates/benilla-app/src/spell/usable.rs index a714d116..31713961 100644 --- a/crates/benilla-app/src/spell/usable.rs +++ b/crates/benilla-app/src/spell/usable.rs @@ -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; diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 9ab04b95..d2d967d7 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -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, /// 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, + /// The spells whose view built against the pet is in the VM's pet store. + pet_pushed: std::collections::HashSet, + /// What the pet views read off the pet unit. + pet: Option, /// The bind point `$z` names. home: Option, /// The form the required-form line's colour follows (`0x52f1e3`). @@ -342,6 +395,53 @@ struct SpellFeedMemory { reagents: std::collections::BTreeMap, } +impl SpellFeedMemory { + /// Queue every pushed view, the player's and the pet's, for a rebuild. + fn invalidate(&mut self, wanted: &mut Vec, wanted_pet: &mut Vec) { + wanted.extend(self.pushed.drain()); + wanted_pet.extend(self.pet_pushed.drain()); + } +} + +/// The pet unit's fields a pet view reads (`0x52e610` with the unit selector set): its level, the +/// cost bases `0x612c50` reads, its form, and its and its melee target's reach, as bit patterns. +#[derive(Clone, Copy, PartialEq, Default)] +struct PetInputs { + guid: Option, + level: u32, + base_mana: u32, + max_health: u32, + max_power: [u32; 5], + form: u8, + combat_reach: u32, + attack_target_reach: Option, +} + +impl PetInputs { + fn of( + guid: Option, + pet: Option<&benilla_protocol::ObjectFields>, + attack_target_reach: Option, + ) -> Self { + let Some(p) = pet else { + return Self { + guid, + ..Self::default() + }; + }; + Self { + guid, + level: p.unit_level().unwrap_or(0), + base_mana: p.unit_base_mana().unwrap_or(0), + max_health: p.unit_max_health().unwrap_or(0), + max_power: std::array::from_fn(|i| p.unit_max_power(i as u8).unwrap_or(0)), + form: p.unit_shapeshift_form(), + combat_reach: p.unit_combat_reach().to_bits(), + attack_target_reach: attack_target_reach.map(f32::to_bits), + } + } +} + /// The hoverable spell sources [`feed_spell_tooltips`] reads, one parameter under Bevy's 16. #[derive(bevy::ecs::system::SystemParam)] struct SpellTooltipSources<'w> { @@ -359,8 +459,9 @@ fn feed_spell_tooltips( actions: Option>, spell_sources: SpellTooltipSources, self_q: Query<&ObjectStore, With>, - // 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>, + engaged_units: Query<&crate::creature_anim::Engaged>, objects: Objects, home_bind: Option>, area_names: Option>, @@ -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 = 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 = memory.reagents.keys().copied().collect(); let reagent_state: std::collections::BTreeMap = 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, + 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. diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index bed1986a..acaa17f7 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -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 { + 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::() + .init_resource::() + .init_resource::() + .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::(); + 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::(); + let d = script + .eval::<(String, String, Option)>( + 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()) + ) + ); +} diff --git a/crates/benilla-app/src/ui_trainer/mod.rs b/crates/benilla-app/src/ui_trainer/mod.rs index 9dba70c7..011fc598 100644 --- a/crates/benilla-app/src/ui_trainer/mod.rs +++ b/crates/benilla-app/src/ui_trainer/mod.rs @@ -99,9 +99,13 @@ impl TrainerOpen { #[derive(Resource, Default)] pub(crate) struct TrainerErrors(pub Vec); -/// 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); +/// 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, + pub(crate) pet: Vec, +} /// 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 = 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. diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index db879f0d..d26613c7 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -796,6 +796,10 @@ pub(crate) struct Model { /// Spell id to its tooltip view, and the misses asked for. pub(crate) spell_tooltips: HashMap, pub(crate) spell_tooltip_asks: HashSet, + /// 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, + pub(crate) pet_spell_tooltip_asks: HashSet, /// The tooltips whose spell render missed its view, re-rendered when the app answers. pub(crate) spell_tooltip_waits: HashMap, /// `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(), diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index 7c706c9f..d28f2ee5 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -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) } diff --git a/crates/benilla-ui/src/script/talent.rs b/crates/benilla-ui/src/script/talent.rs index 85809c70..ba344327 100644 --- a/crates/benilla-ui/src/script/talent.rs +++ b/crates/benilla-ui/src/script/talent.rs @@ -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 } } diff --git a/crates/benilla-ui/src/script/tests/tooltip_spell.rs b/crates/benilla-ui/src/script/tests/tooltip_spell.rs index 32651787..600a21bc 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_spell.rs @@ -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::(&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::("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::("return HID:NumLines()").unwrap(), 0); assert!(!s.eval::("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") diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index 740c60e6..8ebee599 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -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 = { 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 { 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 { + 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 { + pub fn pet_spell_tooltip_subjects(&self) -> Vec { 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 { + 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 { .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::().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 { +/// 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, + &mut std::collections::HashSet, +) { + 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 { let mut model = lua.app_data_mut::().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, ) -> 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() {