Files
benilla-octo/crates/benilla-app/src/ui_tooltip/mod.rs
T
SamandClaude Opus 5.5 ef0fcc327c a Vanish clears your target at once, and a painted road covers the grass
- a Vanish, a stealth or a unit leaving range clears your target at once, not two seconds later, and TAB reaches a mob behind you (2345)
- a fully painted terrain layer covers fully: roads and rock no longer let about 6% of the grass underneath show through (2345)
- companion pets show their names, the saved camera pitch survives a login, and a missed resurrect offer no longer blocks later ones (2345)
- ESC on a quest giver closes it, browsing the auction house by category searches the right items, and abandon removes the quest you chose (2345)
- a profession window open at logout no longer reopens for the next character (2345)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:22:10 +07:00

1065 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The world-mouseover tooltip system (decision 0274 P3) — the app half of the byte-verified
//! mouseover flow (0276): the engine rebuilds the tooltip **once per hover-target change**
//! (`world_tooltip_unit` / `world_tooltip_gameobject`: default anchor via
//! `OnTooltipSetDefaultAnchor`, the verified line laws, `UPDATE_MOUSEOVER_UNIT` for the unit
//! recolor), the health bar tracks the per-frame `set_unit` pushes in between (the HEALTH
//! watcher), and hover loss ARMS a fade (`world_tooltip_fade`) rather than hiding.
//!
//! The picks are the byte-verified pair the cursor already rides: [`Hovered`] (units) and
//! [`HoveredObject`] (GameObjects), arbitrated by [`go_is_nearest`] exactly like the click
//! router. A hovered GameObject shows its template name (gold) and, when flag-locked, the red
//! Lock.dbc requirement lines ("Requires <key item>" / "Requires <skill>") — the verified
//! `0x52aa20` law. The standalone-corpse builder joins when corpse objects stream.
use crate::ui_items::{count_of, InventoryScope};
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::{TooltipTint, UiScript, UnitState};
use benilla_ui::strings::Arg;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::target::{
go_is_nearest, ring_reaction, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC,
};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_unit::{enrich_unit, snapshot, UnitFeed};
pub struct UiTooltipPlugin;
impl Plugin for UiTooltipPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(
drive_mouseover_tooltip.in_set(UnitFeed),
feed_spell_tooltips.in_set(UnitFeed),
),
);
}
}
/// Everything a view needs beyond the spell catalogs — the player-dependent halves of the line
/// law: the worn set (law §3.6's equipped-item test), the bags (§3.8's reagent possession, and
/// the item-name cache the reagent names come from), the current form, and the bind point `$z`
/// substitutes against.
struct ViewCtx<'a, 'w, 's> {
home_area: Option<&'a str>,
form: u8,
store: Option<&'a ObjectStore>,
/// The caster's own `UNIT_FIELD_COMBATREACH`. 1.5 is the descriptor default, not a guess.
combat_reach: f32,
/// The reach of whoever the caster is currently auto-attacking, when it is auto-attacking —
/// the melee range arm's second term. The tooltip passes no target, but `0x6e3480`'s melee
/// arm resolves `[caster+0xc48]` itself, so this cell moves with the mob you are swinging at
/// and falls back to doubling the caster's own reach when nothing is engaged.
attack_target_reach: Option<f32>,
/// The one object index (2334) — what the possession cells resolve an instance guid
/// through: the worn-item line's search and each reagent's carried count.
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 RESOLVED cost, which since
/// `SPELLMOD_COST` landed means the modified one (`crate::spell::usable::power_cost`).
spell_mods: &'a crate::spell::SpellModifiers,
/// The VM's own `GlobalStrings.lua` (decision 2045) — every cell this builder composes is a
/// key, and this is where they resolve. `text` is the `%d`-filling twin the `$`-engine's
/// keyed tokens take (`benilla_formats::TokenContext::text`).
get: &'a dyn Fn(&str) -> Option<String>,
text: &'a dyn Fn(&str, &[i64]) -> Option<String>,
}
/// Fill a key's template out of the VM's own `GlobalStrings.lua`, or nothing at all when the
/// chain has no string for it — the reference's data-suppression face, and the reason no cell
/// here carries a fallback sentence (decision 2045).
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 (decision 0274 P2) — the verified spell line law's inputs,
/// every string resolved here where the catalogs live: the cost cell (the RESOLVED
/// `power_cost` through the power-type key array, health fallback, `_PER_TIME` composite — law
/// §3.3, 1074; rage prints wire-cost ÷ 10), the range cell ("N yd range", "N-M yd range" when
/// the resolved min is nonzero — the law's `"%d-%d"` fork; the melee family resolves through the
/// same key off the caster's combat reach, and the on-next-swing class shows no cell at all), the cast cell
/// (the full `52eb45` ladder: sec/min, the negative-base sentinel, "Next melee"/"Attack
/// speed"/"Channeled", and the mana-keyed Instant fork — law §3.4, 1074; None = the law's
/// passive gate, which omits the whole line), the cooldown cell
/// (`max(RecoveryTime, CategoryRecoveryTime)` — law §3.4), the required-item and required-form
/// lines (law §3.6), the reagents line (law §3.8), and the $-substituted
/// description/aura-description (byte-exact formulas, `benilla_formats::substitute`).
///
/// Line TEXT is composed in English here, as every cell in this builder is; the reference reads
/// its own GlobalStrings templates ("Reagents: ", "Requires %s"). That is this builder's standing
/// INTERIM shape, not a new debt introduced by these lines.
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 (law §3.3, the `0x52e8ad` caller): the RESOLVED cost — `GetPowerCost`'s
// number, the same `power_cost` the usable walk compares (0948) — through the power-type key
// array (`0x85416c`: Mana/Rage/Focus/Energy/Happiness) with HEALTH as the out-of-range
// fallback (the `jl`/`cmp 5` fork at `0x52e8fc` — Life Tap's and Bloodrage's −2 → "N Health";
// B192, 1074). Rage displays wire÷10 (the `0x6e7130` per-type divisor), applied to the flat
// and per-second values alike; a `manaPerSecond` column composes the `_PER_TIME` string
// ("11 Health, plus 5 per sec" — Health Funnel), including the cost-0 case. Zero cost with
// zero per-second leaves the cell EMPTY. The ref never prints a percentage — a pct-only cost
// resolves to its flat number like any other, which is why the "% of base mana" line this
// replaces was unfaithful (B152). No store (a DBC-only view) degrades to the flat cost.
// HAPPINESS_COST has no GlobalStrings entry and no 5875 player spell reaches powerType 4;
// the unit word is a dead arm kept for the array's shape.
let resolved_cost = vctx.store.map_or(d.mana_cost, |s| {
crate::spell::usable::power_cost(d, s, vctx.spell_mods)
});
let cost = {
// The one `0x6e7130` table, not a local `if power_type == 1` — decision 2117 found three
// hand-rolled copies of it and one of them had been applied at a single site out of four.
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 (law §3.3 / wow-re `tooltip-globalstring-key-resolves.md` §A3, VERIFIED):
// two attribute gates, then `GetMinMaxRange 0x6e3480` with **`target = NULL`** (`0x52e9c2`),
// then a third gate on the resolved `max <= 0`.
//
// **There is no melee wording anywhere in the client** — `SPELL_RANGE_AREA` is not in
// `WoW.exe` at all, and the melee family is not a display name either: a melee spell is one
// whose `SpellRange` row sets flags bit 0 (the single shipped such row is id 2 "Combat
// Range", 813 spells), and the resolver hands back `max(reach + casterReach + 1.3333334,
// 5.0)` — which then prints through the SAME `SPELL_RANGE` + `"%d"` path as every other
// range, normally **"5 yd range"** (decision 2080 named this cell; it printed the invented
// "Melee Range" until it was converted).
//
// The tooltip passes no target, but that does NOT make both reaches the caster's: the melee
// arm resolves the caster's own `attack_target_guid` and uses that unit's reach, so the cell
// reads wider while you are auto-attacking something big.
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 yd range"` — and its hole is a **string**, which is what makes
// the law's pair fork (`0x854fb4`'s `"%d-%d"`, Charge: 8-25) expressible at all: the
// number cell is composed by a nested `SStrPrintf` first and handed over whole. Both
// holes are `fistp` conversions — round-to-nearest, never truncation (the melee sum
// is the only value that is ever fractional).
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();
// Law §3.4's own gate — wider than the spellbook's `passive`: a TRADE_SKILL or ATTACK
// Effect[0] omits the line too ([`SpellDisplay::tooltip_omits_cast_line`]). The arm order is
// the byte ladder `0x52eb45-0x52ec90` (1074): a positive time prints sec/min at the 60 s
// threshold; a NEGATIVE SpellCastTimes base is the "Instant cast" sentinel (`52ebce`); at
// zero — next-swing (attr 0x404) → "Next melee", auto-shot (attr 0x2) → "Attack speed",
// channeled (attrEx 0x44) → "Channeled", else the mana fork: "Instant cast" iff
// powerType==0 AND the resolved cost is nonzero (`52ec4b` re-reads `GetPowerCost`'s edi) —
// every other type reads bare "Instant" whatever it costs (Sinister Strike, Life Tap).
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);
// Each arm is a key. The two timed ones are `%.3g` templates — significant digits, not
// decimals — so the seconds are handed over as a real and the shipped string decides how
// it reads (decision 2080); this used to round to one decimal on our side.
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 {
// "Instant" without the "cast" — its own key, and the reason the mana fork above
// exists at all.
"SPELL_CAST_TIME_INSTANT_NO_MANA"
};
keyed(vctx.get, key, &[])
}
};
// The cooldown cell reads BOTH recovery columns (law §3.4: `max([+0x4c],[+0x50])>0` —
// Charge's 15 s is CategoryRecoveryTime; its RecoveryTime is 0).
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 (law §3.6): the Stances mask's form names off
// SpellShapeshiftForm.dbc, joined; bit b = form id b+1. Met against the CURRENT form.
//
// `Stances != 0` is NOT the gate on its own — the column is overloaded. When `AttributesEx2`
// bit 19 is set the mask is *permissive* ("may ALSO be cast in these forms"), which is how
// 5875 encodes Shadowform-castable priest spells, Spirit-of-Redemption-castable heals and
// Moonkin-castable balance spells; reading those as requirements put a red "Requires
// Shadowform" on Inner Fire and "Requires Spirit of Redemption" on Flash Heal (1483).
//
// Now byte-carved as wow-re §3-REQFORM, `[0x52f10a, 0x52f2ae)`: `52f115` reads AttributesEx2,
// ands `0x80000`, and jumps *over the whole name loop* — the loop is the only writer of the
// "nothing appended" flag `[ebp-8]`, so the AddLine is skipped entirely. The line is
// **suppressed, not recoloured**. Three earned negatives came with it: StancesNot `+0x30` is
// never read here, `Attributes` bit 16 is never tested, and `0x612480` is never called — the
// builder duplicates the gate inline rather than sharing the usable walk's helper, which is
// why the two could drift apart in the first place.
//
// Two ref behaviours we knowingly don't model, both unreachable for us: the composed text is
// capped at 0x80 bytes (our longest join is "Battle Stance, Berserker Stance"), and a *met*
// requirement is dropped in the COMPACT tooltip — benilla has no compact spell tooltip.
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 equipped-item-class half of law §3.6 — "Requires Wands" on the wand Shoot, and "Requires
// Melee Weapon" on Parry. A mask with several bits set is NOT a reason to print nothing (what we
// used to do): the reference names the whole mask through ItemSubClassMask.dbc first, and only
// falls back to comma-joining the individual subclasses. Law §3-EQUIPITEM, in the catalog.
// §3-EQUIPITEM's three entry gates, all of which skip the line: Targets bit 0x10 set, class < 0,
// or an empty mask.
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)]));
// The chance-to-X line (law line 10, §3-CHANCE): `Effect[0]` picks which of the player's four
// avoidance/crit percentages to print, and — except for ATTACK, which bypasses the gate — the
// spell must be passive. The percentages are already percents on the wire.
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 (law §3.8): the named slots, `count > 1` suffixed, a slot the player is short of
// wrapped in the builder's inline red. A reagent whose item template hasn't streamed yet is
// simply absent from this snapshot — `feed_spell_tooltips` re-pushes when it lands, which is
// our shape of the ref's own query-then-redisplay callback.
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 (law §3-BUFF) — gated inside the catalog by
// SpellDispelType.dbc's own `[+0x28]`, so Stealth-class auras hand back None.
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` — set ⇒ the equipped-item requirement line is skipped whatever the class
/// and mask say (§3-EQUIPITEM's first gate; the bit test is verified, the `TARGET_FLAG_ITEM` name
/// wow-re flags as inferred).
const TARGET_ITEM: u32 = 0x10;
/// `SPELL_EFFECT_DODGE` / `_PARRY` / `_BLOCK` / `_ATTACK` — the four `Effect[0]` values that select
/// a chance-to-X line (law §3-CHANCE's jump table).
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 (law line 10 / §3-CHANCE) — `None` when the spell names none of the four
/// effects, when the gate rejects it, or before the player's descriptor has streamed.
///
/// The predicate is the reference's, and its asymmetry is the point: **ATTACK bypasses the passive
/// gate** while dodge/parry/block require it. That is why Attack — whose `Attributes` carry `0x10`,
/// not the passive `0x40` — still shows a crit line, which is exactly the missing line reported.
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 reference's `%.2f%%` lives in its GlobalStrings, not the binary — this is the same shape.
Some(format!("{percentage:.2}% chance to {label}"))
}
/// The push loop's change detectors — everything a view SNAPSHOTS at build time. A change to any
/// of them means every pushed view is stale, so the loop re-pushes the lot (the reference simply
/// re-runs its builder on every hover, so a stale snapshot is a benilla-only failure mode).
#[derive(Default)]
struct SpellFeedMemory {
/// Spell ids whose view has been pushed at least once.
pushed: std::collections::HashSet<u32>,
/// The bind point `$z` substitutes against.
home: Option<String>,
/// The current shapeshift form (law §3.6's stance line, white/red).
form: Option<u8>,
/// The 19 worn-slot guids (law §3.6's item line, white/red).
worn: Option<[u64; 19]>,
/// The player's block/dodge/parry/crit percentages, as raw bit patterns so the diff needs no
/// float comparison (law line 10's printed value — it moves with gear, buffs and talents).
avoidance: Option<[u32; 4]>,
/// The two reaches the melee range cell reads, as raw bit patterns — the caster's own
/// (which moves with scale and shapeshift) and its auto-attack target's (which changes with
/// the mob).
combat_reach: Option<(Option<u32>, Option<u32>)>,
/// Per reagent entry currently on show: `(owned count, item name resolved)` — law §3.8's
/// inline red plus the ask-once template landing.
reagents: std::collections::BTreeMap<u32, (u32, bool)>,
}
/// The spell-tooltip store's push half: every spell the UI can HOVER is owed its view at
/// arrival — the known book (spellbook/bar), the class's talent rank spells (`SetTalent`'s
/// display + next-rank reads), and the live aura spells (`SetPlayerBuff`) — so a first hover
/// never misses, exactly like the reference's all-local reads. The renderers' recorded asks
/// (the odd id outside those sets) answer through the same build as the fallback.
fn feed_spell_tooltips(
script: Option<NonSendMut<UiScript>>,
actions: Option<Res<PlayerActions>>,
spells: Option<Res<Spells>>,
talents: Option<Res<crate::ui_talent::Talents>>,
auras: Option<Res<crate::ui_aura::PlayerAuraCache>>,
selection: Res<crate::target::Selection>,
stores: Query<&ObjectStore>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
// Who the player is auto-attacking, if anyone — the melee range cell's second reach.
engaged_q: Query<&crate::creature_anim::Engaged, With<SelfPlayer>>,
// The object lookup (2334) — the guid index the engaged-target reach resolves through,
// plus the bag walk each watched reagent's carried count takes.
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 (Bevy's 16-SystemParam ceiling): the two lookups the view builder reads
// straight through — the item sub-class names for the required-item line, and the talent
// spell-modifier tables the cost cell resolves through.
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 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 talent window's hoverables: every rank spell of the class's pages (a talent tooltip
// reads rank max(1, current) + the next rank — pushing ALL ranks stays correct as ranks
// are learned, and the whole class set is ~250 ids once).
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)),
);
}
}
}
// The buff bar's hoverables: the live aura spells, at arrival.
if let Some(auras) = auras.as_deref() {
wanted.extend(auras.spell_ids().filter(|s| !memory.pushed.contains(s)));
}
// The minimap tracking icon's hover (SetTrackingSpell): the tracking aura never enters the
// display cache above (the rebuild's tracking-effect exclusion, `ui_aura`), so pre-feed from
// the player's raw aura array — covers it at arrival, at worst a few extra views for
// display-filtered auras nothing hovers.
if let Ok(store) = self_q.single() {
wanted.extend(
store
.0
.unit_auras()
.map(|a| a.spell_id)
.filter(|s| !memory.pushed.contains(s)),
);
}
// The target frame's aura rows (SetUnitBuff/SetUnitDebuff): the target's live aura spells,
// at selection/arrival — same first-hover guarantee as the buff bar'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 pushed view ($z — Astral Recall's shape).
if memory.home != home_area {
memory.home = home_area.clone();
wanted.extend(memory.pushed.drain());
}
// A stance/form change re-pushes too: the required-form line's white/red tracks the CURRENT
// form (law §3.6), and the views are static snapshots until re-pushed.
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();
// …and so do the two BAG-dependent halves, which the views likewise snapshot: the worn set
// (law §3.6's item line flips white/red on a weapon swap) and the owned counts of the
// reagents currently on show (law §3.8's inline red, plus the item names themselves landing
// from the ask-once template cache — the ref's query-then-redisplay callback in our shape).
// Both signatures are tiny: 19 slot guids, and one count per DISTINCT reagent in play.
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());
}
// …and so does the chance-to-X line's percentage (law line 10), which the views snapshot the
// same way: a weapon swap, a buff or a talent moves it.
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());
}
// …and so does the range cell's melee arm, whose inputs are the caster's own reach and the
// reach of whatever it is auto-attacking (`0x6e3480` resolves the latter itself).
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());
}
// …and so does the cost cell, whose resolved number now goes through the talent
// spell-modifier tables: a respec changes what every affected spell costs, and this feed is
// the one consumer of `power_cost` that memoizes its build (the action bar recomputes every
// frame). The reference has no cache here at all — it reads the tables live at every call
// site — so this is what keeps the cell honest about the same change (`crate::spell_mods`).
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());
}
// The string table is read while the views are BUILT and the store is written when they are
// PUSHED, so the two passes are split — one borrow of the VM cannot be both, and the build's
// borrow ends with this block.
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) {
// Register this spell's reagents in the watch set, seeded with the state the view was
// just built against — so the next recompute re-pushes on a REAL change only.
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 tooltip was last driven for — the change detector (the byte law rebuilds once per
/// hover-target change).
#[derive(Default, PartialEq, Clone, Copy)]
enum LastHover {
#[default]
None,
Unit(u64),
Go(u64),
/// A hovered **corpse object** — its own plate, keyed on the corpse's guid (1729).
Corpse(u64),
}
/// The snapshot fields the unit tooltip's LINES read (everything except the bar's
/// health/power) — the rebuild key: a change here means the rendered lines are stale.
/// The world-hover driver's own memory, bundled — which plate it put up, the line-affecting fields
/// that plate was built from, and the headless probe's say-once-on-change trace line (2255).
///
/// One [`SystemParam`](bevy::ecs::system::SystemParam) rather than three parameters for the reason
/// [`crate::target::hover::GoPickSet`] is one: [`drive_mouseover_tooltip`] sits at Bevy's 16-param
/// function-system ceiling. A bare tuple did the same job and tripped `clippy::type_complexity`,
/// which is the lint asking for exactly this.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct HoverMemo<'s> {
/// Which world plate is currently up — the driver's "do not rebuild this every frame" memo.
/// **Faithful**: on an unchanged mouseover the reference makes no call at all (`0x482090`
/// returns at `0x4820b5`), so a plate Lua takes mid-hover staying gone is reference behaviour
/// and not a bug to fix here. See 2255.
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 headless probe's last trace line, so a stationary probe says it once (2255).
trace: Local<'s, String>,
}
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`, decision 0770).
///
/// The builder seats red `0xc0d3a8` as the default *before* it calls the resolver, then re-colours
/// on the answer. Every non-`Unmet` answer lands on the same green `0xc0d420`, by two separate
/// branches that agree: an opener was found with **no item** and no matched spell (`0x52ab22 je`
/// — the no-requirement case), or an opener was found **with** an item, i.e. a KEY (`0x52ab29
/// jne`). Only a lock nothing can open keeps the red.
///
/// `None` = a flag-locked object with no `Lock.dbc` row at all, which is the reference's
/// no-requirement arm and therefore green — the flag alone is not a refusal.
///
/// **Not modelled:** the third branch, where a *skill* opener satisfied the lock and the colour
/// becomes the difficulty ramp `0x529fa0` (grey `0xc0cf50` / green `0xc0d420` / yellow `0xc0cf18`
/// / orange `0xc0d3a4` / red `0xc0d3a8`, banded at the requirement +0/25/50/100 — the trade-skill
/// ladder). Green is that ramp's comfortable rung, so a well-skilled opener already reads right
/// and only a marginal one reads too green. Pinned in decision 0770; deliberately left for its own
/// change, since it needs the resolver to hand back the margin it currently discards.
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>,
// The cursor-arm seat of the GO anchor fork (decision 0766).
window: Query<&Window, With<PrimaryWindow>>,
stores: Query<&ObjectStore>,
// The stored GAMEOBJECT_STATE the lock lines' Action gate reads (decision 0752).
anims: Query<&crate::go_anim::GoAnim>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
names: Res<NameCache>,
commands: Res<NetCommands>,
// The standing pair as one param — see [`crate::target::ReactionInputs`]. Bundled here and
// not elsewhere because this system sits on Bevy's tuple limit.
rx: crate::target::ReactionInputs,
// The lock chain's own data set, shared verbatim with the click router (`target::lock`) so the
// hover and the click can never disagree about whether a lock is satisfiable — the same reason
// `usable` and the click share one resolver (0752). Carries the go-template, Lock.dbc and
// item caches this system used to take as three separate params.
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 (0582/0584): the anchor below is UI units, not px.
ui_scale: Res<crate::ui_script::UiScaleCvar>,
mut memo: HoverMemo,
// `ChrClasses.dbc` field 16 — `UnitHasRelicSlot`'s only input. Absent when the client data
// failed to load, in which case no class reads as having 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);
// The hovered UNIT's snapshot (a hovered non-unit resolves no store here).
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 = ring_reaction(
rx.factions.as_deref(),
&rx.reputations,
Some(store),
self_store,
) + 1;
let mut s = snapshot(store, name, reaction, chr);
enrich_unit(
&mut s,
guid,
&names,
store,
rx.factions.as_deref(),
self_store,
);
Some((guid, s))
});
// The hovered GAMEOBJECT, when it is the nearer pick (the click router's own arbitration).
// Deliberately NOT gated on the highlightable predicate — §5-VERIFIED (wow-re 2026-07-20,
// 0558/0559): the mouseover publisher `0x492890` dispatches the GO tooltip builder `0x52aa20`
// by object KIND on both branches; highlightable is never read on the tooltip path (it gates
// the cursor and the click only). So a GENERIC(5) signpost, a pre-quest INTERACT_COND chest,
// and an IN_USE object all show the gold name plate while showing NO interact cursor — 0466's
// "no cursor AND no tooltip" coupling was the regression. Transports never reach here — they
// are excluded from the pick set itself (0466's correct half).
// "Nothing else was picked, or the GameObject is the nearer pick." **A hovered corpse counts
// as something else picked** (decision 1723) — it has no tooltip of its own yet, and the
// `unit.is_none()` short-circuit would otherwise hand a farther GameObject the gold plate the
// moment the ray landed on a body instead of a unit.
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 engine's builder + the recolor's UnitReaction read the token), then
// rebuild on a hover-target change OR when a LINE-affecting field changes under the
// same hover — the late-arriving name/creature-info case: the first render often
// precedes the SMSG_NAME_QUERY/CREATURE_QUERY answers, and a once-per-guid render
// would keep the stale (even empty) lines for the whole hover. Health/power stay OUT
// of the key: the byte law's watcher drives the BAR without a line rebuild.
let key = lines_view(&state);
script.set_unit("mouseover", Some(state));
if *last != LastHover::Unit(guid) || last_lines.as_ref() != Some(&key) {
script.world_tooltip_unit("mouseover");
*last = LastHover::Unit(guid);
*last_lines = Some(key);
}
return;
}
// The **corpse plate** — "Corpse of <owner>" (the reference's own builder `0x52aef0`, wow-re
// `corpse-click-and-reclaim.md` Q6, §5 cross-checked). Ahead of the GameObject arm because the
// corpse is the nearer pick whenever `go` came back empty.
//
// It is a *name plate and nothing else*: no health bar, no reaction recolour, no level or
// creature-type line — a corpse is not a unit, and the publisher deliberately fires **no
// event** for it, so it never becomes the Lua `mouseover` unit token either (`UnitName
// ("mouseover")` on a body is nil in the reference, and stays nil here). Corner-seated like a
// unit's, not cursor-seated like a signpost's.
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 at all — 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 showing; nothing re-seats a corner plate
}
// The owner's name is the ask-once cache like any other player name — a corpse carries
// `CORPSE_FIELD_OWNER`, never a name. Until the answer lands there is no plate to show
// (`last` stays `None`, so it appears the moment the name arrives).
let Some(owner) = store.0.corpse_owner() else {
return;
};
let Some(name) = names.resolve(owner, &commands).map(str::to_string) else {
return;
};
// `CORPSE_TOOLTIP = "Corpse of %s"` — the builder's own key (`0x52aef0`). No string, no
// plate, which is the reference's data-suppression face.
let Some(plate) = keyed(
&|key: &str| benilla_ui::strings::global(script.lua(), key),
"CORPSE_TOOLTIP",
&[Arg::S(&name)],
) else {
return;
};
script.world_tooltip_gameobject(&plate, &[], None);
*last = LastHover::Corpse(guid);
return;
}
if let Some((entity, guid)) = go {
// Which arm of the anchor fork this object takes (decision 0766). The reference asks the
// object's own `[obj->vtbl+0x5c]`; what selects it is not pinned, so we key on the one
// distinction the director's two reference observations agree on — a **GENERIC(5)**
// signpost follows the cursor, an interactable GameObject sits in the corner.
//
// 0766 keyed this on "is it GENERIC(5)" and said plainly that it was a proxy: the real
// client asks the object's own `[vtbl+0x5c]`, and what selects it was not then pinned.
// **It is pinned now, and it is narrower** (decision 2259): `[vtbl+0x5c]` is `0x5f8630`,
// whose body is `template.data[0x621b00(type, semantic 0x13)] != 0`, and key `0x13`
// resolves for exactly ONE of the 31 GO types — GENERIC(5), at `data[0]`. Every other type
// gets `-1` back and `0x5f8150`'s unsigned bound turns that into FALSE.
//
// So the cursor arm is **GENERIC with `data[0]` set**, and a GENERIC with it clear is
// corner-seated like everything else. That also settles 0766's named divergence: the three
// always-eligible types (SPELL_FOCUS 8 / DUEL_ARBITER 16 / FISHINGHOLE 25) carry no key
// `0x13`, so all three are corner-seated — 0766's "GENERIC" reading wins over its "not
// interactable" one.
//
// **`data[0]` is not `data[1]`.** The neighbouring slot is the mouseover-ELIGIBILITY column
// (0762, semantic `0x12`, `0x5f4830`), and the two answer different questions: `data[1]`
// says whether the object is hoverable at all, `data[0]` only says *where its plate sits*.
// 342 of the 447 type-5 entries in the reference's own `gameobjectcache.wdb` carry both;
// the objects that differ are hoverable and corner-seated, not silent.
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 → the VM's y-up 768-virtual units (÷s, the input seam's own conversion) —
// the anchor this point seats is resolved in UI units, so a raw-px point lands the
// plate (s−1)× the cursor's distance from the bottom-left corner away from it.
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 headless probe's aim stands in for a cursor the window does not have
// (2250), so an automated run can carry a GENERIC plate — which is
// cursor-seated — all the way to the screen. A person's pointer always 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 pointer-anchored 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 retry next frame (`last` stays, so the show
// fires the moment the name lands).
go_inputs.templates.request(guid, &commands);
return;
};
// The lock lines, transcribed from the builder `0x52aa20` (decision 0756). Two blocks, in
// the binary's order, and both are narrower than the sweep we used to print:
//
// A) **"Locked"** — emitted iff `GAMEOBJECT_FLAGS & GO_FLAG_LOCKED` (`0x52aae5`:
// `shr 1; test dl,1`). That flag gates this line and nothing else.
// B) **ONE requirement line, from Lock.dbc SLOT 0 ONLY** (`[lockRow+4]` / `[lockRow+0x24]`
// — the builder never walks the other seven), and only when slot 0 passes the same
// per-slot Action gate the resolver uses (`0x52ab7e` → `0x5f81d0`, decision 0752):
// · KEY → **white** `LOCKED_WITH_ITEM` "Requires <item>" (`0x854988`; `0x52acd9`
// pushes `0xc0cf60` = white)
// · SKILL, opener unknown **and** the object is flag-locked → **nothing at all**
// (`0x52abf7: jne done`) — a padlocked door says "Locked" and names its key, never
// its lockpicking rank
// · SKILL, opener unknown, not flagged → **red** "Requires <word>" (the herb node)
//
// That is why the reference shows *"Locked" + "Requires Key to Searing Gorge"* on the
// Searing Gorge door and not the `Requires Lockpicking (225)` line we used to add: the
// Pick Lock requirement is that lock's slot **1**, and the builder never looks past 0.
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' string table, taken for the length of the build. Every one of them is a
// key whose enUS value is "Requires %s" and whose identity only the binary settles.
let go_get = |key: &str| benilla_ui::strings::global(script.lua(), key);
if flag_locked {
// **The "Locked" line is coloured by whether you can actually open it** (director-
// reported: a door you hold the key for read red). The builder sets red `0xc0d3a8` as
// the default (`0x52ab03`), calls the SAME resolver the click uses (`0x52ab14` →
// `0x5f83d0`) and re-colours on its answer (`0x52ab19`-`0x52ab43`):
// · resolver says NO opener -> keep red
// · a KEY item satisfied it -> `0xc0d420` green (spell out-param set AND item
// out-param set: `0x52ab29 jne` takes the green arm)
// · no lock requirement at all-> the same green (`0x52ab22 je`)
// · a SKILL opener satisfied it -> the difficulty ramp `0x529fa0` — NOT modelled;
// see this line's follow-up note in decision 0770. Green is its second rung, so a
// comfortably-skilled opener already reads correctly; a marginal one reads too
// green rather than yellow/orange.
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`, resolved at `0x52acb8` — one of eleven 1.12 keys
// whose enUS value is exactly "Requires %s", and the only one this arm
// reaches (wow-re `tooltip-globalstring-key-resolves.md` §B).
if let Some(text) = keyed(&go_get, "LOCKED_WITH_ITEM", &[Arg::S(&t.name)]) {
lines.push((text, TooltipTint::White));
}
}
}
// The opener-*known* arm additionally wants the reference's skill-margin colour
// ramp (`0x529fa0`) — now pinned (decision 0770) but not modelled here; what we
// model is the unknown arm, which is what a hovering player almost always is. A
// flagged object stays silent there, exactly as the binary does.
benilla_formats::LOCK_KEY_SKILL if !flag_locked => {
// **`LOCKED_WITH_SPELL`, not `LOCKED_WITH_SPELL_KNOWN`** — the two are the
// same sentence in enUS and the byte test between them is a pure MEMBERSHIP
// one: `0x5f83d0` writes a non-zero spell id into its first out-param iff the
// player knows *any* spell that opens this LockType, and `0x52abcb` branches
// on that. This is the not-known arm (the flag-clear leg at `0x52ac04`);
// skill *sufficiency* never changes the key, only the colour.
//
// The `%s` is `LockType.dbc`'s own localized `Name` — "Pick Lock", not
// "Lockpicking", which is what a hand-typed table here used to say.
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));
}
}
_ => {}
}
}
script.world_tooltip_gameobject(&template.name, &lines, cursor_ui);
*last = LastHover::Go(guid);
return;
}
if !matches!(*last, LastHover::None) {
// Hover lost: arm the fade (the byte law's timestamped fade, never an instant hide).
// The "mouseover" state stays until the next hover overwrites it, so the fading
// lines/bar keep their last content.
script.world_tooltip_fade();
*last = LastHover::None;
}
}
#[cfg(test)]
mod tests;