Files
benilla-octo/crates/benilla-ui/src/script/tooltip_unit.rs
T
SamandClaude Fable 5.1 8f0bece3be loot and pet refusals become message ids, and waterfalls flow again
- a loot refusal is a message id, not a sentence: `loot_error_text` composed eight sentences for `SMSG_LOOT_RESPONSE`'s refusals and six of them said something 1.12 never says — "too far away to loot that." for a string that ends "that corpse.", "You can't loot that from there." for "You must be facing the corpse to loot it.", and an invented "Those pockets are already empty." for a code the client has no string for; it and the `LootErrors` queue are deleted, and the refusal is raised by GlobalStrings key into `UiErrorKeys` at the net bridge, the way `fish_verdict` two functions above already did, off the reference's own eleven-entry jump table at `0x5ebc64`; a composed string had no record to read a surface or a voice off, so all eleven — `MsgKind::Error`, the red `UIErrorsFrame` line — had been going into the chat window as yellow SYSTEM lines, and the four spoken in the player's own race and gender (`DIDNT_KILL`, `BAD_FACING`, `LOCKED`, `TOO_FAR`) were silent; a second bug fell out of reading the leg: codes 4–9, 11 and the default fall through a release tail at `0x5ebac2` that zeroes `[player+0x1d28]` — our `LootLatch` — while 10, 12, 13 and 14 jump past it, so a master looter refused a hand-off no longer stands up out of the kneel at a still-open corpse; the default arm's `ja 0x5ebabd` lands on code 7's arm, so every unnamed code prints `ERR_LOOT_DIDNT_KILL` and releases (2035); and the pet has its own two message tables, not the player's with a flag: `SMSG_PET_ACTION_FEEDBACK`'s handler `0x4bdb70` decrements the byte, bounds it at 3 and jump-tables it through `0x4bdbe8` to `ERR_PET_SPELL_DEAD`, `ERR_NO_ATTACK_TARGET`, `ERR_INVALID_ATTACK_TARGET`, `ERR_PET_SPELL_NOPATH` — one per vmangos `PetFeedback` value, where our two-entry map had printed "No path available for your pet" for a dead pet, "Out of range" for no target and nothing for 3 and 4; `SMSG_PET_CAST_FAILED` is `0x6e8eb0`, a separate 142-byte index table that overrides ten reasons, six of them to `ERR_PET_SPELL_*` rows with no other raise site in the client, so `CastFail` grows a `Caster` and picks its table from it while layers 1 and 3 stay shared, as the binary shares them; `0x6e8eb0` calls neither the combat-log formatter `0x62c360` nor the error sound `0x458a50`, so a pet's refused Growl no longer prints "You fail to cast Growl: …" as the player's own failure; and the source walk that exists to catch a non-catalog key was blind to both wrong keys because all 465 catalog keys begin `ERR_`, so the tripwire moves to the funnel — `Shown::keyed` warns on a key `by_key` cannot resolve (2033)
- the hunter-pet feedback cluster, five opcodes carved off the binary where four had sat name-table-only: `SMSG_PET_TAME_FAILURE` (`0x173`) is two GlobalStrings lookups in order — `0x6e6a20` maps `reason − 1` through the eleven-arm jump table at `0x6e6ac0` to a `PETTAME_*` key and hands the resolved string to `DisplayError(ERR_TAME_FAILED)`, anything outside `1..=11` taking `PETTAME_UNKNOWNERROR`, and Call Pet and Revive Pet raise it too; `SMSG_PET_NAME_INVALID` (`0x178`) raises `ERR_INVALID_PETNAME` from the shared dispatcher `0x5e38c0`'s arm `0x5e3e33` — 1066 had called a refused rename silent on a carve that gave `0x178` the handler `0x4bdc00`, which is `0x2AF SMSG_PET_BROKEN`'s and raises `ERR_PET_BROKEN`, body unread; `SMSG_PET_ACTION_SOUND` (`0x324`) is a bark, not a sound id — its two talk values reach the bark dispatcher `0x623a40` at states 1 and 2, i.e. `CreatureSoundData` columns 28 and 27, and the shipped DBC names them from the other side: exactly four of its 406 rows carry them, the Imp, Succubus, Doomguard and Voidwalker voices, as `A_<demon>_ORDER`/`A_<demon>_KILL`; `Latch::Voice` now carries the bark state as a number and every one-shot creature vocal goes through one `play_bark` that is `0x623a40` — aggro at 0, the pet barks at 1 and 2, the death cry at 4, which had been on a bare `play_kit` off the slot so a pet bark could talk over it — with the reference's `jle` priority rule, because under the old boolean slot a demon's voice would have lost every race against its own aggro flares and been inaudible; it is a warlock-demon surface end to end — vmangos only sends it for a summoned pet, at a 10% roll per order, and a hunter's wolf has neither the columns nor the packet; and the fifth opcode surfaced because the cross-check went looking for column 29's consumer: `SMSG_PET_DISMISS_SOUND` (`0x325`), handler `0x604140`, reads column 29 twice (`0x6041c4`/`0x6041dc`) off a row resolved fresh by `CreatureModelData` id rather than the cached `[unit+0xb40]` — which is why a census over the cache had called the column dead — and plays `A_<demon>_DISMISS` at the packet's point with `z + 1.0`, no unit and no latch; column 29 is loaded, the column nothing reads is 22 (zero on all 406 rows), and the dismiss handler is built although vmangos never sends the packet (2039)
- every UV scroll and animated tint in the world had been frozen for three days: 1940's deferred realization parks a freshly built `WowModelMaterial` behind a reserved handle until something view-visible binds it, and `doodad_anim::register_uv`/`register_tint` still reached for the store alone — `Assets::get_mut` answered `None` for every registration, the slot was freed, the registry stayed empty and the shader read the pinned-zero identity row for every batch, silently, because the `else` branch was a bare `return`; the fix is the address, not the timing: `Parked::value_mut`/`value`/`holds` reach a value wherever it currently lives and `lazy::with_material_mut`/`with_material`/`holds` publish that, so the one-shot build stamp lands in the parked value and rides realization in — realizing it to stamp it would have thrown away exactly what deferral buys; the tick's eviction on `Assets::contains` read a parked material as dead and asks `lazy::holds` now, the empty-registry early-out says so out loud when marked parts exist, and two instruments ship — `WOW_MATANIM_PROBE=<secs>` prints per animated part whether it is registered, whether its loop moves, what its table row holds and whether it is drawn, and `WOW_WORLDVIEW_AT=x,y[,z]` opens the server-less viewer anywhere; measured at the reported waterfall: 0 uv / 0 tint entries before, 87 uv / 6 tint around the Elwynn valley after, its two sheets on slots 1 and 2 with live rows −0.2122 and −0.4246 — the authored 1:2 ratio (2038); and the paper doll and the inspect pane are lit from the model's right: `CGLight+0x24` is the direction the light PROPAGATES — the `CharacterModelBase` ctor `0x505680` writes `(0, 1, 0)` and `0x71bce0` negates all three components before building the SH basis — so `model_pane_light()` hands the packer the ctor's vector as written where 0638 had negated it on the way in and lit both panes from the left; the ambient `(0.7, 0.7, 0.7)`, the diffuse `(0.8, 0.8, 0.64)` and the side-light analysis all hold, which is why the A/B that approved 0638 could not tell the two readings apart (2034)
- a unit tooltip whose name query has not answered titles `UNKNOWNOBJECT`, not a blank gold row: the reference's builder `0x529fe0` reads its NAME line through `CGUnit_C::GetUnitName 0x609210` — the same function `UnitName 0x517020` delegates to — and every miss on its three legs, the null creature record included, ends at the one `FrameScript_GetText("UNKNOWNOBJECT")` tail at `0x609324`, so `unknownobject` moves into `unit` proper and the plate and the verb share it, read out of the VM's `_G` with the binary's "Unknown Being" behind a missing global; the faction line stops waiting for the creature record — its entry gate `0x612610` returns 1 when there is none, so it resolves off `UNIT_FIELD_FACTIONTEMPLATE` and keeps only `HIDE_FACTION_TOOLTIP` for when the record lands; the reference's placeholder sticks for the whole hover (stock `GameTooltip.xml` registers `UPDATE_MOUSEOVER_UNIT` alone) and our line-change repaint, which replaces it when the answer lands, is kept (2040); a unit predicate answers the NUMBER 1 or nil, never a Lua boolean: `UnitExists`, `UnitIsDead`, `UnitIsGhost`, `UnitIsDeadOrGhost` and `UnitOnTaxi` pushed a boolean and twenty-three more open-coded the right shape in eighteen places, where the reference pushes through `lua_pushnumber 0x6f3810` or `lua_pushnil 0x6f37f0`; all twenty-eight now reach 1830's one push site `binding_abi::predicate` — via `unit::unit_predicate` for a `UnitState` field, directly otherwise — `UnitIsCharmed`'s `Number(1.0)` and `ClearTarget`'s hand-branched nil included, and `UnitOnTaxi` gains the reference's `Usage:` raise (gate `0x517a48`) where a nil token had raised mlua's type error; truthiness is why it survived (`if UnitIsDead(u)` reads the same either way, and `== nil` inverts), nine `eval::<bool>` tests were vacuous under mlua's coercion and `taxi.rs` had pinned `== false`/`== true` — the one shape the reference cannot emit — so a table-driven test covers all twenty-eight on both legs, and `UnitIsAFK`/`UnitIsDND` now say they are ours (zero occurrences image-wide) (2043); and the quest log's stock title width cap — `275 − 15 − tagWidth`, live off the chain since 1944 — ellipsizes on one line rather than wrapping into the 16-unit row: 1873 declined it reading an explicit width as a wrap width, but the title FontString declares `y="10"`, and a declared height arms both overflow regimes, so a 258 px title in a 206 px box paints as one line ending in three ASCII dots — the reference's own result, its ellipsis gate at `0x771ec0` being `boxW > 0 && (maxLines != 0 || boxH > 0)`, which only an auto-height FontString escapes; two tests pin the render law on the real FRIZQT face and the geometry the stock file hands it, including the cap's per-row-button stickiness, which is the reference's too (2036)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:39:11 +07:00

607 lines
27 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, composed from three slots over the four `TOOLTIP_UNIT_LEVEL*` templates:
//! 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 word `{"", 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::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 table — byte-verified `0x854158[]`: rare-elite prints ELITE, rare prints
/// nothing (there is no distinct "Rare Elite" word in 1.12's builder).
fn rank_word(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 ("Level %s" /
/// "Level %s %s" / "Level %s (%s)" / "Level %s %s (%s)", the extracted enUS strings).
fn level_line(u: &UnitState, player_level: u32) -> String {
// 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 {
Some("Corpse".to_string())
} 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 {
Some("Player".to_string())
} else {
rank_word(u.rank).map(str::to_string)
};
match (class_slot, type_slot) {
(Some(c), Some(t)) => format!("Level {level_text} {c} ({t})"),
(Some(c), None) => format!("Level {level_text} {c}"),
(None, Some(t)) => format!("Level {level_text} ({t})"),
(None, None) => format!("Level {level_text}"),
}
}
/// 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)?;
}
append_line(
lua,
this,
(level_line(&u, player_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(())
}