diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index a8730d8e..67868267 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -2371,9 +2371,11 @@ mod tests { "PaperDollFrame.lua — the second `TRADE_REQUEST` (decision 1764): event 377 is \ registered in 5875 and signalled from NOWHERE (zero fire sites in wow-re's own \ census, `merchant-compare-item-law.md` §8), so this listener is dead code THERE \ - too. benilla fired it from 0283 until 2202 and took it back out — decision 2202; the \ - shopping plates are driven the way the reference's one LIVE caller drives them, \ - `MerchantFrame.xml:63-80`. Producing this would be a divergence, not a fix", + too. benilla fired it from 0283 until 2202, then drove the plates itself on a \ + shift-held hover until 2210; both were supersets. Nothing in this engine seats a \ + shopping plate now — the reference's own callers do (`MerchantFrame.xml:63-80`, \ + the auction rows), which is the whole of the compare in 1.12.1. Producing this \ + event would be a divergence, not a fix", ), ("SYSMSG", "UIErrorsFrame.lua"), ("UNIT_DEFENSE", "PetPaperDollFrame.lua"), diff --git a/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs b/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs index 4405a45d..5b4055b4 100644 --- a/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs +++ b/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs @@ -1,27 +1,21 @@ //! The shopping-compare + chat-link tooltips over the REAL shipped XMLs (decision 0274 P4, -//! re-based by 2202): shift over an item hover seats `ShoppingTooltip1/2` BESIDE the tooltip at -//! `MerchantFrame.xml:63-80`'s own geometry, their armed `SetInventoryItem` renders the byte law's -//! compare shape over the template's own adopted small-font ladder, and `SetItemRef` fills the -//! parked `ItemRefTooltip`. +//! re-based by 2202, scoped back to the reference by 2210): a VENDOR row hover raises +//! `ShoppingTooltip1/2` through the stock `MerchantFrame.xml`, their armed `SetInventoryItem` +//! renders the byte law's compare shape over the template's own adopted small-font ladder, and +//! `SetItemRef` fills the parked `ItemRefTooltip`. //! -//! **Why not the paper-doll listener these tests used to drive.** `SHOW_COMPARE_TOOLTIP` (event -//! 377) has zero fire sites in 5875 — `PaperDollFrame.lua:621-640` is dead code there, and the -//! vendor row is the only live consumer of the shopping plates (wow-re -//! `merchant-compare-item-law.md` §8). benilla fired it anyway until 2202, which is why a compare -//! needed the character window open, landed across the screen from the tooltip it compared to, and -//! could be left stale by a hover whose slot listener never answered. +//! **1.12.1 has no hover compare, and neither does benilla now.** `SHOW_COMPARE_TOOLTIP` (event +//! 377) has zero fire sites in 5875, so `PaperDollFrame.lua:621-640` is dead code there and the +//! vendor row (plus the auction row, 1971) is the only live consumer of the shopping plates +//! (wow-re `merchant-compare-item-law.md` §8). benilla fired that event until 2202 and then drove +//! the plates itself on a shift-held hover until 2210; both were supersets of a client that +//! compares only where its own FrameXML asks. The plates' geometry and lifetime belong to that +//! FrameXML — this engine seats none of them. //! -//! **The bag end of that flow is the REFERENCE's own code since 1751.** The hover source used to be -//! benilla's `BenillaBagSlot_OnEnter` on a `BenillaBagFrame`; it is the reference's -//! `ContainerFrameItemButton_OnEnter` on one of its recycled `ContainerFrame1..12` now. Same two -//! calls arm the compare (`GameTooltip:SetOwner` + `SetBagItem`), so what these tests pin is -//! unchanged — but the handler reads `this`, so they drive the mouse -//! ([`super::test_ui::hover`]) instead of calling it. -//! -//! **The doll end went the same way.** `CharacterFrame.xml` and `PaperDollFrame.xml` are the -//! reference's own too now, so `BenillaPaperDollSlot_OnEnter` is gone and the hover is stock -//! `PaperDollItemSlotButton_OnEnter` (`PaperDollFrame.lua:739-764`), which reads `this` as well. -//! The doll-slot names (`CharacterHeadSlot` and kin) are unchanged; the mouse is what reaches them. +//! **The hover sources are the REFERENCE's own code since 1751**, so these tests drive the MOUSE +//! ([`super::test_ui::hover`]) rather than calling handlers: `MerchantItemButton`'s OnEnter, and +//! stock `PaperDollItemSlotButton_OnEnter` (`PaperDollFrame.lua:739-764`) for the doll slots +//! (`CharacterHeadSlot` and kin — the names are unchanged; the mouse is what reaches them). use benilla_ui::script::{ ContainerSlot, ContainerState, InvSlotView, InventorySlots, ItemTemplateView, UiScript, @@ -187,101 +181,6 @@ fn seed_items(s: &mut UiScript) { ); } -/// Shift over a bag helm: `ShoppingTooltip1` seats at the tooltip's own TOPRIGHT (0, −10), -/// renders gray "Currently Equipped" + the equipped helm through the template's ADOPTED small-font -/// ladder (line 1 = GameFontNormalSmall's 10px face — the engine-created lines of the MAIN tooltip -/// stay on its own faces), the compact cut drops the description, and releasing shift hides the -/// pair. **The character window is irrelevant to all of it** — it is checked both closed and open, -/// because the listener that used to make it matter is dead code in 5875 (2202). -#[test] -fn shift_compare_over_a_bag_item_seats_beside_the_tooltip() { - let _data = benilla_formats::wow_data_or_skip!(); - let mut s = harness_with_bags(); - s.set_unit("player", Some(player())); - seed_items(&mut s); - - // Open the bag and hover the helm slot. The button is ASKED for by its own `GetID()` — the - // reference numbers a window's buttons backwards and recycles the windows (1751), so neither - // the frame name nor the button index is a property to assume. - s.run("MainMenuBarBackpackButton:Click()").unwrap(); - s.take_sounds(); - let btn = bag_slot_button(&s, 0, 1); - hover(&mut s, &btn); - assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors()); - - // Character window CLOSED — and the compare shows anyway, beside the tooltip. - s.set_modifiers(true, false, false); - assert!(s.errors().is_empty(), "compare errors: {:?}", s.errors()); - let ok: bool = s - .eval( - "local p, rel, rp, x, y = ShoppingTooltip1:GetPoint() \ - return ShoppingTooltip1:IsShown() \ - and ShoppingTooltip1TextLeft1:GetText() == \"Currently Equipped\" \ - and ShoppingTooltip1TextLeft2:GetText() == \"Test Helm\" \ - and rel:GetName() == \"GameTooltip\" \ - and p == \"TOPLEFT\" and rp == \"TOPRIGHT\" and x == 0 and y == -10", - ) - .unwrap(); - assert!( - ok, - "the compare plate seats beside the tooltip with the character window closed" - ); - s.set_modifiers(false, false, false); - - // Opening the character window changes nothing about where it lands. - s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap(); - s.take_sounds(); - hover(&mut s, &btn); - s.set_modifiers(true, false, false); - assert!(s.errors().is_empty(), "compare errors: {:?}", s.errors()); - let ok: bool = s - .eval( - "local p, rel = ShoppingTooltip1:GetPoint() \ - return ShoppingTooltip1:IsShown() \ - and ShoppingTooltip1TextLeft2:GetText() == \"Test Helm\" \ - and rel:GetName() == \"GameTooltip\" and p == \"TOPLEFT\"", - ) - .unwrap(); - assert!(ok, "the open character window does not move the plate"); - // The adopted ladder: the shopping plate's line 1 wears GameFontNormalSmall (10px); the - // MAIN tooltip's engine-created line 1 keeps the header face — different sizes. - let ok: bool = s - .eval( - "local _, sh = ShoppingTooltip1TextLeft1:GetFont() \ - local _, mh = GameTooltipTextLeft1:GetFont() \ - return sh == 10 and mh > sh", - ) - .unwrap(); - assert!( - ok, - "the template's small-font ladder rides the compare plate" - ); - // The compact cut: the equipped helm's description never prints on the compare plate. - let ok: bool = s - .eval( - "for i = 1, ShoppingTooltip1:NumLines() do \ - if getglobal(\"ShoppingTooltip1TextLeft\" .. i):GetText() == \"\\\"Snug.\\\"\" then \ - return false \ - end \ - end \ - return true", - ) - .unwrap(); - assert!(ok, "compact cut drops the description"); - // One helm slot → exactly one shopping tooltip. - assert!( - !s.eval::("return ShoppingTooltip2:IsShown()").unwrap(), - "a single-slot item fires one compare" - ); - // Release hides the pair; the bag hover itself stays. - s.set_modifiers(false, false, false); - let ok: bool = s - .eval("return not ShoppingTooltip1:IsShown() and GameTooltip:IsShown()") - .unwrap(); - assert!(ok, "release hides the compare, keeps the hover"); - assert!(s.errors().is_empty(), "errors: {:?}", s.errors()); -} - /// A chat item link through the ref router: `SetItemRef` shows the parked `ItemRefTooltip` /// (BOTTOM +80, ANCHOR_PRESERVE keeps the XML seat) with the linked item's law; the corner /// close button hides it. @@ -317,11 +216,8 @@ fn item_ref_tooltip_renders_a_chat_link() { /// durability pair off the slot view, never the template's authored max/max (ref /// PaperDollItemSlotButton_OnEnter l.741: `SetInventoryItem`, not an id/template render; /// director-caught: broken gear read 100% in the char window while the bag read it right). -/// And shift over the doll slot compares NOTHING — a worn item compared with itself is no -/// comparison: `SetInventoryItem` never arms, and its content clear drops any stale arm left -/// by an earlier bag hover. #[test] -fn doll_hover_renders_the_live_instance_and_never_self_compares() { +fn doll_hover_renders_the_live_instance() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness_with_bags(); s.set_unit("player", Some(player())); @@ -368,16 +264,9 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() { s.take_sounds(); let btn = bag_slot_button(&s, 0, 1); hover(&mut s, &btn); + // The doll slot needs its window open to be hoverable at all. s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap(); s.take_sounds(); - // …and the arm really IS live at this point — asserted, not assumed, because the whole test - // below is a NEGATIVE and would pass just as well if the bag hover had quietly armed nothing. - s.set_modifiers(true, false, false); - assert!( - s.eval::("return ShoppingTooltip1:IsShown()").unwrap(), - "fixture: the bag hover armed a compare, so the doll hover has something to clear" - ); - s.set_modifiers(false, false, false); // The doll hover: the live pair, not the template's 40/40. Stock // `PaperDollItemSlotButton_OnEnter` reads `this`, so the mouse is what reaches it — and moving @@ -398,165 +287,100 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() { "the doll hover carries the instance's live pair" ); - // Shift over the worn item: no compare — not even off the bag hover's stale arm. - s.set_modifiers(true, false, false); - assert!( - !s.eval::("return ShoppingTooltip1:IsShown()").unwrap(), - "a worn item never compares with itself" - ); - s.set_modifiers(false, false, false); assert!(s.errors().is_empty(), "errors: {:?}", s.errors()); } -/// A **quest reward** row, which is where the director found this: shift over an equippable -/// choice shows what is worn in that slot, beside the tooltip, with no character window open — -/// and a reward whose slot is EMPTY shows nothing at all rather than the plate an earlier hover -/// left behind. +/// **The compare 1.12.1 actually has**, end to end over the stock file: hovering a vendor row +/// raises `ShoppingTooltip1/2` against what is worn, headed by the gray `Currently Equipped`. /// -/// That last clause is the reported symptom, reproduced before it was fixed: a Back-slot cape -/// hover painted `Currently Equipped / Rustmetal Bracers` — a *wrist* plate from an earlier -/// compare — sitting over the tooltip. It survived because the old drive fired -/// `SHOW_COMPARE_TOOLTIP` and left the plate's whole lifecycle to a paper-doll listener that -/// answers only while the character window is visible: with the window closed, nothing hid it and -/// nothing refilled it. The engine owns both plates on every drive now, so the miss path hides. +/// `MerchantFrame.xml`'s `MerchantItemButton` OnEnter is the whole law and this exercises it as +/// written — `SetMerchantCompareItem` as the predicate, `SetOwner(GameTooltip, "ANCHOR_NONE")`, +/// `TOPLEFT`/`GameTooltip` `TOPRIGHT` (0, −10), the same call again, `Show()`; plate 2 the same +/// off plate 1 at (0, 0). The engine supplies only the two bindings and the compare render; the +/// geometry, the second fill and both `Show`s are the reference's Lua. Two worn rings against a +/// ring on the shelf, so BOTH plates answer — the case that pins the offset walk. #[test] -fn shift_compare_over_a_quest_reward_never_shows_a_stale_plate() { +fn shipped_merchant_row_raises_the_compare_plates() { let _data = benilla_formats::wow_data_or_skip!(); - let mut s = quest_harness(); - - // Worn: bracers on the WRIST slot. The BACK slot is empty. + let mut s = harness(); + // Two worn rings, a third on the shelf: offset 1 and 2 both find a candidate. let mut inv: InventorySlots = Default::default(); - inv[9] = Some(worn_item(7000, "Rustmetal Bracers")); + for (slot, id, name) in [(11usize, 7000u32, "Old Loop"), (12, 7001, "Older Loop")] { + inv[slot] = Some(InvSlotView { + item_id: id, + count: 1, + quality: 1, + name: Some(name.into()), + ..Default::default() + }); + s.set_item_template(id, armor_template(name, 11)); + } s.set_inventory_slots(inv); - s.set_item_template(7000, armor_template("Rustmetal Bracers", 9)); - s.set_item_template(8000, armor_template("Short Duskbat Cape", 16)); - s.set_item_template(8001, armor_template("Other Bracers", 9)); + s.set_item_template(8000, armor_template("Shiny Loop", 11)); + s.set_merchant(Some(benilla_ui::script::MerchantState { + items: vec![benilla_ui::script::MerchantItem { + name: Some("Shiny Loop".into()), + texture: Some("Interface\\Icons\\INV_Jewelry_Ring_03".into()), + price: 100, + quantity: 1, + num_available: -1, + item_id: 8000, + max_stack: Some(1), + ..Default::default() + }], + ..Default::default() + })); + s.fire_event("MERCHANT_SHOW", vec![]); + s.take_sounds(); - // A wrist compare first — the plate this hover must not inherit. Rendered straight onto the - // main tooltip, because what made the stale plate stick was the tooltip never HIDING between - // the two renders (a mouse move between buttons hides it and would mask the bug). - s.run(r#"GameTooltip:SetOwner(UIParent, "ANCHOR_NONE") GameTooltip:BenillaSetItemById(8001)"#) - .unwrap(); - s.set_modifiers(true, false, false); + // The mouse, not the handler: `MerchantItemButton`'s OnEnter reads `this`. + hover(&mut s, "MerchantItem1ItemButton"); + assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors()); let ok: bool = s .eval( - "return ShoppingTooltip1:IsShown() \ - and ShoppingTooltip1TextLeft2:GetText() == \"Rustmetal Bracers\"", - ) - .unwrap(); - assert!(ok, "fixture: the wrist compare really is on screen"); - - // Now the cape, still holding shift, without the tooltip hiding in between: the back slot is - // empty, so the plate goes away instead of riding along. - s.run(r#"GameTooltip:SetOwner(UIParent, "ANCHOR_NONE") GameTooltip:BenillaSetItemById(8000)"#) - .unwrap(); - assert!( - !s.eval::("return ShoppingTooltip1:IsShown()").unwrap(), - "an empty slot shows no compare, and never the previous hover's plate" - ); - - // And with something worn on the back, the real quest row compares against it — the mouse on - // the stock reward button, no character window open. - s.set_modifiers(false, false, false); - let mut inv: InventorySlots = Default::default(); - inv[9] = Some(worn_item(7000, "Rustmetal Bracers")); - inv[15] = Some(worn_item(7001, "Old Cloak")); - s.set_inventory_slots(inv); - s.set_item_template(7001, armor_template("Old Cloak", 16)); - s.fire_event("QUEST_COMPLETE", vec![]); - hover(&mut s, "QuestRewardItem1"); - s.set_modifiers(true, false, false); - assert!(s.errors().is_empty(), "compare errors: {:?}", s.errors()); - let ok: bool = s - .eval( - "local p, rel, rp, x, y = ShoppingTooltip1:GetPoint() \ - return not CharacterFrame:IsVisible() \ - and GameTooltipTextLeft1:GetText() == \"Short Duskbat Cape\" \ - and ShoppingTooltip1:IsShown() \ + "local p1, r1, rp1, x1, y1 = ShoppingTooltip1:GetPoint() \ + local p2, r2, rp2, x2, y2 = ShoppingTooltip2:GetPoint() \ + return GameTooltipTextLeft1:GetText() == \"Shiny Loop\" \ + and ShoppingTooltip1:IsShown() and ShoppingTooltip2:IsShown() \ and ShoppingTooltip1TextLeft1:GetText() == \"Currently Equipped\" \ - and ShoppingTooltip1TextLeft2:GetText() == \"Old Cloak\" \ - and rel:GetName() == \"GameTooltip\" \ - and p == \"TOPLEFT\" and rp == \"TOPRIGHT\" and x == 0 and y == -10", + and ShoppingTooltip1TextLeft2:GetText() == \"Old Loop\" \ + and ShoppingTooltip2TextLeft2:GetText() == \"Older Loop\" \ + and p1 == \"TOPLEFT\" and r1:GetName() == \"GameTooltip\" \ + and rp1 == \"TOPRIGHT\" and x1 == 0 and y1 == -10 \ + and p2 == \"TOPLEFT\" and r2:GetName() == \"ShoppingTooltip1\" \ + and rp2 == \"TOPRIGHT\" and x2 == 0 and y2 == 0", ) .unwrap(); assert!( ok, - "the quest reward compares against the worn cloak, beside its own tooltip" + "the vendor row raises both plates at the stock geometry" ); - assert!( - !s.eval::("return ShoppingTooltip2:IsShown()").unwrap(), - "one candidate slot fills one plate" - ); - - // Release hides it; the reward hover itself stays. - s.set_modifiers(false, false, false); + // The plate wears the template's own small-font ladder (10px), the main tooltip its header face. let ok: bool = s - .eval("return not ShoppingTooltip1:IsShown() and GameTooltip:IsShown()") + .eval( + "local _, sh = ShoppingTooltip1TextLeft1:GetFont() \ + local _, mh = GameTooltipTextLeft1:GetFont() \ + return sh == 10 and mh > sh", + ) .unwrap(); - assert!(ok, "release hides the compare, keeps the hover"); + assert!( + ok, + "the template's small-font ladder rides the compare plate" + ); + // Leaving the row hides the tooltip, and the stock OnHide takes both plates with it. + hover(&mut s, "UIParent"); + let ok: bool = s + .eval( + "return not GameTooltip:IsShown() \ + and not ShoppingTooltip1:IsShown() and not ShoppingTooltip2:IsShown()", + ) + .unwrap(); + assert!(ok, "the plates leave with the tooltip that owns them"); assert!(s.errors().is_empty(), "errors: {:?}", s.errors()); } -/// [`harness`] plus the questgiver window, showing a one-choice reward panel. -fn quest_harness() -> UiScript { - let mut s = UiScript::new().unwrap(); - s.set_screen_size(1024.0, 768.0); - let mut seen = Vec::new(); - load_once(&s, &mut seen, CHARACTER_UI); - load_once(&s, &mut seen, &ROUTER_UI); - load_once( - &s, - &mut seen, - &[ - "Interface\\FrameXML\\QuestFrame.xml", - "Interface\\FrameXML\\QuestLogFrame.xml", - ], - ); - s.set_money(0); - s.set_unit("player", Some(player())); - s.set_quest(Some(benilla_ui::script::QuestState { - panel: benilla_ui::script::QuestPanel::Reward, - title: "A Threat Within".into(), - body: "Here — have one of the better items I've found.".into(), - choices: vec![benilla_ui::script::QuestItemView { - name: Some("Short Duskbat Cape".into()), - texture: Some("Interface\\Icons\\INV_Misc_Cape_01".into()), - count: 1, - quality: 1, - item_id: 8000, - usable: true, - link: Some("|cffffffff|Hitem:8000:0:0:0|h[Short Duskbat Cape]|h|r".into()), - }], - ..Default::default() - })); - s.fire_event("QUEST_COMPLETE", vec![]); - s -} - -/// One worn armour piece, template-backed by [`armor_template`]. -fn worn_item(item_id: u32, name: &str) -> InvSlotView { - InvSlotView { - duration_ms: None, - already_bound: false, - bar_placeable: true, - durability: None, - flags: 0, - item_id, - icon: None, - count: 1, - contents_count: None, - quality: 1, - name: Some(name.into()), - link: Some(format!("|cffffffff|Hitem:{item_id}:0:0:0|h[{name}]|h|r")), - locked: false, - equip_slots: Vec::new(), - creator: None, - enchants: Vec::new(), - } -} - -/// An armour template in one `InventoryType`. The CLASS matters: the selection law only compares -/// a worn item of the same item class (wow-re `merchant-compare-item-law.md` §3). +/// An armour template in one `InventoryType` — the CLASS is what the selection law compares by +/// (wow-re `merchant-compare-item-law.md` §3). fn armor_template(name: &str, inventory_type: u32) -> ItemTemplateView { ItemTemplateView { name: name.into(), diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index f779d786..2b462226 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -748,9 +748,10 @@ impl Loader<'_> { /// Materialize one (already template-expanded) frame element into a live frame, then recurse its /// nested `` and fire its `OnLoad` **after** them (bottom-up, rf26). /// - /// `parent` is the enclosing frame's wrapper (`None` at top level). `parent_name` is the - /// already-resolved name of the nearest named ancestor (or `"Top"`), used to substitute `$parent` - /// in this frame's name and in its anchors' `relativeTo` (rf27). + /// `parent` is the **lexically** enclosing frame's wrapper (`None` at top level) and + /// `parent_name` its already-resolved name (or `"Top"`). A `parent=` attribute overrides both, + /// and it is the *effective* parent that `$parent` substitutes against — in this frame's name + /// and in its anchors' `relativeTo` (rf27). /// Returns the created frame's wrapper — `None` when `CreateFrame` refused (an unknown frame /// type, rf24 `0x6ee280`'s factory-table miss), which also skips the whole subtree. The /// `` pass is the one caller that needs the handle back. @@ -760,14 +761,10 @@ impl Loader<'_> { parent: Option<&Table>, parent_name: &str, ) -> Option { - // ─ Step 1 and ONLY step 1 lives here: resolve the name and call CreateFrame. Everything - // after it is `decorate`, because the runtime template path enters at exactly that seam - // (`apply_template`) — its frame already exists, and re-entering here would recurse. - // Name (with $parent substitution) — the resolved name is what CreateFrame publishes and what - // this frame's own children substitute `$parent` against. - let resolved_name: Option = el - .name() - .map(|raw| framexml::resolve_name(raw, parent_name)); + // ─ Step 1 and ONLY step 1 lives here: resolve the parent, resolve the name against it, + // and call CreateFrame. Everything after it is `decorate`, because the runtime template + // path enters at exactly that seam (`apply_template`) — its frame already exists, and + // re-entering here would recurse. // 0 · **The type lookup, and its XML miss LOGS and skips the node** (decision 2191). // @@ -800,39 +797,98 @@ impl Loader<'_> { // // The attribute WINS over the lexical parent when both are present, which is the // reference's rule and is what lets an addon nest an element for authoring - // convenience and still attach it elsewhere. An unresolvable name warns and falls back - // to the lexical parent rather than erroring — the frame is real and usable either - // way, and 0068's log-and-continue is the house rule for a name lookup. - let attr_parent: Option
= el.attr("parent").and_then(|raw| { - let name = framexml::resolve_name(raw, parent_name); - // A FRAME, not merely a global of that name. `_G` is one namespace: an addon's own - // `MyAddon = {}` sits in it beside every frame, and the corpus really does write - // `parent="TheoryCraft"` where the addon owns that name. Handing a plain table to - // `CreateFrame` would raise and take the element's whole subtree with it — a name - // collision costing a window. The identity test is RF-0023's own: a frame wrapper - // carries its handle at `T[0]` as lightuserdata, and nothing else does. - let hit = self - .lua() - .globals() - .get::
(name.as_str()) - .ok() - .filter(|t| matches!(t.raw_get::(0), Ok(Value::LightUserData(_)))); - if hit.is_none() { - self.report.warnings.push(format!( - "{}: parent=\"{name}\" names no frame — falling back to the enclosing one", - resolved_name.as_deref().unwrap_or(&el.tag) - )); - } - hit - }); - // `$parent` in THIS element's own anchors means its parent — so when the attribute supplied - // one, that is the name to substitute against (rf27's rule, applied to rf27's other input). - let attr_parent_name = attr_parent.as_ref().and_then(|_| { + // convenience and still attach it elsewhere. + // + // **The attribute is NOT `$parent`-expanded.** `0x6ee280` hands the raw string to the + // by-name resolver `0x76c760` **directly** at `0x6ee3e8`, bypassing the expander that + // `name=` and `relativeTo=` reach through `SetName`/`0x76c700` (rf27 §2 and + // `name-string-widget-resolution.md` §9, both VERIFIED). Nothing in ~1100 corpus XML + // files writes `parent="$parent…"`, so expanding it was a dead deviation — but a dead + // deviation on this line is exactly what made 2208's live one hard to see. + // + // **Three outcomes, not two** (decision 2213, wow-re `xml-parent-attach-order.md`): + // the slot `[ebp-0x8]` is seeded at `0x6ee28b` with the incoming default parent, and + // `0x6ee3ef mov [ebp-0x8],eax` writes the lookup's result back **unconditionally** — + // so a *miss* stores 0 over that seed and the frame is constructed **parentless** + // (`0x6ee408 mov ecx,[ebp-0x8]`), after logging `0x8710f0`. It does NOT fall back to + // the enclosing frame, which is what we used to do; the divergence is invisible at + // top level (there is no enclosing frame to fall back to) and only shows on a nested + // element naming a parent that is not loaded yet. And an **empty** `parent=""` + // short-circuits earlier still, at `0x6ee3c7`: the default parent is kept, silently, + // and the `inherits` chain is not consulted for one — only an *absent* attribute + // walks it (which for us happens in template expansion, upstream of here). + // + // `None` = no attribute, or an empty one: keep the enclosing parent. + // `Some(None)` = named a parent that does not resolve: parentless, and logged. + let attr_parent: Option> = el.attr("parent") - .map(|raw| framexml::resolve_name(raw, parent_name)) - }); + .filter(|name| !name.is_empty()) + .map(|name| { + // A FRAME, not merely a global of that name. `_G` is one namespace: an addon's own + // `MyAddon = {}` sits in it beside every frame, and the corpus really does write + // `parent="TheoryCraft"` where the addon owns that name. Handing a plain table to + // `CreateFrame` would raise and take the element's whole subtree with it — a name + // collision costing a window. The identity test is RF-0023's own: a frame wrapper + // carries its handle at `T[0]` as lightuserdata, and nothing else does. The + // reference's own `0x76c760` reads the frame registry, so a non-frame global of + // the right name is the same miss, with the same message. + let hit = + self.lua().globals().get::
(name).ok().filter(|t| { + matches!(t.raw_get::(0), Ok(Value::LightUserData(_))) + }); + if hit.is_none() { + // The reference's own wording (`0x8710f0`), like the frame-type miss above. + self.report + .warnings + .push(format!("Couldn't find frame parent: {name}")); + } + hit + }); + let parent = match &attr_parent { + Some(resolved) => resolved.as_ref(), + None => parent, + }; + + // 1b · **The name, resolved AFTER the parent — and against it** (B387). `$parent` is not a + // lexical token: `SetName 0x76c650` expands it in `0x76c5b0` by walking the frame's + // **actual** parent chain (`this+0x9c`) to the nearest non-empty name, seeded `"Top"` + // (rf27, VERIFIED). And in `Instantiate 0x6ee280` the parent is attached *first* — + // resolved at `0x6ee3e8` into `[ebp-0x8]` and passed as `ecx` to the factory's + // constructor at `0x6ee408 call [ebx+0x18]` — while the node-apply step that reads + // `name=` and calls `SetName` runs only afterwards, at `0x6ee4d6 call [edx+0x20]` + // (read off `system/ui/scratch/disasm-full.txt`, the whole body linear between the + // two). So a **top-level** element whose name leans on `$parent` and whose parent + // comes from the attribute resolves against that attribute's frame. + // + // We did it the other way round: the name was substituted against the *lexical* + // ancestor — `"Top"` for a top-level element — and never recomputed once the + // attribute rebound the parent. ClassIcons' `` was therefore + // published as `TopClassIcon`, and the addon's `PlayerFrameClassIcon:SetPoint` at + // `ClassIcons.lua:228` indexed a nil. + // + // The base name comes from the parent FRAME, not from the attribute text — the + // reference reads `p->GetName()`, so a `parent="playerframe"` that resolves + // case-insensitively still yields `PlayerFrame…`; and a parent that is itself + // nameless walks on up, which is what [`Self::frame_names`] already does for the + // runtime template path. + // A parent the attribute nulled leaves the chain walk with nothing to find, so the + // seed survives and `$parent` is `"Top"` — the same answer, by the same route, as a + // top-level element with no parent at all. + let attr_parent_name: Option = + attr_parent.as_ref().map(|resolved| match resolved { + Some(p) => { + let (own, ancestor) = self.frame_names(p); + own.filter(|n| !n.is_empty()).unwrap_or(ancestor) + } + None => framexml::DEFAULT_PARENT_NAME.to_string(), + }); let parent_name = attr_parent_name.as_deref().unwrap_or(parent_name); - let parent = attr_parent.as_ref().or(parent); + // The resolved name is what CreateFrame publishes and what this frame's own children + // substitute `$parent` against. + let resolved_name: Option = el + .name() + .map(|raw| framexml::resolve_name(raw, parent_name)); // 1 · CreateFrame(kind = element tag, name, parent). The type is already known to resolve // (step 0), so an error here is something else the call raised — record it and skip diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index bfd19511..c7a411fc 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -177,6 +177,155 @@ mod loader_tests { ); } + /// **A `parent=` that resolves to nothing leaves the frame PARENTLESS** — it does not fall back + /// to the enclosing frame (decision 2213). `0x6ee280` seeds `[ebp-0x8]` with the incoming + /// default parent and then writes the lookup's result back **unconditionally** at + /// `0x6ee3ef`, so a miss stores 0 over the seed and `0x6ee408` constructs with `ecx = 0` + /// (wow-re `xml-parent-attach-order.md`, VERIFIED). And an **empty** `parent=""` short-circuits + /// at `0x6ee3c7` before any of that: the default parent is kept, silently. + /// + /// Only the nested case can tell these apart — at top level there is no enclosing frame to + /// fall back to, which is why + /// [`Self::a_top_level_parent_attribute_attaches_and_anchors`] passed either way. + #[test] + fn a_parent_attribute_that_misses_leaves_the_frame_parentless() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(800.0, 600.0); + let doc = parse( + r#" + + + + + + + + + + + + + + + "#, + ); + let report = load(&s, &doc, &no_files); + assert!(report.errors.is_empty(), "{:?}", report.errors); + s.resolve(); + + // The miss: parentless, NOT re-attached to Enclosing. + assert!(s.eval::("return TopMissed ~= nil").unwrap()); + assert!( + s.eval::("return TopMissed:GetParent() == nil") + .unwrap(), + "a parent= that names nothing nulls the parent; it does not fall back" + ); + // …and with no parent, the `$parent` chain walk finds nothing, so the "Top" seed survives + // (rf27 §5) — hence `TopMissed`, not `EnclosingMissed`. + assert!(s.eval::("return EnclosingMissed == nil").unwrap()); + assert!( + report + .warnings + .iter() + .any(|w| w == "Couldn't find frame parent: NotLoadedYet"), + "logged in the reference's own words (0x8710f0): {:?}", + report.warnings + ); + + // The empty attribute: the enclosing frame is kept, and nothing is logged. + assert_eq!( + s.eval::("return EnclosingEmpty:GetParent():GetName()") + .unwrap(), + "Enclosing", + "parent=\"\" keeps the enclosing parent" + ); + assert!( + !report.warnings.iter().any(|w| w.contains("Empty")), + "and says nothing about it: {:?}", + report.warnings + ); + } + + /// **A top-level `name="$parent…"` resolves against the `parent=` attribute** (B387) — the + /// reference attaches the parent *first* (`0x6ee280` resolves it at `0x6ee3e8` and passes it to + /// the constructor at `0x6ee408`) and only then runs the node-apply step that reads `name=` and + /// calls `SetName` (`0x6ee4d6`), whose expander walks the frame's **actual** parent chain + /// (rf27 `0x76c5b0`). We resolved the name first, against the lexical ancestor, so ClassIcons' + /// `` was published as `TopClassIcon` and + /// every `PlayerFrameClassIcon` lookup in the addon indexed a nil. + /// + /// Four claims: the name takes the attribute parent's name; that frame's own children compose + /// off the corrected name; a top-level `$parent` with no attribute still falls to the `"Top"` + /// seed; and the `parent=` attribute itself is NOT expanded (`0x6ee3e8` calls the by-name + /// resolver direct, bypassing `0x76c5b0` — rf27 §2). + #[test] + fn a_dollar_parent_name_resolves_against_the_parent_attribute() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(800.0, 600.0); + let doc = parse( + r#" + + + + + + + + + + + + + + + + + + + + + + + "#, + ); + let report = load(&s, &doc, &no_files); + assert!(report.errors.is_empty(), "{:?}", report.errors); + s.resolve(); + + assert!( + s.eval::("return PlayerFrameClassIcon ~= nil") + .unwrap(), + "the top-level $parent name took the parent= frame's name, not the lexical \"Top\"" + ); + assert!(s.eval::("return TopClassIcon == nil").unwrap()); + assert_eq!( + s.eval::("return PlayerFrameClassIcon:GetParent():GetName()") + .unwrap(), + "PlayerFrame", + "and it is really attached there" + ); + assert!( + s.eval::("return PlayerFrameClassIconDot ~= nil") + .unwrap(), + "a nested child composes off the CORRECTED name" + ); + + // No attribute, no lexical parent: the expander's `"Top"` seed survives (rf27 §5). + assert!(s.eval::("return TopLoose ~= nil").unwrap()); + + // `parent="$parentPlayerFrame"` is taken LITERALLY — `0x6ee3e8` hands the raw string to + // `0x76c760`, so no frame of that name exists and the element falls back (and warns). + assert!(s.eval::("return Literal:GetParent() == nil").unwrap()); + assert!( + report + .warnings + .iter() + .any(|w| w.contains("$parentPlayerFrame")), + "the unexpanded attribute is named verbatim in the warning: {:?}", + report.warnings + ); + } + /// End-to-end: a virtual template, an instance inheriting it (with ``, screen ``, /// a `` coloured ``, a nested child `` in ``, and `` /// handlers on both) — proving name publication, bottom-up OnLoad, `$parent`, and that diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 66ad634d..bae5ea92 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -831,18 +831,8 @@ impl UiScript { /// a click handler's modifier fork (the reference's shift-split / ctrl-dressup / /// shift-pickup) reads the state as of the click. pub fn set_modifiers(&mut self, shift: bool, ctrl: bool, alt: bool) { - let shift_was = { - let mut model = self.model_mut(); - let was = model.modifiers.0; - model.modifiers = (shift, ctrl, alt); - was - }; - // The shift EDGE drives the shopping-compare tooltips (0274 P4, re-based by 2202): press - // over a live equippable item hover seats the plates beside the tooltip, release hides - // them. - if shift_was != shift { - tooltip_item::on_shift_edge(&self.lua, shift); - } + let mut model = self.model_mut(); + model.modifiers = (shift, ctrl, alt); } /// Push the player's WMO-containment state onto every Minimap widget (the client's `0xceaa60`). diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs index 66ccbbe9..0ed2edc2 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs @@ -1,4 +1,4 @@ -//! The Set* entry points: the shift-compare seats and their CURRENTLY_EQUIPPED shape, +//! The Set* entry points: the vendor compare's CURRENTLY_EQUIPPED shape, //! SetInventoryItem outside compare, and SetHyperlink's item-link filter. use std::collections::HashMap; @@ -6,14 +6,17 @@ use std::collections::HashMap; use super::script; use crate::script::*; -/// The shopping-compare pipeline end-to-end (0274 P4, re-based by 2202): a bag-ring hover on the -/// main GameTooltip with shift held seats ShoppingTooltip1/2 **beside the tooltip**, at -/// `MerchantFrame.xml:63-80`'s own geometry, and their ARMED render carries the byte law's compare -/// shape — the gray CURRENTLY_EQUIPPED header, WHITE name, the compact cut (the description never -/// prints). Two finger slots fill both plates. Releasing shift hides the pair; nothing shows until -/// the rising edge, and no `SHOW_COMPARE_TOOLTIP` is ever fired (that event is dead in 5875). +/// The compare SHAPE, driven the only way 1.12.1 drives it: the vendor row's +/// `SetMerchantCompareItem` (`MerchantFrame.xml:63-80`). The armed render carries the byte law's +/// compare mode — the gray CURRENTLY_EQUIPPED header, the name WHITE instead of quality-colored, +/// and the compact cut at `0x52e14c` (the description never prints). +/// +/// **And the negative control that keeps this engine out of it** (2210): a bag hover seats +/// nothing, with shift or without, because the reference has no hover compare at all — +/// `SHOW_COMPARE_TOOLTIP` has zero fire sites in 5875, so nothing may reach a listener for it +/// either. The plates belong to whatever FrameXML raises them, and this engine raises none. #[test] -fn shift_compare_seats_beside_the_tooltip_and_renders_the_compare_shape() { +fn merchant_compare_renders_the_compare_shape_and_no_hover_seats_a_plate() { let mut s = script(); s.set_screen_size(800.0, 600.0); let mut inv: InventorySlots = Default::default(); @@ -91,9 +94,11 @@ fn shift_compare_seats_beside_the_tooltip_and_renders_the_compare_shape() { slots, }), ); + // The plates are ordinary GameTooltip frames the FrameXML owns — the engine knows nothing + // about them (`ShoppingTooltip` does not occur in the image at all). s.run( r#" - local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) + local a = CreateFrame("Button", "Slot"); a:SetPoint("LEFT", 0, 0); a:SetWidth(10); a:SetHeight(10) -- CreateFrame'd frames start SHOWN; the shipped XML instances are hidden="true". CreateFrame("GameTooltip", "GameTooltip"):Hide() CreateFrame("GameTooltip", "ShoppingTooltip1"):Hide() @@ -110,37 +115,55 @@ fn shift_compare_seats_beside_the_tooltip_and_renders_the_compare_shape() { "#, ) .unwrap(); - // Shift up: the hover renders the main tooltip, no compare fires. + + // ── A hover is not a compare, with shift or without ──────────────────────────────────── s.run( r#" GameTooltip:SetOwner(Slot, "ANCHOR_RIGHT") GameTooltip:SetBagItem(0, 1) - assert(table.getn(compare_calls) == 0, "the dead event never fires") - assert(not ShoppingTooltip1:IsShown(), "no compare while shift is up") + assert(not ShoppingTooltip1:IsShown(), "a bag hover seats no plate") "#, ) .unwrap(); - // The rising edge fires both ring slots in order. s.set_modifiers(true, false, false); s.run( r#" - assert(table.getn(compare_calls) == 0, "the dead event still never fires") - assert(ShoppingTooltip1:IsShown() and ShoppingTooltip2:IsShown()) + assert(table.getn(compare_calls) == 0, "the dead event never fires") + assert(not ShoppingTooltip1:IsShown() and not ShoppingTooltip2:IsShown(), + "shift over a hover seats no plate either — 1.12 has no hover compare") + assert(GameTooltip:IsShown(), "and the hover itself is untouched") + "#, + ) + .unwrap(); + s.set_modifiers(false, false, false); + + // ── The vendor row, which is where the compare actually lives ────────────────────────── + s.set_merchant(Some(MerchantState { + items: vec![MerchantItem { + name: Some("New Loop".into()), + item_id: 7002, + ..Default::default() + }], + ..Default::default() + })); + s.run( + r#" + -- MerchantFrame.xml:67-78, in miniature: fill as the predicate, seat, fill again, show. + assert(ShoppingTooltip1:SetMerchantCompareItem(1, 1), "the worn ring is a candidate") + ShoppingTooltip1:SetOwner(GameTooltip, "ANCHOR_NONE") + ShoppingTooltip1:ClearAllPoints() + ShoppingTooltip1:SetPoint("TOPLEFT", "GameTooltip", "TOPRIGHT", 0, -10) + ShoppingTooltip1:SetMerchantCompareItem(1, 1) + ShoppingTooltip1:Show() + assert(ShoppingTooltip1:IsShown()) assert(ShoppingTooltip1TextLeft1:GetText() == "[CURRENTLY_EQUIPPED]") assert(ShoppingTooltip1TextLeft2:GetText() == "Old Loop") - assert(ShoppingTooltip2TextLeft2:GetText() == "Older Loop") -- The compact cut at 0x52e14c: the description never prints on a compare. for i = 1, ShoppingTooltip1:NumLines() do assert(getglobal("ShoppingTooltip1TextLeft" .. i):GetText() ~= "\"Round.\"", "compact cut dropped the description") end - -- The pair rides BESIDE the tooltip, at MerchantFrame.xml:68-78's own seats. - local p1, r1, rp1, x1, y1 = ShoppingTooltip1:GetPoint() - local p2, r2, rp2, x2, y2 = ShoppingTooltip2:GetPoint() - assert(p1 == "TOPLEFT" and r1:GetName() == "GameTooltip" and rp1 == "TOPRIGHT" - and x1 == 0 and y1 == -10, "plate 1 hangs off the tooltip's TOPRIGHT") - assert(p2 == "TOPLEFT" and r2:GetName() == "ShoppingTooltip1" and rp2 == "TOPRIGHT" - and x2 == 0 and y2 == 0, "plate 2 hangs off plate 1") + assert(table.getn(compare_calls) == 0, "and still no dead event") "#, ) .unwrap(); @@ -167,44 +190,6 @@ fn shift_compare_seats_beside_the_tooltip_and_renders_the_compare_shape() { ); let name = color_of("Old Loop"); assert_eq!(name, [1.0, 1.0, 1.0, 1.0], "compare name is WHITE"); - // A ONE-slot hover after a two-slot one takes plate 2 down with it — the selection is - // monotone, so plate 2's miss is the only thing that can clear what the rings left there. - s.set_item_template( - 7003, - ItemTemplateView { - name: "New Band".into(), - quality: 2, - inventory_type: 2, // neck: one candidate slot, and nothing worn in it - ..Default::default() - }, - ); - s.run( - r#" - GameTooltip:SetOwner(Slot, "ANCHOR_RIGHT") - GameTooltip:BenillaSetItemById(7003) - assert(not ShoppingTooltip1:IsShown(), "an empty neck slot shows no plate") - assert(not ShoppingTooltip2:IsShown(), "and plate 2 does not survive the ring hover") - "#, - ) - .unwrap(); - // Back to the rings, so the release assertions below have a live pair to take down. - s.run( - r#" - GameTooltip:SetOwner(Slot, "ANCHOR_RIGHT") - GameTooltip:SetBagItem(0, 1) - assert(ShoppingTooltip1:IsShown() and ShoppingTooltip2:IsShown()) - "#, - ) - .unwrap(); - // Releasing shift hides the pair; the main tooltip stays. - s.set_modifiers(false, false, false); - s.run( - r#" - assert(not ShoppingTooltip1:IsShown() and not ShoppingTooltip2:IsShown(), "release hides") - assert(GameTooltip:IsShown(), "the item hover itself stays") - "#, - ) - .unwrap(); assert!(s.take_errors().is_empty()); } diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index 46f199e0..a36de89c 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -327,10 +327,6 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) { t.min_width = 0.0; t.unit_token = None; t.world_owned = false; - // Content-scoped like the rest; `compare_armed` and `padding` survive on purpose — - // the arm spans FrameXML's SetOwner (kinds.rs has the seam note), the padding is a - // frame property (ref: set once in OnLoad). - t.compare_offer = None; } // 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). diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index dfde745c..0e75aca3 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -14,15 +14,17 @@ //! Residual INTERIMs cited inline: the dual-wield/off-hand proficiency exception //! (`0x5eab70`), the type cell's override red, the set-owned count source. //! -//! **Compare mode** (0274 P4, re-based by 2202): `SetInventoryItem` on an ARMED shopping tooltip -//! renders the byte law's compare shape — gray "Currently Equipped" first (`[arg+0x18]≠0`), the -//! NAME white instead of quality-colored, and the compact early-return at `0x52e14c` -//! (`[arg+0x14]≠0`): nothing after the charges/cooldown block. **What seats the plates is -//! [`drive_compare`]**, transcribed from `MerchantFrame.xml:63-80` — the only LIVE compare caller -//! in 1.12.1, since `SHOW_COMPARE_TOOLTIP` has no fire site in the whole image and the paper-doll -//! listener is dead code there (wow-re `merchant-compare-item-law.md` §8). So a compare rides -//! beside the tooltip it compares to, needs no window open, and is driven by SHIFT — the one -//! named departure from the vendor row, which compares unconditionally. +//! **Compare mode** (0274 P4, re-based by 2202, scoped back to the reference by 2210): +//! `SetInventoryItem` on an ARMED shopping tooltip renders the byte law's compare shape — gray +//! "Currently Equipped" first (`[arg+0x18]≠0`), the NAME white instead of quality-colored, and +//! the compact early-return at `0x52e14c` (`[arg+0x14]≠0`): nothing after the charges/cooldown +//! block. **Nothing in this engine seats a shopping plate**: the two callers that arm this mode +//! are [`compare_against_worn`]'s own bindings — `SetMerchantCompareItem` and +//! `SetAuctionCompareItem` — and the FrameXML that calls them (`MerchantFrame.xml:63-80`, +//! `AuctionFrame`) owns the plates' geometry and lifetime, exactly as in 1.12.1. There is no +//! hover compare in the reference at all: `SHOW_COMPARE_TOOLTIP` has zero fire sites in the whole +//! image, so `PaperDollFrame.lua`'s slot listener is dead code there (wow-re +//! `merchant-compare-item-law.md` §8). //! //! **The red "you can't use this" law** (the director's explicit ask) — byte-verified §1-RED: //! red is the AddLine color `0xffff2020`, applied to requirement lines the ACTIVE player fails @@ -42,7 +44,6 @@ use mlua::{Lua, MultiValue, Table, Value}; use super::object::frame_handle_of; use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empty}; use super::{ItemTemplateView, Model}; -use crate::widget::CompareOffer; mod names; mod render; @@ -50,16 +51,14 @@ mod render; use names::{quality_color, GRAY, WHITE}; use render::render_view; -/// Look up the store; a miss records the ask (the app sends `CMSG_ITEM_QUERY` and pushes back — -/// the hover's re-enter loop repaints on arrival). /// The compare tooltips' shared body — what `SetMerchantCompareItem 0x536080` and /// `SetAuctionCompareItem 0x535d70` both run once their wrapper has an offered template and a /// re-based `offset` (wow-re `merchant-compare-item-law.md`): walk the offered type's candidate /// slots (`0x809200[InventoryType]`), skipping EMPTY slots without decrementing and slots whose /// worn item's CLASS differs, until `offset` occupied matches have been passed; then fill the /// ordinary EQUIPPED-item tooltip with the compare header on — the FULL body (`p4 = 0`) plus one -/// gray `CURRENTLY_EQUIPPED` line (`p5 = 1`), by arming the same `compare_armed` latch the -/// shift-compare path uses and running `SetInventoryItem`, which is what the reference does too: +/// gray `CURRENTLY_EQUIPPED` line (`p5 = 1`), by arming the `compare_armed` latch and running +/// `SetInventoryItem`, which is what the reference does too: /// `0x536080` calls the ordinary item builder `0x52b650` with the header flag set. The NUMBER 1 on /// success; nil on a miss, which does NOT clear the tooltip (the reference's nil exit touches /// nothing else). @@ -67,7 +66,7 @@ fn compare_against_worn( lua: &Lua, this: &Table, h: crate::widget::FrameHandle, - offered: CompareOffer, + offered: &ItemTemplateView, mut left: f64, ) -> mlua::Result { let found = { @@ -109,6 +108,8 @@ fn compare_against_worn( Ok(Value::Integer(1)) } +/// Look up the store; a miss records the ask (the app sends `CMSG_ITEM_QUERY` and pushes back — +/// the hover's re-enter loop repaints on arrival). fn view_of(lua: &Lua, item_id: u32) -> Option { let mut model = lua.app_data_mut::().expect("model app_data"); let v = model.item_templates.get(&item_id).cloned(); @@ -208,7 +209,6 @@ fn render_by_id( match view_of(lua, item_id) { Some(v) => { render_view(lua, this, &v, false, None)?; - arm_compare(lua, h, &v); } None => { if let Some(name) = fb_name { @@ -282,162 +282,6 @@ fn equip_slots_for(inventory_type: u32) -> &'static [u32] { } } -/// The two fields the compare selection law reads, out of a rendered template. -fn offer_of(v: &ItemTemplateView) -> CompareOffer { - CompareOffer { - class: v.class, - inventory_type: v.inventory_type, - } -} - -/// After an item render on the MAIN GameTooltip: remember what the hover is OFFERING, and when -/// shift is already held, seat the compare plates now. Other tooltip frames — the shopping plates -/// themselves, ItemRef — never arm. An item with no equip slot arms nothing, so the drive's own -/// gate (`compare_offer`) stays `None` and it never touches a plate somebody else is showing. -fn arm_compare(lua: &Lua, h: crate::widget::FrameHandle, v: &ItemTemplateView) { - let (is_main, shift) = { - let mut model = lua.app_data_mut::().expect("model app_data"); - let is_main = model.arena.lookup("GameTooltip") == Some(h); - let shift = model.modifiers.0; - if is_main { - let offer = (!equip_slots_for(v.inventory_type).is_empty()).then(|| offer_of(v)); - if let Ok(t) = super::tooltip::tip_mut(&mut model, h) { - t.compare_offer = offer; - } - } - (is_main, shift) - }; - if is_main && shift { - drive_compare(lua, h); - } -} - -/// Seat and fill `ShoppingTooltip1/2` beside the main tooltip for the item it is offering — the -/// **live** 1.12.1 compare, transcribed from its one reference caller. -/// -/// The paper-doll path this used to drive is **dead in 5875**: `SHOW_COMPARE_TOOLTIP` (event 377) -/// has zero fire sites in the whole image, so `PaperDollFrame.lua:621-640` never runs there and -/// the vendor row is the only live consumer of the shopping plates (wow-re -/// `merchant-compare-item-law.md` §8, `system/ui/ui.md`). Firing a registered-but-never-signalled -/// event is a divergence, not a fidelity win — the `TRADE_REQUEST` precedent, decision 1764 — and -/// driving the compare through a listener that answers only while the character window is open is -/// what left a stale plate from an earlier hover sitting over an unrelated tooltip. -/// -/// So the geometry and the sequence here are `MerchantFrame.xml:63-80`'s, verbatim: plate 1 owned -/// by the tooltip at `TOPLEFT`/`GameTooltip` `TOPRIGHT` (0, −10), plate 2 hanging off plate 1's -/// `TOPRIGHT`, each **filled, re-seated, filled again** (the reference's own double build — the -/// `SetOwner` between the two clears the first one) and shown only on a hit. What differs from the -/// vendor row, deliberately and namedly, is *when*: the vendor compares unconditionally, this -/// compares while SHIFT is held. 1.12 has no `COMPAREITEMS` modified click to inherit — the token -/// does not occur anywhere in its FrameXML — so the modifier is benilla's, and the director's ask. -/// -/// A `compare_offer` of `None` returns without touching either plate: the vendor row's own -/// `SetMerchantCompareItem` pair is not ours to hide. -fn drive_compare(lua: &Lua, h: crate::widget::FrameHandle) { - let Some(offer) = ({ - let mut model = lua.app_data_mut::().expect("model app_data"); - super::tooltip::tip_mut(&mut model, h) - .ok() - .and_then(|t| t.compare_offer) - }) else { - return; - }; - if let Err(e) = seat_compare_plates(lua, offer) { - lua.app_data_mut::() - .expect("model app_data") - .errors - .push(e.to_string()); - } -} - -/// [`drive_compare`]'s body, split out so its Lua calls can use `?`. -fn seat_compare_plates(lua: &Lua, offer: CompareOffer) -> mlua::Result<()> { - let globals = lua.globals(); - let Ok(main) = globals.get::
("GameTooltip") else { - return Ok(()); - }; - // Plate 2 anchors to plate 1, so the loop carries the previous plate as the next one's owner. - let mut owner = main; - let mut dy = -10.0; - for n in 1..=2u32 { - let Ok(plate) = globals.get::
(format!("ShoppingTooltip{n}")) else { - return Ok(()); - }; - let ph = frame_handle_of(lua, &plate)?; - let hit = compare_against_worn(lua, &plate, ph, offer, f64::from(n - 1))? != Value::Nil; - if !hit { - // A miss leaves the plate's content alone (the selection's own nil contract), so the - // hide is what keeps an earlier hover's plate from riding under this one. **Both** - // plates are walked even after a miss: the selection is monotone, so plate 1 missing - // means plate 2 misses too, but a two-slot hover (a ring) leaves plate 2 shown and the - // one-slot hover after it has to take it down. - super::tooltip::hide_tooltip(lua, ph); - continue; - } - plate.get::("SetOwner")?.call::<()>(( - plate.clone(), - owner.clone(), - "ANCHOR_NONE", - ))?; - plate - .get::("ClearAllPoints")? - .call::<()>(plate.clone())?; - plate.get::("SetPoint")?.call::<()>(( - plate.clone(), - "TOPLEFT", - owner.clone(), - "TOPRIGHT", - 0.0, - dy, - ))?; - compare_against_worn(lua, &plate, ph, offer, f64::from(n - 1))?; - plate - .get::("Show")? - .call::<()>(plate.clone())?; - owner = plate; - dy = 0.0; - } - Ok(()) -} - -/// The shift-edge compare drive (called from `UiScript::set_modifiers` on a shift transition): -/// pressing shift over a live equippable item-hover seats the compare plates; releasing hides -/// them. Both edges are gated on the main tooltip actually OFFERING something, so neither one -/// reaches a plate the stock vendor row is driving. (The real engine has no shift-compare at all — -/// see [`drive_compare`] — so this lifecycle is benilla's, not an INTERIM reading of a trace.) -pub(super) fn on_shift_edge(lua: &Lua, down: bool) { - let (main, shown, offering) = { - let mut model = lua.app_data_mut::().expect("model app_data"); - let Some(h) = model.arena.lookup("GameTooltip") else { - return; - }; - let shown = model.arena.frame(h).map(|f| f.shown).unwrap_or(false); - let offering = match super::tooltip::tip_mut(&mut model, h) { - Ok(t) => t.compare_offer.is_some(), - Err(_) => false, - }; - (h, shown, offering) - }; - if !offering { - return; - } - if down { - if shown { - drive_compare(lua, main); - } - } else { - for n in 1..=2 { - let plate = { - let model = lua.app_data_mut::().expect("model app_data"); - model.arena.lookup(&format!("ShoppingTooltip{n}")) - }; - if let Some(plate) = plate { - super::tooltip::hide_tooltip(lua, plate); - } - } - } -} - /// Register the item content channels into the GameTooltip kind method table. pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // GameTooltip:BenillaSetItemById(itemId [, fallbackName, fallbackQuality]) — the id-keyed @@ -551,8 +395,6 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { Some(v) => { render_view(lua, &this, &v, false, Some(&inst))?; // Unchanged from the `render_by_id` this leg used to share: a link dropped on - // the MAIN tooltip still arms the shopping compare (`arm_compare`'s own gate). - arm_compare(lua, h, &v); } None => { if let Some(name) = link_name(&link) { @@ -785,7 +627,6 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { append_line(lua, &this, (t, WHITE), None, false)?; } } - arm_compare(lua, h, &v); } None => { // Template in flight. The real client early-outs to an EMPTY tooltip here @@ -1064,7 +905,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { return Ok(Value::Nil); }; - compare_against_worn(lua, &this, h, offer_of(&offered), left) + compare_against_worn(lua, &this, h, &offered, left) }, )?, )?; @@ -1135,7 +976,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { let Some(offered) = view_of(lua, item_id) else { return Ok(Value::Nil); }; - compare_against_worn(lua, &this, h, offer_of(&offered), left) + compare_against_worn(lua, &this, h, &offered, left) }, )?, )?; @@ -1177,9 +1018,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // release edge would then hide a compare the player never asked it to. // The BUYBACK tab is the other branch of the same button and seats // nothing, so it arms like any other hover. - if buyback { - arm_compare(lua, h, &v); - } + if buyback {} } None => { // Template in flight: the row's own stat head as a minimal view. @@ -1244,7 +1083,6 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { }; if let Some(v) = view_of(lua, item_id) { render_view(lua, &this, &v, false, Some(&inst))?; - arm_compare(lua, h, &v); } show_or_hide_empty(lua, h); Ok(()) @@ -1294,7 +1132,6 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { }; if let Some(v) = view_of(lua, item_id) { render_view(lua, &this, &v, false, Some(&inst))?; - arm_compare(lua, h, &v); } show_or_hide_empty(lua, h); Ok(()) @@ -1331,7 +1168,6 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { fire_cleared(lua, h); if let Some(v) = view_of(lua, item_id) { render_view(lua, &this, &v, false, None)?; - arm_compare(lua, h, &v); } show_or_hide_empty(lua, h); Ok(()) @@ -1363,7 +1199,6 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { fire_cleared(lua, h); if let Some(v) = view_of(lua, item_id) { render_view(lua, &this, &v, false, None)?; - arm_compare(lua, h, &v); } show_or_hide_empty(lua, h); Ok(()) diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 5cc47a80..e0d80769 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -494,6 +494,36 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { unit_predicate(lua, &token, |u| u.is_player) })?, )?; + // UnitIsPlusMob(unit) → 1 if this unit is a "plus" mob, else nil (`0x516d40`, extent + // `[0x516d40,0x516d8f)`). One bit of the SAME `UNIT_FIELD_FLAGS` word `UnitPlayerControlled` + // reads: `shr ecx,6; test cl,1` — bit 6, `UNIT_FLAG_PLUS_MOB 0x40`. + // + // **It is not a rank read**, which is the trap the name sets. `UnitClassification` is its + // table neighbour and answers off the gated creature rank (decision 0782), so the obvious + // implementation is `rank > 0` — but `0x516d40` never calls the rank getter `0x605620` (whose + // six callers are enumerated) and never touches the creature cache. The two agree in practice + // because the SERVER derives the bit from the rank — vmangos `Creature::UpdateEntry` sets it + // on `!IsPet() && rank > 0` (`Creature.cpp:634`, INFERRED from source) — so **rare** answers 1 + // here alongside elite, rare-elite and world boss, while a player, a pet and a normal mob + // answer nil. Where they part is the unstreamed unit: a creature whose cache record has not + // arrived still carries its own flags, so this answers truthfully where a rank read would say + // "normal". No client code writes the bit (zero bitwise RMWs at `+0xa0` image-wide), so it is + // the server's word verbatim. + // + // No stock FrameXML file calls it, which is why nothing shipped ever raised on its absence; + // addons do (`FuBar_DakSmak` colours its tooltip with it), and calling the nil global is what + // B385 reported. Last of the three verbs decision 1834 left loudly absent. + // Decision 2209 (wow-re `9f84e7e4`, + // `system/ui/scratch/unit-verbs-controlled-charmed-creaturetype.md` §4.2). + g.set( + "UnitIsPlusMob", + lua.create_function(|lua, token: Option| { + // One of 1834's quiet thirteen: no `lua_isstring` gate and no `Usage:` arm, so a nil + // or absent token is a quiet nil. An UNRECOGNISED one still raises — the body reaches + // the shared resolver through `0x515940`, and that is `unit_predicate`'s own gate. + unit_predicate(lua, &token, |u| u.flags & 0x40 != 0) + })?, + )?; // The identity predicates (decision 0434 §5 — the unit popup's menu pick + gating). Same-token // is trivially the same unit; otherwise both snapshots must carry a real (nonzero) guid. diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index 6b469b8e..6eb985d0 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -923,6 +923,94 @@ fn unit_is_charmed_answers_one_or_nil_and_only_for_the_charmed_side() { ); } +/// **`UnitIsPlusMob` reads a FLAG BIT, not the creature rank** (B385; `0x516d40`). +/// +/// Decision 2209. The name says "elite" and its table neighbour `UnitClassification` answers off +/// the gated rank, so the natural implementation is `rank > 0`. The binary's is not: it takes +/// `UNIT_FIELD_FLAGS` and tests bit 6 (`UNIT_FLAG_PLUS_MOB 0x40`), never calling the rank getter +/// `0x605620` (wow-re `9f84e7e4`, +/// `system/ui/scratch/unit-verbs-controlled-charmed-creaturetype.md` §4.2). The two normally +/// agree because the server derives the bit from the rank — so **rare** answers 1 here, not just +/// elite — and they part on the unit whose creature-cache record has not arrived, which still +/// carries its own flags while its rank reads 0. +/// +/// That divergence is the whole reason this is a test and not a reading: a `rank > 0` +/// implementation passes every ordinary case and is wrong exactly where the flag is the only +/// thing the client has. +#[test] +fn unit_is_plus_mob_reads_the_flag_bit_and_not_the_rank() { + let with = |flags: u32, rank: u32| UnitState { + exists: true, + has_object: true, + flags, + rank, + ..Default::default() + }; + let mut s = UiScript::new().unwrap(); + s.set_unit("player", Some(player())); + + // The ordinary agreeing cases: the server sets the bit on `!IsPet() && rank > 0`, so elite, + // rare-elite, world boss AND rare all carry it, and a normal mob does not. + for (rank, word) in [ + (1u32, "elite"), + (2, "rareelite"), + (3, "worldboss"), + (4, "rare"), + ] { + s.set_unit("target", Some(with(0x40, rank))); + assert_eq!( + s.eval::(r#"return UnitIsPlusMob("target")"#).unwrap(), + 1, + "a {word} carries UNIT_FLAG_PLUS_MOB, so the answer is the number 1" + ); + } + s.set_unit("target", Some(with(0, 0))); + assert!( + s.eval::>(r#"return UnitIsPlusMob("target")"#) + .unwrap() + .is_none(), + "a normal mob answers nil" + ); + + // ── The two legs a rank read gets wrong ── + // The flag WITHOUT a rank: an unstreamed creature, whose cache record has not arrived, still + // has its own descriptor. `rank > 0` would call it normal; the client says 1. + s.set_unit("target", Some(with(0x40, 0))); + assert_eq!( + s.eval::(r#"return UnitIsPlusMob("target")"#).unwrap(), + 1, + "the bit alone decides — no creature-cache rank is consulted" + ); + // A rank WITHOUT the flag — a player's pet, which the server excludes (`!IsPet()`). + s.set_unit("target", Some(with(0x8, 2))); + assert!( + s.eval::>(r#"return UnitIsPlusMob("target")"#) + .unwrap() + .is_none(), + "no bit, no plus mob — a neighbouring flag (player-controlled) must not read as one" + ); + + // The token grammar: quiet on nil/absent (one of 1834's thirteen with no `lua_isstring` + // gate), quiet on a recognised token naming nothing, and a raise on an unrecognised one — + // the body reaches the shared resolver through `0x515940`. + assert!(s.run("UnitIsPlusMob()").is_ok(), "nil token stays quiet"); + assert!( + s.run("UnitIsPlusMob(nil)").is_ok(), + "…and so does an explicit nil" + ); + assert!( + s.eval::>(r#"return UnitIsPlusMob("party5")"#) + .unwrap() + .is_none(), + "recognised but naming nothing — nil, not a raise" + ); + let err = s.run(r#"UnitIsPlusMob("bogus")"#).unwrap_err().to_string(); + assert!( + err.contains("Unknown unit name: bogus"), + "an unrecognised token raises the resolver's own text: {err}" + ); +} + /// **Unit tokens fold case, because the client's resolver does.** /// /// `0x515970` compares every one of its literals with `SStrCmpI` → `_strnicmp`, whose fold is @@ -1637,6 +1725,15 @@ fn every_unit_predicate_is_one_or_nil_and_never_a_boolean() { ..Default::default() }, ), + ( + "UnitIsPlusMob", + r#"UnitIsPlusMob("target")"#, + UnitState { + exists: true, + flags: 0x40, + ..Default::default() + }, + ), ( "UnitIsCharmed", r#"UnitIsCharmed("target")"#, diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index 80fe977e..4ef5c309 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -383,16 +383,10 @@ pub struct TooltipState { /// the byte law's compare mode (`[arg+0x14]≠0` compact + `[arg+0x18]≠0` "Currently Equipped" /// — wow-re tooltip-content-law.md). Set by the engine right before it fills a shopping /// plate, and consumed by that render. Survives `SetOwner`'s content clear, because the - /// reference's own live compare caller (`MerchantFrame.xml:67-72`) SetOwners *between* the - /// arm and the render; how the real engine plumbs the flag to `0x52b650` is unrecorded, so + /// reference's own compare caller (`MerchantFrame.xml:67-72`) SetOwners *between* the arm and + /// the render; how the real engine plumbs the flag to `0x52b650` is unrecorded, so /// this seam is the INTERIM model of it. pub compare_armed: bool, - /// What the MAIN GameTooltip is currently offering for comparison — the class and - /// `InventoryType` of the equippable item it shows (`None` = not equippable, not an item - /// tooltip, or a surface whose own FrameXML drives the plates). It is what the shift-held - /// compare drive re-runs the selection law against, and the gate that keeps that drive from - /// touching plates it does not own. Dropped with the content. - pub compare_offer: Option, /// `SetPadding(w)` — extra width beyond the measured content (ref ItemRefTooltip's /// OnLoad `SetPadding(16)`: room for the corner close button). 0 for ordinary tooltips. pub padding: f32, @@ -403,20 +397,6 @@ pub struct TooltipState { pub xml_declared_lines: bool, } -/// What a compare drive selects a worn item *against* — the two template fields the byte-verified -/// selection law reads (wow-re `merchant-compare-item-law.md` §3/§5): the offered item's -/// `InventoryType`, which names the candidate slots, and its item CLASS, which every candidate's -/// worn item must match. Carried instead of a whole `ItemTemplateView` because these two fields -/// are the entire input — a hover that re-drives on a shift edge must not depend on the rest of a -/// template that may have been replaced under it. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CompareOffer { - /// `ItemTemplateView::class`. - pub class: u32, - /// `ItemTemplateView::inventory_type`. - pub inventory_type: u32, -} - /// The tooltip plate's text inset — the real template seats `TextLeft1` at TOPLEFT (10,−10). pub const TOOLTIP_PAD: f32 = 10.0; /// Inter-line gap — each `TextLeftN` hangs at the previous line's BOTTOMLEFT (0,−2). diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 4b0c6137..033f28c2 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -174,14 +174,13 @@ impl Arena { mod kinds; pub use kinds::{ model_key, slider_fraction, slider_grab, slider_set_value, ArmedSequence, ButtonFont, - ButtonState, ButtonVisualState, ColorSelectState, CompareOffer, EditAction, EditBoxState, - EditOutcome, EditUnit, FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, - MinimapState, ModelFileFacts, ModelFog, ModelLight, ModelPlayHead, ModelState, RegionKind, - ScrollFrameState, ScrollingMessageState, SequenceFacts, SliderState, StatusBarState, - TooltipAnchor, TooltipState, MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, - MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, - MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, - TOOLTIP_WRAP_WIDTH, + ButtonState, ButtonVisualState, ColorSelectState, EditAction, EditBoxState, EditOutcome, + EditUnit, FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, MinimapState, + ModelFileFacts, ModelFog, ModelLight, ModelPlayHead, ModelState, RegionKind, ScrollFrameState, + ScrollingMessageState, SequenceFacts, SliderState, StatusBarState, TooltipAnchor, TooltipState, + MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL, + MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, + TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH, }; // ─────────────────────────────────────────────────────────────────────────────────────────────