Reported from play with a video: hovering a bag item draws the enhanced tooltip — MasterTradeSkills' recipe block, AdvancedTradeSkillWindow2's Bags/Bank/Total — for about nine frames of sixty, then the plate falls back to the bare one and stays bare while the cursor sits still. The loop is stock. `ContainerFrameItemButton_OnUpdate` re-enters `..._OnEnter` every `TOOLTIP_UPDATE_TIME` (`ContainerFrame.lua:693` in his own patch-4.mpq; pfUI's bag slots are `ContainerFrameItemButtonTemplate`, so they run it too), and each turn is `SetOwner` + `SetBagItem` — a clear that wipes every line an addon appended. What puts them back in the reference is a hide/show pair, and we had neither half: the `SetOwner` binding hides the plate before it even reads the owner argument (`0x531179` zeroes the content gate at `+0xd0`, `0x531183` calls `[vtable+0x84]` = `0x530a60`, which is the `SetOwner(NULL)` core plus the base hide `0x76ad50`), and the content `Set*` re-shows it through the tooltip's own Show override (`0x530a80`). So every rebuild raises `OnHide` then `OnShow`. Ours cleared in place, and the base show early-outs on an already-visible frame (`0x76ae3b`), so the plate never hid and the `OnShow` never came. That is why exactly two addons lost their lines while others kept theirs: `LibItemTooltip-1.0`, the tooltip contract of MasterTradeSkills and Gromissingcrafts, hangs entirely on a scratch child frame of `GameTooltip` listening for `OnShow`, where pfUI's ItemID and sell-price lines hook the `Set*` methods themselves and so re-run on every refresh. The reference also clears twice per `SetOwner` (once inside the hide, once in the core) and so fires `OnTooltipCleared` twice; that falls out of calling the same hide, and the existing lifecycle test already allowed for it. Addresses read off the player's own `WoW.exe` with objdump, not from memory. The new test is the hover loop in miniature — three turns of `SetOwner` + a content `Set*` under a `LibItemTooltip`-shaped child probe — and fails on the old behaviour with one `OnShow` instead of three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
599 lines
29 KiB
Rust
599 lines
29 KiB
Rust
//! The GameTooltip Lua methods over the parent module's line pool, plus the content builders'.
|
|
|
|
use mlua::{Lua, Table, Value, Variadic};
|
|
|
|
use crate::layout::{Anchor, Point};
|
|
use crate::script::binding_abi::optional_string;
|
|
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};
|
|
|
|
/// The Lua line that called the binding we are inside, as `debugstack` renders it, for a
|
|
/// diagnostic that has to name its own caller.
|
|
///
|
|
/// `debugstack(2)` skips `debugstack` itself and this binding's frame, so its first line is the
|
|
/// call site. Only that line is kept — a whole traceback per warning would bury the log, and the
|
|
/// call site is the fact being hunted. An empty string reads as "unknown" rather than pretending.
|
|
///
|
|
/// Paid only on a diagnostic path. `debugstack` names frames from `lua_getinfo` alone (the
|
|
/// reference's own walk, `0x703760`) and searches no table — unlike the traceback mlua used to
|
|
/// build, which walked every global and is why `third_party/mlua-sys` is vendored.
|
|
fn caller_site(lua: &Lua) -> String {
|
|
let Ok(f) = lua.globals().get::<mlua::Function>("debugstack") else {
|
|
return String::new();
|
|
};
|
|
let Ok(stack) = f.call::<Option<String>>(2) else {
|
|
return String::new();
|
|
};
|
|
stack
|
|
.unwrap_or_default()
|
|
.lines()
|
|
.next()
|
|
.unwrap_or("")
|
|
.trim()
|
|
.to_string()
|
|
}
|
|
|
|
use super::{
|
|
append_line, bool_arg, clear_content, fire_cleared, full_alpha, 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` calls the core `0x52ffe0`,
|
|
// which sets full alpha (`0x52fff4`), disarms the fade, stores the owner (`0x53000c`), mode
|
|
// and offsets, then applies the anchor (`0x52fe90`). The content clears and OnTooltipCleared
|
|
// fires, so a new owner never inherits the last hover's lines or money.
|
|
//
|
|
// A missing, non-string or unknown anchorType is mode 0, ANCHOR_LEFT, with no error: the
|
|
// binding's local starts at 0 (`0x53120d`). The core's pre-store of 7 (`0x530012`) survives
|
|
// only a NULL owner, the un-own path (`0x530a60`) of every hide.
|
|
//
|
|
// The anchor apply returns first for ANCHOR_PRESERVE (8, `0x52fead`), then clears all points
|
|
// (`0x767ed0` at `0x52fec2`) for modes 0..7; modes 0..5 then set a point (`0x52ffbc`).
|
|
m.set(
|
|
"SetOwner",
|
|
lua.create_function(
|
|
|lua, (this, owner, anchor, x, y): (Table, Table, Value, Option<f32>, Option<f32>)| {
|
|
let h = frame_handle_of(lua, &this)?;
|
|
// **The plate hides first.** The binding zeroes the content gate (`+0xd0`,
|
|
// `0x531179`) and calls the tooltip's own virtual `Hide` (`0x531183`,
|
|
// `[vtable+0x84]` = `0x530a60`) on *itself* before it even reads the owner
|
|
// argument; that `Hide` is the `SetOwner(NULL)` core (`0x52ffe0`: clear, so
|
|
// `OnTooltipCleared`) followed by the base hide (`0x76ad50`, so `OnHide`). The
|
|
// content `Set*` that follows then re-shows it through `0x530a80`, and the base
|
|
// show raises `OnShow` because the frame really was hidden (`0x76ae3b` early-outs
|
|
// on an already-visible one).
|
|
//
|
|
// That hide/show pair is the **only** signal an addon gets that the plate was
|
|
// wiped and wants its lines back, and the hover loop wipes it five times a second:
|
|
// `ContainerFrameItemButton_OnUpdate` re-enters `..._OnEnter` every
|
|
// `TOOLTIP_UPDATE_TIME` (`ContainerFrame.lua:693`, and pfUI's bag slots inherit
|
|
// `ContainerFrameItemButtonTemplate`), which is `SetOwner` + `SetBagItem`. Without
|
|
// the hide the rebuild is silent, so an addon's lines live 0.2 s and never come
|
|
// back: `LibItemTooltip-1.0` (MasterTradeSkills, Gromissingcrafts) hangs its whole
|
|
// contract on a scratch child frame's `OnShow`, and pfQuest and pfUI read the
|
|
// tooltip from `OnShow` too.
|
|
super::hide_tooltip(lua, h);
|
|
let owner_h = frame_handle_of(lua, &owner)?;
|
|
// The gate is `lua_isstring` (`0x6f3510`): a string or number is read, and any
|
|
// other type counts as absent rather than raising.
|
|
let anchor = optional_string(lua, &anchor);
|
|
{
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
clear_content(&mut model, h);
|
|
full_alpha(&mut model, h);
|
|
tip_mut(&mut model, h)?.owner = Some(owner_h);
|
|
let owner_id = model.frame_id(owner_h);
|
|
// Resolved once: the placement and `GetAnchorType` both read this value. The
|
|
// warning on an unknown name is ours; the reference is silent, and Lua sees no
|
|
// difference.
|
|
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 => {
|
|
// The owner's name is in the message because the anchor string alone
|
|
// does not identify the caller. A real session produced 566 of these
|
|
// with an anchor no installed addon passes anywhere on disk — every
|
|
// `SetOwner` call site in every addon `.lua` and `.xml` passes a valid
|
|
// `ANCHOR_*` — and it does not reproduce under the addon harness, so
|
|
// static search and the harness both came up empty. The owner frame
|
|
// names it: a `pfUI*` owner is the addon, a stock FrameXML name is the
|
|
// reference UI off the player's patch chain.
|
|
let owner_name = model
|
|
.arena
|
|
.frame(owner_h)
|
|
.and_then(|f| f.name.clone())
|
|
.unwrap_or_else(|| "<anonymous>".to_string());
|
|
// Name the **calling line**, not just the owner. The owner alone was
|
|
// not enough: it said `WorldFrame` for all 283 of a session's warnings
|
|
// while a paren-matching scan of every `SetOwner` call in every
|
|
// installed addon's `.lua` and `.xml` found only valid `ANCHOR_*`
|
|
// strings, and the addon harness has never reproduced it — so the
|
|
// caller is reached by a path static search cannot see (a stored
|
|
// method reference, a config value, or a wrapper). `debugstack` is the
|
|
// reference's own walk (`0x703760`) and is what `event.rs`'s handler
|
|
// traceback already uses; one frame of it is enough to end the hunt,
|
|
// and it is paid only on the warning path, which is already the
|
|
// unusual one.
|
|
let site = caller_site(lua);
|
|
// The hunt is over, so the terminal gets this **once** per distinct
|
|
// `(anchor, owner, site)` — the three the message already carries, so
|
|
// the message is the dedup key. The caller is `Shop_ProcessEntries`
|
|
// in TurtleWoW's own `Interface\FrameXML\Turtle_ShopUI`, off the
|
|
// player's patch chain: not an addon, not ours, nothing the player
|
|
// can fix, and our mode-0 fallback already matches the reference. 283
|
|
// identical lines a session bury every other warning in the log; the
|
|
// first is still the whole diagnostic, and the retained row's `count`
|
|
// keeps the true total for `BenillaScriptLogFrame`.
|
|
model.record_warning_once(format!(
|
|
"SetOwner: unknown anchor '{other}' on owner '{owner_name}' \
|
|
from {site} (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 all points and stop. The clear lets
|
|
// `GameTooltip_SetDefaultAnchor` (GameTooltip.lua:76-77) set one point with no
|
|
// stale owner anchor; mode 6 is then placed per frame by `cursor_anchor`. The
|
|
// drop is a named retarget to no targets, not a conservative touch.
|
|
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();
|
|
// No-op when unchanged, so the bag hover's per-frame SetOwner does not
|
|
// dirty the layout.
|
|
let same = input.anchors.len() == 1
|
|
&& crate::script::object::anchor_bits_eq(&input.anchors[0], &new);
|
|
if !same {
|
|
// A named retarget: the cached layout graph re-points this edge
|
|
// instead of being rebuilt.
|
|
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(): not a 1.12 method (1.12 has only `IsOwned`); the dev
|
|
// hover recorder (`benilla-app/src/hover_log.rs`) reads the owner's name through it.
|
|
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(): the last SetOwner's mode by name (`0x5313e0`, name table `0x531530`), never
|
|
// nil; a never-owned tooltip answers "ANCHOR_NONE". The reference also answers it after any
|
|
// hide, whose un-own leaves mode 7; this keeps the last mode. Addons compare it with what they
|
|
// passed to SetOwner, so the name must round-trip.
|
|
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 loop's OnUpdate gate (`ContainerFrame.lua:657`); the owner drops
|
|
// on hide, so a stale OnUpdate never brings a hidden tooltip back.
|
|
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, wrap) (`0x531b90`): alpha has its own number gate, default 1,
|
|
// and a text that is neither string nor number raises the binding's usage error. It clears,
|
|
// writes line 1 and shows: the empty-slot hover calls no Show after it
|
|
// (PaperDollFrame.lua:747).
|
|
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`): appends to line 1's left cell in place, keeping its colour,
|
|
// and does nothing with no lines, as the reference's body does (`0x5305b0`, line 1 at
|
|
// `0x5305cc`). The bag tooltips hang a keybinding on their SetText title with it
|
|
// (ContainerFrame.xml:198, MainMenuBarBagButtons.lua:91).
|
|
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, and appends without showing; the
|
|
// `(text, "", r, g, b)` shape has a non-number r-slot, so it renders default gold.
|
|
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]) (`0x531840`): each side's
|
|
// colour gates on its own r-slot. The core forces wrap off when the right text is non-empty;
|
|
// this never wraps.
|
|
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) (`0x530c40`): adopts two caller-made FontStrings as the next
|
|
// line pair, placed like an engine-grown pair but keeping the caller's fonts.
|
|
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
|
|
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 (`SetTooltipMoney` passes the money
|
|
// row's, GameTooltip.lua:91).
|
|
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 that survives a content clear (ItemRefTooltip's 16
|
|
// keeps its close button off the text, ItemRef.xml:40).
|
|
m.set(
|
|
"SetPadding",
|
|
lua.create_function(|lua, (this, w): (Table, f32)| {
|
|
with_tip(lua, &this, |t| t.padding = w.max(0.0))
|
|
})?,
|
|
)?;
|
|
// FadeOut(): alpha ramps to 0 over `TOOLTIP_FADE_SECS`, then the full hide; new 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 and Hide shadow the shared pair. Show (`0x530a80`, the override the generic Show at
|
|
// `0x7a3350` calls) shows only a plate with an owner and at least one line, checked at Show
|
|
// time only; otherwise it takes the full hide (`0x530a60`): un-owned, cleared,
|
|
// OnTooltipCleared fired. Hide is the full hide.
|
|
m.set(
|
|
"Show",
|
|
lua.create_function(|lua, this: Table| {
|
|
let h = frame_handle_of(lua, &this)?;
|
|
let live = {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
full_alpha(&mut model, h);
|
|
tip_mut(&mut model, h)
|
|
.map(|t| t.owner.is_some() && t.num_lines > 0)
|
|
.unwrap_or(false)
|
|
};
|
|
match live {
|
|
true => set_shown(lua, h, true),
|
|
false => hide_tooltip(lua, h),
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"Hide",
|
|
lua.create_function(|lua, this: Table| {
|
|
let h = frame_handle_of(lua, &this)?;
|
|
hide_tooltip(lua, h);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// The content methods: items, spells and auras, talents, units.
|
|
crate::script::tooltip_item::install_methods(lua, &m)?;
|
|
crate::script::tooltip_spell::install_methods(lua, &m)?;
|
|
crate::script::talent::install_tooltip_method(lua, &m)?;
|
|
crate::script::tooltip_unit::install_methods(lua, &m)?;
|
|
|
|
lua.set_named_registry_value(REG_TOOLTIP_METHODS, m)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::script::UiScript;
|
|
|
|
/// The warning has to name the **line that called it**, not just the owner frame.
|
|
///
|
|
/// This diagnostic exists because a real session emits 283 of
|
|
/// `SetOwner: unknown anchor 'BOTTOMRIGHT' on owner 'WorldFrame'` and three static hunts failed
|
|
/// to find the caller: a paren-matching scan of every `SetOwner` in every installed addon's
|
|
/// `.lua` and `.xml` found only valid `ANCHOR_*` strings, and the addon harness has never
|
|
/// reproduced it. Whatever reaches it does so by a path source search cannot see, so the
|
|
/// warning must identify itself.
|
|
#[test]
|
|
fn an_unknown_anchor_warning_names_the_lua_line_that_passed_it() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.run(
|
|
r#"T = CreateFrame("GameTooltip", "SiteProbeTip", UIParent)
|
|
Host = CreateFrame("Frame", "SiteProbeHost", UIParent)
|
|
function PassABarePoint() T:SetOwner(Host, "BOTTOMRIGHT") end
|
|
PassABarePoint()"#,
|
|
)
|
|
.unwrap();
|
|
let warnings = s.take_warnings().join("\n");
|
|
assert!(
|
|
warnings.contains("unknown anchor 'BOTTOMRIGHT'"),
|
|
"the anchor itself must still be reported: {warnings}"
|
|
);
|
|
assert!(
|
|
warnings.contains("on owner 'SiteProbeHost'"),
|
|
"the owner must still be reported: {warnings}"
|
|
);
|
|
// The point of the change: a caller the warning can be traced back to.
|
|
assert!(
|
|
warnings.contains("from ") && !warnings.contains("from "),
|
|
"the warning must carry a non-empty call site: {warnings}"
|
|
);
|
|
assert!(
|
|
warnings.contains("PassABarePoint") || warnings.contains("chunk"),
|
|
"the site must name the calling frame, not the binding: {warnings}"
|
|
);
|
|
}
|
|
|
|
/// The hunt is over, so the repeats are noise: one terminal line per distinct `(anchor, owner,
|
|
/// site)`, while the retained log still counts every occurrence. The caller the diagnostic
|
|
/// found is `Shop_ProcessEntries` in TurtleWoW's own `Interface\FrameXML\Turtle_ShopUI` — off
|
|
/// the player's patch chain, so nothing in the session can stop it calling, and 283 copies of
|
|
/// one unfixable warning bury every other line in the log.
|
|
#[test]
|
|
fn a_repeated_unknown_anchor_reaches_the_host_once_and_the_retained_row_counts_them_all() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.run(
|
|
r#"T = CreateFrame("GameTooltip", "NoisyTip", UIParent)
|
|
Host = CreateFrame("Frame", "NoisyHost", UIParent)
|
|
function ShopProcessEntries() T:SetOwner(Host, "BOTTOMRIGHT") end
|
|
for i = 1, 283 do ShopProcessEntries() end"#,
|
|
)
|
|
.unwrap();
|
|
let host: Vec<String> = s
|
|
.take_warnings()
|
|
.into_iter()
|
|
.filter(|w| w.contains("unknown anchor 'BOTTOMRIGHT'"))
|
|
.collect();
|
|
assert_eq!(
|
|
host.len(),
|
|
1,
|
|
"283 identical warnings are ONE terminal line — the whole point: {host:#?}"
|
|
);
|
|
// The first line is still the full diagnostic: it is how the caller was found.
|
|
assert!(
|
|
host[0].contains("on owner 'NoisyHost'") && host[0].contains("ShopProcessEntries"),
|
|
"the one line kept must still name the owner and the call site: {host:#?}"
|
|
);
|
|
let row = s
|
|
.diagnostics()
|
|
.into_iter()
|
|
.find(|d| d.message.contains("unknown anchor 'BOTTOMRIGHT'"))
|
|
.expect("the warning is retained for the player's log");
|
|
assert_eq!(
|
|
row.count, 283,
|
|
"…and nothing is lost: the retained row carries the true total"
|
|
);
|
|
}
|
|
|
|
/// Two call sites are two diagnostics, so the dedup folds repeats and never a second caller:
|
|
/// the message carries `(anchor, owner, site)` and is itself the key.
|
|
#[test]
|
|
fn two_different_unknown_anchor_call_sites_each_get_their_own_first_line() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.run(
|
|
r#"T = CreateFrame("GameTooltip", "TwoSiteTip", UIParent)
|
|
A = CreateFrame("Frame", "OwnerAlpha", UIParent)
|
|
B = CreateFrame("Frame", "OwnerBeta", UIParent)
|
|
function SiteAlpha() T:SetOwner(A, "BOTTOMRIGHT") end
|
|
function SiteBeta() T:SetOwner(B, "TOPLEFT") end
|
|
SiteAlpha() SiteAlpha() SiteBeta() SiteBeta()"#,
|
|
)
|
|
.unwrap();
|
|
let host: Vec<String> = s
|
|
.take_warnings()
|
|
.into_iter()
|
|
.filter(|w| w.contains("unknown anchor"))
|
|
.collect();
|
|
assert_eq!(host.len(), 2, "one line each, not one in total: {host:#?}");
|
|
assert!(
|
|
host.iter().any(|w| w.contains("'OwnerAlpha'"))
|
|
&& host.iter().any(|w| w.contains("'OwnerBeta'")),
|
|
"both callers must be named: {host:#?}"
|
|
);
|
|
}
|
|
|
|
/// A valid anchor stays silent — the diagnostic must not start charging `debugstack` on the
|
|
/// path every tooltip in the UI takes.
|
|
#[test]
|
|
fn a_known_anchor_warns_nothing_and_so_walks_no_stack() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.run(
|
|
r#"T = CreateFrame("GameTooltip", "QuietTip", UIParent)
|
|
Host = CreateFrame("Frame", "QuietHost", UIParent)
|
|
T:SetOwner(Host, "ANCHOR_BOTTOMRIGHT")"#,
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
!s.take_warnings().join("\n").contains("unknown anchor"),
|
|
"ANCHOR_BOTTOMRIGHT is a real 1.12 anchor"
|
|
);
|
|
}
|
|
}
|