Files
benilla-octo/crates/benilla-ui/src/script/tooltip_unit.rs
T
SamandClaude Fable 5.1 663851b17d every line is a key, four find their sound, and the ding prints once
- the last app-side lines become GlobalStrings keys — tooltip cells, party, petition, duel, quest, PvP and rest lines read the install's own wording, so a non-enUS chain shows its own text (2045, 2080)
- a locked chest says Requires Pick Lock, ammo and thrown weapons drop their invented slot word, a met set bonus reads Set: without the count, and a 100 s cooldown reads 1.67 min as the template prints it
- the level-up block and the free-professions line printed twice in chat and print once; a declined invite, a friend coming online, a duel request and a failed quest play the sound 1.12 gives them
- Duel cancelled. is the yellow line at the top of the screen, and a trainer refusal or a failed quest with no cached template shows nothing, as on 1.12

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:19:46 +07:00

628 lines
28 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 engine **unit tooltip builder** (decision 0274 P3) — the byte-verified line law of
//! `0x529fe0` (wow-re `ui/scratch/tooltip-content-law.md`, the 0276 fold-back):
//!
//! - NAME (gold — FrameXML recolors `TextLeft1` by reaction on `UPDATE_MOUSEOVER_UNIT`, exactly
//! like the reference's `GameTooltip_UnitColor`; the guild line is likewise FrameXML's and
//! joins when guild data streams). A name still in flight reads `UNKNOWNOBJECT`, never an
//! empty line — the builder's name read is `0x609210`, the same resolver `UnitName` uses, and
//! its miss tail is the same string (decisions 2002/2040);
//! - the creature SUBTITLE ("Stable Master") — white;
//! - the LEVEL line, filled from three slots into one of the four `TOOLTIP_UNIT_LEVEL*` templates
//! the player's own `GlobalStrings.lua` carries (decision 2045 — the keys are the builder's,
//! read off `0x52a622`/`0x52a64d`/`0x52a682`/`0x52a6ac`, never matched on their English):
//! level text (`"??"` for a world boss, a much-higher hostile, or level ≤ 0 — the hostile
//! delta is INTERIM at +10 pending a byte pin of the comparison), the class slot (the creature
//! TYPE word for hostile/neutral creatures, `"Race Class"` for players, `CORPSE` when dead),
//! and the type slot (the rank key table `{"", ELITE, ELITE, BOSS, ""}`; `PLAYER` for players);
//! - the FACTION NAME ("Stormwind", white) — the builder-tail block `0x52a7a0..` the law's §2
//! order originally omitted: the app resolves it (`faction_name`), every gate applied;
//! - "PvP" (white) · "Skinnable" (**red**) · "Civilian" (green, `0x612550`: PvP-flagged +
//! query-civilian + HOSTILE + GREY/trivial — the dishonorable-kill warning) · "Leader"
//! (white, `0x6125c0`: PvP-flagged + query racial_leader);
//! - health on the attached status bar (`<name>StatusBar`, the template child below the plate),
//! refreshed by every `set_unit` push for the live token — the byte law's HEALTH watcher.
//!
//! The world mouseover drives this through [`super::UiScript::world_tooltip_unit`] (the engine
//! shows the tooltip at the default anchor by firing `OnTooltipSetDefaultAnchor`, renders, and
//! fires `UPDATE_MOUSEOVER_UNIT` for the Lua recolor) and [`super::UiScript::world_tooltip_fade`]
//! (the byte law ARMS a fade on hover loss, not an instant hide). Unit-frame hovers call the
//! same `SetUnit` from Lua.
use mlua::{Lua, Table};
use super::object::frame_handle_of;
use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empty};
// The grey band + trivial/GREY check (`0x5f0700`, the CIVILIAN line's last gate: a green-or-
// better con never warns of a dishonorable kill) and the "??" gate live in one shared home
// (`unit.rs`), alongside `UnitLevel`'s −1 return and the `GetQuestGreenRange` binding.
use super::unit::{is_civilian_kill, level_reads_unknown, unknownobject};
use super::{KindState, Model, UnitState};
use crate::layout::{Anchor, Point};
use crate::strings::{fill, Arg};
use crate::widget::FrameHandle;
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
const RED: [f32; 4] = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
/// `0xc0d420` = `0xff40c040` — the satisfiable-lock green (see [`TooltipTint::LockOpen`]).
const LOCK_OPEN: [f32; 4] = [64.0 / 255.0, 192.0 / 255.0, 64.0 / 255.0, 1.0];
/// Unit names render gold (byte-verified `0xffffd200`); FrameXML recolors line 1 by reaction.
const GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
/// A GameObject tooltip line's colour, as the builder `0x52aa20` picks it (decision 0756). The
/// app half decides *which* line gets which tint from the lock law; this enum is only the
/// crate-boundary spelling, so the colour constants stay private to the UI engine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TooltipTint {
/// `0xc0cf60` — the requirement line's normal colour (the key-item "Requires %s").
White,
/// The "Locked" line's UNMET colour `0xc0d3a8` = `0xffff2020`, and the unknown-skill
/// "Requires %s" (whose own red is `0xffff0000` — a difference of 32/255 in G and B that no
/// eye resolves, so one slot serves both).
Red,
/// The "Locked" line when the lock **is** satisfiable — `0xc0d420` = `0xff40c040`
/// (64,192,64). Deliberately NOT the tooltip's ordinary green `0xc0d3ac` = `0xff00ff00`: this
/// is the second rung of the skill-difficulty ramp `0x529fa0` (grey/green/yellow/orange/red,
/// static-init writers at `0x5290d0` &c.), which the lock line borrows wholesale, and it is a
/// visibly softer green than the one every other tooltip line uses.
LockOpen,
}
/// The rank word's GlobalString **key** — byte-verified `0x854158[]`, the pointer table the
/// builder indexes by rank at `0x52a5bd`/`0x52a5d4`. Its entries are key names, not words: index 0
/// and 4 are the pre-seeded empty string `0x882748`, 1 and 2 are both `ELITE` (rare-elite prints
/// ELITE, rare prints nothing — there is no distinct "Rare Elite" word in 1.12's builder), 3 is
/// `BOSS`.
fn rank_key(rank: u32) -> Option<&'static str> {
match rank {
1 | 2 => Some("ELITE"),
3 => Some("BOSS"),
_ => None,
}
}
/// The level line — three slots over the four `TOOLTIP_UNIT_LEVEL*` templates, each resolved off
/// the player's own `GlobalStrings.lua` (decision 2045). The keys are the builder's own, pushed at
/// `0x52a622` (`_CLASS_TYPE`), `0x52a64d` (`_CLASS`), `0x52a682` (`_TYPE`) and `0x52a6ac` (the
/// bare one) — which matters, because `TOOLTIP_UNIT_LEVEL_CLASS`'s enUS "Level %s %s" is also the
/// wording of `FRIENDS_LEVEL_TEMPLATE`, `UNIT_TYPE_LEVEL_TEMPLATE` and `CHARACTER_SELECT_INFO`,
/// and the bare one collides with `ITEM_LEVEL`, `LEVEL_GAINED` and `UNIT_LEVEL_TEMPLATE`. Seven
/// wrong keys are reachable by matching the English here; none by reading the builder.
///
/// `None` = the install's string table carries no template for the slot combination in hand, and
/// the plate shows no level row rather than a composed one.
fn level_line(lua: &Lua, u: &UnitState, player_level: u32) -> Option<String> {
let get = |key: &str| crate::strings::global(lua, key);
// The "??" gate, byte-pinned (0x529fe0 §2-LEVEL): much-higher HOSTILE — internal reaction
// ≤ 1 = hated/hostile = UnitReaction ≤ 2 on our 1..8 API scale (the UnitIsEnemy mapping) —
// with playerLevel ≤ targetLevel−10; OR WorldBoss; OR level ≤ 0. Players NEVER read "??".
// Shared with `UnitLevel`'s −1 return, so the tooltip and the target frame can never
// disagree on who reads "??" ([`level_reads_unknown`]).
let level_text = if level_reads_unknown(u, player_level) {
"??".to_string()
} else {
u.level.to_string()
};
// The class slot: "Race Class" for players; the creature TYPE word for hostile/neutral
// creatures (a friendly creature shows none — the byte law's hostile/neutral-only gate);
// "Corpse" when dead.
let class_slot = if u.dead {
get("CORPSE")
} else if u.is_player {
match (&u.race, &u.class) {
(Some(r), Some(c)) => Some(format!("{r} {c}")),
(Some(r), None) => Some(r.clone()),
(None, Some(c)) => Some(c.clone()),
(None, None) => None,
}
} else if u.reaction != 0 && u.reaction <= 4 {
u.creature_type_name.clone()
} else {
None
};
// The type slot: the rank word, or PLAYER for players.
let type_slot = if u.is_player {
get("PLAYER")
} else {
rank_key(u.rank).and_then(&get)
};
let (key, args): (&str, Vec<Arg<'_>>) = match (&class_slot, &type_slot) {
(Some(c), Some(t)) => (
"TOOLTIP_UNIT_LEVEL_CLASS_TYPE",
vec![Arg::S(&level_text), Arg::S(c), Arg::S(t)],
),
(Some(c), None) => (
"TOOLTIP_UNIT_LEVEL_CLASS",
vec![Arg::S(&level_text), Arg::S(c)],
),
(None, Some(t)) => (
"TOOLTIP_UNIT_LEVEL_TYPE",
vec![Arg::S(&level_text), Arg::S(t)],
),
(None, None) => ("TOOLTIP_UNIT_LEVEL", vec![Arg::S(&level_text)]),
};
Some(fill(&get(key)?, &args))
}
/// Render the unit tooltip for `token`'s current snapshot; returns whether the unit existed.
fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result<bool> {
let h = frame_handle_of(lua, this)?;
let (unit, player_level) = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
(
model.unit(token).cloned().filter(|u| u.exists),
model.player_req.level,
)
};
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
let Some(u) = unit else {
// No unit: drop the live token and leave the tooltip hidden-empty.
set_live_token(lua, h, None);
show_or_hide_empty(lua, h);
return Ok(false);
};
// The NAME line. A unit whose name query has not answered yet does NOT title an empty plate:
// the builder resolves the name through `CGUnit_C::GetUnitName 0x609210` (`0x52a187`), and
// every one of that function's misses — a creature whose `creaturecache.wdb` record
// (`CGUnit+0xb30`) is still null, a pet whose `petnamecache.wdb` row is absent or stale, a
// player row `namecache.wdb` has not answered — falls to the SAME
// `FrameScript_GetText("UNKNOWNOBJECT")` tail `UnitName` falls to. One seam, one resolver
// ([`unknownobject`], decisions 2002/2040), so the verb and the plate can never disagree.
//
// The builder has no counterpart to `UnitName`'s two nils: the `"player"` fast path is the
// *binding's* (`0x517083`, before any resolve), and a token resolving to GUID 0 never reaches
// a builder at all — the entry gate `0x468460(typemask 8)` hands back no object, `SetUnit`
// answers nil and no plate is drawn (the `unit` early-return above).
let title = match &u.name {
Some(n) => n.clone(),
None => unknownobject(lua)?.to_str()?.to_string(),
};
append_line(lua, this, (title, GOLD), None, false)?;
if let Some(sub) = &u.subtitle {
append_line(lua, this, (sub.clone(), WHITE), None, false)?;
}
if let Some(level) = level_line(lua, &u, player_level) {
append_line(lua, this, (level, WHITE), None, false)?;
}
// The faction-name line ("Stormwind", white) sits between the level line and "PvP" — the
// builder-tail block at `0x52a7a0`. The app resolved every gate into `faction_name`.
if let Some(faction) = &u.faction_name {
append_line(lua, this, (faction.clone(), WHITE), None, false)?;
}
if u.pvp {
append_line(lua, this, ("PvP".into(), WHITE), None, false)?;
}
if u.skinnable {
append_line(lua, this, ("Skinnable".into(), RED), None, false)?;
}
// CIVILIAN (green) — `0x612550`, whole: the unit's PvP bit + the query civilian flag + the
// unit is HOSTILE to the player (UnitReaction ≤ 2 — internal reaction < 2) + the kill would
// be GREY/trivial. PVP_RANK_CIVILIAN = "Civilian" (extracted GlobalStrings). The predicate
// itself lives in `unit` because `UnitPVPName` gates its own civilian arm on the same call.
if is_civilian_kill(&u, player_level) {
append_line(lua, this, ("Civilian".into(), GREEN), None, false)?;
}
// LEADER (white) — `0x6125c0`: the PvP bit + the query racial_leader flag, no other gate.
// PVP_RANK_LEADER = "Leader" (extracted GlobalStrings).
if u.racial_leader && u.pvp {
append_line(lua, this, ("Leader".into(), WHITE), None, false)?;
}
set_live_token(lua, h, Some(token.to_string()));
update_bar(lua, h, Some(&u));
show_or_hide_empty(lua, h);
Ok(true)
}
/// Mark the tooltip as world-hover-owned (the fade-on-loss gate).
fn set_world_owned(lua: &Lua, h: FrameHandle) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
t.world_owned = true;
}
}
}
/// Remember (or drop) the tooltip's live unit token — the health watcher's key.
fn set_live_token(lua: &Lua, h: FrameHandle, token: Option<String>) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
t.unit_token = token;
}
}
}
/// Drive the `<name>StatusBar` child from a unit snapshot (`None` hides it) — the engine pushes
/// health, FrameXML's `HealthBar_OnValueChanged` colors the fill.
fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) {
let bar = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
let name = model.arena.frame(h).and_then(|f| f.name.clone());
name.and_then(|n| model.arena.lookup(&format!("{n}StatusBar")))
};
let Some(bar) = bar else { return };
let (changed, shown) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(f) = model.arena.frame_mut(bar) else {
return;
};
match (&mut f.kind_state, unit) {
(KindState::StatusBar(sb), Some(u)) => {
sb.min = 0.0;
sb.max = u.max_health.max(1) as f32;
let v = u.health.min(u.max_health.max(1)) as f32;
let changed = (sb.value - v).abs() > f32::EPSILON;
sb.value = v;
(changed, true)
}
_ => (false, false),
}
};
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.arena.set_shown(bar, shown);
}
if changed {
let (id, value) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let v = match model.arena.frame(bar).map(|f| &f.kind_state) {
Some(KindState::StatusBar(sb)) => sb.value,
_ => 0.0,
};
(model.frame_id(bar), v)
};
if let Err(e) = super::event::fire_widget_handler(
lua,
id,
"OnValueChanged",
vec![mlua::Value::Number(f64::from(value))],
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
}
}
}
/// The `set_unit` push hook: a fresh snapshot for the tooltip's LIVE token re-drives the health
/// bar (the byte law's HEALTH UpdateField watcher — the bar tracks without a line rebuild).
pub(super) fn on_unit_push(lua: &Lua, token: &str) {
let hits: Vec<FrameHandle> = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
// The arena's tooltip registry (1634), gated by the resolve's roster exactly as the walk
// it replaces was — `update_bar` writes layout.
model
.arena
.tooltip_kinds()
.iter()
.copied()
.filter(|&h| {
model.frame_to_id.contains_key(&h)
&& matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::Tooltip(t)) if t.unit_token.as_deref() == Some(token)
)
})
.collect()
};
for h in hits {
let unit = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
model.unit(token).cloned().filter(|u| u.exists)
};
update_bar(lua, h, unit.as_ref());
}
}
impl super::UiScript {
/// Show the world-mouseover unit tooltip (the engine-driven flow the byte law describes):
/// fire `OnTooltipSetDefaultAnchor` (FrameXML seats the plate at the default corner), render
/// `token`'s snapshot, fire `UPDATE_MOUSEOVER_UNIT` (FrameXML recolors the name line by
/// reaction). Call on hover-target CHANGE — the health bar tracks pushes in between; a
/// re-show mid-fade resurrects at full alpha. `false` = no tooltip frame / no such unit.
pub fn world_tooltip_unit(&mut self, token: &str) -> bool {
let h = {
let mut model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return false;
};
let id = model.frame_id(h);
(h, id)
};
let (h, id) = h;
if let Err(e) =
super::event::fire_widget_handler(&self.lua, id, "OnTooltipSetDefaultAnchor", vec![])
{
self.push_error(e);
}
let wrapper = match super::object::frame_wrapper(&self.lua, id) {
Ok(w) => w,
Err(e) => {
self.push_error(e);
return false;
}
};
let shown = match render_unit(&self.lua, &wrapper, token) {
Ok(s) => s,
Err(e) => {
self.push_error(e);
false
}
};
if shown {
set_world_owned(&self.lua, h);
// Cancel any running fade at full alpha (a re-hover resurrects).
let mut model = self.model_mut();
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
if t.fade_start.take().is_some() {
model.arena.set_alpha(h, 1.0);
}
}
}
drop(model);
self.fire_event("UPDATE_MOUSEOVER_UNIT", vec![]);
}
shown
}
/// Show the world-mouseover GAMEOBJECT tooltip — the byte-verified GO builder `0x52aa20`
/// (decisions 0276 / **0756**): the NAME (gold) followed by the lock lines the caller
/// resolved, each with its own tint.
///
/// **The anchor FORKS per object — it is not uniform** (decision 0766, correcting 0756). The
/// publisher's GameObject leg calls the picked object's `[obj->vtbl+0x5c]` and branches:
///
/// - **true** (`0x492a01`) → `0x52ffe0(owner, 6, 0, 0)` — anchor-state **6**, the *cursor*
/// anchor. Corroborated by the loss path: `0x530ae0` hides an anchor-state-6 tooltip
/// **immediately** instead of arming the fade, which is how a pointer-following label behaves.
/// - **false** (`0x492a42`) → no `SetOwner` at all; the plate keeps its default corner seat.
///
/// The **unit** leg (`0x492983`) never consults `+0x5c` — units are always corner-seated, which
/// is why the fork went unnoticed when 0756 made every GameObject corner-seated too. `cursor`
/// carries the caller's verdict: `Some(ui_xy)` = the cursor arm (and then
/// [`Self::world_tooltip_move`] follows the pointer), `None` = the corner.
///
/// Same fade lifecycle as the unit flow (no `UPDATE_MOUSEOVER_UNIT` — that recolor is the
/// unit line's).
pub fn world_tooltip_gameobject(
&mut self,
name: &str,
lines: &[(String, TooltipTint)],
cursor: Option<(f32, f32)>,
) -> bool {
let (h, id, root_id) = {
let mut model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return false;
};
let Some(root) = model.arena.lookup("UIParent") else {
return false;
};
let (id, root_id) = (model.frame_id(h), model.frame_id(root));
(h, id, root_id)
};
match cursor {
// The cursor arm: seated centred above the pointer, clamped by the tooltip frame's own
// flag. Compare-then-touch so a still pointer never re-layouts.
Some((ui_x, ui_y)) => {
let mut model = self.model_mut();
let input = model.layout_inputs.entry(h).or_default();
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
let same = input.anchors.len() == 1
&& super::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
input.anchors = vec![new];
model.touch_layout();
}
}
// The corner arm: the same default-anchor handler the unit flow fires.
None => {
if let Err(e) = super::event::fire_widget_handler(
&self.lua,
id,
"OnTooltipSetDefaultAnchor",
vec![],
) {
self.push_error(e);
}
}
}
let wrapper = match super::object::frame_wrapper(&self.lua, id) {
Ok(w) => w,
Err(e) => {
self.push_error(e);
return false;
}
};
let render = || -> mlua::Result<()> {
{
let mut model = self.lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(&self.lua, h);
append_line(&self.lua, &wrapper, (name.to_string(), GOLD), None, false)?;
for (text, tint) in lines {
let colour = match tint {
TooltipTint::White => WHITE,
TooltipTint::Red => RED,
TooltipTint::LockOpen => LOCK_OPEN,
};
append_line(&self.lua, &wrapper, (text.clone(), colour), None, false)?;
}
Ok(())
};
if let Err(e) = render() {
self.push_error(e);
return false;
}
set_world_owned(&self.lua, h);
{
let mut model = self.model_mut();
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
if t.fade_start.take().is_some() {
model.arena.set_alpha(h, 1.0);
}
}
}
}
show_or_hide_empty(&self.lua, h);
true
}
/// Show the minimap BLIP tooltip — a landmark's `AreaPOI` name or a quest-dot NPC's name,
/// one GOLD line (the reference's engine SetText gold; a cross-interior dot renders FAINT
/// gold — the byte law's `|cffb0b0b0` wrap modulating the gold base, director-matched),
/// seated centred ABOVE the cursor: the tooltip's BOTTOM at the given UI-space point. The
/// plate FOLLOWS the pointer — `Self::minimap_tooltip_move` re-seats it as the cursor
/// drifts within one blip. Same world-owned fade lifecycle as the mouseover tooltip:
/// hover loss arms [`Self::world_tooltip_fade`].
pub fn minimap_tooltip(&mut self, text: &str, ui_x: f32, ui_y: f32, grey: bool) -> bool {
let (h, id, root_id) = {
let mut model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return false;
};
let Some(root) = model.arena.lookup("UIParent") else {
return false;
};
let (id, root_id) = (model.frame_id(h), model.frame_id(root));
(h, id, root_id)
};
let wrapper = match super::object::frame_wrapper(&self.lua, id) {
Ok(w) => w,
Err(e) => {
self.push_error(e);
return false;
}
};
{
let mut model = self.model_mut();
clear_content(&mut model, h);
let input = model.layout_inputs.entry(h).or_default();
// Anchor only — the screen clamp (G flags bit4: a cursor-seated plate near the window
// edge slides back in instead of clipping, director's report at the screen-right
// minimap) is the tooltip FRAME's own flag (`Frame::clamped_to_screen`), synced with
// live extents at every resolve.
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
// Compare-then-touch: a still cursor re-seats the plate at the same point every
// frame — tier 1 of the layout gate stays quiet unless the pointer actually moved.
let same =
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
input.anchors = vec![new];
model.touch_layout();
}
}
fire_cleared(&self.lua, h);
// The blip name renders GOLD like the reference (the engine tooltip's SetText line —
// the same 0xffffd200 the unit-name line uses); a cross-interior entry renders the
// byte law's `|cffb0b0b0` wrap MODULATING that gold — faint gold, not flat grey
// (director-matched against the reference, 2026-07-13).
let dim = 0xb0 as f32 / 255.0;
let color = if grey {
[GOLD[0] * dim, GOLD[1] * dim, GOLD[2] * dim, 1.0]
} else {
GOLD
};
if let Err(e) = append_line(&self.lua, &wrapper, (text.to_string(), color), None, false) {
self.push_error(e);
return false;
}
set_world_owned(&self.lua, h);
{
let mut model = self.model_mut();
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
if t.fade_start.take().is_some() {
model.arena.set_alpha(h, 1.0);
}
}
}
}
show_or_hide_empty(&self.lua, h);
true
}
/// Re-seat the showing cursor-anchored tooltip at a new pointer point (the follow half of
/// [`Self::minimap_tooltip`] and [`Self::world_tooltip_gameobject`]): anchor-only — no
/// content rebuild, no fade churn. No-op when the tooltip isn't the world-owned shown plate.
pub fn world_tooltip_move(&mut self, ui_x: f32, ui_y: f32) {
let mut model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return;
};
let Some(root) = model.arena.lookup("UIParent") else {
return;
};
let owned_shown = model
.arena
.frame(h)
.map(|f| matches!(&f.kind_state, KindState::Tooltip(t) if t.world_owned) && f.shown)
.unwrap_or(false);
if !owned_shown {
return;
}
let root_id = model.frame_id(root);
let input = model.layout_inputs.entry(h).or_default();
// Anchor only — the frame's own clamp flag keeps the plate on screen (see
// `Frame::clamped_to_screen`).
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
// Compare-then-touch, as in the seat/blip paths above: the follow fires per pointer
// event, and a still cursor must not dirty the layout gate's tier 1.
let same =
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
input.anchors = vec![new];
model.touch_layout();
}
}
/// Arm the mouseover tooltip's fade-out (hover loss — the byte law arms a timestamped fade,
/// never an instant hide). No-op when the tooltip isn't showing a world unit.
pub fn world_tooltip_fade(&mut self) {
let now = self.now();
let mut model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return;
};
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
if t.world_owned && f.shown && t.fade_start.is_none() {
t.fade_start = Some(now);
}
}
}
}
}
/// Register the unit content channel into the GameTooltip kind method table.
pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
// GameTooltip:SetUnit(token) → 1/nil — the unit-frame hover (ref UnitFrame_OnEnter reads
// the return to arm its re-poll).
m.set(
"SetUnit",
lua.create_function(|lua, (this, token): (Table, String)| {
// Same gate as every `Unit*` verb: `SetUnit` resolves its argument through the client's
// one token resolver, so an unrecognised name raises here too rather than drawing an
// empty tooltip. NOT inside `render_unit` — the app's own push calls that with a
// canonical token and must never be gated against itself.
crate::script::unit::check_unit_token(&Some(token.clone()))?;
let ok = render_unit(lua, &this, &token)?;
Ok(if ok {
mlua::Value::Integer(1)
} else {
mlua::Value::Nil
})
})?,
)?;
Ok(())
}