Files
benilla-octo/crates/benilla-app/src/ui_tooltip/mod.rs
T
SamandClaude Fable 5 d16d33dfca world books open, and anvils stop offering a cursor gear
- clicking a book in the world opens it and turns its pages: letters and world books share one reader
- the cursor's object rules are the client's own 31-row strategy table now, so decorative objects stop pretending to be usable
- trainer and tradeskill windows draw each row's icon by the reference's own law, and the trainer's filter dropdown toggles again
- water color follows the zone's light blend, so lakes stop snapping between area tints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:03:35 +07:00

775 lines
37 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 bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::{TooltipTint, UiScript, UnitState};
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{NetCommands, ObjectStore, Reputations, SelfPlayer};
use crate::target::{
go_is_nearest, ring_reaction, Factions, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC,
};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_script::UiInput;
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).before(UiInput),
feed_spell_tooltips.in_set(UnitFeed).before(UiInput),
),
);
}
}
/// 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> {
home_area: Option<&'a str>,
form: u8,
store: Option<&'a ObjectStore>,
items: &'a mut Items,
commands: &'a NetCommands,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
}
/// 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 row's min is nonzero — the law's `"%d-%d"` fork; "Melee Range" for the melee family —
/// INTERIM text, the proper source is SpellRange.dbc's own display-name column), 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,
};
// 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::ui_action::usable::power_cost(d, s));
let cost = {
let div = if d.power_type == 1 { 10 } else { 1 };
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))
}
};
let range = spells.ranges.get(d.range_index).and_then(|r| {
if r.is_melee() {
Some("Melee Range".to_string())
} else if r.max > 0.0 {
// The law's fork (`0x854fb4`): a nonzero min prints the "%d-%d" pair (Charge: 8-25).
Some(if r.min > 0.0 {
format!("{}-{} yd range", r.min as i32, r.max as i32)
} else {
format!("{} yd range", r.max as i32)
})
} else {
None
}
});
// 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);
Some(if base > 0 {
if base >= 60_000 {
format!("{} min cast", trim_secs(f64::from(base) / 60_000.0))
} else {
format!("{} sec cast", trim_secs(f64::from(base) / 1000.0))
}
} else if base < 0 {
"Instant cast".to_string()
} else if d.on_next_swing() {
"Next melee".to_string()
} else if d.tooltip_on_next_ranged() {
"Attack speed".to_string()
} else if d.tooltip_channeled() {
"Channeled".to_string()
} else if d.power_type == 0 && resolved_cost > 0 {
"Instant cast".to_string()
} else {
"Instant".to_string()
})
};
// 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;
if secs >= 60.0 {
format!("{} min cooldown", trim_secs(secs / 60.0))
} else {
format!("{} sec cooldown", trim_secs(secs))
}
});
// 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.
let requires_form = (d.stances != 0)
.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(|| format!("Requires {}", names.join(", ")))
})
.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()
.map(|name| format!("Requires {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::ui_action::usable::equipped_item_fits(d, s, 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| crate::ui_items::count_of(&s.0, vctx.items, entry) < 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 `%.3g`-style terse seconds (1.5 → "1.5", 2.0 → "2") — the SPELL_CAST_TIME/RECAST shape.
fn trim_secs(v: f64) -> String {
if (v - v.round()).abs() < 1e-9 {
format!("{}", v.round() as i64)
} else {
format!("{v:.1}")
}
}
/// 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]>,
/// 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.
#[allow(clippy::too_many_arguments)]
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>>,
home_bind: Option<Res<crate::net::HomeBind>>,
area_names: Option<Res<crate::ui_quest_log::QuestHeaderNamesRes>>,
mut items: ResMut<Items>,
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
commands: Res<NetCommands>,
mut memory: Local<SpellFeedMemory>,
) {
let Some(mut script) = script else {
return;
};
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());
}
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| crate::ui_items::count_of(&s.0, &items, entry));
(entry, (owned, named))
})
.collect();
if memory.reagents != reagent_state {
memory.reagents = reagent_state;
wanted.extend(memory.pushed.drain());
}
let mut vctx = ViewCtx {
home_area: home_area.as_deref(),
form,
store: self_store,
items: &mut items,
commands: &commands,
sub_classes: sub_classes.as_deref().map(|c| &c.0),
};
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| crate::ui_items::count_of(&s.0, vctx.items, entry));
slot.insert((owned, named));
}
}
}
script.set_spell_tooltip(id, view);
memory.pushed.insert(id);
}
}
}
/// 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),
}
/// 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.
fn lines_view(s: &UnitState) -> UnitState {
UnitState {
health: 0,
max_health: 0,
power: 0,
max_power: 0,
..s.clone()
}
}
/// `LockType` index → the requirement word (the `LOCKED_WITH_SPELL[_KNOWN]` "Requires %s" text
/// for skill locks — vanilla's small fixed vocabulary; item-key locks name the item instead).
fn lock_type_word(index: u32) -> Option<&'static str> {
Some(match index {
1 => "Lockpicking",
2 => "Herbalism",
3 => "Mining",
4 => "Disarm Trap",
_ => return None,
})
}
/// 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,
}
}
#[allow(clippy::too_many_arguments)]
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>>,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
// 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.
mut 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 last: Local<LastHover>,
mut last_lines: Local<Option<UnitState>>,
) {
let Some(mut script) = script else {
return;
};
let self_store = self_q.iter().next();
// 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(guid, &commands).map(str::to_string);
let reaction =
ring_reaction(factions.as_deref(), &reputations, Some(store), self_store) + 1;
let mut s = snapshot(store, name, reaction);
enrich_unit(&mut s, guid, &names, store, 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).
let go = hovered_go
.target
.zip(hovered_go.guid)
.filter(|_| unit.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;
}
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.
//
// Not merely a guess-shaped proxy: after 0762 the only objects that are eligible for a
// tooltip *and* never highlightable are GENERIC ones, so "GENERIC" and "not interactable"
// pick out the same set here. They diverge only for the three always-eligible types
// (SPELL_FOCUS 8 / DUEL_ARBITER 16 / FISHINGHOLE 25), which is exactly where a pin would
// settle it. Flagged INTERIM in 0766 rather than presented as verified.
let cursor_seated =
stores.get(entity).map(|s| s.0.gameobject_type_id()) == Ok(GO_TYPE_GENERIC);
// 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);
w.cursor_position()
.map(|c| (c.x / s, (w.height() - c.y) / s))
})
})
.flatten();
if *last == LastHover::Go(guid) {
// 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
}
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();
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(),
self_store,
&go_inputs.items,
facts,
&mut matched,
)
});
lines.push(("Locked".to_string(), 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) {
lines.push((format!("Requires {}", t.name), 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 => {
if let Some(word) = lock_type_word(slot0.index) {
lines.push((format!("Requires {word}"), 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;