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:
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")"#,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user