//! 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::("debugstack") else { return String::new(); }; let Ok(stack) = f.call::>(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, Option)| { 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::().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(|| "".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 = 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 = 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::().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)| { 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::().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::().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)| { 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)| { 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::().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::().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::().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 = 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 = 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" ); } }