the action bars' empty slots go ghost, and a rooted creature stays put

- picking a spell up off the bar no longer blacks out every empty slot: `UI-Quickslot` is a FILLED 60%-black plate, not a hollow ring (decoded off the shipped BLPs — Quickslot2's interior is RGBA 0,0,0,0, Quickslot's 0,0,0,153), and the reference dims it to half alpha in `ActionButton_ShowGrid`, so an empty well now ghosts at 30% black where it read as a hole; the wow-re cross-check then found the reference never UN-dims it (a 3-argument `SetVertexColor` preserves the current alpha at `0x79abd0`, and `SetNormalTexture` reuses the region), so in the real client the first drag leaves every button's border at half alpha for the rest of the session — benilla ends the dim on purpose and records it as the divergence it is (1782)
- the bag bar is the reference's own `MainMenuBarBagButtons.xml` (window 3 of the stock-first migration), and the swap found a real bug rather than a move: the reference's `ItemAnim_OnEvent` matches a push against the button's inventory-slot id (20..23) and benilla emitted its own container numbering (1..4), so all four bag-push cards would have silently never played against the stock body — `push_container` now emits the reference's selector (`bag + 1`), which is what an addon expects anyway; beside it, a readiness probe that asks the LOADER, not a grep, which of the 85 stock windows load off the player's chain today (58 do; the 27 that don't name their own blocker)
- a rooted, hovering, water-walking or walking creature reached benilla as an unhandled opcode: the twelve `SMSG_SPLINE_MOVE_*` (the observer twin of the ack'd force family — bare packed guid, no ack, any unit) now decode into one MOVEMENTFLAGS word per unit, which benilla kept for our mover and for a relayed player and not at all for a creature, the one body this family is ever addressed to; ROOT wipes the direction bits so the dead-reckon stops walking a rooted body on for its next heartbeat (`0x7c7340`), HOVER clears a yard off the floor without climbing a body already inside that yard (`0x636e81`), WATERWALK elects the liquid surface against the terrain, SAFE_FALL's terminal is 7.0 not 60.148 (`0x7c5d23`), and SET_RUN_MODE's bool is inverted against the bit, folded at the parse; the inspect card names a unit's granted modes in words (`granted rooted+hover`) so a creature resting a yard off the floor reads as intended rather than broken

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 16:57:55 +07:00
co-authored by Claude Fable 5.1
parent 631b4a99c1
commit 6da7ed3fca
43 changed files with 2048 additions and 1266 deletions
@@ -765,7 +765,7 @@ fn shipped_bag_frame_drives_end_to_end() {
// [`BAG_UI`] is `benilla.toc`'s own order for everything a bag window needs. Three files join
// it at the positions the manifest gives them:
// * ActionBar.xml straight after Cooldown.xml — the bag bar is anchored INTO
// MainMenuBarArtFrame, so the bar must exist before BagFrame.xml loads or the toggle's
// MainMenuBarArtFrame, so the bar must exist before the bag bar loads or the toggle's
// cross-file `relativeTo` silently falls back to the screen root (which would land in the
// right place here anyway, by the 1024-wide coincidence: screen BOTTOMRIGHT == the
// full-width bar's art-frame BOTTOMRIGHT — so the failure would be invisible);
@@ -780,7 +780,7 @@ fn shipped_bag_frame_drives_end_to_end() {
if *file == "Cooldown.xml" {
load_ui(&s, "ActionBar.xml");
}
if *file == "BagFrame.xml" {
if *file == "Interface\\FrameXML\\MainMenuBarBagButtons.xml" {
bar_frames = frames;
}
}
@@ -788,12 +788,13 @@ fn shipped_bag_frame_drives_end_to_end() {
load_ui(&s, "MerchantFrame.xml");
assert_eq!(
bar_frames, 12,
bar_frames, 16,
"the bag bar is six CheckButtons — MainMenuBarBackpackButton, CharacterBag0..3Slot, \
KeyRingButton — each carrying one $parentItemAnim push card (decision 0887): 6 + 6. \
Every window this file used to declare (the backpack, four equipped bags, the keyring) \
is the reference's ContainerFrame now, and its slot buttons, close buttons and money \
rows went with it (decision 1751)"
KeyRingButton — each carrying one $parentItemAnim Model (6 + 6), plus a $parentCooldown \
Model on each of the four slots that inherit PaperDollItemSlotButtonTemplate (+4). The \
deleted BagFrame.xml built 12: it mirrored the six buttons and their push cards but had \
no cooldown on a bag-bar slot at all, which is the reference's own and is what the swap \
to Interface\\FrameXML\\MainMenuBarBagButtons.xml brought with it (1751 window 3)"
);
// The app's feed: a backpack with Tough Jerky ×5 in slot 1.
+102 -63
View File
@@ -95,7 +95,7 @@ fn backpack_toggle_plays_open_and_close_kits() {
assert!(!open(&s, 0));
// Toggle open → OnShow → igBackPackOpen.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(open(&s, 0));
assert_eq!(
s.take_sounds(),
@@ -104,7 +104,7 @@ fn backpack_toggle_plays_open_and_close_kits() {
);
// Toggle closed → OnHide → igBackPackClose.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(!open(&s, 0));
assert_eq!(
s.take_sounds(),
@@ -155,7 +155,7 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() {
);
// Toggle open: the BACKPACK, and only the backpack — bag 2 is equipped and stays shut.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(open(&s, 0), "backpack opens");
assert!(
@@ -171,7 +171,7 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() {
// takes the lot down.
s.run("ToggleBag(2)").unwrap();
assert!(open(&s, 2));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(
!open(&s, 0) && !open(&s, 2),
@@ -181,7 +181,7 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() {
// Bag 0 shut, bag 2 open: the ref's condition reads bag 0, so this is the OPEN arm — the
// backpack joins the bag already up instead of closing it.
s.run("ToggleBag(2)").unwrap();
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(
open(&s, 0) && open(&s, 2),
@@ -441,7 +441,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
s.resolve();
@@ -517,7 +517,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.resolve();
let btn = bag_slot_button(&s, 0, 1);
@@ -756,7 +756,7 @@ fn readable_letter_hover_shows_the_inspect_magnifier() {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
s.resolve();
let b1 = bag_slot_button(&s, 0, 1);
@@ -824,7 +824,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
s.resolve();
@@ -910,8 +910,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
assert!(!open(&s, 1), "hidden by default");
// CharacterBag0Slot == bag id 1 (BenillaBagBarSlot_OnLoad(self, 1) in BagFrame.xml).
s.run("BenillaBagBarSlot_OnClick(CharacterBag0Slot)")
.unwrap();
s.run("CharacterBag0Slot:Click()").unwrap();
let _ = s.take_sounds();
assert!(open(&s, 1), "the bag-bar click opened bag 1's window");
let w = window(&s, 1);
@@ -1040,7 +1039,7 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
s.resolve();
@@ -1391,10 +1390,19 @@ fn bag_slot_cooldown_sweeps_through_the_xml() {
}
/// The bar's five bag buttons had NO hover at all — the director's "the bags are missing their
/// simple tooltips". These are the ref's plain `SetText` plates (ref-MainMenuBarBagButtons.xml
/// l.91-99 for the backpack, ref-MainMenuBarBagButtons.lua l.86-96 for the four slots), NOT the
/// two-line newbie kind the micro buttons next to them use — so what's pinned here is the label,
/// the empty-slot fallback, and that they seat BESIDE the button rather than at the screen corner.
/// simple tooltips". These are the ref's plain `SetText` plates (MainMenuBarBagButtons.xml l.91-99
/// for the backpack, MainMenuBarBagButtons.lua l.86-96 for the four slots), NOT the two-line
/// newbie kind the micro buttons next to them use — so what's pinned here is the label, the
/// empty-slot fallback, and that they seat BESIDE the button rather than at the screen corner.
///
/// Those two bodies are the reference's OWN now (1751 window 3), which changes one thing this test
/// records: **the binding-key suffix appears on the four slots too.** The reference appends
/// `GetBindingKey("TOGGLEBAG"..(4 - (this:GetID() - CharacterBag0Slot:GetID())))` — its numbering
/// runs the bar right-to-left, so the slot next to the backpack is `TOGGLEBAG4`, our F11
/// (`bindings/commands.rs`, quoting ref Bindings.xml l.564-575). Our own handler never did this,
/// so the suffix is new here and is the reference's behaviour, not a regression. The backpack's
/// key is the reference's `TOGGLEBACKPACK` rather than the `OPENALLBAGS` ours read; both default
/// to B, so the string is unchanged and only the reason is.
#[test]
fn the_bar_bag_buttons_name_themselves_on_hover() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1410,14 +1418,13 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
s.resolve();
// The backpack: its label plus OPENALLBAGS's live key (default B; 0997's divergence note).
s.run("BenillaBagToggle_OnEnter(MainMenuBarBackpackButton)")
.unwrap();
hover(&mut s, "MainMenuBarBackpackButton");
let line = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap();
assert!(
line.starts_with("Backpack") && line.contains("(B)"),
"the backpack names itself and its key: {line:?}"
"the backpack names itself and its TOGGLEBACKPACK key: {line:?}"
);
// Beside the button, not the default corner — the ref's ANCHOR_LEFT.
assert!(
@@ -1431,8 +1438,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
);
// An empty bag slot falls back to the ref's EQUIP_CONTAINER rather than showing nothing.
s.run("BenillaBagBarSlot_OnEnter(CharacterBag0Slot)")
.unwrap();
hover(&mut s, "CharacterBag0Slot");
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
@@ -1461,16 +1467,20 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
enchants: Vec::new(),
});
s.set_inventory_slots(inv);
s.run("BenillaBagBarSlot_OnEnter(CharacterBag0Slot)")
hover(&mut s, "CharacterBag0Slot");
let line = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Small Brown Pouch",
"an equipped slot shows that bag, not the empty-slot fallback"
assert!(
line.starts_with("Small Brown Pouch"),
"an equipped slot shows that bag, not the empty-slot fallback: {line:?}"
);
assert!(
line.contains("(F11)"),
"…and the reference appends this slot's own TOGGLEBAG4 key: {line:?}"
);
s.run("BenillaBagBarButton_OnLeave()").unwrap();
unhover(&mut s);
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"leaving hides the plate"
@@ -1651,8 +1661,7 @@ fn the_keyring_button_opens_a_keyring_window() {
let _ = s.take_sounds();
assert!(!open(&s, -2));
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
s.run("KeyRingButton:Click()").unwrap();
assert!(open(&s, -2), "the button opens the keyring");
assert_eq!(
s.take_sounds(),
@@ -1687,8 +1696,7 @@ fn the_keyring_button_opens_a_keyring_window() {
7146
);
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
s.run("KeyRingButton:Click()").unwrap();
assert!(!open(&s, -2));
assert_eq!(
s.take_sounds(),
@@ -1730,8 +1738,7 @@ fn a_key_dropped_on_the_button_files_itself() {
s.run("PickupContainerItem(0, 3)").unwrap();
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
s.run("KeyRingButton:Click()").unwrap();
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
@@ -1762,28 +1769,42 @@ fn a_key_dropped_on_the_button_files_itself() {
}
s.set_container(-2, Some(full));
s.run("PickupContainerItem(0, 3)").unwrap();
// The refusal is now the REFERENCE's own `PutKeyInKeyRing`, reporting through the name its
// own line asks for — which 1.12 never defines. `BagFrame.xml` supplies it from the string the
// reference meant, and that repair is what this half of the test guards (its comment there
// carries the reasoning).
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
// The refusal is the REFERENCE's own `PutKeyInKeyRing`, reporting through the name its own
// line asks for — `NO_EMPTY_KEYRING_SLOTS`, which 1.12 never defines. So the drop is refused
// and **nothing is printed**: a real 1.12 bug, and faithful here on purpose.
//
// This client un-quirked it once, by assigning the string the reference plainly meant. Window
// 1 of 1751 retired that (ContainerFrameAdapters.xml's header records it), but the assignment
// itself outlived the decision in `BagFrame.xml` for a window and a half. Window 3 deleted that
// file, so the stated posture and the behaviour agree again. Restoring the repair is one line
// in the adapters if it is ever wanted; it is not silent either way.
s.run("KeyRingButton:Click()").unwrap();
assert!(
s.take_container_moves().is_empty(),
"a full keyring queues no move"
);
// ...and says so, on the errors frame — read where a player reads it, off the drawn quads.
// (`UIErrorsFrame` is a real `<MessageFrame>`; its lines are the widget's, not FontStrings.)
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(
!s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Text { text: Some(t), .. } if t == "Your keyring is full."
QuadContent::Text { text: Some(t), .. } if t.contains("keyring is full")
)),
"the refusal posts to UIErrorsFrame — the reference's own dangling string name prints \
nothing"
"the reference's own dangling string name prints nothing — un-quirking it is a decision, \
not a default"
);
// The nil ALSO raises, out of `MessageFrame:AddMessage`. That is deliberately not asserted as
// correct: our binding takes its text as an mlua `String`, which raises on nil by default —
// not from a carved gate (`messageframe/plain.rs` reads `0x795590`'s rgb/alpha parse off the
// bytes and says nothing about the text argument). Whether 1.12 raises here, no-ops, or prints
// a blank line is out with wow-re. So this pins the keyring's own behaviour — the refusal, and
// the silence — and lets exactly that one error through by name, which still fails the test on
// any OTHER error the click produces.
let errs = s.errors();
assert!(
errs.iter()
.all(|e| e.contains("AddMessage") && e.contains("PutKeyInKeyRing")),
"the only error may be the reference's own nil message: {errs:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The item-push drop animation (decision 0887): `ITEM_PUSH(container, icon)` runs the pushed item's
@@ -1806,13 +1827,13 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
s.resolve();
let shown = |s: &UiScript, name: &str| {
s.eval::<bool>(&format!("return {name}ItemAnim:IsShown()"))
s.eval::<bool>(&format!("return {name}BenillaItemPush:IsShown()"))
.unwrap()
};
// The card's centre relative to its button's centre, in screen px (y-up): (dx, dy).
let offset = |s: &UiScript, name: &str| -> (f32, f32) {
s.eval::<(f32, f32)>(&format!(
"local a, b = {name}ItemAnim, {name} \
"local a, b = {name}BenillaItemPush, {name} \
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
local bx, by = b:GetLeft() + b:GetWidth() / 2, b:GetBottom() + b:GetHeight() / 2 \
return ax - bx, ay - by"
@@ -1820,11 +1841,11 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
.unwrap()
};
let size = |s: &UiScript, name: &str| {
s.eval::<f32>(&format!("return {name}ItemAnim:GetWidth()"))
s.eval::<f32>(&format!("return {name}BenillaItemPush:GetWidth()"))
.unwrap()
};
let alpha = |s: &UiScript, name: &str| {
s.eval::<f32>(&format!("return {name}ItemAnim:GetAlpha()"))
s.eval::<f32>(&format!("return {name}BenillaItemPush:GetAlpha()"))
.unwrap()
};
// The card's centre relative to its button's BOTTOMRIGHT corner — the reference's OWN frame of
@@ -1834,7 +1855,7 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
// instead is what let 0887's placement look plausible.
let corner = |s: &UiScript, name: &str| -> (f32, f32) {
s.eval::<(f32, f32)>(&format!(
"local a, b = {name}ItemAnim, {name} \
"local a, b = {name}BenillaItemPush, {name} \
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
return ax - b:GetRight(), ay - b:GetBottom()"
))
@@ -1850,11 +1871,14 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
assert!(!shown(&s, b), "{b}'s card starts hidden");
}
// A push into equipped bag 2.
// A push into equipped bag 2. `arg1` is the reference's own vocabulary since 1751 window 3 —
// the button's INVENTORY-slot id, which is what `ItemAnim_OnEvent` compares against — so bag 2
// is `CharacterBag1Slot`'s 21, not the container id 2 this used to send (`ui_loot.rs`
// `push_container`, the `0x491bb5` selector).
s.fire_event(
"ITEM_PUSH",
vec![
benilla_ui::script::ScriptValue::Int(2),
benilla_ui::script::ScriptValue::Int(21),
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_08".into()),
],
);
@@ -1988,11 +2012,16 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
(cx + 16.87).abs() < 0.2 && (cy - 18.68).abs() < 0.2,
"arrives at the ref's landing point: got ({cx}, {cy})"
);
// Which is also, within a pixel, the button's own centre — the reference's card really does
// drop INTO the bag button, and that is the one thing 0887 got right for the wrong reason.
// Which is also, within a couple of pixels, the button's own centre — the reference's card
// really does drop INTO the bag button, and that is the one thing 0887 got right for the wrong
// reason. The corner-relative assertion above is the exact one; this is the sanity check, and
// its tolerance is a button-size term, not slack: the landing point is fixed relative to the
// button's BOTTOMRIGHT, so the offset from its CENTRE moves by half of any size change. The
// bar's buttons are 37x37 now — `ItemButtonTemplate`'s own size, inherited through the
// reference's `PaperDollItemSlotButtonTemplate` — where the deleted BagFrame.xml authored 36.
let (dx, dy) = offset(&s, "CharacterBag1Slot");
assert!(
dx.abs() < 1.5 && dy.abs() < 1.5,
dx.abs() < 2.0 && dy.abs() < 2.0,
"lands on the button it went into: got ({dx}, {dy})"
);
assert!(
@@ -2064,7 +2093,7 @@ fn an_addon_that_hooks_toggle_backpack_receives_the_click() {
.unwrap();
// The button's own OnClick path — what the B binding and a plain backpack-button click run.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return HOOK_RAN").unwrap(),
@@ -2123,12 +2152,22 @@ fn the_bag_slots_carry_the_references_names_and_icon_names() {
);
}
// 0-based: CharacterBag0Slot is bag 1. Pinned because the off-by-one is the whole trap, and
// two name-building call sites had to learn it.
// 0-based: CharacterBag0Slot is bag 1, and the off-by-one is the whole trap. Asked the way the
// reference asks it, which is the only way there is now: the bar is stock
// `MainMenuBarBagButtons.xml` since 1751's third window, its buttons carry no `bagId` field,
// and every one of its handlers recomputes `this:GetID() - CharacterBag0Slot:GetID() + 1` at
// the call site. The ids themselves come from `GetInventorySlotInfo("Bag0Slot")` = 20 through
// the sourced `PaperDollItemSlotButton_OnLoad`, which is what makes the arithmetic work.
assert_eq!(
s.eval::<i64>("return CharacterBag0Slot.bagId").unwrap(),
1,
"CharacterBag0Slot IS bag 1 — the reference names them 0-based"
s.eval::<i64>("return CharacterBag0Slot:GetID()").unwrap(),
20,
"CharacterBag0Slot's id is its INVENTORY slot — 8 corpus addons compute `GetID() - 19`"
);
assert_eq!(
s.eval::<i64>("return CharacterBag3Slot:GetID() - CharacterBag0Slot:GetID() + 1")
.unwrap(),
4,
"the reference's own translation: the fourth button is bag 4"
);
// Bagnon's exact reach.
@@ -455,11 +455,11 @@ fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference
s.tick(0.133); // the opaque peak — the instant the card is most visible
s.resolve();
assert!(
shown(&s, "MainMenuBarBackpackButtonItemAnim"),
shown(&s, "MainMenuBarBackpackButtonBenillaItemPush"),
"the card plays"
);
let card = rect(&s, "MainMenuBarBackpackButtonItemAnim");
let card = rect(&s, "MainMenuBarBackpackButtonBenillaItemPush");
let bar = rect(&s, "MultiBarBottomRight");
assert!(
(card.1 - 48.9).abs() < 0.5 && (card.3 - 93.1).abs() < 0.5,
@@ -49,7 +49,6 @@ fn load_room(s: &UiScript) {
"UIParent.xml",
"GameTooltip.xml",
"Cooldown.xml",
"BagFrame.xml",
] {
load_xml(s, file);
}
@@ -107,7 +106,7 @@ fn backpack_with_jerky(s: &mut UiScript) -> String {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
s.resolve();
bag_slot_button(s, 0, 1)
@@ -75,7 +75,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
s.set_container(0, Some(one_item_backpack()));
// Open the bag and the loot window; drain the open kits (not under test here).
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.set_loot(Some(LootState {
fishing: false,
master_candidates: Vec::new(),
@@ -147,7 +147,7 @@ fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(
s.focus_editbox("ChatFrameEditBox"),
"the chat edit box focuses"
@@ -264,7 +264,7 @@ fn escape_closes_an_open_stack_split_frame() {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(bag_open(&s, 0), "the backpack window is up");
// Open the spinner with a real SHIFT + left-click on the button holding slot 1 — asked of the
@@ -311,7 +311,7 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
load_xml(&s, "MerchantFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(bag_open(&s, 0));
s.set_unit(
@@ -405,7 +405,7 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
load_xml(&s, "MerchantFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(bag_open(&s, 0));
@@ -347,37 +347,17 @@ const KNOWN: &[Known] = &[
},
// ── id ─────────────────────────────────────────────────────────────────────────────────────
//
// The reference side of this comparison is XML only: the sweep cannot see a `SetID` the
// reference makes from Lua at `OnLoad`, so a frame the reference numbers *there* reads as 0
// here. Every entry below is that case, and each ends at the same runtime number ours declares
// — which is why they are accepted rather than fixed. (The blind spot only ever under-reports:
// it can hide a divergence, never invent one, so nothing it misses is a false failure.)
Known {
frame: "CharacterBag0Slot",
flag: Flag::Id,
why: "the bag slots are numbered from Lua in the reference (ContainerFrame.lua's bag-slot \
OnLoad); ours declares the same inventory slot in XML",
},
Known {
frame: "CharacterBag1Slot",
flag: Flag::Id,
why: "as CharacterBag0Slot",
},
Known {
frame: "CharacterBag2Slot",
flag: Flag::Id,
why: "as CharacterBag0Slot",
},
Known {
frame: "CharacterBag3Slot",
flag: Flag::Id,
why: "as CharacterBag0Slot",
},
Known {
frame: "KeyRingButton",
flag: Flag::Id,
why: "-2 is KEYRING_CONTAINER, which the reference also arrives at — from Lua, not XML",
},
// Empty, and it is worth saying why rather than leaving a bare header. The reference side of
// this comparison is XML only: the sweep cannot see a `SetID` the reference makes from Lua at
// `OnLoad`, so a frame the reference numbers *there* used to read as 0 here while ours
// declared the number in XML. The five entries that lived here were all one case — the bag
// bar's `CharacterBag0..3Slot` and `KeyRingButton`.
//
// 1751's third window deleted them by making the difference not exist: the bar IS
// `Interface\FrameXML\MainMenuBarBagButtons.xml` now, so those ids come from
// `PaperDollItemSlotButton_OnLoad`'s `GetInventorySlotInfo` and from `this:SetID(
// KEYRING_CONTAINER)` — the reference's own Lua, on both sides. This gate reported them the
// frame it was built to report (1751 §5: the drift instruments retire with the copies).
// ── parent ─────────────────────────────────────────────────────────────────────────────────
//
// Three, and each is a seat inside the SAME tree the reference seats it in — which is the
@@ -204,7 +204,7 @@ fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_
s.set_container(0, Some(backpack()));
// A press with a window open is eaten by CloseAllWindows — the menu stays down.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
s.run("ToggleGameMenu()").unwrap();
assert!(!bag_open(&s, 0), "the press closed the bag");
@@ -239,7 +239,7 @@ fn the_clicked_form_closes_everything_and_opens_the_menu_in_one_go() {
let mut s = bag_harness_with(&[], &["MerchantFrame.xml"]);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(bag_open(&s, 0));
s.run("ToggleGameMenu(1)").unwrap();
@@ -272,7 +272,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.set_loot(Some(LootState {
fishing: false,
master_candidates: Vec::new(),
@@ -441,7 +441,7 @@ fn vendor_leaves_an_already_open_backpack_alone() {
equip_bag(&mut s, 0, "Backpack", 16);
// Open the bag first (the 'B' toggle), then the vendor over it.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(bag_open(&s, 0), "fixture: the player's own bag is up first");
let _ = s.take_sounds();
s.set_merchant(Some(MerchantState::default()));
@@ -5,7 +5,7 @@
//! Since decision 1500 all four bars are player options that ship OFF, so most of what is here
//! raises the bar it is about first — through the same two globals the Options rows write.
use benilla_ui::script::{ActionSlot, QuadContent, SpellTooltipView, UiScript};
use benilla_ui::script::{ActionSlot, QuadContent, ScriptValue, SpellTooltipView, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
@@ -802,6 +802,131 @@ fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A held payload GHOSTS the wells it opens — the reference's `SetVertexColor(1, 1, 1, 0.5)` on
/// a shown grid ring (`ActionButton.lua:247`), which ours skipped from 0216 to 1781.**
///
/// `UI-Quickslot` is not a second ring. Measured off the shipped BLPs: `UI-Quickslot2`'s interior
/// is fully transparent (alpha 0) — a hollow border — while `UI-Quickslot`'s is solid black at
/// alpha 0.6 across its whole middle. So the grid swap does not *outline* an empty well, it
/// *fills* it, and at full alpha picking up a spell dropped a 60%-black square into every empty
/// slot on every raised bar at once. That is the director's report. Half alpha takes the well to
/// 30% black, where it reads as a drop target instead of a hole.
///
/// The occupied rings stay opaque, and letting go of the payload restores the wells — both
/// DIVERGENCES, chosen in 1782. In stock 1.12.1 the dim is fanned to every button and then never
/// cleared by anything: `SetNormalTexture(path)` reuses the region and writes only its texture
/// handle, and a 3-argument `SetVertexColor` is `SetVertexColor(r, g, b, currentAlpha)` — so the
/// first spell anyone picks up leaves every border at alpha 0x80 for the session (wow-re
/// `button-state-texture-path-setter.md` §2/§7, byte-verified). That is a stuck state keyed to
/// nothing the player can see. We take the look the dim was written for and let go of it with the
/// payload, which is what the last two assertions here pin.
#[test]
fn a_held_payload_ghosts_the_empty_wells_it_opens() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_SteelMelee".into()),
kind: 0x00,
action: 100,
count: 0,
consumable: false,
}),
);
// The raise comes AFTER the seed: `UIParent.xml`'s PLAYER_ENTERING_WORLD arm reads the
// server's bar-toggle byte once and would put an early raise straight back down.
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
show_bars(&s, &[1]);
s.resolve();
/// The drawn alpha of every ring quad wearing `path` — `None` colour is untinted white.
fn ring_alphas(s: &UiScript, path: &str) -> Vec<f32> {
s.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p == path => Some(color.map_or(1.0, |c| c[3])),
_ => None,
})
.collect()
}
let quickslot = "Interface\\Buttons\\UI-Quickslot";
let quickslot2 = "Interface\\Buttons\\UI-Quickslot2";
// Nothing in hand: the main bar's 12 always-visible wells wear the hollow ring, all opaque.
assert_eq!(ring_alphas(&s, quickslot), Vec::<f32>::new());
let resting = ring_alphas(&s, quickslot2);
assert_eq!(
resting.len(),
12,
"the 12 main wells; the multibar's are hidden"
);
assert!(
resting.iter().all(|a| *a == 1.0),
"a resting ring is opaque: {resting:?}"
);
// Payload up. Every well the grid opened is the FILLED plate at HALF alpha.
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
s.resolve();
let ghosts = ring_alphas(&s, quickslot);
assert_eq!(
ghosts.len(),
23,
"11 empty main wells swap art + MultiBarBottomLeft's 12 appear"
);
assert!(
ghosts.iter().all(|a| *a == 0.5),
"every grid ring is the ref's half-alpha ghost, not an opaque plate: {ghosts:?}"
);
// …and the one OCCUPIED button is untouched by it.
let occupied = ring_alphas(&s, quickslot2);
assert_eq!(occupied, vec![1.0], "the occupied ring does not ghost");
// A well that was ghosted and is then FILLED comes back opaque. This is the assertion that
// outlives the engine's own `SetVertexColor` divergence (1782): the day an absent alpha stops
// meaning 1.0 and starts meaning "whatever is already there", a `SetRing` that let the fourth
// argument default would carry the 0.5 straight into the occupied ring, and only this catches
// it — the HIDEGRID check below would not, because that path re-derives an EMPTY well.
s.set_action(
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Rogue_Ambush".into()),
kind: 0x00,
action: 101,
count: 0,
consumable: false,
}),
);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Number(2.0)]);
s.resolve();
assert_eq!(
ring_alphas(&s, quickslot2),
vec![1.0, 1.0],
"a ghosted well that gets an action is opaque again, payload still held"
);
// Letting go restores both halves — art and tint, which is why they share one setter.
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
s.resolve();
assert_eq!(ring_alphas(&s, quickslot), Vec::<f32>::new());
let after = ring_alphas(&s, quickslot2);
assert_eq!(after.len(), 12);
// (10 empty main wells back to the hollow ring + the 2 occupied ones.)
assert!(
after.iter().all(|a| *a == 1.0),
"the dim does not outlive the payload (the ref's own stuck-dim bug): {after:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The byte the row sends is the four globals, and the seed brings that byte back.**
///
/// The whole round trip, over the REAL bindings (`benilla_ui::script::action_bar_toggles`): the
@@ -733,7 +733,6 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
@@ -200,6 +200,137 @@ mod tests {
assert!(s.errors().is_empty(), "{:#?}", s.errors());
}
/// **The migration readiness probe** — which stock FrameXML file could be swapped in *today*,
/// asked of the running loader rather than guessed from a source scan.
///
/// ```text
/// cargo test -p benilla-app --lib chain_readiness_report -- --ignored --nocapture
/// ```
///
/// 1751 is a long migration — 88 manifest entries, three of them chain entries at the time this
/// was written — and the expensive question at every step is *which window is ready*. Picking by
/// eye means reading a stock file, listing the globals it calls, and grepping each one; that is
/// slow, and it is wrong in both directions. It over-reports (a name that exists in a comment
/// greps as present — `framexml-file-demand.py` states that crudeness about itself) and it
/// under-reports the things a grep cannot see at all: an XML element type the loader does not
/// build, a script handler nothing dispatches, an attribute silently dropped (1739 measured 151
/// of those), a template inherited before its definer.
///
/// So the probe does not analyse. It **loads the file** — the whole shipped manifest first, into
/// a fresh VM, exactly as a real run does, and then the candidate off the chain on top — and
/// reports what the loader and the VM actually said. That is ground truth: the same machinery
/// that would run it for real, answering the same question, with no model of the engine in
/// between that could be out of date.
///
/// **What a clean line does and does not mean.** It means the file *loads* — every element
/// built, every template resolved, every load-time body ran without raising. It does not mean
/// the window *works*: a verb that only a click reaches is not exercised by loading, and neither
/// is anything behind an event. Clean is "start here", not "done"; the window's own test module
/// and the director's eye are what finish it (§7).
///
/// **Loading on top of the manifest, not instead of it**, because that is the position a
/// migrated file occupies — every template it inherits is declared by an earlier entry, and
/// asking whether `ContainerFrame.xml` loads *alone* only measures that it has predecessors.
/// Names it collides with are simply overwritten in the probe VM, which is thrown away; the
/// probe is asking about load failures, not about who wins a collision (the manifest's order is
/// what settles those, and it is settled per-window when the swap is made).
///
/// Ignored because it stands up ~90 fresh VMs and each one loads the entire interface; it is an
/// instrument you run when choosing the next window, not a gate.
#[test]
#[ignore = "instrument: run by hand when choosing the next window to migrate"]
fn chain_readiness_report() {
let _data = benilla_formats::wow_data_or_skip!();
// The reference's OWN order, off the chain — never a hand-kept list here. A file's position
// in it is also the answer to "where does its manifest line go", so the report prints it.
let toc = String::from_utf8_lossy(
&super::read("Interface\\FrameXML\\FrameXML.toc").expect("the reference's own toc"),
)
.into_owned();
let stock: Vec<String> = toc
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#') && l.ends_with(".xml"))
.map(str::to_string)
.collect();
let migrated: Vec<String> = super::super::addons::Addon::builtin()
.toc
.files
.iter()
.filter(|f| super::is_chain_entry(f))
.map(|f| {
f.rsplit(['\\', '/'])
.next()
.unwrap_or(f.as_str())
.to_string()
})
.collect();
println!(
"\n=== 1751 migration readiness — {} stock windows ===",
stock.len()
);
println!(
"{:>3} {:<32} what stops it (empty = loads clean)",
"pos", "file"
);
let mut clean = Vec::new();
for (i, name) in stock.iter().enumerate() {
let pos = i + 1;
if migrated.iter().any(|m| m == name) {
println!("{pos:>3} {name:<32} — already migrated");
continue;
}
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
let base = super::super::manifest::load_default_ui(&s);
assert!(base.is_empty(), "the shipped manifest itself: {base:#?}");
s.resolve();
let before = s.errors().len();
let path = format!("Interface\\FrameXML\\{name}");
let addon = super::addon(vec![path.clone()]);
let mut said = addon.load_files(&s, std::slice::from_ref(&path));
s.resolve();
said.extend(s.errors().into_iter().skip(before));
if said.is_empty() {
clean.push((pos, name.clone()));
println!("{pos:>3} {name:<32} CLEAN");
} else {
// One line per distinct complaint, deduped and truncated: the same missing verb
// reported by twelve frames is one fact, and the tail of a Lua traceback is noise.
let mut seen: Vec<String> = Vec::new();
for e in said {
let one = e.lines().next().unwrap_or("").trim().to_string();
let one = if one.len() > 140 {
format!("{}…", &one[..140])
} else {
one
};
if !one.is_empty() && !seen.contains(&one) {
seen.push(one);
}
}
println!("{pos:>3} {name:<32} {} issue(s)", seen.len());
for one in seen.iter().take(6) {
println!(" · {one}");
}
if seen.len() > 6 {
println!(" · … and {} more", seen.len() - 6);
}
}
}
println!("\n=== loads clean today: {} ===", clean.len());
for (pos, name) in &clean {
println!(" {pos:>3} {name}");
}
}
/// A path is a chain entry; a bare name is ours. The one-line rule the manifest rests on.
#[test]
fn a_separator_is_what_makes_an_entry_the_players_own_file() {
+31 -7
View File
@@ -65,7 +65,25 @@ fn read(req: &str) -> Option<Vec<u8>> {
/// 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.
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
// (`ui_script/mod.rs`, `setup_script`); a test VM has to say so itself. Not optional since
// 1751's third window: stock `MainMenuBarBagButtons.lua`'s hovers pass these straight into
// `GameTooltip:SetText`, and `SetText(nil)` raises rather than showing an empty plate. Our
// deleted `BagFrame.xml` carried `X = X or "…"` fallbacks for exactly this gap; the real file
// is the better answer, and these tests already gate on the install.
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml",
// The reference's own `PaperDollItemSlotButton_*` family, sourced at manifest entry 2. Not
// optional since 1751's third window: stock `BagSlotButtonTemplate`'s OnLoad *is*
// `PaperDollItemSlotButton_OnLoad()`, which is what gives each bag button its inventory-slot
// id (20..23 via `GetInventorySlotInfo`), its six event registrations and its first paint.
"Interface\\FrameXML\\PaperDollFrame.lua",
// `TEXT()` — the reference's own identity-function wrapper, which stock
// `MainMenuBarBackpackButton`'s OnEnter calls (`GameTooltip:SetText(TEXT(BACKPACK_TOOLTIP)…)`)
// and `BagSlotButton_OnEnter` calls for `EQUIP_CONTAINER`. Manifest entry 3, and not optional
// for the bag bar since 1751's third window made that bar the reference's own.
"BasicControls.xml",
// `UIParent` itself: the twelve `ContainerFrame`s declare `parent="UIParent"`, and
// `updateContainerFrameAnchors` anchors each open bag to `frame:GetParent()` while
// `OpenAllBags` opens with `if not UIParent:IsVisible() then return end`. Without it the
@@ -76,16 +94,21 @@ pub(super) const BAG_UI: &[&str] = &[
"UiPanels.xml",
"GameTooltip.xml",
"Cooldown.xml",
// The bag BAR hangs off `MainMenuBarArtFrame` and seats itself one frame level above the bar
// art (`BenillaActionBarArt_SeatAbove`), both at LOAD — so without this the five buttons fall
// back to UIParent and sit at a level no production run ever puts them at.
// The bag BAR declares `parent="MainMenuBarArtFrame"`, resolved at LOAD — so without this the
// six buttons fall back to UIParent and sit at a level no production run ever puts them at.
// It also carries `MainMenuBar_UpdateKeyRing`, which is what puts the keyring on the bar.
"ActionBar.xml",
// `UpdateMicroButtons` — the KEYRING's own OnShow/OnHide calls it (ContainerFrame.lua l.117,
// l.137), because in the reference the keyring's existence moves the micro-button row.
"MicroMenu.xml",
"UIPanelTemplates.xml",
"Interface\\FrameXML\\ContainerFrame.xml",
"BagFrame.xml",
// `PaperDollItemSlotButtonTemplate`, which every bag button inherits — resolved at load, so it
// has to precede the bar exactly as it does in the manifest.
"ItemSlotButtonTemplates.xml",
// The bag BAR itself, the reference's own since 1751's third window: MainMenuBarBackpackButton,
// CharacterBag0..3Slot, KeyRingButton, `BagSlotButtonTemplate`, and `KEYRING_CONTAINER`.
"Interface\\FrameXML\\MainMenuBarBagButtons.xml",
// `StackSplitFrame` is not optional either: the reference's own
// `ContainerFrameItemButton_OnClick` calls `StackSplitFrame:Hide()` on EVERY plain click
// (ContainerFrame.lua l.581) before the pickup, and opens it on the shift fork.
@@ -94,9 +117,10 @@ pub(super) const BAG_UI: &[&str] = &[
// `if ( ChatFrameEditBox:IsShown() )` (ContainerFrame.lua l.569) to decide between posting the
// item's link and splitting the stack, so a VM without it raises before either.
"ChatFrame.xml",
// Our adapters over the reference's container files — one wrapper for the keyring tooltip, and
// the three bag verbs 0561 shadows (`OpenBackpack`/`CloseBackpack`/`CloseAllBags`). It has to
// be AFTER `ContainerFrame.xml` for either job, which is why it is here and not up with
// Our adapters over the reference's container files — the keyring tooltip wrapper, the three
// bag verbs 0561 shadows (`OpenBackpack`/`CloseBackpack`/`CloseAllBags`), and the item-push
// card the reference draws with a `<Model>` this engine does not render (0887). It has to be
// AFTER `ContainerFrame.xml` and after the bar, which is why it is here and not up with
// UiPanels.xml.
"ContainerFrameAdapters.xml",
// `updateContainerFrameAnchors` measures every open bag against `BankFrame:GetRight()`
@@ -34,7 +34,6 @@ fn harness() -> UiScript {
"ItemRef.xml",
"MerchantFrame.xml",
"Cooldown.xml",
"BagFrame.xml",
"CharacterFrame.xml",
] {
load_xml(&s, f);
@@ -159,7 +158,7 @@ fn shift_compare_over_a_bag_item_seats_on_the_doll_slot() {
// 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("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
let btn = bag_slot_button(&s, 0, 1);
hover(&mut s, &btn);
@@ -308,7 +307,7 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
// Arm a compare first through a BAG hover (the stale-arm hazard the doll hover must clear) —
// the reference's own `ContainerFrameItemButton_OnEnter` since 1751, reached by moving the
// mouse onto the button rather than by calling the handler.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
let btn = bag_slot_button(&s, 0, 1);
hover(&mut s, &btn);