no hover compare, UnitIsPlusMob answers, and $parent names land right

- the shift-hover item compare is gone: 1.12.1 compares only from the vendor and auction rows, and the plate those seat picks its side so it never slides back over its own tooltip (2210, 2207)
- UnitIsPlusMob is implemented, off the flag bit and not the creature rank, so an addon that calls it no longer raises (2209)
- a top-level frame named $parentX with a parent= attribute is published under that parent's name, so ClassIcons loads (2208)
- a parent= that resolves to nothing leaves the frame parentless and logs it, as the reference does, instead of falling back to the enclosing frame (2213)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-15 16:55:11 +07:00
co-authored by Claude Fable 5.1
parent cde247fe39
commit 1fc52f754b
12 changed files with 544 additions and 601 deletions
@@ -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"),
@@ -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::<bool>("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::<bool>("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::<bool>("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::<bool>("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::<bool>("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(),
+98 -42
View File
@@ -748,9 +748,10 @@ impl Loader<'_> {
/// Materialize one (already template-expanded) frame element into a live frame, then recurse its
/// nested `<Frames>` 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
/// `<ScrollChild>` pass is the one caller that needs the handle back.
@@ -760,14 +761,10 @@ impl Loader<'_> {
parent: Option<&Table>,
parent_name: &str,
) -> Option<Table> {
// ─ 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<String> = 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<Table> = 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::<Table>(name.as_str())
.ok()
.filter(|t| matches!(t.raw_get::<Value>(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<Option<Table>> =
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::<Table>(name).ok().filter(|t| {
matches!(t.raw_get::<Value>(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' `<Frame name="$parentClassIcon"
// inherits="UnitFrameClassIconTemplate" parent="PlayerFrame"/>` 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<String> =
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<String> = 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
+149
View File
@@ -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#"<Ui>
<Frame name="Enclosing">
<Size><AbsDimension x="200" y="100"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Frames>
<Frame name="$parentMissed" parent="NotLoadedYet">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Frame>
<Frame name="$parentEmpty" parent="">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Frame>
</Frames>
</Frame>
</Ui>"#,
);
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::<bool>("return TopMissed ~= nil").unwrap());
assert!(
s.eval::<bool>("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::<bool>("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::<String>("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'
/// `<Frame name="$parentClassIcon" parent="PlayerFrame"/>` 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#"<Ui>
<Frame name="PlayerFrame">
<Size><AbsDimension x="200" y="100"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Frame>
<Frame name="$parentClassIcon" parent="PlayerFrame">
<Size><AbsDimension x="20" y="20"/></Size>
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
<Frames>
<Frame name="$parentDot">
<Size><AbsDimension x="4" y="4"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Frame>
</Frames>
</Frame>
<Frame name="$parentLoose">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Frame>
<Frame name="Literal" parent="$parentPlayerFrame">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
s.resolve();
assert!(
s.eval::<bool>("return PlayerFrameClassIcon ~= nil")
.unwrap(),
"the top-level $parent name took the parent= frame's name, not the lexical \"Top\""
);
assert!(s.eval::<bool>("return TopClassIcon == nil").unwrap());
assert_eq!(
s.eval::<String>("return PlayerFrameClassIcon:GetParent():GetName()")
.unwrap(),
"PlayerFrame",
"and it is really attached there"
);
assert!(
s.eval::<bool>("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::<bool>("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::<bool>("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 `<Size>`, screen `<Anchors>`,
/// a `<Layers>` coloured `<Texture>`, a nested child `<Frame>` in `<Frames>`, and `<OnLoad>`
/// handlers on both) — proving name publication, bottom-up OnLoad, `$parent`, and that
+2 -12
View File
@@ -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`).
@@ -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());
}
@@ -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).
+19 -184
View File
@@ -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<Value> {
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<ItemTemplateView> {
let mut model = lua.app_data_mut::<Model>().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::<Model>().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::<Model>().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::<Model>()
.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::<Table>("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::<Table>(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::<mlua::Function>("SetOwner")?.call::<()>((
plate.clone(),
owner.clone(),
"ANCHOR_NONE",
))?;
plate
.get::<mlua::Function>("ClearAllPoints")?
.call::<()>(plate.clone())?;
plate.get::<mlua::Function>("SetPoint")?.call::<()>((
plate.clone(),
"TOPLEFT",
owner.clone(),
"TOPRIGHT",
0.0,
dy,
))?;
compare_against_worn(lua, &plate, ph, offer, f64::from(n - 1))?;
plate
.get::<mlua::Function>("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::<Model>().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::<Model>().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(())
@@ -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<String>| {
// 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.
@@ -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::<i64>(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::<Option<i64>>(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::<i64>(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::<Option<i64>>(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::<Option<i64>>(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")"#,
+2 -22
View File
@@ -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<CompareOffer>,
/// `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).
+7 -8
View File
@@ -174,14 +174,13 @@ impl<T> Arena<T> {
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,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────