Comments in benilla-ui: one line of fact each, no history, no record numbers (#143)

Part of #41: the comments of `benilla-ui` (the Lua UI engine, its FrameXML loader and its widgets) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 43,140 comment lines become 15,161.

- **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal.
- **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them:
  - `0x64a480` is the case-sensitive compare (a byte `repz cmpsb`), and `0x64a4c0` the case-insensitive one.
  - A frame's id sits at `+0xb0`, not `+0xb4`. `EnableKeyboard`/`IsKeyboardEnabled` are `0x776ec0`/`0x776f90`.
  - A region's unpinned edge has no fallback to its owner's rect: the owner supplies only the scale.
  - `$parent` expands to the nearest named ancestor, or "Top" when none is named (`0x76c5b0`), not simply to the parent.
  - A StatusBar fills from its internal normalised fraction, not from `GetValue()`.
  - The addresses of `StartDuel` and `StartDuelUnit` were swapped.
  - Many "untraced" notes now give the traced reference fact: the drag threshold, the per-grip sizing edges, the empty-`autoFocus` skip, and `SetTexCoordModifiesRect`'s default.
  - Stock FrameXML line citations are checked against the stock 1.12 files and corrected. `ref-` file names become the stock file names.
- **Deviation labels.** "Deviation:" now marks only a choice with a reason. A surface that exists only for the retired Era addon target says so plainly: it is not 1.12.
- **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Nine second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference.

Behaviour differences the rewrite surfaced are filed as their own issues (#131–#142), and more are being validated. The comments here state what the code does. String literals are untouched; they are #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-25 01:58:49 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8505187668
commit fd386e75ae
233 changed files with 14108 additions and 42094 deletions
+96 -284
View File
@@ -1,28 +1,14 @@
//! The `GameTooltip` method surface + engine behavior (decision 0274): the line stack, the
//! owner/anchor law, auto-size, and the fade — the modeled behavior of the client's game-layer
//! tooltip class over `CSimpleFrame` (the reference's full 38-binding Lua surface, plus its
//! money/cooldown internals; the *content* builders land per 0274's phases on top of these
//! mechanics).
//! The `GameTooltip` method surface and engine behaviour: the line stack, owner and anchor,
//! auto-size and the fade.
//!
//! **Lines are real named FontString regions**, engine-created on demand and published as Lua
//! globals (`<name>TextLeft1` …) — reference Lua addresses them by name
//! (`GameTooltipTextLeft1:SetTextColor`, UnitFrame.lua), so they cannot be private state. The
//! real template pre-declares 30 pairs and the class grows past them with `AddFontStrings`;
//! benilla uses the one growth mechanism for all of them.
//! Lines are named FontString regions published as Lua globals (`<name>TextLeft1` …), since
//! FrameXML addresses them by name (`GameTooltipTextLeft1:SetTextColor`, `UnitFrame.lua:76`). The
//! template declares 30 pairs, which are adopted; the reference grows past them through
//! `AddFontStrings`, and this also creates pairs on demand.
//!
//! **Layout is a `resolve` pre-pass** ([`layout_tooltips`]): frame size = max measured line width
//! (+ the double-line gap) + 2·pad × summed line heights + gaps, with `SetMinimumWidth` as a
//! floor, and each double line's right column re-pointed flush to the text inset — the job the
//! real client's C++ line layout does over the template's static anchors. It reads the measure
//! round-trip's cached extents, so a fresh tooltip converges in ~2 frames (same as the retired
//! Lua `OnUpdate` measure loop it replaces).
//!
//! `AddLine` accepts both shipped-FrameXML shapes — `(text, r, g, b[, wrap])` and the archaic
//! `(text, "", r, g, b)` (both appear across the real 1.12 corpus; the non-number second slot is
//! skipped). A wrap-flagged line has [`crate::widget::TOOLTIP_WRAP_WIDTH`] pinned onto its
//! region at APPEND time, so its very first measure comes back wrapped — one round-trip, stable
//! under the hover re-enter loop's per-frame content clears (the item description /
//! trigger-effect lines).
//! Layout is a pre-pass of every `resolve` ([`layout_tooltips`]): the width is the widest line (a
//! double line adds the column gap) plus padding, floored by `SetMinimumWidth`; the height is the
//! summed line heights and gaps; each right column sits flush with the text inset.
use mlua::{Lua, Table, Value};
@@ -37,11 +23,10 @@ use crate::widget::{
TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD,
};
/// Registry key of the GameTooltip method table (the MAXCSTACK discipline: named registry root).
/// Registry key of the GameTooltip method table, a named registry root (the MAXCSTACK discipline).
pub(super) const REG_TOOLTIP_METHODS: &str = "__benilla_tooltip_methods";
/// Run `f` over a frame's tooltip state under one short write borrow. Errors if `this` is not a
/// live GameTooltip (unreachable through the kind dispatcher, but the table is a plain Lua value).
/// Runs `f` over a GameTooltip's state under one short write borrow.
fn with_tip<T>(lua: &Lua, this: &Table, f: impl FnOnce(&mut TooltipState) -> T) -> mlua::Result<T> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -55,17 +40,12 @@ pub(super) fn tip_mut(model: &mut Model, h: FrameHandle) -> mlua::Result<&mut To
}
}
/// Copy a named font object's resolved paint onto a line region (the `SetFontObject` copy,
/// minus the error on a missing name: engine tests load file subsets where Fonts.xml may be
/// absent — an unregistered name leaves the renderer's default face rather than failing the
/// hover).
/// `SetFontObject` onto a line cell, except that a missing font object keeps the default face
/// rather than raising (a VM loaded without Fonts.xml).
fn apply_font(d: &mut RegionData, name: &str, fo: Option<&FontObject>) {
d.font_object = Some(name.to_string());
// The severance mask is left standing — a re-point does NOT restore inheritance in the
// reference (see `super::font`'s module doc on the `+0x2c` inheritMask). A freshly created
// line has a clean mask anyway; an adopted XML-declared line keeps whatever its own attributes
// severed. Either way the line inherits the object LIVE from here: a later
// `GameTooltipText:SetFont(…)` re-paints every tooltip line through `font::propagate`.
// The inherit mask (`+0x2c`) stays: a re-point does not restore inheritance in the reference.
// From here the line follows the font object live, through `font::propagate`.
if let Some(f) = fo {
d.font_path = f.font.clone();
d.font_height = f.height;
@@ -77,14 +57,9 @@ fn apply_font(d: &mut RegionData, name: &str, fo: Option<&FontObject>) {
}
}
/// Ensure the line-pair pool holds at least `n` pairs — ADOPTING XML-declared pairs
/// (`<name>TextLeft<i>`/`TextRight<i>`, the ref template's pre-declared ladder: their faces
/// ride, the engine owns justify/visibility/anchors) and creating the rest (faces cloned from
/// the previous pair when a ladder exists, else the header/text defaults).
/// Pair `i` (1-based): left at TOPLEFT (pad,−pad) of the frame (i = 1) or the previous left's
/// BOTTOMLEFT (0,−gap); right at RIGHT→left RIGHT (its x offset is [`layout_tooltips`]'s to
/// recompute). Line 1 wears `GameTooltipHeaderText`, the rest `GameTooltipText` — the real
/// template's fonts.
/// Grows the line-pair pool to `n` pairs: XML-declared pairs (`<name>TextLeft<i>`) are adopted
/// with their fonts, and created pairs clone the previous pair's fonts past a declared ladder, or
/// else wear the template's `GameTooltipHeaderText` (line 1) and `GameTooltipText`.
fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
// (id, global name) pairs to publish once the model borrow is dropped.
@@ -113,10 +88,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
}
let i = have + 1;
// An XML-declared pair for this index? The ref template mechanism: FrameXML declares
// the leading pairs (ShoppingTooltipTemplate's small-font ladder, TextLeft1..N), the
// class ADOPTS them and, past the ladder, grows lines that clone the previous pair's
// faces. GameTooltip declares none, so the header/text defaults hold there.
// The template's declared pair for this index (ShoppingTooltipTemplate's ladder).
let declared = |model: &Model, cell: &str| -> Option<crate::widget::RegionHandle> {
if frame_name.is_empty() {
return None;
@@ -128,8 +100,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
let rh = model.id_to_region.get(&id).copied()?;
(model.arena.region(rh)?.owner == h).then_some(rh)
};
// Font source for a CREATED cell: the previous line's declared/cloned faces when the
// frame carries an XML ladder, else the engine defaults.
// A created cell's fonts: the previous line's under an XML ladder, else the defaults.
let seed_font =
|model: &mut Model,
d: &mut RegionData,
@@ -179,9 +150,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
d
};
d.hidden = true;
// Left-justified like the template's line stack — invisible while the rect hugs
// the measured text, load-bearing once the wrap consumer pins a region to the
// wrap column.
// Left-justified as in the template; it shows once a wrap line pins its width.
d.justify.set_h(super::JustifyH::Left);
d.anchors = vec![match prev_left {
None => Anchor::new(
@@ -204,7 +173,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
}];
model.region_data.insert(left, d);
model.touch_measure(left); // an adopted cell can arrive text-in-hand
model.touch_layout(); // a line row entered the layout graph (decision 0740)
model.touch_layout(); // a line row entered the layout graph
}
let left_id = model.region_id(left);
@@ -232,12 +201,12 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)];
model.region_data.insert(right, d);
model.touch_measure(right); // an adopted cell can arrive text-in-hand
model.touch_layout(); // a line row entered the layout graph (decision 0740)
model.touch_layout(); // a line row entered the layout graph
}
let right_id = model.region_id(right);
if !frame_name.is_empty() {
// Adopted pairs are already named + published by the loader.
// Adopted pairs are already named and published by the loader.
if adopted_left.is_none() {
let ln = format!("{frame_name}TextLeft{i}");
model.region_names.entry(ln.clone()).or_insert(left_id);
@@ -265,7 +234,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
Ok(())
}
/// One text cell's write: text + color + shown. `color` alpha defaults opaque.
/// Writes a line cell's text and colour and shows it.
pub(super) fn write_cell(
model: &mut Model,
rh: crate::widget::RegionHandle,
@@ -275,18 +244,15 @@ pub(super) fn write_cell(
let d = model.region_data.entry(rh).or_default();
d.text = Some(text.to_string());
d.vertex_color = Some(color);
// `AddLine("…", r, g, b)`'s colour is this line's OWN colour, exactly as if Lua had called
// `SetTextColor` on the cell — so it must survive a later mutation of the font object the
// line inherits ([`super::types::FontExplicit`]). Without the mark, one
// `GameTooltipText:SetTextColor(…)` would repaint every red/green line in the tooltip.
// The line's own colour, as if set by `SetTextColor`: a later
// `GameTooltipText:SetTextColor` must not repaint it.
d.font_explicit.color = true;
d.hidden = false;
model.touch_measure(rh);
}
/// Hide + blank every line cell and zero the counters. The caller fires `OnTooltipCleared`
/// (outside the model borrow) — the engine-fired widget script the reference wires to its
/// money-row clear.
/// Hides and blanks every line cell and resets the counters; the caller fires `OnTooltipCleared`
/// outside the model borrow.
pub(super) fn clear_content(model: &mut Model, h: FrameHandle) {
let (lefts, rights) = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Tooltip(t)) => (t.left_lines.clone(), t.right_lines.clone()),
@@ -296,30 +262,10 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) {
if let Some(d) = model.region_data.get_mut(&rh) {
d.text = None;
d.hidden = true;
// `measured` is deliberately KEPT. It is a cache whose validity gate is its own
// content hash ([`RegionData::measure_key`] over text/font/wrap/outline), so wiping
// it here invalidates nothing the key would not — while destroying the one thing that
// makes the hover re-enter loop affordable. That loop
// (`ContainerFrameItemButton_OnUpdate`, unthrottled in 1.12 and shipped verbatim as
// the reference's `ContainerFrameItemButton_OnUpdate`) clears and rebuilds the SAME content
// every frame: keeping the cache lets each rebuilt line re-validate against its own
// key, so nothing re-shapes, the measure list stays empty, its forced second resolve
// never happens, and the layout gate's fingerprint — which exists precisely to
// "absorb idempotent writes for free" (layout.rs) — closes over the whole loop.
// Wiping it cost +10 CPU ms/frame on a live bag hover (measured 13.2 → 23.9 cpu_ms,
// `WOW_CAPTURE=ui-bag` vs `ui-tooltip`); the gate is
// `layout_gate::the_hover_re_enter_loop_neither_re_measures_nor_re_solves`.
//
// The wrap pin (`size`) is likewise KEPT, and for the stronger reason: wiping it here
// was the last write that made the hover re-enter loop differ from itself. The wipe
// and `append_line`'s re-pin are a round trip inside ONE frame — same cell, same
// width, no observable state between them — but each half bumped the layout epoch, so
// tier 1 went dirty every hover frame and tier 2 hashed the whole UI to conclude
// nothing had moved (~0.65 ms/frame, `WOW_UI_COST=1` on `WOW_CAPTURE=ui-tooltip`, at
// solves=0). The pin is now written from the line's own wrap flag at append time —
// set for a wrap line, cleared for a plain one — which covers the case this wipe
// existed for (a cell whose new content doesn't wrap) without a write per frame.
// A hidden, textless cell contributes nothing to layout in between either way.
// `measured` and the wrap pin (`size`) stay: the measure cache is keyed by content
// ([`RegionData::measure_key`]) and `append_line` rewrites the pin, so the hover
// loop's per-frame clear and rebuild (`ContainerFrameItemButton_OnUpdate`) re-measures
// and re-lays out nothing.
}
}
if let Ok(t) = tip_mut(model, h) {
@@ -328,9 +274,8 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) {
t.unit_token = None;
t.world_owned = false;
}
// The mouseover health bar is UNIT content: it hides with the lines (the ref bar exists
// only while a unit shows; the byte law's watcher re-shows it on the next unit render).
// Without this, the bar from the last mob hover rode under every ITEM tooltip.
// The `<name>StatusBar` health bar is unit content: it hides with the lines, and the next
// unit render re-shows it.
let bar = model
.arena
.frame(h)
@@ -341,7 +286,7 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) {
}
}
/// Fire `OnTooltipCleared` on the tooltip frame (errors → [`Model::errors`], never propagated).
/// Fires `OnTooltipCleared`; a script error is recorded, never propagated.
pub(super) fn fire_cleared(lua: &Lua, h: FrameHandle) {
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -354,11 +299,9 @@ pub(super) fn fire_cleared(lua: &Lua, h: FrameHandle) {
}
}
/// A content `Set*`'s tail: show when it rendered lines, hide when it rendered none. The real
/// client's uncached path leaves a CLEARED tooltip whose size collapses to nothing — with our
/// declared-size plate the visible equivalent is a hide; the hover's re-enter repaints the
/// moment the app answers the ask. Hiding through the arena directly (not [`hide_tooltip`])
/// keeps the owner: IsOwned must stay true for the re-enter loop.
/// A content `Set*`'s tail: shown with lines, hidden without. The reference leaves an uncached
/// tooltip cleared and collapsed to nothing; this plate keeps its declared size, so it hides. It
/// hides without [`hide_tooltip`] to keep the owner, which the hover loop's `IsOwned` needs.
pub(super) fn show_or_hide_empty(lua: &Lua, h: FrameHandle) {
let lines = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -370,10 +313,8 @@ pub(super) fn show_or_hide_empty(lua: &Lua, h: FrameHandle) {
set_shown(lua, h, lines > 0);
}
/// Cancel a running fade and restore full alpha (fresh CONTENT does this — re-hovering during
/// the fade-out resurrects the tooltip at full strength). Conditional on a fade actually running,
/// because an addon that lowered the plate's alpha itself and then added a line keeps its alpha:
/// only the three sites in [`full_alpha`] are documented to stamp it back.
/// New content cancels a running fade at full alpha; with no fade running, an alpha the addon set
/// stays, since only the paths in [`full_alpha`] reset it.
fn cancel_fade(model: &mut Model, h: FrameHandle) {
if let Ok(t) = tip_mut(model, h) {
if t.fade_start.take().is_some() {
@@ -382,14 +323,8 @@ fn cancel_fade(model: &mut Model, h: FrameHandle) {
}
}
/// `SetAlpha(255)` — **UNCONDITIONAL**, and the FIRST thing the SetOwner core `0x52ffe0` does
/// (`0x52fff4`), before the five stores; `0x530a80`'s show arm does it too, and its self-hide arm
/// reaches it through `0x530a60` → `0x52ffe0(0, 0, 0, 0)`. So all three of SetOwner, Show and the
/// effective-hide leave the plate at full strength whether or not one of OUR fades was running
/// (`0x52ffe0` / `0x530a80`).
///
/// [`cancel_fade`] is not this: it only restores the alpha a fade of ours took away, so a plate
/// left dim by any other path never recovered where the reference recovers on the next SetOwner.
/// `SetAlpha(255)`, unconditional: the SetOwner core `0x52ffe0` does it first (`0x52fff4`), and
/// Show (`0x530a80`) and its self-hide (`0x530a60`, the core with no owner) reach it too.
fn full_alpha(model: &mut Model, h: FrameHandle) {
if let Ok(t) = tip_mut(model, h) {
t.fade_start = None;
@@ -397,12 +332,12 @@ fn full_alpha(model: &mut Model, h: FrameHandle) {
model.arena.set_alpha(h, 1.0);
}
/// The engine's `GetTime` clock (the `__benilla_now` global [`super::UiScript::tick`] advances).
/// The engine's `GetTime` clock, which [`super::UiScript::tick`] advances.
fn now(lua: &Lua) -> f64 {
lua.globals().get("__benilla_now").unwrap_or(0.0)
}
/// Show/hide through the arena with the visibility events fired (the shared Show/Hide's path).
/// Shows or hides through the arena and fires the visibility events.
pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) {
let changed = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -411,37 +346,15 @@ pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) {
event::fire_visibility_changes(lua, changed);
}
/// The engine tooltip's default text colour — the byte constant `0xc0d3e8` = `0xffffd200` gold
/// every no-colour `AddLine`/`SetText` renders with. This is why the reference's zone tooltip is
/// GOLD despite Minimap.lua's `AddLine(text, "", 1.0, 1.0, 1.0)`: 1.12's binding reads the colour
/// at its fixed positions, the `""` in the r-slot is not a number, and the whole colour tail is
/// dropped in favour of this default — the trailing 1.0s never shift into place (the last one is
/// consumed as the truthy wrap flag). Byte-pinned: the static-init writer `0x528e50` stores
/// `0xffffd200` at `0xc0d3e8`.
/// The default line colour, gold `0xffffd200` (stored at `0xc0d3e8` by `0x528e50`). The zone
/// tooltip's `AddLine(text, "", 1.0, 1.0, 1.0)` (`Minimap.lua:40`) renders in it: the `""` r-slot
/// drops the whole colour, and the last `1.0` lands in the wrap slot.
pub(in crate::script) const DEFAULT_TEXT_GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
/// The client's duration-string formatter, `0x52fa50` — the one that turns a millisecond count into
/// "2 hours remaining" / "45 seconds remaining". Its parameter vector is the binary's: `(V_ms,
/// keyPrefix, roundUp)`, minus the `extraArg` slot only the enchant line fills.
///
/// **The ladder, with the thresholds decoded from the compare instructions** (`52fa76`, `52fab4`,
/// `52fb03`, falling through to `0x52fb4f`): `≥ 86_400_000` → `_DAYS` · `≥ 3_600_000` → `_HOURS` ·
/// `≥ 60_000` → `_MIN` · else `_SEC`. Each arm divides by its own threshold, so the number shown is
/// always in the unit the arm names.
///
/// **`roundUp` ceils — but only in the top three arms; `_SEC` always truncates.** That asymmetry is
/// the whole reason this is a shared function and not four `format!`s, and it is what produces the
/// reference's three surprising readings: 61 s is "2 minutes remaining" (ceil, not "1"), 3 599 999 ms
/// is "60 minutes remaining" (the hour arm needs the full hour, so there is no "1 hour" until
/// exactly 3 600 000), and the final second before an aura lapses reads "**0** seconds remaining"
/// (truncation, not "1").
///
/// **The text is the player's own `GlobalStrings.lua`, never ours.** `get` is the VM-global lookup
/// (`load_global_strings` runs the shipped file into this VM at boot, off the patch chain); the
/// template arrives as `"%d minutes remaining"` and the count fills its one `%d`. A key the string
/// table doesn't carry yields **no line at all** rather than an invented one — the same disposition
/// 0620 §5 took for a reagent whose template hasn't streamed. That is also why this house ships no
/// copy of the eight strings: on an install they are read, and off one there is nothing to render.
/// The client's duration formatter (`0x52fa50`): days, hours, minutes or seconds by threshold,
/// each dividing by its own unit. `round_up` ceils every arm but seconds, which truncates, so 61 s
/// reads "2 minutes" and 3 599 999 ms "60 minutes". The text is the player's own
/// `GlobalStrings.lua` through `get`; a missing key renders no line.
pub(in crate::script) fn duration_text(
ms: u32,
key_prefix: &str,
@@ -462,8 +375,6 @@ pub(in crate::script) fn duration_text(
} else {
("SEC", SEC_MS)
};
// `roundUp` reaches the day/hour/minute arms only — the seconds arm has no rounding branch at
// all, so it truncates whatever `roundUp` says.
let n = if round_up && divisor != SEC_MS {
ms.div_ceil(divisor)
} else {
@@ -473,19 +384,9 @@ pub(in crate::script) fn duration_text(
Some(template.replacen("%d", &n.to_string(), 1))
}
/// [`crate::strings::plural`] for this module's callers — the `_P1` pick behind
/// `GetText(token, gender, n)`, whose byte law (and the refuted `> 1` reading: `703c89` is a
/// *signed* test, so a zero ordinal takes the plural arm and a lapsing aura reads "0 seconds
/// remaining") now lives with the primitive rather than in a fourth private copy of it. Four
/// copies of one rule is the shape decision 2045 wrote about; `fill` had eight.
///
/// **Gender is not modelled, and that is faithful, not a gap:** `0x52fa50` pushes a literal `0`
/// on both arms (`52fb8d: 6a 00`, `52fbaa: 6a 00`), which `0x703bf0` turns into nil, and enUS
/// folds nil to no suffix.
///
/// (When BOTH the twin and the bare token are absent the reference renders an EMPTY line, off its
/// pre-seeded `0x882748`; we render no line at all. Unreachable with shipped data — all eight of
/// the aura family's keys ship — and off an install there is no string table to be faithful to.)
/// [`crate::strings::plural`], the `_P1` pick of `GetText(token, gender, n)`. There is no gender:
/// the formatter passes a literal 0 (`0x52fa50`), which `0x703bf0` reads as nil. With neither key
/// present the lookup yields "" (`0x882748`), which the line core drops, so no line either way.
pub(in crate::script) fn plural_template(
token: &str,
n: u32,
@@ -494,8 +395,7 @@ pub(in crate::script) fn plural_template(
crate::strings::plural(token, Some(n), get)
}
/// A colour-component argument — `lua_tonumber`'s coercion (a numeric string counts, `""` and
/// other strings don't).
/// A colour component by `lua_tonumber`'s coercion: a numeric string counts, other strings do not.
fn color_num(v: Option<&Value>) -> Option<f32> {
match v {
Some(Value::Number(n)) => Some(*n as f32),
@@ -505,8 +405,9 @@ fn color_num(v: Option<&Value>) -> Option<f32> {
}
}
/// The bindings' boolean-argument read (`GetBoolOrDefault 0x6f1c10`): nil/absent, false, numeric 0,
/// and the strings `"0"`/`"off"`/`"disabled"` are false; everything else is true.
/// A wrap flag: nil, false, 0 and the strings "0", "off" and "disabled" are false, anything else
/// true. The reference reads it with `GetBoolOrDefault` (`0x6f1c10`,
/// `binding_abi::bool_or_default`), where "false", "no" and 0.5 are false too.
fn bool_arg(v: Option<&Value>) -> bool {
match v {
None | Some(Value::Nil) | Some(Value::Boolean(false)) => false,
@@ -519,12 +420,8 @@ fn bool_arg(v: Option<&Value>) -> bool {
}
}
/// The colour block of an `AddLine`-family tail (the values at the r/g/b positions): applied
/// only when the r-slot is a number (`lua_isnumber 0x6f34d0` — the sole gate);
/// anything else drops the WHOLE block to the default gold [`DEFAULT_TEXT_GOLD`] — the ref's
/// gold zone tooltip, whose `(text, "", 1.0, 1.0, 1.0)` shape has `""` at the r-slot. When the
/// gate passes, g/b are UNGATED `lua_tonumber` reads: a missing/non-number component is **0.0**
/// (byte-pinned). Alpha is forced opaque (the bindings pack `0xFF`).
/// An `AddLine`-family colour, gated on the r-slot alone (`lua_isnumber`, `0x6f34d0`), else
/// [`DEFAULT_TEXT_GOLD`]; past the gate a non-number g or b reads 0, and alpha is always opaque.
fn parse_line_color(r: Option<&Value>, g: Option<&Value>, b: Option<&Value>) -> [f32; 4] {
match color_num(r) {
Some(r) => [
@@ -537,9 +434,8 @@ fn parse_line_color(r: Option<&Value>, g: Option<&Value>, b: Option<&Value>) ->
}
}
/// Parse an `AddLine` tail (the args after `text`): positional `r, g, b, wrapText` — the
/// byte-pinned `0x531630` signature. The wrap flag is read at its position REGARDLESS of the
/// colour gate (the ref's zone-tooltip shape wraps: its trailing `1.0` lands in the wrap slot).
/// An `AddLine` tail, positional `r, g, b, wrap` (`0x531630`); the wrap flag is read whatever the
/// colour gate decides.
fn parse_line_tail(args: &[Value]) -> ([f32; 4], bool) {
(
parse_line_color(args.first(), args.get(1), args.get(2)),
@@ -547,8 +443,7 @@ fn parse_line_tail(args: &[Value]) -> ([f32; 4], bool) {
)
}
/// A Lua text argument → the line's string: strings pass through, numbers stringify (Lua
/// coercion), nil/absent is an empty line (`GameTooltip:AddLine()` ships in the corpus).
/// A text argument as a line's string: numbers stringify as Lua does, and nil is empty.
fn text_of(v: Option<&Value>) -> String {
match v {
Some(Value::String(s)) => s.to_str().map(|s| s.to_string()).unwrap_or_default(),
@@ -565,10 +460,9 @@ fn text_of(v: Option<&Value>) -> String {
}
}
/// Append one line (both columns; `right = None` leaves the right cell hidden). Grows the pool,
/// writes the cells, bumps `num_lines` (+ the line's wrap flag), cancels any fade. Does NOT show
/// the tooltip — `AddLine` leaves showing to the caller (the corpus' `AddLine … Show()`),
/// `SetText`/the content builders show themselves.
/// Appends one line and cancels any fade, without showing the tooltip: `AddLine` callers call
/// `Show`. The reference's line core drops a line whose sides are both empty (`0x530270`); this
/// appends it.
pub(super) fn append_line(
lua: &Lua,
this: &Table,
@@ -585,30 +479,14 @@ pub(super) fn append_line(
_ => return Err(mlua::Error::runtime("not a GameTooltip")),
};
write_cell(&mut model, lh, &left.0, left.1);
// A wrap-flagged line PINS its wrap column at write time, so its very first measure ask
// carries the width and comes back WRAPPED (multi-row height) in the same frame. The old
// shape — layout re-pinning after an overflowing single-line measure — needed a second
// round-trip that the bag re-enter loop's per-frame content clear kept wiping out: wrapped
// lines never converged live (the plate under-counted their rows forever — the bread/
// hearthstone spill). One step, no oscillation.
// The wrap pin is written from the LINE's own flag every append — set for a wrap line,
// cleared for a plain one — so a cell that changes role can never keep a stale pin, and
// `clear_content` needs no wipe of its own (which is what used to make this write differ
// every frame). The epoch bump is gated on a REAL change: in the hover re-enter loop the
// rebuilt line pins the same width it already carried, so tier 1 stays clean and the
// whole-UI fingerprint (tier 2) never runs — see `clear_content`'s note.
// The wrap width is pinned at append, so the first measure already comes back wrapped. It is
// written from this line's own flag every time, and layout is touched only on a change.
let pin = wrap.then_some((crate::widget::TOOLTIP_WRAP_WIDTH, 0.0));
if let Some(d) = model.region_data.get_mut(&lh) {
if d.size != pin {
d.size = pin;
// NAMED, not conservative (decision 1388). This writes one region's EXPLICIT SIZE and
// nothing else — no anchor target, no roster membership — which is the exact shape
// 1388 migrated every size setter to. It was missed because it is an internal write
// rather than a `SetWidth` binding, and the miss is expensive in the one place it can
// least afford to be: the pin flips whenever a line changes ROLE between wrapped and
// plain, which is what every hover from one item or spell to a differently-shaped one
// does. A conservative touch there re-derives the whole layout graph — every live
// frame's scale re-synced, every anchored region re-hashed — on each hover.
// A size-only write takes the named touch: a conservative one would re-derive the
// whole layout graph on every hover that turns a line between wrapped and plain.
model.touch_layout_region(lh);
// The wrap pin is the measure key's wrap-width input.
model.touch_measure(lh);
@@ -627,8 +505,7 @@ pub(super) fn append_line(
mod verbs;
pub(super) use verbs::install;
/// The full hide: drop the owner, cancel the fade, clear the content, hide the frame, fire
/// `OnTooltipCleared`. Shared by the `Hide` method and the fade's end-of-ramp.
/// The full hide shared by `Hide`, a `Show` with no owner or no lines, and the end of a fade.
pub(super) fn hide_tooltip(lua: &Lua, h: FrameHandle) {
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -642,14 +519,8 @@ pub(super) fn hide_tooltip(lua: &Lua, h: FrameHandle) {
fire_cleared(lua, h);
}
/// One line cell's layout input: `Some((w, h))` once the cell is shown with text AND the measure
/// round-trip has answered; one floored unit each way for a shown but EMPTY cell (the reference's
/// `AddLine("")` adds no line at all, and its virtual `GetHeight` never reads back `0.0`); `None`
/// for hidden/cleared cells and for PENDING measures. A pending cell contributes nothing — no
/// width, no height, and (in the caller) no gaps — so a fresh tooltip holds its declared default
/// size until real extents land, instead of collapsing to a gaps-only rect (the guard the first
/// cut got wrong: it summed LINE_GAP/DOUBLE_GAP for unmeasured rows, caught by the migration
/// pass).
/// A line cell's measured extent; `None` for a hidden or unmeasured cell, which adds no size and
/// no gap, so a fresh tooltip keeps its declared size until its lines are measured.
type Cell = Option<(f32, f32)>;
fn cell(model: &Model, rh: crate::widget::RegionHandle) -> Cell {
@@ -659,11 +530,8 @@ fn cell(model: &Model, rh: crate::widget::RegionHandle) -> Cell {
return None;
}
if text.is_empty() {
// The same floor the sweep applies to the line's own rect (`layout::FONTSTRING_MIN_SPAN`):
// the reference's `GetHeight 0x772a60` is virtual and ends in a one-unit clamp, so `0.0` is
// not a height any caller — plate arithmetic included — can read back off a FontString.
// Reporting zero here while the chain below the row resolved one unit is the B309 shape at
// 1/14th the size, and the point of one constant is that the two cannot drift apart.
// One unit each way, the floor of the line's own rect: the reference's `GetHeight`
// (`0x772a60`) clamps to one unit, so no FontString reads back 0.
return Some((
super::layout::FONTSTRING_MIN_SPAN,
super::layout::FONTSTRING_MIN_SPAN,
@@ -672,52 +540,13 @@ fn cell(model: &Model, rh: crate::widget::RegionHandle) -> Cell {
d.measured.map(|m| (m.w, m.h))
}
/// The auto-size + right-flush pre-pass, run at the top of every layout resolve (decision 0274):
/// for each live GameTooltip, frame size = max measured line width (a double line is
/// left + gap + right) + 2·pad, floored by `SetMinimumWidth`, × summed line heights + gaps; each
/// visible right column's anchor re-points so its right edge sits at the text inset. Skips
/// frames whose lines haven't been measured yet — the XML default size holds until the extents
/// land. With a [`TextMeasure`](super::TextMeasure) installed that is the SAME `resolve`
/// (`resolve` solves, fills the pending measures inline, and solves again, so this pre-pass runs
/// a second time with real extents); without one it is the host's batch round-trip, one frame
/// later, which is the engine-less path every measurer-free VM still takes.
/// **ANCHOR_CURSOR — mode 6, re-anchored to the live cursor every frame** (`0x530b20`, the
/// `CGameTooltip` override of `vtable+0x38` on `0x808f60`). Nine corpus files ask for it — `pfUI`'s
/// tooltip, xpbar and chat modules, `pfQuest/browser.lua`, `TipBuddy` — and until decision 2176
/// benilla warned and placed them by some other mode.
///
/// The whole of the re-anchor is one `SetPoint`:
///
/// ```text
/// 530b32 cmp [this+0x318],6 ; jne -- every other mode skips
/// 530b3b eax = [this+0xa0] -- the CSimpleTop root == [0xcf0bd8]
/// 530b4f ecx = [eax+0x111c] ; edx = [eax+0x1118] -- the SAVED cursor, normalised [0,1]
/// 530b63 call 0x41ad80 -- x *= G44 (screen w), y *= G48 (screen h)
/// 530b68 fld [this+0x7c] ; fdivr 1.0f -- 1 / the tooltip's own effective scale
/// 530b97 call 0x767c70(BOTTOM=7, [0xcf0bd8], BOTTOMLEFT=6, x/scale, y/scale, doResolve=1)
/// ```
///
/// So **the plate's BOTTOM-centre is pinned to the screen's BOTTOMLEFT plus the cursor's absolute
/// position** — centred horizontally on the cursor and sitting directly above it. Four details are
/// worth keeping because none is guessable from the name:
///
/// * The `relativeTo` is the screen root passed **raw**, not `+0x24` like every other anchor — the
/// `CSimpleTop`'s `CLayoutFrame` base sits at offset 0. Ours is [`SCREEN`](super::SCREEN), which
/// is the same object.
/// * The **divide by the tooltip's own effective scale** is the "cursor box recip": an anchor
/// offset is stored raw and multiplied by the child's scale at resolve time, so dividing here is
/// what makes the result land at the cursor's *absolute* position. Ours does the same divide,
/// against the same scale [`GetWidth`](super::object::eff_scale) reads.
/// * **The `SetOwner`-time offsets `+0x3c4/+0x3c8` do not participate** — 12 reads of them, every
/// one on a modes-0..5 arm of `0x52fe90`.
/// * There is **no `ClearAllPoints` here** (earned zero over the function's complete call set): one
/// anchor slot is overwritten, and `0x767c70`'s own no-op gate — same target, same
/// `relativePoint`, both offsets within 2⁻²² — is what keeps a still cursor free.
///
/// It runs from the per-frame update pump, which a hidden tooltip is not in (`0x76ad9d`
/// deregisters on hide), and in the **same frame** as the placement it produces: the layout drain
/// `0x768ed0` runs at `0x765799`, right after the `+0x38` loop and before the draw. Hence its home
/// here, at the top of the resolve, beside the auto-size pass.
/// `ANCHOR_CURSOR` (mode 6), re-anchored every frame by the update override `0x530b20`: one
/// `SetPoint` (`0x767c70`) pins the plate's BOTTOM to the screen root's BOTTOMLEFT (`0xcf0bd8`)
/// at the cursor position divided by the tooltip's own effective scale, so it sits centred above
/// the cursor. There is no `ClearAllPoints`, and `SetPoint`'s no-op gate keeps a still cursor
/// free. The `SetOwner` offsets play no part (only `0x52fe90` reads them). Only a shown tooltip is
/// in the update pump (`0x76ad9d`), and the placement lands the same frame, before the layout
/// drain (`0x768ed0`).
fn cursor_anchor(model: &mut Model, h: FrameHandle) {
let scale = crate::script::object::eff_scale(model, h);
let (cx, cy) = model.cursor_pos;
@@ -729,7 +558,7 @@ fn cursor_anchor(model: &mut Model, h: FrameHandle) {
cy / scale,
);
let input = model.layout_inputs.entry(h).or_default();
// `0x767c70`'s change-detect, in our own terms: one slot, replaced only on a real change.
// `0x767c70`'s change detect: the one slot is replaced only on a real change.
let same =
input.anchors.len() == 1 && crate::script::object::anchor_bits_eq(&input.anchors[0], &new);
if same {
@@ -741,10 +570,8 @@ fn cursor_anchor(model: &mut Model, h: FrameHandle) {
}
pub(super) fn layout_tooltips(model: &mut Model) {
// The arena's tooltip registry, not the resolve's whole frame roster: this pre-pass runs at
// the top of EVERY resolve, and finding two or three tooltips by scanning ~4000 ids was most
// of what it cost (decision 1634). `frame_to_id` still gates each one — a tooltip outside the
// resolve's roster would mint an id here and take the ledger's conservative branch with it.
// Only tooltips in the resolve's roster: one outside it would mint an id here and take the
// layout ledger's conservative branch.
let tips: Vec<FrameHandle> = model
.arena
.tooltip_kinds()
@@ -753,9 +580,7 @@ pub(super) fn layout_tooltips(model: &mut Model) {
.filter(|h| model.frame_to_id.contains_key(h))
.collect();
for h in tips {
// Mode 6 first, and **before the line gate below**: the reference's per-frame update is
// not gated on content at all, only on the plate being in the pump's list — which is the
// shown list. See [`cursor_anchor`].
// Mode 6 runs before the line gate: the reference re-anchors any shown plate, lines or not.
let cursor_mode = match model.arena.frame(h).map(|f| (&f.kind_state, f.shown)) {
Some((KindState::Tooltip(t), shown)) => shown && t.anchor == TooltipAnchor::Cursor,
_ => false,
@@ -776,13 +601,6 @@ pub(super) fn layout_tooltips(model: &mut Model) {
if num == 0 {
continue;
}
// (Wrap columns are pinned at append time — append_line — so every measure here is
// already the wrapped one; no second round-trip.)
// Per-line metrics next (one read pass), then the size + right-flush writes. Only
// measured cells contribute — a row with no measured cell adds no height and no gap, and
// a tooltip with nothing measured yet is skipped whole (its declared size holds until the
// extents land — this resolve's own `fill_measures` with a measurer installed, the host's
// batch round-trip a frame later without one).
let n = num.min(lefts.len()).min(rights.len());
let rows: Vec<(Cell, Cell)> = (0..n)
.map(|i| (cell(model, lefts[i]), cell(model, rights[i])))
@@ -804,22 +622,20 @@ pub(super) fn layout_tooltips(model: &mut Model) {
maxw = maxw.max(lw + rw);
}
if counted == 0 {
continue; // nothing measured yet — hold the declared size
continue; // nothing measured yet: the declared size holds
}
maxw = maxw.max(min_w);
if maxw <= 0.0 || totalh <= 0.0 {
continue;
}
let input = model.layout_inputs.entry(h).or_default();
// `pad_w` = SetPadding's extra width (ref ItemRefTooltip: room for the close button).
// `pad_w` is `SetPadding`'s extra width (ItemRefTooltip's room for its close button).
input.width = maxw + 2.0 * TOOLTIP_PAD + pad_w;
input.height = totalh + 2.0 * TOOLTIP_PAD;
// This pre-pass runs INSIDE the resolve, so it must not open tier 1 (see the note method's
// own doc) — but the per-node ledger decision 1388's incremental pass seeds from still has
// to hear that these two inputs were written by something other than a Lua setter.
// Inside the resolve, so a note and not a touch; the incremental pass still has to hear
// that these inputs changed.
model.note_layout_frame_write(h);
// Right-flush each double line: right edge = left.right + (maxw − left.width)
// = frame.left + pad + maxw = the text inset's right.
// Right-flush each double line: left.right + (maxw - left.width) is the text inset.
for (i, (l, r)) in rows.iter().enumerate() {
if r.is_none() {
continue;
@@ -839,19 +655,15 @@ pub(super) fn layout_tooltips(model: &mut Model) {
}
}
/// Advance every fading tooltip (called from [`super::UiScript::tick`], the ScrollingMessage/
/// Cooldown pattern): alpha ramps 1→0 over [`TOOLTIP_FADE_SECS`]; at the end the tooltip hides
/// for real (owner dropped, content cleared, `OnTooltipCleared` fired).
/// Advances every fading tooltip: alpha ramps from 1 to 0 over [`TOOLTIP_FADE_SECS`], then the
/// full hide.
pub(super) fn tick_fades(lua: &Lua) {
let t = now(lua);
let mut ramping: Vec<(FrameHandle, f32)> = Vec::new();
let mut finished: Vec<FrameHandle> = Vec::new();
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
// The arena's tooltip registry (1634) — this runs every tick, and the roster it used to
// walk is ~4000 entries at a corpus UI. The `frame_to_id` gate is kept: `hide_tooltip`
// below writes layout, and a frame outside the resolve's roster takes the ledger's
// conservative branch.
// Roster-gated as in `layout_tooltips`, since `hide_tooltip` below writes layout.
for &h in model.arena.tooltip_kinds() {
if !model.frame_to_id.contains_key(&h) {
continue;
@@ -866,7 +678,7 @@ pub(super) fn tick_fades(lua: &Lua) {
continue;
};
if !frame.shown {
finished.push(h); // hidden mid-fade by other means — just tidy the state
finished.push(h); // hidden mid-fade by other means: just tidy the state
continue;
}
let a = 1.0 - ((t - start) / TOOLTIP_FADE_SECS) as f32;
+58 -189
View File
@@ -1,7 +1,4 @@
//! 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.
//! The GameTooltip Lua methods over the parent module's line pool, plus the content builders'.
use mlua::{Lua, Table, Value, Variadic};
@@ -21,49 +18,25 @@ use super::{
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.
// 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.
//
// **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.
// 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 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"; the bytes refute
// both.
// 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)?;
let owner_h = frame_handle_of(lua, &owner)?;
// The gate is `lua_isstring 0x6f3510`, whose whole body is `lua_type` then
// `cmp eax,4 / cmp eax,3` — LUA_TSTRING or LUA_TNUMBER and nothing else — so the
// anchor argument is read the reference's way, by COERCION, and a boolean, table,
// function or userdata is indistinguishable from absent. Taking it as
// `Option<String>` made mlua's converter the gate instead, and mlua raises on a
// table: `Questie`'s `Tooltip:SetOwner(this, this)` (QuestieNotes.lua, the map-note
// hover) died with "bad argument #3: error converting Lua table to String" on a
// call the reference completes silently at mode 0. This function's doc comment has
// stated the law since 2176 — only the signature disagreed.
// 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");
@@ -71,17 +44,9 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
full_alpha(&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).
// 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)
@@ -117,23 +82,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
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).
// 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() => {
@@ -150,18 +102,13 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
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.
// 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 {
// 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.
// 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];
@@ -174,14 +121,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
},
)?,
)?;
// 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.
// 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| {
@@ -198,21 +139,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
}
})?,
)?;
// 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.
// 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| {
@@ -220,9 +150,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
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.
// 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)| {
@@ -232,13 +161,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// 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).
// 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>)| {
@@ -269,27 +195,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
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 the empty case is unconfirmed in the binary.
//
// INFERRED, unconfirmed in the binary: that `0x531e30` targets 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.
// 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)| {
@@ -312,10 +221,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
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.
// 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>)| {
@@ -324,11 +231,9 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
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.
// 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(
@@ -361,19 +266,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"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`; 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.
// 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)| {
@@ -418,31 +312,31 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
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)
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 (the ref's SetTooltipMoney calls it
// with the money row's width so coins never overhang the plate).
// 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 (ref ItemRefTooltip OnLoad: SetPadding(16) keeps the
// corner close button off the text). A frame property — persists across content clears.
// 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() — the world-mouseover loss ramp (alpha 1→0 over TOOLTIP_FADE_SECS, INTERIM
// constant, then a real hide). Fresh content or Show cancels it.
// 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| {
@@ -454,28 +348,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})
})?,
)?;
// 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).
// Show is an EXISTENCE GATE, not a plain show. `0x530a80` — the CGameTooltip override of
// `vtbl+0x88`, which is the slot a Lua `:Show()` lands in (the generic widget Show path
// `0x7a3350` sets `[+0xd0]=1` then calls slot `+0x88`) — shows only when BOTH `+0x314` (the
// owner) and `+0x31c` (the line count) are non-zero; otherwise it calls its own `vtbl+0x84`
// effective-hide `0x530a60`, which is the SetOwner core with a NULL owner, so the plate is
// hidden AND un-owned and OnTooltipCleared fires. Evaluated at Show time only — it is never a
// visibility poll (`0x530a80`).
//
// Without the gate an addon that reaches `Show()` having added no lines leaves a VISIBLE
// empty plate — and, because `layout_tooltips` skips a zero-line plate rather than collapsing
// it, one still wearing the last hover's width and height. That is what `Questie`'s tracker
// does: `QuestieTracker.lua`'s quest-button OnEnter has a dead zone between its two arms (an
// in-progress quest whose objective text IS in its database adds nothing) and then calls
// `Tooltip:Show()` unconditionally. On the reference `0x530a80` swallows it; we drew the
// empty plate.
//
// `show_or_hide_empty` is the same law for the app-answered content asks, and deliberately
// does NOT un-own (its re-enter repaint needs the owner). This path is the reference's own,
// so it takes the reference's full effective-hide.
// 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| {
@@ -503,17 +379,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// The item content channels (BenillaSetItemById/SetBagItem/SetMerchantItem/SetBuybackItem) live in
// their own module — the one shared renderer + the red usable law (decision 0274 P1).
// The content methods: items, spells and auras, talents, units.
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)?;