//! 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 " / "Requires ") — 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 { 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 = 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 { 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, /// The bind point `$z` substitutes against. home: Option, /// The current shapeshift form (law §3.6's stance line, white/red). form: Option, /// 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, } /// 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>, actions: Option>, spells: Option>, talents: Option>, auras: Option>, selection: Res, stores: Query<&ObjectStore>, self_q: Query<&ObjectStore, With>, home_bind: Option>, area_names: Option>, mut items: ResMut, sub_classes: Option>, commands: Res, mut memory: Local, ) { let Some(mut script) = script else { return; }; let Some(spells) = spells.as_deref() else { return; }; let mut wanted: Vec = 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 = 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 = memory.reagents.keys().copied().collect(); let reagent_state: std::collections::BTreeMap = 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) -> TooltipTint { match outcome { Some(crate::target::lock::LockOutcome::Unmet) => TooltipTint::Red, _ => TooltipTint::LockOpen, } } #[allow(clippy::too_many_arguments)] fn drive_mouseover_tooltip( script: Option>, hovered: Res, hovered_go: Res, // The cursor-arm seat of the GO anchor fork (decision 0766). window: Query<&Window, With>, 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>, mut names: ResMut, commands: Res, factions: Option>, reputations: Res, // 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, // The cursor seat crosses the VM seam (0582/0584): the anchor below is UI units, not px. ui_scale: Res, mut last: Local, mut last_lines: Local>, ) { 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 " (`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 " (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;