"Mousing over player graves provides no tooltip" could have been any of three silent `return`s on a path a player can see, and the log said nothing at all. Each now reports itself once per corpse and reason: an empty `CORPSE_FIELD_OWNER`, an owner name that has not answered its `CMSG_NAME_QUERY`, or a missing `CORPSE_TOOLTIP` global. The third is already ruled out by probe — `CORPSE_TOOLTIP` reads "Corpse of %s" on this chain — and the gate above them is correct for a grave (`corpse_mouseover_eligible`: a body always, a bone pile only while lootable, and a grave is not bones). So this is the measurement, not the fix: a hover with no `corpse plate:` line at all would mean the pick is not landing on the corpse, which is a different bug in a different file. The name case is normally transient, the query being a round trip away, and is logged anyway: if it never answers, that one line is the whole diagnosis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
902 lines
38 KiB
Rust
902 lines
38 KiB
Rust
//! The world-mouseover tooltip and the spell tooltip views. The world tooltip rebuilds once per
|
|
//! hover-target change and a lost hover arms a fade; the pick is [`Hovered`] or
|
|
//! [`HoveredObject`], arbitrated by [`go_is_nearest`] as the click router does.
|
|
|
|
use crate::ui_items::{count_of, InventoryScope};
|
|
use bevy::prelude::*;
|
|
use bevy::window::PrimaryWindow;
|
|
|
|
use benilla_protocol::guid;
|
|
use benilla_ui::script::{TooltipTint, UiScript, UnitState};
|
|
use benilla_ui::strings::Arg;
|
|
|
|
use crate::items::Items;
|
|
use crate::names::NameCache;
|
|
use crate::net::{Guid, NetCommands, ObjectStore, Objects, SelfPlayer};
|
|
use crate::target::{go_is_nearest, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC};
|
|
use crate::ui_action::{PlayerActions, Spells};
|
|
use crate::ui_script::UiFeed;
|
|
use crate::ui_trainer::{TrainerFeed, TrainerTooltipSubjects};
|
|
use crate::ui_unit::{enrich_unit, snapshot, unit_reaction, UnitFeed};
|
|
|
|
pub struct UiTooltipPlugin;
|
|
|
|
impl Plugin for UiTooltipPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.add_systems(
|
|
Update,
|
|
(
|
|
drive_mouseover_tooltip.in_set(UnitFeed),
|
|
// After the trainer feed, so a list that lands this frame is hoverable in its tick;
|
|
// outside `UnitFeed`, which the trainer feed follows, so it takes that set's gate.
|
|
feed_spell_tooltips
|
|
.in_set(UiFeed)
|
|
.after(TrainerFeed)
|
|
.run_if(crate::ui_script::ingame_ui_up),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The player-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>,
|
|
form: u8,
|
|
store: Option<&'a ObjectStore>,
|
|
/// 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.
|
|
attack_target_reach: Option<f32>,
|
|
/// The object index the worn-item search and each reagent's carried count resolve through.
|
|
objects: &'a Objects<'w, 's>,
|
|
items: &'a mut Items,
|
|
commands: &'a NetCommands,
|
|
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
|
|
/// The talent spell-modifier tables: the cost cell shows the modified cost (`power_cost`).
|
|
spell_mods: &'a crate::spell::SpellModifiers,
|
|
/// The VM's `GlobalStrings.lua`, where the keyed cells resolve; `text` is the `%d`-filling form
|
|
/// the `$` tokens take.
|
|
get: &'a dyn Fn(&str) -> Option<String>,
|
|
text: &'a dyn Fn(&str, &[i64]) -> Option<String>,
|
|
}
|
|
|
|
/// Fill a key's template from the VM's `GlobalStrings.lua`; no string shows nothing.
|
|
fn keyed(get: &dyn Fn(&str) -> Option<String>, key: &str, args: &[Arg<'_>]) -> Option<String> {
|
|
let text = benilla_ui::strings::fill(&get(key)?, args);
|
|
(!text.is_empty()).then_some(text)
|
|
}
|
|
|
|
/// Build one spell's tooltip view, every cell resolved here where the catalogs live. The range,
|
|
/// cast, cooldown, form and item cells fill their `GlobalStrings.lua` keys; the cost, reagents and
|
|
/// chance cells are composed in English, where the reference fills `MANA_COST` and its kin,
|
|
/// `SPELL_REAGENTS` and `CHANCE_TO_*`.
|
|
fn spell_tooltip_view(
|
|
spell_id: u32,
|
|
spells: &Spells,
|
|
vctx: &mut ViewCtx,
|
|
) -> Option<benilla_ui::script::SpellTooltipView> {
|
|
let d = spells.catalog.get(spell_id)?;
|
|
let home_area = vctx.home_area;
|
|
let form = vctx.form;
|
|
let ctx = benilla_formats::TokenContext {
|
|
durations: &spells.durations,
|
|
radii: &spells.radii,
|
|
lookup: &|id| spells.catalog.get(id),
|
|
home_area,
|
|
text: vctx.text,
|
|
};
|
|
// 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)
|
|
});
|
|
let cost = {
|
|
// The shared per-type divisor (`0x6e7130`), never a local `power_type == 1` test.
|
|
let div = benilla_protocol::messages::power_display_scale(d.power_type);
|
|
let unit = match d.power_type {
|
|
0 => "Mana",
|
|
1 => "Rage",
|
|
2 => "Focus",
|
|
3 => "Energy",
|
|
4 => "Happiness",
|
|
_ => "Health",
|
|
};
|
|
if resolved_cost == 0 && d.mana_per_second == 0 {
|
|
None
|
|
} else if d.mana_per_second > 0 {
|
|
Some(format!(
|
|
"{} {unit}, plus {} per sec",
|
|
resolved_cost / div,
|
|
d.mana_per_second / div
|
|
))
|
|
} else {
|
|
Some(format!("{} {unit}", resolved_cost / div))
|
|
}
|
|
};
|
|
// The range cell: two attribute gates, `GetMinMaxRange` (`0x6e3480`, `0x52e9c2`), then
|
|
// `max <= 0`. There is no melee wording: the melee row (flags bit 0, row 2 only) resolves to
|
|
// `max(reach + casterReach + 1.3333334, 5.0)` and prints like any other, usually "5 yd range".
|
|
let range = (!d.tooltip_omits_range_line())
|
|
.then(|| {
|
|
let (min, max) = benilla_formats::min_max_range(
|
|
d,
|
|
spells.ranges.get(d.range_index),
|
|
vctx.combat_reach,
|
|
vctx.attack_target_reach,
|
|
)?;
|
|
if max <= 0.0 {
|
|
return None;
|
|
}
|
|
// `SPELL_RANGE`'s hole is a string: the number is composed first, `"%d"` or `"%d-%d"`
|
|
// (`0x854fb4`, Charge's 8-25), each a `fistp` conversion that rounds to nearest.
|
|
let yd = |v: f32| f64::from(v).round_ties_even() as i64;
|
|
let yards = if min > 0.0 {
|
|
format!("{}-{}", yd(min), yd(max))
|
|
} else {
|
|
format!("{}", yd(max))
|
|
};
|
|
keyed(vctx.get, "SPELL_RANGE", &[Arg::S(&yards)])
|
|
})
|
|
.flatten();
|
|
// The cast line: gate `0x52eb15` also omits it for a TRADE_SKILL or ATTACK `Effect[0]`; then
|
|
// the ladder `0x52eb45`-`0x52ec90`, where a negative base is "Instant cast" (`0x52ebce`) and a
|
|
// zero one is "Instant cast" only for a mana spell with a cost (`0x52ec4b`).
|
|
let cast_time = if d.tooltip_omits_cast_line() {
|
|
None
|
|
} else {
|
|
let base = spells
|
|
.cast_times
|
|
.get(d.casting_time_index)
|
|
.map(|c| c.base_ms as i32)
|
|
.unwrap_or(0);
|
|
// `%.3g` templates: the seconds go over as a real.
|
|
if base > 0 {
|
|
let (key, v) = if base >= 60_000 {
|
|
("SPELL_CAST_TIME_MIN", f64::from(base) / 60_000.0)
|
|
} else {
|
|
("SPELL_CAST_TIME_SEC", f64::from(base) / 1000.0)
|
|
};
|
|
keyed(vctx.get, key, &[Arg::F(v)])
|
|
} else {
|
|
let key = if base < 0 {
|
|
"SPELL_CAST_TIME_INSTANT"
|
|
} else if d.on_next_swing() {
|
|
"SPELL_ON_NEXT_SWING"
|
|
} else if d.tooltip_on_next_ranged() {
|
|
"SPELL_ON_NEXT_RANGED"
|
|
} else if d.tooltip_channeled() {
|
|
"SPELL_CAST_CHANNELED"
|
|
} else if d.power_type == 0 && resolved_cost > 0 {
|
|
"SPELL_CAST_TIME_INSTANT"
|
|
} else {
|
|
"SPELL_CAST_TIME_INSTANT_NO_MANA"
|
|
};
|
|
keyed(vctx.get, key, &[])
|
|
}
|
|
};
|
|
// The larger recovery column (`0x52eada`): Charge's 15 s is its category recovery.
|
|
let recovery_ms = d.recovery_ms.max(d.category_recovery_ms);
|
|
let cooldown = (recovery_ms > 0)
|
|
.then(|| {
|
|
let secs = f64::from(recovery_ms) / 1000.0;
|
|
let (key, v) = if secs >= 60.0 {
|
|
("SPELL_RECAST_TIME_MIN", secs / 60.0)
|
|
} else {
|
|
("SPELL_RECAST_TIME_SEC", secs)
|
|
};
|
|
keyed(vctx.get, key, &[Arg::F(v)])
|
|
})
|
|
.flatten();
|
|
// The required-form line (`0x52f10a`-`0x52f2ae`). With `AttributesEx2` bit 19 set the mask is
|
|
// permissive (Inner Fire in Shadowform), and the line is skipped (`0x52f115`).
|
|
let requires_form = (d.stances != 0 && !d.form_mask_is_permissive())
|
|
.then(|| {
|
|
let names: Vec<&str> = (0..32u32)
|
|
.filter(|b| d.stances & (1 << b) != 0)
|
|
.filter_map(|b| spells.forms.get(&(b + 1)).map(|f| f.name.as_str()))
|
|
.filter(|n| !n.is_empty())
|
|
.collect();
|
|
(!names.is_empty())
|
|
.then(|| {
|
|
keyed(
|
|
vctx.get,
|
|
"SPELL_REQUIRED_FORM",
|
|
&[Arg::S(&names.join(", "))],
|
|
)
|
|
})
|
|
.flatten()
|
|
})
|
|
.flatten();
|
|
let form_met = form != 0 && d.stances & (1u32 << (u32::from(form) - 1)) != 0;
|
|
// The required-item line (`0x52eea7`-`0x52f10a`): a mask is named whole by
|
|
// `ItemSubClassMask.dbc` before its subclasses are joined; an empty mask or class < 0 skips it.
|
|
let requires_item = (d.targets & TARGET_ITEM == 0 && d.equipped_item_class >= 0)
|
|
.then(|| {
|
|
vctx.sub_classes?
|
|
.requirement_name(d.equipped_item_class as u32, d.equipped_item_subclass_mask)
|
|
})
|
|
.flatten()
|
|
.and_then(|name| keyed(vctx.get, "SPELL_EQUIPPED_ITEM", &[Arg::S(&name)]));
|
|
let chance = chance_line(d, vctx.store);
|
|
let item_met = vctx.store.is_none_or(|s| {
|
|
crate::spell::usable::equipped_item_fits(d, s, vctx.objects, vctx.items, vctx.commands)
|
|
});
|
|
// Reagents (`SPELL_REAGENTS`, `0x854e54`), red when short; one whose template has not
|
|
// streamed is absent until `feed_spell_tooltips` re-pushes on its arrival.
|
|
let reagents = {
|
|
let mut parts: Vec<String> = Vec::new();
|
|
for (entry, count) in d.reagents.iter().copied().filter(|&(e, _)| e != 0) {
|
|
let Some(name) = vctx
|
|
.items
|
|
.template(entry, 0, vctx.commands)
|
|
.map(|t| t.name.clone())
|
|
else {
|
|
continue;
|
|
};
|
|
let text = if count > 1 {
|
|
format!("{name} ({count})")
|
|
} else {
|
|
name
|
|
};
|
|
let short = vctx.store.is_some_and(|s| {
|
|
count_of(&s.0, vctx.objects, entry, InventoryScope::CARRIED) < count
|
|
});
|
|
parts.push(if short {
|
|
format!("|cffff2020{text}|r")
|
|
} else {
|
|
text
|
|
});
|
|
}
|
|
(!parts.is_empty()).then(|| format!("Reagents: {}", parts.join(", ")))
|
|
};
|
|
Some(benilla_ui::script::SpellTooltipView {
|
|
name: d.name.clone(),
|
|
rank: d.rank.clone(),
|
|
// The aura variant's right column (`0x52f8e5`), gated on `SpellDispelType.dbc` `[+0x28]`.
|
|
dispel_type: spells.catalog.dispel_name(d).map(str::to_string),
|
|
cost,
|
|
range,
|
|
cast_time,
|
|
cooldown,
|
|
requires_item,
|
|
item_met,
|
|
requires_form,
|
|
form_met,
|
|
chance,
|
|
reagents,
|
|
description: d
|
|
.description
|
|
.as_deref()
|
|
.map(|t| benilla_formats::substitute(t, d, &ctx))
|
|
.unwrap_or_default(),
|
|
aura_description: d
|
|
.aura_description
|
|
.as_deref()
|
|
.map(|t| benilla_formats::substitute(t, d, &ctx))
|
|
.unwrap_or_default(),
|
|
})
|
|
}
|
|
|
|
/// `Targets` bit `0x10` (name inferred): set, the required-item line is skipped (`0x52eeaa`).
|
|
const TARGET_ITEM: u32 = 0x10;
|
|
|
|
/// The four `Effect[0]` values that select a chance-to-X line (jump table `0x52f7c4`).
|
|
const EFFECT_DODGE: u32 = 20;
|
|
const EFFECT_PARRY: u32 = 22;
|
|
const EFFECT_BLOCK: u32 = 23;
|
|
const EFFECT_ATTACK: u32 = 78;
|
|
|
|
/// The chance-to-X line (`0x52f5b1`-`0x52f697`): ATTACK skips the passive gate that dodge, parry
|
|
/// and block need. The wire's values are already percents.
|
|
fn chance_line(d: &benilla_formats::SpellDisplay, store: Option<&ObjectStore>) -> Option<String> {
|
|
let (label, percentage) = match d.effects[0] {
|
|
EFFECT_ATTACK => ("crit", store?.0.player_crit_percentage()?),
|
|
EFFECT_DODGE if d.passive => ("dodge", store?.0.player_dodge_percentage()?),
|
|
EFFECT_PARRY if d.passive => ("parry", store?.0.player_parry_percentage()?),
|
|
EFFECT_BLOCK if d.passive => ("block", store?.0.player_block_percentage()?),
|
|
_ => return None,
|
|
};
|
|
// The enUS text of the reference's `CHANCE_TO_*` keys (`%.2f%% chance to crit`).
|
|
Some(format!("{percentage:.2}% chance to {label}"))
|
|
}
|
|
|
|
/// What a pushed view snapshots at build time; a change to any of it re-pushes every view. The
|
|
/// reference rebuilds on every hover and keeps no such snapshot.
|
|
#[derive(Default)]
|
|
struct SpellFeedMemory {
|
|
pushed: std::collections::HashSet<u32>,
|
|
/// The bind point `$z` names.
|
|
home: Option<String>,
|
|
/// The form the required-form line's colour follows (`0x52f1e3`).
|
|
form: Option<u8>,
|
|
/// The 19 worn-slot guids the required-item line's colour follows (`0x5f0c50`).
|
|
worn: Option<[u64; 19]>,
|
|
/// The block, dodge, parry and crit percentages as bit patterns (`0x52f5b1`).
|
|
avoidance: Option<[u32; 4]>,
|
|
/// The caster's and its auto-attack target's reach as bit patterns.
|
|
combat_reach: Option<(Option<u32>, Option<u32>)>,
|
|
/// Per reagent on show, `(owned count, name resolved)` (the inline red, `0x854120`).
|
|
reagents: std::collections::BTreeMap<u32, (u32, bool)>,
|
|
}
|
|
|
|
/// The hoverable spell sources [`feed_spell_tooltips`] reads, one parameter under Bevy's 16.
|
|
#[derive(bevy::ecs::system::SystemParam)]
|
|
struct SpellTooltipSources<'w> {
|
|
spells: Option<Res<'w, Spells>>,
|
|
trainer_subjects: Option<Res<'w, TrainerTooltipSubjects>>,
|
|
talents: Option<Res<'w, crate::ui_talent::Talents>>,
|
|
}
|
|
|
|
/// Push a view for every spell the UI can hover (the book, the class's talent ranks, the open
|
|
/// trainer's services, the auras) before it is hovered, as the reference reads them all locally;
|
|
/// an ask for any other id too.
|
|
fn feed_spell_tooltips(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
actions: Option<Res<PlayerActions>>,
|
|
spell_sources: SpellTooltipSources,
|
|
auras: Option<Res<crate::ui_aura::PlayerAuraCache>>,
|
|
selection: Res<crate::target::Selection>,
|
|
stores: Query<&ObjectStore>,
|
|
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
|
// The auto-attack target, the melee range cell's second reach.
|
|
engaged_q: Query<&crate::creature_anim::Engaged, With<SelfPlayer>>,
|
|
objects: Objects,
|
|
home_bind: Option<Res<crate::net::HomeBind>>,
|
|
area_names: Option<Res<crate::ui_quest_log::QuestHeaderNamesRes>>,
|
|
mut items: ResMut<Items>,
|
|
// One tuple param, under Bevy's 16-param ceiling.
|
|
lookups: (
|
|
Option<Res<crate::ui_items::ItemSubClasses>>,
|
|
Res<crate::spell::SpellModifiers>,
|
|
),
|
|
commands: Res<NetCommands>,
|
|
mut memory: Local<crate::ui_script::VmMemo<SpellFeedMemory>>,
|
|
) {
|
|
let (sub_classes, spell_mods) = &lookups;
|
|
let SpellTooltipSources {
|
|
spells,
|
|
trainer_subjects,
|
|
talents,
|
|
} = spell_sources;
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
let memory = memory.get(&script);
|
|
let Some(spells) = spells.as_deref() else {
|
|
return;
|
|
};
|
|
let mut wanted: Vec<u32> = script.take_spell_tooltip_asks();
|
|
if let Some(actions) = actions.as_deref() {
|
|
wanted.extend(
|
|
actions
|
|
.spells
|
|
.iter()
|
|
.copied()
|
|
.filter(|s| !memory.pushed.contains(s)),
|
|
);
|
|
}
|
|
// The open trainer's services, which the detail icon's `SetTrainerService` renders.
|
|
if let Some(trainer) = trainer_subjects.as_deref() {
|
|
wanted.extend(
|
|
trainer
|
|
.0
|
|
.iter()
|
|
.copied()
|
|
.filter(|s| !memory.pushed.contains(s)),
|
|
);
|
|
}
|
|
// Every rank of the class's talents: a talent tooltip reads the current and the next rank.
|
|
if let (Some(talents), Ok(store)) = (talents.as_deref(), self_q.single()) {
|
|
let race = store.0.unit_race().unwrap_or(0);
|
|
let class = store.0.unit_class().unwrap_or(0);
|
|
for tab in talents.catalog.tabs_for_class(race, class) {
|
|
for t in talents.catalog.talents_in_tab(tab.id) {
|
|
wanted.extend(
|
|
t.ranks
|
|
.iter()
|
|
.copied()
|
|
.filter(|s| *s != 0 && !memory.pushed.contains(s)),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if let Some(auras) = auras.as_deref() {
|
|
wanted.extend(auras.spell_ids().filter(|s| !memory.pushed.contains(s)));
|
|
}
|
|
// `SetTrackingSpell`: the display cache leaves the tracking aura out, so read the raw auras.
|
|
if let Ok(store) = self_q.single() {
|
|
wanted.extend(
|
|
store
|
|
.0
|
|
.unit_auras()
|
|
.map(|a| a.spell_id)
|
|
.filter(|s| !memory.pushed.contains(s)),
|
|
);
|
|
}
|
|
if let Some(store) = selection.target.and_then(|e| stores.get(e).ok()) {
|
|
wanted.extend(
|
|
store
|
|
.0
|
|
.unit_auras()
|
|
.map(|a| a.spell_id)
|
|
.filter(|s| !memory.pushed.contains(s)),
|
|
);
|
|
}
|
|
let home_area: Option<String> = home_bind
|
|
.as_deref()
|
|
.and_then(|b| b.0)
|
|
.and_then(|id| area_names.as_deref()?.0.resolve(id as i32))
|
|
.map(str::to_string);
|
|
// A bind-point change re-substitutes every view's `$z` (Astral Recall).
|
|
if memory.home != home_area {
|
|
memory.home = home_area.clone();
|
|
wanted.extend(memory.pushed.drain());
|
|
}
|
|
// So does a form change: the required-form line's colour follows the form (`0x52f1e3`).
|
|
let form = self_q
|
|
.single()
|
|
.map(|s| s.0.unit_shapeshift_form())
|
|
.unwrap_or(0);
|
|
if memory.form != Some(form) {
|
|
memory.form = Some(form);
|
|
wanted.extend(memory.pushed.drain());
|
|
}
|
|
let self_store = self_q.single().ok();
|
|
// 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());
|
|
}
|
|
let avoidance = self_store.map(|s| {
|
|
[
|
|
s.0.player_block_percentage(),
|
|
s.0.player_dodge_percentage(),
|
|
s.0.player_parry_percentage(),
|
|
s.0.player_crit_percentage(),
|
|
]
|
|
.map(|v| v.unwrap_or(0.0).to_bits())
|
|
});
|
|
if memory.avoidance != avoidance {
|
|
memory.avoidance = avoidance;
|
|
wanted.extend(memory.pushed.drain());
|
|
}
|
|
let attack_target_reach = engaged_q
|
|
.single()
|
|
.ok()
|
|
.and_then(|e| objects.object(e.0))
|
|
.map(|f| f.unit_combat_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());
|
|
}
|
|
// So does a spell-modifier change: the cost cell resolves through the talent tables.
|
|
if spell_mods.is_changed() {
|
|
wanted.extend(memory.pushed.drain());
|
|
}
|
|
let watched: Vec<u32> = memory.reagents.keys().copied().collect();
|
|
let reagent_state: std::collections::BTreeMap<u32, (u32, bool)> = watched
|
|
.into_iter()
|
|
.map(|entry| {
|
|
let named = items.template(entry, 0, &commands).is_some();
|
|
let owned = self_store.map_or(0, |s| {
|
|
count_of(&s.0, &objects, entry, InventoryScope::CARRIED)
|
|
});
|
|
(entry, (owned, named))
|
|
})
|
|
.collect();
|
|
if memory.reagents != reagent_state {
|
|
memory.reagents = reagent_state;
|
|
wanted.extend(memory.pushed.drain());
|
|
}
|
|
// 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 get = |key: &str| benilla_ui::strings::global(script.lua(), key);
|
|
let text = crate::ui_script::token_text(&script);
|
|
let mut vctx = ViewCtx {
|
|
home_area: home_area.as_deref(),
|
|
form,
|
|
store: self_store,
|
|
combat_reach: self_store.map_or(1.5, |s| s.0.unit_combat_reach()),
|
|
attack_target_reach,
|
|
objects: &objects,
|
|
items: &mut items,
|
|
commands: &commands,
|
|
sub_classes: sub_classes.as_deref().map(|c| &c.0),
|
|
spell_mods,
|
|
get: &get,
|
|
text: &text,
|
|
};
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
for (id, view) in built {
|
|
script.set_spell_tooltip(id, view);
|
|
}
|
|
}
|
|
|
|
/// What the world tooltip was last built for; the reference rebuilds once per hover-target change.
|
|
#[derive(Default, PartialEq, Clone, Copy)]
|
|
enum LastHover {
|
|
#[default]
|
|
None,
|
|
Unit(u64),
|
|
Go(u64),
|
|
/// A hovered corpse object, keyed on the corpse's guid.
|
|
Corpse(u64),
|
|
}
|
|
|
|
/// The world-hover driver's memory, one `SystemParam` because the driver is at Bevy's 16-param
|
|
/// ceiling.
|
|
#[derive(bevy::ecs::system::SystemParam)]
|
|
pub(crate) struct HoverMemo<'s> {
|
|
/// Which world plate is up. On an unchanged mouseover the reference makes no call (`0x482090`
|
|
/// returns at `0x4820b5`), so a plate Lua takes mid-hover stays gone.
|
|
last: Local<'s, crate::ui_script::VmMemo<LastHover>>,
|
|
/// The line-affecting fields the current unit plate was built from, so a late-arriving name or
|
|
/// creature-info rebuilds it under the same hover.
|
|
last_lines: Local<'s, crate::ui_script::VmMemo<Option<UnitState>>>,
|
|
/// The hover probe's last trace line, so a stationary probe logs it once.
|
|
trace: Local<'s, String>,
|
|
/// The last `(corpse guid, reason)` the corpse plate gave up on, so a hover that cannot draw
|
|
/// one says why **once** instead of every frame it is held.
|
|
corpse_miss: Local<'s, Option<(u64, &'static str)>>,
|
|
}
|
|
|
|
/// Report a corpse plate this hover could not build, once per corpse and reason.
|
|
///
|
|
/// Each of the three ways out below is a silent `return` on a path a player can see: hovering a
|
|
/// grave and getting nothing. "Mousing over player graves provides no tooltip" could be any of
|
|
/// them and the log said nothing at all, so the first thing to fix was the silence.
|
|
fn note_corpse_miss(last: &mut Option<(u64, &'static str)>, guid: u64, why: &'static str) {
|
|
if *last == Some((guid, why)) {
|
|
return;
|
|
}
|
|
*last = Some((guid, why));
|
|
info!("corpse plate: {guid:#x} draws none — {why}");
|
|
}
|
|
|
|
fn lines_view(s: &UnitState) -> UnitState {
|
|
UnitState {
|
|
health: 0,
|
|
max_health: 0,
|
|
power: 0,
|
|
max_power: 0,
|
|
..s.clone()
|
|
}
|
|
}
|
|
|
|
/// The "Locked" line's colour (`0x52ab03`-`0x52ab43`): red `0xc0d3a8` unless the resolver finds an
|
|
/// opener, then green `0xc0d420`, for a key (`0x52ab29`) or no requirement (`0x52ab22`); `None`, a
|
|
/// flag-locked object with no `Lock.dbc` row, is no requirement. A skill opener takes the
|
|
/// reference's difficulty ramp `0x529fa0` (grey, green, yellow, orange, red at +0/25/50/100);
|
|
/// benilla shows its green, since the resolver discards the margin.
|
|
fn locked_line_tint(outcome: Option<crate::target::lock::LockOutcome>) -> TooltipTint {
|
|
match outcome {
|
|
Some(crate::target::lock::LockOutcome::Unmet) => TooltipTint::Red,
|
|
_ => TooltipTint::LockOpen,
|
|
}
|
|
}
|
|
|
|
fn drive_mouseover_tooltip(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
hovered: Res<Hovered>,
|
|
hovered_go: Res<HoveredObject>,
|
|
// Seats the cursor-anchored GameObject plate.
|
|
window: Query<&Window, With<PrimaryWindow>>,
|
|
stores: Query<&ObjectStore>,
|
|
// `UnitState::position`, SuperWoW's `UnitPosition("mouseover")`/`CursorPosition()`. `With<Guid>`
|
|
// keeps this to network-synced objects, not every `Transform` in the game.
|
|
positions: Query<&Transform, With<Guid>>,
|
|
// The stored GAMEOBJECT_STATE the lock lines' Action gate reads.
|
|
anims: Query<&crate::go_anim::GoAnim>,
|
|
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
|
names: Res<NameCache>,
|
|
commands: Res<NetCommands>,
|
|
rx: crate::target::ReactionInputs,
|
|
// The lock chain's data, shared with the click router (`target::lock`) so hover and click
|
|
// agree on whether a lock can be opened.
|
|
go_inputs: crate::target::lock::GoLockInputs,
|
|
// The known-spell set the resolver's SKILL arm scans.
|
|
player_actions: Res<crate::ui_action::PlayerActions>,
|
|
// The cursor seat crosses the VM seam: the anchor below is UI units, not px.
|
|
ui_scale: Res<crate::ui_script::UiScaleCvar>,
|
|
mut memo: HoverMemo,
|
|
// `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input; without it no class has a relic slot.
|
|
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
let (last, last_lines, trace) = (&mut memo.last, &mut memo.last_lines, &mut *memo.trace);
|
|
let last = last.get(&script);
|
|
let last_lines = last_lines.get(&script);
|
|
let self_store = self_q.iter().next();
|
|
let chr = classes.as_deref().map(|t| &t.0);
|
|
|
|
let unit = hovered.target.zip(hovered.guid).and_then(|(entity, guid)| {
|
|
let store = stores.get(entity).ok()?;
|
|
let name = names
|
|
.resolve_unit(guid, Some(store), &commands)
|
|
.map(str::to_string);
|
|
let reaction = unit_reaction(rx.factions.as_deref(), &rx.reputations, store, self_store);
|
|
let position = positions
|
|
.get(entity)
|
|
.ok()
|
|
.map(|t| (t.translation.x, t.translation.y, t.translation.z));
|
|
let mut s = snapshot(store, name, reaction, chr, position);
|
|
enrich_unit(
|
|
&mut s,
|
|
guid,
|
|
&names,
|
|
store,
|
|
rx.factions.as_deref(),
|
|
self_store,
|
|
);
|
|
Some((guid, s))
|
|
});
|
|
// The hovered GameObject, when nothing else was picked or it is nearer. Not gated on
|
|
// highlightability: the publisher `0x492890` sends every object to the builder by kind, so a
|
|
// signpost or an in-use object shows its plate with no interact cursor.
|
|
let go = hovered_go.target.zip(hovered_go.guid).filter(|_| {
|
|
(unit.is_none() && hovered.corpse.is_none()) || go_is_nearest(&hovered, &hovered_go)
|
|
});
|
|
|
|
if let Some((guid, state)) = unit.filter(|_| go.is_none()) {
|
|
// Push first: the builder reads the token. Rebuild on a new target or a late name or
|
|
// creature answer; health and power stay out of the key, since the watcher drives the bar.
|
|
let key = lines_view(&state);
|
|
script.set_unit("mouseover", Some(state));
|
|
if *last != LastHover::Unit(guid) || last_lines.as_ref() != Some(&key) {
|
|
// The engine's own mouseover just moved, so any `SetMouseoverUnit` override it was
|
|
// standing behind is spent — see `clear_mouseover_override`.
|
|
script.clear_mouseover_override();
|
|
script.world_tooltip_unit("mouseover");
|
|
*last = LastHover::Unit(guid);
|
|
*last_lines = Some(key);
|
|
}
|
|
return;
|
|
}
|
|
// The corpse plate, "Corpse of <owner>" (`0x52aef0`): a name and nothing else, corner-seated.
|
|
// The publisher fires no event for a corpse, so `UnitName("mouseover")` on one stays nil.
|
|
if let Some((entity, guid)) = hovered
|
|
.corpse
|
|
.zip(hovered.corpse_guid)
|
|
.filter(|_| go.is_none())
|
|
{
|
|
let Some(store) = stores.get(entity).ok() else {
|
|
return;
|
|
};
|
|
// A bone pile with nothing to take publishes no mouseover: no plate, no brighten.
|
|
if !crate::target::corpse_mouseover_eligible(store) {
|
|
if !matches!(*last, LastHover::None) {
|
|
script.world_tooltip_fade();
|
|
*last = LastHover::None;
|
|
}
|
|
return;
|
|
}
|
|
if *last == LastHover::Corpse(guid) {
|
|
return; // already up; a corner plate never re-seats
|
|
}
|
|
// A corpse carries `CORPSE_FIELD_OWNER`, not a name; the plate waits for the name query.
|
|
let Some(owner) = store.0.corpse_owner() else {
|
|
note_corpse_miss(&mut memo.corpse_miss, guid, "CORPSE_FIELD_OWNER is empty");
|
|
return;
|
|
};
|
|
let Some(name) = names.resolve(owner, &commands).map(str::to_string) else {
|
|
// Normally transient — the query is in flight and the next frame has it. Logged once
|
|
// per corpse all the same: if it never answers, this line is the whole diagnosis.
|
|
note_corpse_miss(
|
|
&mut memo.corpse_miss,
|
|
guid,
|
|
"the owner's name has not answered a CMSG_NAME_QUERY yet",
|
|
);
|
|
return;
|
|
};
|
|
// `CORPSE_TOOLTIP`, the builder's key; no string, no plate.
|
|
let Some(plate) = keyed(
|
|
&|key: &str| benilla_ui::strings::global(script.lua(), key),
|
|
"CORPSE_TOOLTIP",
|
|
&[Arg::S(&name)],
|
|
) else {
|
|
note_corpse_miss(
|
|
&mut memo.corpse_miss,
|
|
guid,
|
|
"the CORPSE_TOOLTIP global is missing from this chain's FrameXML",
|
|
);
|
|
return;
|
|
};
|
|
// No object id: a corpse is not a GameObject, it only borrows this plate's one gold line.
|
|
script.world_tooltip_gameobject(&plate, None, &[], None);
|
|
*last = LastHover::Corpse(guid);
|
|
return;
|
|
}
|
|
if let Some((entity, guid)) = go {
|
|
// The plate follows the cursor iff `[vtbl+0x5c]` (`0x5f8630`) finds
|
|
// `data[0x621b00(type, 0x13)]` set, and semantic `0x13` exists only for GENERIC(5), at
|
|
// `data[0]`. `data[1]` (`0x5f4830`) is whether it can be hovered, not where it sits.
|
|
let cursor_seated = stores.get(entity).map(|s| s.0.gameobject_type_id())
|
|
== Ok(GO_TYPE_GENERIC)
|
|
&& go_inputs
|
|
.templates
|
|
.get(guid)
|
|
.is_some_and(|t| t.floating_tooltip);
|
|
// Window px to the VM's y-up UI units, as the input seam converts.
|
|
let cursor_ui = cursor_seated
|
|
.then(|| {
|
|
window.iter().next().and_then(|w| {
|
|
let s = crate::ui_script::seam_scale(w.height(), ui_scale.0);
|
|
// The hover probe's aim stands in for a missing cursor; a real pointer wins.
|
|
w.cursor_position()
|
|
.or_else(crate::target::hover_probe_point)
|
|
.map(|c| (c.x / s, (w.height() - c.y) / s))
|
|
})
|
|
})
|
|
.flatten();
|
|
if *last == LastHover::Go(guid) {
|
|
if crate::target::hover_probe_armed() {
|
|
let line = format!(
|
|
"held Go({guid:#x}) — plate up {}",
|
|
script.world_tooltip_up()
|
|
);
|
|
if *trace != line {
|
|
info!("hover probe/tooltip: {line}");
|
|
*trace = line;
|
|
}
|
|
}
|
|
// The cursor arm follows the pointer; the corner arm has nothing to re-seat.
|
|
if let Some((x, y)) = cursor_ui {
|
|
script.world_tooltip_move(x, y);
|
|
}
|
|
return;
|
|
}
|
|
if cursor_seated && cursor_ui.is_none() {
|
|
return; // cursor off-window: nothing to seat the plate against
|
|
}
|
|
if crate::target::hover_probe_armed() {
|
|
info!(
|
|
"hover probe/tooltip: guid {guid:#x} cursor_seated {cursor_seated} cursor_ui \
|
|
{cursor_ui:?} template {:?}",
|
|
go_inputs.templates.get(guid).map(|t| t.name.clone()),
|
|
);
|
|
}
|
|
let Some(template) = go_inputs.templates.get(guid).cloned() else {
|
|
// Template in flight: ask once, and show it when it lands.
|
|
go_inputs.templates.request(guid, &commands);
|
|
return;
|
|
};
|
|
// The builder's lock lines (`0x52aa20`): "Locked" iff `GO_FLAG_LOCKED` (`0x52aae5`), then
|
|
// one line from `Lock.dbc` slot 0 only, if it passes the Action gate (`0x52ab7e`): a key in
|
|
// white (`0x52acd9`), an unknown skill in red but only on an unflagged object (`0x52abf7`),
|
|
// so a locked door names its key, never its lockpicking.
|
|
let go_store = stores.get(entity).ok();
|
|
let flags = go_store.map_or(0, |s| s.0.gameobject_flags());
|
|
let flag_locked = flags & GO_FLAG_LOCKED != 0;
|
|
let state = go_store.map_or(benilla_formats::GO_STATE_ACTIVE, |s| {
|
|
crate::go_anim::go_state(anims.get(entity).ok(), s)
|
|
});
|
|
let slots = go_inputs
|
|
.locks
|
|
.as_ref()
|
|
.filter(|_| template.lock_id != 0)
|
|
.and_then(|l| l.0.slots(template.lock_id));
|
|
let mut lines: Vec<(String, TooltipTint)> = Vec::new();
|
|
// The lock lines' keys all read "Requires %s" in enUS; only the binary tells them apart.
|
|
let go_get = |key: &str| benilla_ui::strings::global(script.lua(), key);
|
|
if flag_locked {
|
|
// Coloured by whether the player can open it: the builder asks the resolver the click
|
|
// uses (`0x52ab14` → `0x5f83d0`), and `locked_line_tint` maps the answer.
|
|
let facts = crate::target::lock::go_facts(go_store.map(|s| (s, state)));
|
|
let mut matched = None;
|
|
let outcome = slots.map(|slots| {
|
|
crate::target::lock::resolve_lock(
|
|
slots,
|
|
&player_actions.spells,
|
|
go_inputs.spells.as_deref(),
|
|
go_inputs.skill_lines.as_ref().map(|s| &s.catalog),
|
|
self_store,
|
|
&go_inputs.objects,
|
|
facts,
|
|
&mut matched,
|
|
)
|
|
});
|
|
if let Some(text) = keyed(&go_get, "LOCKED", &[]) {
|
|
lines.push((text, locked_line_tint(outcome)));
|
|
}
|
|
}
|
|
if let Some(slot0) = slots
|
|
.map(|s| s[0])
|
|
.filter(|s| s.available(state, flag_locked))
|
|
{
|
|
match slot0.key_type {
|
|
benilla_formats::LOCK_KEY_ITEM => {
|
|
if let Some(t) = go_inputs.items.template(slot0.index, 0, &commands) {
|
|
// `LOCKED_WITH_ITEM`, at `0x52acb8`.
|
|
if let Some(text) = keyed(&go_get, "LOCKED_WITH_ITEM", &[Arg::S(&t.name)]) {
|
|
lines.push((text, TooltipTint::White));
|
|
}
|
|
}
|
|
}
|
|
// Every skill lock takes the opener-unknown arm, silent on a flag-locked object;
|
|
// the known arm, `LOCKED_WITH_SPELL_KNOWN` in the margin ramp's colour
|
|
// (`0x529fa0`), is not built.
|
|
benilla_formats::LOCK_KEY_SKILL if !flag_locked => {
|
|
// `LOCKED_WITH_SPELL` (`0x52ac04`), named by `LockType.dbc` ("Pick Lock"); the
|
|
// reference takes the known arm iff any known spell opens it (`0x52abcb`).
|
|
let word = go_inputs
|
|
.lock_types
|
|
.as_deref()
|
|
.and_then(|c| c.0.name(slot0.index));
|
|
if let Some(text) =
|
|
word.and_then(|w| keyed(&go_get, "LOCKED_WITH_SPELL", &[Arg::S(w)]))
|
|
{
|
|
lines.push((text, TooltipTint::Red));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
// The subject id for ClassicAPI's `GameTooltip:GetGameObject`: `OBJECT_FIELD_ENTRY` off
|
|
// the descriptor — literally "the spawn-packet template field" pfQuest's own call site
|
|
// asks for (`map.lua:294`) — falling back to the guid's own entry bits, which is where
|
|
// `GameObjectTemplates::get` read it to resolve `template` two lines up, so the two
|
|
// cannot name different templates.
|
|
let object_id = go_store
|
|
.and_then(|s| s.0.object_entry())
|
|
.or_else(|| guid::entry(guid))
|
|
.filter(|&e| e != 0);
|
|
script.world_tooltip_gameobject(&template.name, object_id, &lines, cursor_ui);
|
|
*last = LastHover::Go(guid);
|
|
return;
|
|
}
|
|
if !matches!(*last, LastHover::None) {
|
|
// Hover lost: arm the fade, and **drop the mouseover with it**.
|
|
//
|
|
// This used to keep the unit, on the reasoning that the fading plate still wanted it. The
|
|
// plate does not: its lines are already drawn, and the fade only ramps alpha. What keeping
|
|
// it actually bought was a `"mouseover"` that never went away — `UnitName("mouseover")`
|
|
// answered the last unit hovered for the rest of the session, so every addon reading it
|
|
// saw a stale unit forever. Measured: four hovers in a row (a boar's body, a rabbit's
|
|
// nameplate, a trogg's nameplate, a trogg's body) all answered "Small Crag Boar", the
|
|
// first of them.
|
|
//
|
|
// The reference clears its own mouseover on hover loss (`[0xb4e2c8]`), which is why
|
|
// `UnitExists("mouseover")` is the standard way to ask "am I pointing at anything".
|
|
script.set_unit("mouseover", None);
|
|
script.clear_mouseover_override();
|
|
script.world_tooltip_fade();
|
|
*last = LastHover::None;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|