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:
co-authored by
Claude Opus 5.5
parent
e4b06c5c2f
commit
f000aa0128
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user