Files
benilla-octo/crates/benilla-ui/src/script/tooltip/verbs.rs
T
SamandClaude Fable 5.1 eebbcc050f NTFS load order, a title-sorted AddOns list, and tooltips at the cursor
- addons load in the order NTFS lists a folder, case-insensitive with an underscore before the letters, the order the vanilla corpus was written against and the one that decides which addon provides a shared library (2178)
- GetNumAddOns and the index form of every AddOn verb walk a title-sorted list that hides the Blizzard addons the server says to hide, and the character-select AddOns list is in title order (2175)
- SetPoint and SetAllPoints raise the reference's own errors for a frame name that does not exist or an omitted relative frame instead of quietly anchoring to the parent, and an explicit nil means the screen root (2176)
- a tooltip owned with ANCHOR_CURSOR follows the mouse, which pfUI's tooltip, xpbar and chat modules, pfQuest's browser and TipBuddy ask for; an omitted or unknown anchor is ANCHOR_LEFT (2176)
- a $parent name handed to CreateFrame, CreateTexture or CreateFontString expands as it does in XML, so FonzAppraiser's thirty dropdowns and pfQuest's search icon get the names they asked for (2176)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 21:03:49 +07:00

494 lines
26 KiB
Rust

//! The GameTooltip Lua verb table — [`install`] registers the widget methods (SetOwner /
//! AddLine / AddDoubleLine / SetText / ClearLines / SetMinimumWidth / SetPadding / FadeOut /
//! IsOwned / NumLines / the anchor law) over the parent module's line-pool mechanics, then
//! chains the item/unit content builders' method sets.
use mlua::{Lua, Table, Value, Variadic};
use crate::layout::{Anchor, Point};
use crate::script::object::{frame_handle_of, frame_wrapper};
use crate::script::region::region_handle_of;
use crate::script::Model;
use crate::widget::{KindState, TooltipAnchor, TOOLTIP_LINE_GAP, TOOLTIP_PAD};
use super::{
append_line, bool_arg, cancel_fade, clear_content, fire_cleared, hide_tooltip, now,
parse_line_color, parse_line_tail, set_shown, text_of, tip_mut, with_tip, write_cell,
REG_TOOLTIP_METHODS,
};
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// SetOwner(owner, anchorType [, x, y]) — the binding `0x5310d0` and the core it calls,
// `0x52ffe0(owner_region, mode, dx, dy)`: SetAlpha(255) (`0x52fff4`), fade-disarm
// (`0x530002`), `+0x314 = owner` (`0x53000c`), the mode (`0x530031`), the offsets, then the
// anchor-apply `0x52fe90`. We clear the content too, because the engine fires
// OnTooltipCleared: a fresh owner never inherits the last hover's lines or money.
//
// **The omitted / unreadable / unrecognised anchorType is mode 0 = ANCHOR_LEFT, silently.**
// `0x53120d` writes 0 into the binding's local mode *before any compare runs*; `0x531214`'s
// `lua_isstring` gate jumps the whole `SStrCmpI` chain when arg 3 is absent, nil, boolean or a
// table; the chain's last `jne` reaches the same join untouched; and `[0x531221, 0x53133a)`
// contains no `luaL_error` (earned zero, positive-controlled against the five this function
// *does* have in its owner-validation prologue). `0x5313c0` then hands that local to the core.
//
// The core's own `mov [+0x318],7` at `0x530012` is **not** that default: it is an
// unconditional pre-store that `0x530031` overwrites with the argument, and it survives only
// when the owner pointer is NULL — the un-own path `0x530a60` takes on every hide.
//
// **The anchor-apply clears, and only ANCHOR_PRESERVE escapes it** (`0x52fe90` read
// contiguously): a NULL owner returns at `0x52fe9e`; **mode 8 returns at `0x52fead`, before
// the clear**; then `0x52feb8` tests the caller's arg1, which `0x530037` always passes as
// **1** (`0x53002d push 0x1`), so the mode-7 skip at `0x52feba` is dead on this path and
// `0x52fec2 call 0x767ed0` (ClearAllPoints) runs for **every mode 0..7**; and only then does
// `0x52fed0 ja` send 6 and 7 home while 0..5 take the SetPoint jump table `0x52ffbc`.
//
// So the three arms below are: 0..5 clear-and-point, **6 and 7 clear**, 8 nothing. Decision
// 2176 replaces 2142's open thread 1, which had it backwards on both counts — it read the
// core's pre-store as the Lua default and read "no SetPoint" as "no clear", and a wow-re §5
// dispatched for the CURSOR mechanism refuted both at the bytes (`system/ui/scratch/`
// `tooltip-cursor-anchor-law.md` §0.2/§0.3/§2).
m.set(
"SetOwner",
lua.create_function(
|lua,
(this, owner, anchor, x, y): (
Table,
Table,
Option<String>,
Option<f32>,
Option<f32>,
)| {
let h = frame_handle_of(lua, &this)?;
let owner_h = frame_handle_of(lua, &owner)?;
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
cancel_fade(&mut model, h);
tip_mut(&mut model, h)?.owner = Some(owner_h);
let owner_id = model.frame_id(owner_h);
// The anchor mode is resolved ONCE, into the reference's own enum, and both
// the placement below and `GetAnchorType`'s answer are taken from that one
// value — so the plate and the getter can never tell different stories.
//
// Absent, unreadable and unrecognised all land on **ANCHOR_LEFT**, which is
// the binding's zero-initialised local (`0x53120d`) surviving the
// `lua_isstring` gate or the compare chain, with no raise. The warning on the
// unrecognised leg is ours and stays: the reference is silent, but nothing
// observable to Lua changes, and this exact warning is what made
// `ANCHOR_CURSOR` visible as a real ninth mode nine corpus files ask for
// rather than a typo (2142's open thread 2).
let anchor = match anchor
.as_deref()
.map(str::to_ascii_uppercase)
.as_deref()
.unwrap_or("")
{
"ANCHOR_LEFT" => TooltipAnchor::Left,
"ANCHOR_RIGHT" => TooltipAnchor::Right,
"ANCHOR_BOTTOMLEFT" => TooltipAnchor::BottomLeft,
"ANCHOR_BOTTOMRIGHT" => TooltipAnchor::BottomRight,
"ANCHOR_TOPLEFT" => TooltipAnchor::TopLeft,
"ANCHOR_TOPRIGHT" => TooltipAnchor::TopRight,
"ANCHOR_CURSOR" => TooltipAnchor::Cursor,
"ANCHOR_NONE" => TooltipAnchor::None,
"ANCHOR_PRESERVE" => TooltipAnchor::Preserve,
"" => TooltipAnchor::Left,
other => {
model.record_warning(format!(
"SetOwner: unknown anchor '{other}' (mode 0, ANCHOR_LEFT)"
));
TooltipAnchor::Left
}
};
tip_mut(&mut model, h)?.anchor = anchor;
let pts = match anchor {
TooltipAnchor::Right => Some((Point::BottomLeft, Point::TopRight)),
TooltipAnchor::Left => Some((Point::BottomRight, Point::TopLeft)),
TooltipAnchor::TopRight => Some((Point::BottomRight, Point::TopRight)),
TooltipAnchor::TopLeft => Some((Point::BottomLeft, Point::TopLeft)),
TooltipAnchor::BottomRight => Some((Point::TopLeft, Point::BottomRight)),
TooltipAnchor::BottomLeft => Some((Point::TopRight, Point::BottomLeft)),
TooltipAnchor::Cursor | TooltipAnchor::None | TooltipAnchor::Preserve => {
None
}
};
// **Modes 6 and 7 CLEAR and stop; mode 8 does neither.** `0x52fe90` reaches
// `0x52fec2 call 0x767ed0` for every mode 0..7 — the mode-7 skip beside it is
// gated on an arg1 that `SetOwner`'s core always passes as 1 — and mode 8
// returned two compares earlier, at `0x52fead`, without touching anything.
//
// For ANCHOR_NONE that clear is what makes `GameTooltip_SetDefaultAnchor`
// (`SetOwner(owner, "ANCHOR_NONE")` → `ClearAllPoints()` → `SetPoint(...)`,
// the path of every action-bar hover) safe: a stale owner anchor cannot win
// the frame the caller forgets to re-point. For ANCHOR_CURSOR it is the
// opening move of a placement the per-frame update finishes
// (`script::tooltip::cursor_anchor`).
//
// Dropping every anchor is a retarget to the EMPTY target set, and it names
// its node like any other (decision 1625). It matters here more than
// anywhere: left on the conservative touch, this line was a whole-graph
// derivation on every action button the cursor crossed and on nothing else —
// exactly the shape the director's recorder reported (decision 1630).
if matches!(anchor, TooltipAnchor::Cursor | TooltipAnchor::None) {
let dropped = match model.layout_inputs.get_mut(&h) {
Some(input) if !input.anchors.is_empty() => {
Some(input.anchors.drain(..).map(|a| a.relative_to).collect())
}
_ => None,
};
if let Some(old_targets) = dropped {
let old_targets: Vec<u32> = old_targets;
model.touch_layout_retarget_frame(h, &old_targets, &[]);
}
}
if let Some((own, rel)) = pts {
let new =
Anchor::new(own, owner_id, rel, x.unwrap_or(0.0), y.unwrap_or(0.0));
let input = model.layout_inputs.entry(h).or_default();
// The no-op compare keeps the per-frame SetOwner idiom (the bag
// hover's OnUpdate re-enter) from dirtying tier 1 by itself; the
// content clear above already reports its own writes.
let same = input.anchors.len() == 1
&& crate::script::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
// The plate re-points at the button under the cursor, which moves
// an EDGE — structural under 1388, and therefore a whole-graph
// derivation on every bag slot and every spellbook button a hover
// sweep crossed. It names its node now (decision 1625): the old
// and new target lists are both right here, so the cached graph's
// edges are re-pointed instead of thrown away.
let old_targets: Vec<u32> =
input.anchors.iter().map(|a| a.relative_to).collect();
input.anchors = vec![new];
model.touch_layout_retarget_frame(h, &old_targets, &[owner_id]);
}
}
}
fire_cleared(lua, h);
Ok(())
},
)?,
)?;
// GameTooltip:BenillaGetTooltipOwner() — **ours, and the prefix is what makes it honest.**
// 1.12 registers `IsOwned` (ask about ONE frame) and no getter at all, so there is no
// reference spelling for "which frame owns this plate" to be faithful to; a `GetOwner` here
// would be an unexplained superset an addon can feature-detect (1188, and the census that
// moved it, 2142). Under the `Benilla` prefix it is unreachable by accident from an addon
// that means to call a WoW function. Its one caller is the dev-only hover recorder
// (`benilla-app/src/hover_log.rs`), which needs the owner's NAME per frame so a trace reads
// the same names the director sees.
m.set(
"BenillaGetTooltipOwner",
lua.create_function(|lua, this: Table| {
let owner = with_tip(lua, &this, |t| t.owner)?;
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
owner
.filter(|&h| model.arena.frame(h).is_some())
.map(|h| model.frame_id(h))
};
match id {
Some(id) => Ok(Value::Table(frame_wrapper(lua, id)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetAnchorType() — ONE string, the mode the last SetOwner placed this plate by
// (`0x5313e0`, table `0x854198`, argc 1, arity 1, kinds `(string)`; it reads `[+0x318]` back
// through the 9-entry name table `0x531530`). The kinds column has no nil alternative and this
// does not answer one: a tooltip nothing has owned yet answers `"ANCHOR_NONE"`, which is
// [`TooltipAnchor`]'s default and the same mode the reference's own SetOwner core falls back to.
//
// Two corpus callers, both unguarded, both raising against this VM until now, and they are the
// two shapes the verb has: `pfUI/modules/tooltip.lua:97` compares against ONE mode
// (`if GameTooltip:GetAnchorType() == "ANCHOR_NONE" then` — its OnShow reposition, so an equality
// on the exact reference spelling), and `_Nameplates/_Nameplates.lua:479` compares against a
// mode it computed (`if GameTooltip:GetAnchorType() ~= Anchor then GameTooltip:SetOwner(Column,
// Anchor) end` — a re-SetOwner suppressor, so an answer that never equals what SetOwner was
// given would re-own the plate on every OnUpdate). Both need the string to round-trip through
// the setter verbatim, which is why the mode is resolved once up there and read straight back
// here rather than re-derived from the plate's anchors.
m.set(
"GetAnchorType",
lua.create_function(|lua, this: Table| {
let anchor = with_tip(lua, &this, |t| t.anchor)?;
Ok(anchor.name())
})?,
)?;
// IsOwned(frame) — the hover re-enter loop's gate (ref ContainerFrame.lua OnUpdate): true
// only while `frame` is the live owner; the owner drops on hide, so a hidden tooltip never
// resurrects from a stale OnUpdate.
m.set(
"IsOwned",
lua.create_function(|lua, (this, frame): (Table, Table)| {
let owner = with_tip(lua, &this, |t| t.owner)?;
let fh = frame_handle_of(lua, &frame)?;
Ok(owner == Some(fh))
})?,
)?;
// SetText(text, r, g, b, alpha, textWrap) — the byte-pinned `0x531b90` signature (alpha is
// a REAL parsed argument here, its own isnumber gate, default 1.0 — unlike AddLine's forced
// opaque; GameTooltip_AddNewbieTip passes `(text, r, g, b, 1, 1)`). `text` is REQUIRED — a
// non-string raises the binding's own 'Usage: SetText("text" [, color])' FrameScript error
// and adds no line. No colour args → the engine default GOLD. Clears, writes line 1,
// SHOWS — the corpus never calls Show after SetText (PaperDoll's empty-slot text relies
// on it).
m.set(
"SetText",
lua.create_function(|lua, (this, args): (Table, Variadic<Value>)| {
let h = frame_handle_of(lua, &this)?;
if !matches!(
args.first(),
Some(Value::String(_)) | Some(Value::Number(_)) | Some(Value::Integer(_))
) {
return Err(mlua::Error::RuntimeError(
"Usage: SetText(\"text\" [, color])".into(),
));
}
let text = text_of(args.first());
let [r, g, b, _] = parse_line_color(args.get(1), args.get(2), args.get(3));
let a = match args.get(4) {
Some(Value::Number(n)) => *n as f32,
Some(Value::Integer(i)) => *i as f32,
_ => 1.0,
};
let wrap = bool_arg(args.get(5));
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
append_line(lua, &this, (text, [r, g, b, a]), None, wrap)?;
set_shown(lua, h, true);
Ok(())
})?,
)?;
// AppendText(text) — `0x531e30`. The reference's bag/bar tooltips build a title with SetText
// and then hang the keybinding on the end of that same line rather than adding a second one:
//
// GameTooltip:SetText(TEXT(BACKPACK_TOOLTIP), 1.0, 1.0, 1.0);
// if ( GetBindingKey("TOGGLEBACKPACK") ) then
// GameTooltip:AppendText(" "..NORMAL_FONT_COLOR_CODE.."("..key..")"..FONT_COLOR_CODE_CLOSE)
// end
//
// — ContainerFrame.xml l.188-206 (the bag window's portrait button, one arm per bag id) and
// MainMenuBarBagButtons.xml l.96 / .lua l.91 (the bar's). Both files run off the player's own
// chain (1751), so those are live call sites in this VM, not transcriptions we could adjust.
//
// LINE 1's left cell, appended in place, keeping its colour: the escape codes in the argument
// are what colour the suffix, which only works if it lands INSIDE an existing line. A tooltip
// with no lines yet is a no-op rather than a raise — the reference's own callers always
// SetText first, and nothing is carved about the empty case.
//
// INFERRED, pending the byte carve of `0x531e30`: that it is line 1 rather than the last line
// added. Every attested call site sets exactly one line before appending, so the two readings
// are indistinguishable from the corpus alone; line 1 is chosen because the reference's own
// name for the target is `GameTooltipTextLeft1`. Re-measure is unconditional either way.
m.set(
"AppendText",
lua.create_function(|lua, (this, text): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
let extra = text_of(Some(&text));
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let lh = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Tooltip(t)) if t.num_lines > 0 => t.left_lines[0],
Some(KindState::Tooltip(_)) => return Ok(()),
_ => return Err(mlua::Error::runtime("not a GameTooltip")),
};
let (current, color) = match model.region_data.get(&lh) {
Some(d) => (
d.text.clone().unwrap_or_default(),
d.vertex_color.unwrap_or([1.0, 1.0, 1.0, 1.0]),
),
None => (String::new(), [1.0, 1.0, 1.0, 1.0]),
};
write_cell(&mut model, lh, &(current + &extra), color);
Ok(())
})?,
)?;
// AddLine(text [, r, g, b [, wrap]]) — positional; the corpus' archaic `(text, "", r, g, b)`
// shape has a non-number in the r-slot, so the real binding drops the colour tail and the
// line renders the default GOLD (the ref's zone/taxi/repair tooltips — director-matched).
// Appends without showing.
m.set(
"AddLine",
lua.create_function(|lua, (this, args): (Table, Variadic<Value>)| {
let text = text_of(args.first());
let (color, wrap) = parse_line_tail(&args[1.min(args.len())..]);
append_line(lua, &this, (text, color), None, wrap)
})?,
)?;
// AddDoubleLine(textL, textR, rL, gL, bL, rR, gR, bR [, wrap]) — the two-column line
// (slot|type, damage|speed), byte-pinned `0x531840`: each side's colour gates on ITS OWN
// r-slot isnumber (default gold, g/b ungated → 0.0, alpha forced opaque), and the wrap arg
// is dead weight — the core forces wrap=0 whenever the right text is non-empty. The right
// column right-flushes in the layout pass.
m.set(
"AddDoubleLine",
lua.create_function(
|lua, (this, tl, tr, cl): (Table, Value, Value, Variadic<Value>)| {
let left_color = parse_line_color(cl.first(), cl.get(1), cl.get(2));
let right_color = parse_line_color(cl.get(3), cl.get(4), cl.get(5));
append_line(
lua,
&this,
(text_of(Some(&tl)), left_color),
Some((text_of(Some(&tr)), right_color)),
false,
)
},
)?,
)?;
m.set(
"ClearLines",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
Ok(())
})?,
)?;
m.set(
"NumLines",
lua.create_function(|lua, this: Table| with_tip(lua, &this, |t| t.num_lines as i64))?,
)?;
// AddFontStrings(left, right) — adopt two caller-made FontStrings as the next line pair
// (`0x530c40`, wow-re `system/ui/scratch/bindings.md`; the parent module's doc already names it
// as how the real class grows past its template's 30 declared pairs).
//
// The scan-tooltip idiom, and the reason this landed with the font-object work rather than
// separately: `Gratuity-2.0.lua:56-59` builds its whole 30-line hidden tooltip out of
// `tt:CreateFontString()` + `:SetFontObject(GameFontNormal)` + `tt:AddFontStrings(l, r)`, so
// publishing the font globals only moved that library's death one line down. Five corpus addons
// sit behind those four lines.
//
// The pair is placed exactly as an engine-grown one (hidden, left/right justified, hung off the
// previous line) — the layout pass owns line geometry either way — but the regions' FONTS are
// left alone, since choosing them is the entire point of the caller doing this by hand.
m.set(
"AddFontStrings",
lua.create_function(|lua, (this, left, right): (Table, Table, Table)| {
let h = frame_handle_of(lua, &this)?;
let lh = region_handle_of(lua, &left)?;
let rh = region_handle_of(lua, &right)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame_id = model.frame_id(h);
let prev_left = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Tooltip(t)) => t.left_lines.last().copied(),
_ => return Err(mlua::Error::runtime("not a GameTooltip")),
};
let anchor = match prev_left {
None => Anchor::new(
Point::TopLeft,
frame_id,
Point::TopLeft,
TOOLTIP_PAD,
-TOOLTIP_PAD,
),
Some(prev) => {
let prev_id = model.region_id(prev);
Anchor::new(
Point::TopLeft,
prev_id,
Point::BottomLeft,
0.0,
-TOOLTIP_LINE_GAP,
)
}
};
let left_id = model.region_id(lh);
{
let d = model.region_data.entry(lh).or_default();
d.hidden = true;
d.justify.set_h(crate::script::JustifyH::Left);
d.anchors = vec![anchor];
}
{
let d = model.region_data.entry(rh).or_default();
d.hidden = true;
d.justify.set_h(crate::script::JustifyH::Right);
d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)];
}
model.touch_layout(); // two line rows entered the layout graph (decision 0740)
let t = tip_mut(&mut model, h)?;
t.left_lines.push(lh);
t.right_lines.push(rh);
Ok(())
})?,
)?;
// SetMinimumWidth(w) — a floor on the auto-sized width (the ref's SetTooltipMoney calls it
// with the money row's width so coins never overhang the plate).
m.set(
"SetMinimumWidth",
lua.create_function(|lua, (this, w): (Table, f32)| {
with_tip(lua, &this, |t| t.min_width = w.max(0.0))
})?,
)?;
// SetPadding(w) — extra auto-size width (ref ItemRefTooltip OnLoad: SetPadding(16) keeps the
// corner close button off the text). A frame property — persists across content clears.
m.set(
"SetPadding",
lua.create_function(|lua, (this, w): (Table, f32)| {
with_tip(lua, &this, |t| t.padding = w.max(0.0))
})?,
)?;
// FadeOut() — the world-mouseover loss ramp (alpha 1→0 over TOOLTIP_FADE_SECS, INTERIM
// constant, then a real hide). Fresh content or Show cancels it.
m.set(
"FadeOut",
lua.create_function(|lua, this: Table| {
let t = now(lua);
with_tip(lua, &this, |tip| {
if tip.fade_start.is_none() {
tip.fade_start = Some(t);
}
})
})?,
)?;
// Show/Hide — shadow the shared pair (kind tables resolve first) to keep the tooltip's
// state honest: Show cancels a fade at full alpha; Hide drops the owner + content and fires
// OnTooltipCleared (the ref clears its money row through exactly that script).
m.set(
"Show",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
cancel_fade(&mut model, h);
}
set_shown(lua, h, true);
Ok(())
})?,
)?;
m.set(
"Hide",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
hide_tooltip(lua, h);
Ok(())
})?,
)?;
// The item content channels (BenillaSetItemById/SetBagItem/SetMerchantItem/SetBuybackItem) live in
// their own module — the one shared renderer + the red usable law (decision 0274 P1).
crate::script::tooltip_item::install_methods(lua, &m)?;
// The spell/aura/action content channels (SetSpell/SetShapeshift/SetPlayerBuff/SetAction) —
// the verified spell-builder law (decision 0274 P2, grounded by 0276).
crate::script::tooltip_spell::install_methods(lua, &m)?;
// The talent channel (SetTalent) — the spell builder with the talent interleave
// (decision 0304).
crate::script::talent::install_tooltip_method(lua, &m)?;
// The unit content channel (SetUnit + the world-mouseover drivers) — the verified unit law
// (decision 0274 P3, grounded by 0276).
crate::script::tooltip_unit::install_methods(lua, &m)?;
lua.set_named_registry_value(REG_TOOLTIP_METHODS, m)?;
Ok(())
}