druids get their relic slot, and the loot window is the reference's own

- `UnitHasRelicSlot` is real in 1.12 and benilla had no binding for it: `0x519e50` reads `ChrClasses.dbc` field 16 — set for Paladin, Shaman and Druid in `patch.MPQ`'s 17-column copy (the base `dbc.MPQ` copy has 16 columns and no such field, which is how the "always false in vanilla" belief survived twice in decisions and seven times in code) — and stock `PaperDollFrame.lua` calls it unconditionally, so the character sheet would have raised for EVERY class; a druid, paladin or shaman's sheet now hides the ammo slot, shows the relic plate where the bow was, tooltips "Relic", and reports ranged Attack/Damage/Power as N/A, all four branches transcribed from the reference
- the loot window is the reference's own `LootFrame.xml`: `LootButton` is built as a real widget type rather than aliased (a Button plus one slot dword and one overridden OnClick — right-click loots exactly like left, no shift/ctrl/alt takes, a row whose Lua handler errored still loots, a fast double-click takes two slots), `LOOT_SLOT_CLEARED` fires per row again because the stock file hangs hide-in-place AND the emptied-page auto-advance off it, `EditBox:Insert(nil)` is a no-op as `lua_tostring` makes it, and an alias `GameTooltip.xml` had quietly become a reader of would have turned every item tooltip white, silently, had it just gone nil
- the vendor cursor: `PickupMerchantItem` is two verbs behind one name, decided by what the cursor holds and never by the caller — a held bag item SELLS (index ignored, no merchant-open gate), otherwise the vendor row is grabbed as cursor mode 5, which is invisible to `CursorHasItem` (modes 1 and 9 only) so all thirteen stock gates stay closed while it is held; the drop is the buy, `CMSG_BUY_ITEM_IN_SLOT`, count 1, into a bag or an equipment slot, and every refusal is silent and clears the cursor; the four `Model_*` rotation handlers land in UIParent where the reference keeps them
- the gap report reads `:Method(` sites now — it found `SetMerchantCompareItem`, which would have raised on the first vendor-row hover with every check green, and `LootButton:SetSlot`, and 28 "free" windows became 25; the shared coin kit moved out of the merchant window into `MoneyFrame.xml` so the eight windows that draw coins through it can reach it without loading a vendor

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:00:40 +07:00
co-authored by Claude Fable 5.1
parent d017ca1cde
commit ed524ab184
59 changed files with 2084 additions and 1231 deletions
@@ -65,11 +65,14 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
for file in BAG_UI {
load_xml(&s, file);
}
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize — the
// shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.110 vs 478), and the
// kit's backdrop reads TOOLTIP_DEFAULT_COLOR out of GameTooltip.xml (already in BAG_UI).
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
// The loot window is the reference's own since 1751, and its dependency list grew with the
// swap — `LOOT_UI` carries the why for each entry. Loaded whole rather than cherry-picked:
// `PartyFrame.xml` is needed at LOAD (MAX_PARTY_MEMBERS) and skipping it fails loudly, but
// `ItemButtonTemplate` is only a warning and skipping it fails silently.
for file in super::test_ui::LOOT_UI {
load_xml(&s, file);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_Set, BankFrame's purse helper
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
@@ -1161,6 +1161,7 @@ pub(super) fn drive_script(
CursorPayload::Macro(m) => m.texture,
CursorPayload::PetAction(p) => p.texture,
CursorPayload::StablePet(p) => Some(p.texture),
CursorPayload::Merchant(m) => m.texture,
});
if let (Some(texture), Some(pos)) = (texture, window.cursor_position()) {
if let Some(handle) = assets
@@ -264,10 +264,13 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
&[],
&[
"MerchantFrame.xml",
// LootFrame.xml owns GroupLootDropDown, whose OnLoad needs the dropdown kit — which
// in turn reads TOOLTIP_DEFAULT_COLOR from GameTooltip.xml (BAG_UI's, ahead of this).
// The loot window is the reference's own since 1751 — `test_ui::LOOT_UI` carries
// what it needs and why, and PartyFrame's `MAX_PARTY_MEMBERS` is needed at LOAD.
"UIDropDownMenu.xml",
"LootFrame.xml",
"UnitPopup.xml",
"PartyFrame.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\LootFrame.xml",
],
);
s.set_money(0);
@@ -592,7 +595,12 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
"ScrollTemplates.xml",
"WorldMapFrame.xml",
"MerchantFrame.xml",
"LootFrame.xml",
// The loot window is the reference's own since 1751 — see `test_ui::LOOT_UI` for what
// each of these buys; `PartyFrame`'s MAX_PARTY_MEMBERS is needed at LOAD time.
"UnitPopup.xml",
"PartyFrame.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\LootFrame.xml",
]);
s.run("ToggleWorldMap()").unwrap();
+74 -85
View File
@@ -92,17 +92,17 @@ fn coin_and_two_items() -> LootState {
fn shipped_loot_frame_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize —
// the shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.64 vs 383).
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, which the dropdown backdrop reads
load_xml(&s, "UIDropDownMenu.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
assert_eq!(
load_xml(&s, "LootFrame.xml"),
10,
"window + 4 rows + up + down + close + GroupLootDropDown and its template's own $parentButton"
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml"),
34,
"the STOCK file's own shape (1751): the window, its portrait overlay and Next/Prev art, \
four LootButton rows each carrying ItemButtonTemplate's sub-frames, the two pagers, the \
close button, and GroupLootDropDown with the dropdown template's own children. Our \
transcription materialized 10 — a count is a fingerprint of a file, not a property of a \
loot window, and this one is now the reference's"
);
// Hidden by default: no coin icon on screen, left slot empty.
@@ -133,7 +133,9 @@ fn shipped_loot_frame_drives_end_to_end() {
.unwrap();
assert_eq!(vis, (true, true, true, false), "coin + 2 items, 4th hidden");
// Only 3 items ⇒ no pager.
assert!(!s.eval::<bool>("return LootDownButton:IsVisible()").unwrap());
assert!(!s
.eval::<bool>("return LootFrameDownButton:IsVisible()")
.unwrap());
s.resolve();
let quads = s.extract();
@@ -240,6 +242,14 @@ fn shipped_loot_frame_drives_end_to_end() {
let mut coin_looted = coin_and_two_items();
coin_looted.rows[0] = None;
s.set_loot(Some(coin_looted));
// The app's own pair, in the app's own order (`ui_loot`): the per-slot event first — the
// stock file hangs "hide the button in place" off it and only off it — then the summary
// repaint. Firing LOOT_UPDATE alone leaves the looted row drawn, which is what a client that
// sent only the summary would actually show.
s.fire_event(
"LOOT_SLOT_CLEARED",
vec![benilla_ui::script::ScriptValue::Int(1)],
);
s.fire_event("LOOT_UPDATE", vec![]);
let vis2: (bool, bool, bool) = s
.eval(
@@ -254,7 +264,7 @@ fn shipped_loot_frame_drives_end_to_end() {
);
// The close button hides the window → OnHide → CloseLoot() queues the release intent.
s.run("BenillaLootCloseButton_OnClick()").unwrap();
s.run("LootCloseButton:Click()").unwrap();
assert!(
s.take_loot_close(),
"closing the window releases the loot (OnHide → CloseLoot)"
@@ -275,14 +285,10 @@ fn shipped_loot_frame_drives_end_to_end() {
fn loot_empty_roll_plays_the_empty_open_kit() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize —
// the shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.64 vs 383).
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, which the dropdown backdrop reads
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
// A normal, non-empty loot open queues no sound (the normal open kit is C-side).
s.set_loot(Some(coin_and_two_items()));
@@ -320,14 +326,10 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize —
// the shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.64 vs 383).
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, which the dropdown backdrop reads
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
let has_icon = |quads: &[ExtractedQuad], needle: &str| {
quads.iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle))
@@ -375,14 +377,10 @@ fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
fn shipped_loot_frame_pages_five_items() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize —
// the shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.64 vs 383).
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, which the dropdown backdrop reads
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
let rows: Vec<Option<LootRow>> = (0..5)
.map(|i| {
@@ -415,7 +413,7 @@ fn shipped_loot_frame_pages_five_items() {
.unwrap();
assert_eq!(page1, (true, true, true, false), "page 1 shows 3 rows");
let pager1: (bool, bool) = s
.eval("return LootUpButton:IsVisible(), LootDownButton:IsVisible()")
.eval("return LootFrameUpButton:IsVisible(), LootFrameDownButton:IsVisible()")
.unwrap();
assert_eq!(pager1, (false, true), "page 1: Up hidden, Down shown");
@@ -429,13 +427,17 @@ fn shipped_loot_frame_pages_five_items() {
.unwrap();
assert_eq!(page2, (true, true, false), "page 2 shows the last 2 rows");
let pager2: (bool, bool) = s
.eval("return LootUpButton:IsVisible(), LootDownButton:IsVisible()")
.eval("return LootFrameUpButton:IsVisible(), LootFrameDownButton:IsVisible()")
.unwrap();
assert_eq!(pager2, (true, false), "page 2: Up shown, Down hidden");
// Back on page 1, loot all three of its rows: their buttons hide in place, and the emptied
// page advances to page 2 on its own — the ref's LOOT_SLOT_CLEARED tail (LootFrame.lua
// l.38-50: every button hidden + Down visible → PageDown).
//
// The events are the app's own, in the app's own order: one LOOT_SLOT_CLEARED per row that
// went away (`ui_loot`'s emitter), then the summary LOOT_UPDATE. The auto-advance hangs off
// the per-slot event and ONLY off it, which is why the app fires both.
s.run("LootFrame_PageUp()").unwrap();
let mut cleared = rows;
cleared[0] = None;
@@ -446,6 +448,12 @@ fn shipped_loot_frame_pages_five_items() {
master_candidates: Vec::new(),
rows: cleared,
}));
for row in 1..=3 {
s.fire_event(
"LOOT_SLOT_CLEARED",
vec![benilla_ui::script::ScriptValue::Int(row)],
);
}
s.fire_event("LOOT_UPDATE", vec![]);
assert_eq!(
s.eval::<i64>("return LootFrame.page").unwrap(),
@@ -466,14 +474,10 @@ fn shipped_loot_frame_pages_five_items() {
fn shipped_loot_pushed_to_center_by_merchant() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize —
// the shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.64 vs 383).
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, which the dropdown backdrop reads
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
load_xml(&s, "MerchantFrame.xml");
@@ -546,15 +550,10 @@ fn shipped_loot_pushed_to_center_by_merchant() {
fn the_loot_window_draws_over_the_party_frames() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// PartyFrame's per-member dropdown OnLoad walks the whole popup kit (app manifest order).
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "PartyFrame.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
// The party frame up FIRST, the window second — the order that cannot be what saves it.
s.eval::<()>("PartyMemberFrame1:Show()").unwrap();
@@ -605,14 +604,12 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
const WOOL_LINK: &str = "|cffffffff|Hitem:2589:0:0:0|h[Wool Cloth]|h|r";
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml", // BenillaChatEdit_InsertLink, the shared shift-insert helper
"GameTooltip.xml", // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrop
"UIDropDownMenu.xml", // LootFrame owns GroupLootDropDown, initialized at its OnLoad
"LootFrame.xml",
"Interface\\FrameXML\\LootFrame.xml",
"DressUpFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
@@ -747,12 +744,10 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
let row = |name: &str, quality: u32| {
Some(LootRow {
@@ -891,12 +886,10 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
// A raid, so the dropdown takes its nested arm (GetNumRaidMembers() > 0 is the whole gate).
s.set_party(PartyState {
@@ -974,12 +967,10 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
fn the_loot_bind_confirm_raises_the_dialog_and_okay_calls_loot_slot() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
let _ = s.take_loot_picks();
@@ -1063,12 +1054,10 @@ fn the_loot_bind_confirm_raises_the_dialog_and_okay_calls_loot_slot() {
fn a_row_click_takes_rather_than_continues() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
let quads = s.extract();
@@ -566,11 +566,13 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// The quality→colour table (BENILLA_LOOT_QUALITY_COLORS) ships in LootFrame.xml; at runtime
// The quality→colour table is `ITEM_QUALITY_COLORS`, and Fonts.xml is its home — this used
// to load the whole loot window for `BENILLA_LOOT_QUALITY_COLORS`, an alias our own
// LootFrame.xml set to that same table. 1751 took that window to the chain and the alias went
// with it (GameTooltip.xml reads the real name now), so the dependency is gone. At runtime
// every FrameXML file loads before any hover fires, so load it here too — with the dropdown
// kit its GroupLootDropDown initializes against at load (benilla.toc l.64 vs 383).
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
load_xml(&s, "MerchantFrame.xml");
s.set_merchant(Some(MerchantState {
@@ -977,10 +977,11 @@ fn reward_row_hover_serves_the_shared_item_tooltip() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// LootFrame.xml is here for BENILLA_LOOT_QUALITY_COLORS (the quality→colour table); it
// This used to load LootFrame.xml for `BENILLA_LOOT_QUALITY_COLORS`, an alias our own copy of
// that window set to `ITEM_QUALITY_COLORS`. 1751 retired the window and the alias with it, so
// Fonts.xml alone is the source now. Formerly: it
// needs the dropdown kit its GroupLootDropDown initializes against (benilla.toc l.64 vs 383).
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
@@ -389,11 +389,28 @@ mod tests {
/// mostly noise. Here the manifest is loaded first and `_G` is read after, so the answer is
/// what this client *actually* answers to.
///
/// Two crudenesses, stated because they decide how to read the output. The `Name(` scan counts
/// **Three columns, and the third one exists because its absence cost a swap.** `engine=` and
/// `fx=` read bare `Name(` globals. `method=` reads `:Name(` calls against the method surface
/// this engine actually exposes — built by walking `getmetatable(w).__index` over one widget
/// of every type, plus the tooltip.
///
/// Until 1787 the method sites were **silently dropped**: a name in neither the engine nor the
/// FrameXML half of `1.12-globals.tsv` was assumed to be a widget method and skipped, on the
/// reasoning that widget methods are not in `_G`. True, and it meant the report could not see
/// a widget method we had *not built*. `MerchantFrame.xml` read `0 engine` and
/// [`chain_readiness_report`] read CLEAN while the stock row's `<OnEnter>` called
/// `ShoppingTooltip1:SetMerchantCompareItem(...)`, which this engine does not have — so the
/// file loaded, every check passed, and hovering a vendor row would have raised in play. Both
/// instruments were right about what they measure. Neither measured the window.
///
/// Two crudenesses remain, stated because they decide how to read the output. Both scans count
/// a call inside an XML comment (1.12 comments out whole blocks — `GuildRegistrarFrame`'s
/// tabard button is one), so it can over-report; and it cannot see a name reached through
/// `getglobal`, so it can under-report. It is for ranking work, not for proving a window done —
/// [`chain_readiness_report`] and the window's own tests are that.
/// tabard button is one), so they can over-report; and neither can see a name reached through
/// `getglobal`, so they can under-report. The `method=` column is also receiver-blind: it asks
/// "does ANY widget type answer to this name", not "does *this* receiver", so a method that
/// exists on the wrong type still reads as present. It is for ranking work, not for proving a
/// window done — [`chain_readiness_report`] and the window's own tests are that, and the
/// paragraph above is what those two are worth on their own.
#[test]
#[ignore = "instrument: run by hand when choosing what to build next"]
fn chain_gap_report() {
@@ -427,6 +444,94 @@ mod tests {
.into_iter()
.collect();
// `:Name(` — a method call, receiver unknown.
let called_methods = |text: &str| -> std::collections::HashSet<String> {
let b: Vec<char> = text.chars().collect();
let mut out = std::collections::HashSet::new();
let mut i = 1;
while i < b.len() {
// `::` is not a method call, and neither is a `:` inside a word.
if b[i - 1] == ':' && (i < 2 || b[i - 2] != ':') && b[i].is_ascii_alphabetic() {
let mut j = i;
while j < b.len() && super::is_word(b[j]) {
j += 1;
}
let mut k = j;
while k < b.len() && b[k] == ' ' {
k += 1;
}
if k < b.len() && b[k] == '(' {
out.insert(b[i..j].iter().collect::<String>());
}
i = j;
continue;
}
i += 1;
}
out
};
// Whether this engine's widgets answer to a method name — **asked**, not enumerated. A
// widget's methods come through an `__index` FUNCTION, so there is no table to walk; the
// only way to know is to look the name up on a real widget. Receiver-blind by design (a
// `:Name(` site does not say what it is called on), so this answers "does ANY widget type
// answer to this name", which is the question that catches a method we never built.
let answers = |s: &UiScript, names: &[String]| -> std::collections::HashSet<String> {
let list = names
.iter()
.map(|n| format!("{n:?}"))
.collect::<Vec<_>>()
.join(",");
s.eval::<Vec<String>>(&format!(
r#"
local want = {{{list}}}
local probes = {{ GameTooltip }}
local types = {{
"Frame", "Button", "CheckButton", "LootButton", "StatusBar", "EditBox",
"ScrollFrame",
"Slider", "ColorSelect", "MessageFrame", "ScrollingMessageFrame",
"SimpleHTML", "Model", "PlayerModel", "DressUpModel", "TabardModel",
"Minimap", "MovieFrame", "Cooldown",
}}
for i = 1, table.getn(types) do
local ok, w = pcall(function()
return CreateFrame(types[i], "BenillaGapProbe" .. i, UIParent)
end)
if ok and w then table.insert(probes, w) end
end
local host = CreateFrame("Frame", "BenillaGapProbeHost", UIParent)
table.insert(probes, host:CreateTexture())
table.insert(probes, host:CreateFontString())
local out = {{}}
for i = 1, table.getn(want) do
local name, found = want[i], false
for j = 1, table.getn(probes) do
local ok, v = pcall(function() return probes[j][name] end)
if ok and type(v) == "function" then found = true break end
end
if found then table.insert(out, name) end
end
return out
"#
))
.expect("probe the widget method surface")
.into_iter()
.collect()
};
// The instrument's own tripwire: if the probe stops working, the `method=` column goes
// silently empty — which is precisely the failure mode 1787 exists to end.
let control: Vec<String> = ["SetPoint", "SetMerchantItem", "BenillaNotAMethod"]
.iter()
.map(|s| (*s).to_string())
.collect();
let got = answers(&s, &control);
assert!(
got.contains("SetPoint")
&& got.contains("SetMerchantItem")
&& !got.contains("BenillaNotAMethod"),
"the method probe is not working — it answered {got:?} for {control:?}"
);
let migrated: std::collections::HashSet<String> = super::super::addons::Addon::builtin()
.toc
.files
@@ -493,7 +598,8 @@ mod tests {
};
println!("\n=== 1751 gap report — what each unmigrated window would cost ===");
let mut rows: Vec<(usize, String, Vec<String>, Vec<String>)> = Vec::new();
#[allow(clippy::type_complexity)] // (engine count, file, engine, fx, method)
let mut rows: Vec<(usize, String, Vec<String>, Vec<String>, Vec<String>)> = Vec::new();
for f in &stock {
if migrated.contains(f) {
continue;
@@ -526,24 +632,38 @@ mod tests {
let h = home.get(&c).cloned().unwrap_or_else(|| "?".into());
fx.push(format!("{c}<{h}>"));
}
None => {} // a widget method, not a global — the reference's _G would have it
None => {} // not a global at all — the `:Name(` scan below is what sees it
}
}
rows.push((eng.len(), f.clone(), eng, fx));
// The method half. `own`/`have` do not apply: a method is never a global, so the only
// question is whether this engine's widgets answer to the name.
let mut asked: Vec<String> = called_methods(&text).into_iter().collect();
asked.sort();
let known = answers(&s, &asked);
let mut meth: Vec<String> = asked.into_iter().filter(|m| !known.contains(m)).collect();
meth.sort();
rows.push((eng.len() + meth.len(), f.clone(), eng, fx, meth));
}
rows.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
for (n, f, eng, fx) in &rows {
for (n, f, eng, fx, meth) in &rows {
println!("{n:>3} engine {f}");
if !eng.is_empty() {
println!(" engine: {}", eng.join(" "));
}
if !meth.is_empty() {
println!(" method: {}", meth.join(" "));
}
if !fx.is_empty() {
println!(" fx: {}", fx.join(" "));
}
}
// "Free" now means free of BOTH — the count above is engine names plus missing methods,
// because a window blocked on a widget method is exactly as blocked as one missing a
// global, and reading it as free is the mistake 1787 is about.
let free: Vec<&String> = rows.iter().filter(|r| r.0 == 0).map(|r| &r.1).collect();
println!(
"\n=== {} windows need NO engine work at all ===",
"\n=== {} windows need NO engine work at all (no missing global AND no missing \
method) ===",
free.len()
);
for f in free {
@@ -91,6 +91,37 @@ fn read(req: &str) -> Option<Vec<u8>> {
/// Named as a list rather than folded into a `setup()` because the callers all want to add their
/// own files around it (a merchant, the bank, the action bar art), and the ORDER is the thing
/// being reused — it is `benilla.toc`'s, trimmed to what the bags actually reach for.
/// What the **loot window** needs before `Interface\FrameXML\LootFrame.xml` will load and behave —
/// the same shape as [`BAG_UI`], for the same reason, and it grew for 1751 exactly as that one did.
///
/// Three of these are load-bearing in a way that is invisible if you leave them out:
///
/// * **`GlobalStrings.lua`** — the stock file concatenates `GROUP` and `GIVE_LOOT` while building
/// the master-loot menu. The app loads the player's own copy ahead of the whole manifest at VM
/// setup; a bare `UiScript::new()` does not. Our deleted `LootFrame.xml` carried
/// `X = X or "…"` fallbacks for precisely these; the stock file carries none.
/// * **`ItemButtonTemplate.xml`** — stock `LootButtonTemplate` inherits it, and a missing template
/// is a loader *warning*, not an error. Leave it out and every row loads clean with no art and
/// no `$parentIconTexture`, which reads as a pass until an assertion looks for an icon.
/// * **`PartyFrame.xml`** — for `MAX_PARTY_MEMBERS`, which stock `LootFrame.lua:217` does
/// arithmetic on at LOAD time, not at click time. Its home is the reference's own
/// (`PartyMemberFrame.lua:1`), and it wants the dropdown kit and `UnitPopup` ahead of it — the
/// manifest's order (167 → 185 → 262), reproduced rather than short-circuited. Setting the
/// constant by hand would pass and teach nothing about the real load.
///
/// Needs client data, like [`BAG_UI`]: open with `benilla_formats::wow_data_or_skip!()`.
pub(super) const LOOT_UI: &[&str] = &[
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml", // ITEM_QUALITY_COLORS — the row-name palette
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml", // StaticPopup, and the LOOT_BIND / CONFIRM_LOOT_DISTRIBUTION dialogs
"GameTooltip.xml", // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrop
"UIDropDownMenu.xml", // GroupLootDropDown's OnLoad calls UIDropDownMenu_Initialize
"UnitPopup.xml",
"PartyFrame.xml",
];
pub(super) const BAG_UI: &[&str] = &[
// The reference's own localized strings — `BACKPACK_TOOLTIP`, `EQUIP_CONTAINER`, `KEYRING`,
// the `*_FONT_COLOR_CODE` pair. The app loads this at VM setup, ahead of the manifest