//! **"Only the frame header and the gold text. No bag slots at all."** — the director's Bagnon //! report, reproduced end to end and then closed. //! //! ## Why this file exists //! //! The addon survey scored Bagnon `loaded, missing=0, session=ok, probe=ok` — every column green — //! while the window on the director's screen was empty. Every one of those columns asks whether //! something *raised*; none of them asks whether anything was **drawn**. Two independent faults //! were hiding in that gap, and only one of them raises at all: //! //! | | what it does | does it raise? | //! |---|---|---| //! | `UnitName("player")` is nil at addon file scope | Bagnon reads the live bags as another character's offline cache → **0 slots**, window shown | **no** | //! | the `SetItemButton*` family is missing | the per-slot update dies on the first slot → loop aborts, window never shown | yes, once | //! //! The director saw the first (their `Bagnon_Core` loaded at startup, so `currentPlayer` was //! captured nil). Fix only that and you get the second. So the tests below pin **both**, and the //! headline is a quad count, not an error count: [`bagnon_draws_a_slot_for_every_bag_slot`] is //! green only when 16 item-slot quads actually reach the render list. //! //! Nothing from the corpus is committed, and every test here skips cleanly on a machine without //! it — the `ui_chat::ace_gate_tests` rule. use std::path::{Path, PathBuf}; use benilla_ui::script::{AddOnInfo, ContainerSlot, ContainerState, QuadContent, UiScript}; use benilla_ui::toc::Toc; /// Where the vanilla addon corpus might be — `$BENILLA_ADDON_CORPUS`, else a sibling checkout /// resolved from this crate's manifest (a pool worktree's cwd is not stable across tool calls). fn corpus_candidates() -> Vec { let mut out = Vec::new(); if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") { out.push(PathBuf::from(over)); } let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); for up in [2usize, 3, 4] { if let Some(root) = manifest.ancestors().nth(up) { out.push(root.join("wow-addons-vanilla")); } } out } /// The corpus root, or `None` — **a skip, never a failure**: it is third-party content that is /// deliberately not in this repo. fn corpus() -> Option { corpus_candidates().into_iter().find(|c| c.is_dir()) } macro_rules! corpus_or_skip { () => { match corpus() { Some(root) => root, None => { eprintln!( "skipping: no vanilla addon corpus — looked in {:?} (set $BENILLA_ADDON_CORPUS)", corpus_candidates() ); return; } } }; } /// The corpus **and** a client install — for the tests whose subject is a global that comes from /// the reference file this client SOURCES off the player's own patch chain rather than shipping /// ([`super::reference_ui`], decision 1234). /// /// `corpus_or_skip!` alone is the wrong precondition for them. With the corpus present but no /// install, `ContainerFrameItemButton_OnEnter`/`_OnClick` are *legitimately* nil — that is the /// documented "no install, no file" behaviour, not a defect — and the test then fails for a reason /// that is not a bug. `reference_ui`'s own header already says tests that need the file gate on the /// install the way every other client-data test does; these four never got that gate when 1234 /// added the sourcing seam and the tests in one landing. /// /// It stayed invisible because it only bites where the install is *not* found, which on a dev /// machine is only `scripts/gates.sh`'s `player-tests` rung: `--no-default-features` compiles out /// the `dev` project-folder candidate, so the `WoW` symlink beside the worktree stops being visible. macro_rules! corpus_and_install_or_skip { () => {{ let _data = benilla_formats::wow_data_or_skip!(); corpus_or_skip!() }}; } fn read_toc(root: &Path, name: &str) -> Toc { let path = root.join(name).join(format!("{name}.toc")); Toc::parse(&benilla_ui::source::decode( &std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())), )) } /// One addon's `.toc` files through the same two arms the real loader uses — `.lua` as a chunk, /// anything else as FrameXML — with the AddOns root as the provider's path space (decision 1186). fn load_addon_files(script: &UiScript, root: &Path, name: &str) -> Vec { let toc = read_toc(root, name); let provider = |req: &str| -> Option> { std::fs::read(root.join(req)).ok() }; let mut errors = Vec::new(); for file in &toc.files { let path = benilla_ui::loader::join_ref(name, file); let Ok(bytes) = std::fs::read(root.join(&path)) else { errors.push(format!("{file}: not found")); continue; }; if file.to_ascii_lowercase().ends_with(".lua") { // Named as the client names it — the corpus parses its own tracebacks. if let Err(e) = script.run_chunk_named(&bytes, &benilla_ui::script::addon_chunk_name(name, file)) { errors.push(format!("{file}: {e}")); } continue; } match benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes)) { Ok(doc) => { let report = benilla_ui::loader::load_in(script, &doc, &path, &provider); errors.extend(report.errors.into_iter().map(|e| format!("{file}: {e}"))); } Err(e) => errors.push(format!("{file}: {e}")), } } errors } /// The director's exact installed set. Bagnon and Bagnon_Options are `## LoadOnDemand: 1`, so the /// startup walk skips them and `LoadAddOn` brings them in — which is why the registry below /// carries a real root. const INSTALLED: &[&str] = &[ "!OmniCC", "Bagnon", "Bagnon_Core", "Bagnon_Forever", "Bagnon_Options", "MapCoords", ]; /// Whether the VM gets its `"player"` before the addons load — i.e. whether /// [`super::seat_from_roster`] ran. #[derive(Clone, Copy, PartialEq, Eq)] enum Seat { /// What the app does now: the roster row goes in ahead of `load_ingame_ui`. BeforeAddons, /// What it did before the fix: nothing writes `"player"` until `ui_unit::feed_units` sees the /// self descriptor, seconds later. AfterAddons, } /// The roster row the director's session would carry, and the same struct the app reads — /// so the seat under test is built by [`super::seat_from_roster`] itself, not by a copy of it. fn roster() -> crate::char_select::Roster { let row = benilla_protocol::Character { guid: 7, name: "Harness".into(), race: 1, // Human → Alliance class: 1, // Warrior gender: 0, level: 60, skin: 0, face: 0, hair_style: 0, hair_color: 0, facial_hair: 0, zone: 0, map: 0, position: benilla_protocol::wire::Vector3d { x: 0.0, y: 0.0, z: 0.0, }, flags: 0, equipment: [benilla_protocol::CharEnumItem::default(); 19], }; crate::char_select::Roster::with_pending_pick(vec![row], 7) } /// A VM shaped like the director's session: our whole interface, their installed addons walked in /// dependency order the way `addons::load_third_party` walks them, and a real backpack. fn seat(root: &Path, seat: Seat) -> UiScript { let mut s = UiScript::new().expect("VM"); s.set_screen_size(1024.0, 768.0); let mut registry: Vec = INSTALLED .iter() .map(|n| super::addons::info_from_toc(n, &read_toc(root, n))) .collect(); s.register_addons(registry.clone(), Some(root.to_path_buf()), None, None); s.set_realm_name("Harness"); let player = super::seat_from_roster(&roster()).expect("a pending pick seats a player"); if seat == Seat::BeforeAddons { s.set_unit("player", Some(player.clone())); } let failures = super::load_default_ui(&s); assert!( failures.is_empty(), "our own FrameXML failed to load: {failures:#?}" ); // A real backpack — 16 slots, one occupied. `GetContainerNumSlots(0)` answering 16 is what // Bagnon's LIVE path reads; the whole bug is that it took a different path and never asked. // // The slot carries a real `item_id` and `link`, not just an icon: the render half only ever // needed the texture, but the INTERACTION half rides `GameTooltip:SetBagItem` (which requires // a resolved instance) and `PickupContainerItem` (which needs something to pick up), so a // texture-only slot would make every hover and every click a silent no-op — a fixture that // hides the very thing the test is for. let mut slots = std::collections::HashMap::new(); slots.insert( 1, ContainerSlot { texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()), count: 1, item_id: 4496, quality: Some(1), link: Some("|cffffffff|Hitem:4496:0:0:0|h[Small Brown Pouch]|h|r".into()), ..Default::default() }, ); s.set_container( 0, Some(ContainerState { name: Some("Backpack".into()), num_slots: 16, slots, }), ); // The startup walk: dependencies first, LoadOnDemand skipped, one `ADDON_LOADED` each. let mut done: Vec = Vec::new(); for name in INSTALLED { walk(&mut s, root, name, &mut done, &mut registry); } // Re-registered with the startup set marked loaded, so `LoadAddOn` below meets satisfied // dependencies exactly as it does in a real session. s.register_addons(registry, Some(root.to_path_buf()), None, None); for event in ["VARIABLES_LOADED", "PLAYER_LOGIN"] { s.fire_event(event, Vec::new()); } // The self descriptor's arrival: `ui_unit::feed_units` pushes the player and fires // PLAYER_ENTERING_WORLD off it. In the `AfterAddons` arm this is the FIRST time the VM has // ever had a `"player"` — which is the state this file exists to reproduce. if seat == Seat::AfterAddons { s.set_unit("player", Some(player)); } s.fire_event("PLAYER_ENTERING_WORLD", Vec::new()); for _ in 0..10 { s.tick(0.1); } s } fn walk(s: &mut UiScript, root: &Path, name: &str, done: &mut Vec, reg: &mut [AddOnInfo]) { if done.iter().any(|d| d.eq_ignore_ascii_case(name)) { return; } let toc = read_toc(root, name); if toc.load_on_demand() { return; // the reference's `0x51f600` loads only records whose LoadOnDemand byte is 0 } done.push(name.to_string()); for dep in toc.dependencies() { if INSTALLED.iter().any(|n| n.eq_ignore_ascii_case(dep)) { walk(s, root, dep, done, reg); } } load_addon_files(s, root, name); if let Some(i) = reg.iter().position(|a| a.name.eq_ignore_ascii_case(name)) { reg[i].loaded = true; } s.fire_event( "ADDON_LOADED", vec![benilla_ui::script::ScriptValue::Str(name.to_string())], ); } /// Every item-slot quad **Bagnon's own buttons** drew — the `UI-Quickslot2` ring, which is the one /// texture an empty slot still paints (an occupied slot adds its icon on top). /// /// **Attributed, not counted.** Our own `BenillaBagFrame` paints sixteen of exactly this texture, /// so a bare total reads 16 whether Bagnon drew nothing or our window did — the "an addon replaces, /// it does not add" trap. Every quad is charged to the nearest named frame above it /// ([`UiScript::target_owner_name`]) and only `BagnonItem*` counts. fn bagnon_slot_quads(s: &mut UiScript) -> usize { s.resolve(); s.extract() .iter() .filter(|q| { matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("UI-Quickslot2")) }) .filter(|q| { s.target_owner_name(q.target) .is_some_and(|n| n.starts_with("BagnonItem")) }) .count() } /// Bagnon's window, open, with the fixture's backpack in it — the state every interaction test /// below starts from. `ToggleBackpack` is Bagnon's own replacement, and its first call /// demand-loads the `Bagnon` addon (the frame does not exist before that). fn open_bagnon(root: &Path) -> UiScript { let mut s = seat(root, Seat::BeforeAddons); s.run("ToggleBackpack()").expect("ToggleBackpack"); for _ in 0..3 { s.tick(0.1); } assert!( s.eval::("return Bagnon and Bagnon:IsVisible() or false") .unwrap(), "the window must be on screen before anything is clicked" ); s.resolve(); s } /// The centre of a named frame, in the y-up UI space [`UiScript::mouse_move`] / /// [`UiScript::mouse_button`] take — the same space `resolve`/`extract` use, so a frame's own /// `GetLeft`/`GetBottom` read straight back as a cursor position. fn centre_of(s: &mut UiScript, name: &str) -> (f32, f32) { s.resolve(); let r: Vec = s .eval(&format!( "local f = getglobal(\"{name}\") \ return {{ f:GetLeft(), f:GetBottom(), f:GetWidth(), f:GetHeight() }}" )) .unwrap_or_else(|e| panic!("{name}: no resolved rect: {e}")); assert_eq!(r.len(), 4, "{name}: unresolved rect {r:?}"); (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) } /// The name of the Bagnon item button showing backpack slot `slot`. Bagnon lays its buttons out in /// its own order and `SetID`s each to the game slot, so the button holding the fixture's one item /// is found by asking, never by assuming an index. fn bagnon_button_for_slot(s: &UiScript, bag: i64, slot: u32) -> String { s.eval::(&format!( "for i = 1, 36 do \ local b = getglobal(\"BagnonItem\"..i) \ if b and b:GetID() == {slot} and b:GetParent():GetID() == {bag} then \ return \"BagnonItem\"..i \ end \ end \ return \"\"" )) .inspect(|n| assert!(!n.is_empty(), "no BagnonItem* is bag {bag} slot {slot}")) .expect("the item-button scan") } /// Press and release the named button over `name`, the way a player's mouse does — through /// `mouse_button`, so the whole engine path runs (hit test, `RegisterForClicks` gate, the frame's /// own `OnClick`), never `s.run("Handler(...)")`. fn click(s: &mut UiScript, name: &str, button: &str) { let (x, y) = centre_of(s, name); s.mouse_move(x, y); s.mouse_button(x, y, button, true); s.mouse_button(x, y, button, false); } /// **The headline, and the director's own symptom.** Press B with Bagnon installed and every one /// of the backpack's 16 slots must reach the render list. /// /// This is the test that was red before the two fixes and is green after — and the *number* is the /// point, not the absence of an error: before, this VM raised nothing at all on the path the /// director was on. #[test] fn bagnon_draws_a_slot_for_every_bag_slot() { let root = corpus_or_skip!(); let mut s = seat(&root, Seat::BeforeAddons); // Bagnon's own entry point: it replaces `ToggleBackpack`, and its replacement demand-loads the // `Bagnon` addon on the first call (the frame does not exist before that). s.run("ToggleBackpack()").expect("ToggleBackpack"); for _ in 0..3 { s.tick(0.1); } assert!( s.eval::("return Bagnon and Bagnon:IsVisible() or false") .unwrap(), "the window must be on screen" ); assert_eq!( s.eval::("return Bagnon.size").unwrap(), 16, "Bagnon must size itself off the LIVE backpack (GetContainerNumSlots(0) == 16), not off \ Bagnon_Forever's empty offline cache — nil UnitName('player') is what sent it there" ); assert_eq!( bagnon_slot_quads(&mut s), 16, "sixteen bag slots must actually be DRAWN — the director saw a title and a gold line and \ nothing else, while every column of the addon survey called this addon fine" ); assert!( s.errors().is_empty(), "and nothing may raise on the way: {:#?}", s.errors() ); } /// **The control that makes the headline mean something: reproduce the director's screen.** /// /// With the player seated only *after* the addons load — benilla's own order until /// [`super::seat_from_roster`] landed — Bagnon shows a window whose title and money frame are /// right and which contains **not one slot**, and raises nothing doing it. If this ever starts /// drawing slots, the headline above has stopped testing the thing it was written for. #[test] fn without_a_player_at_addon_load_bagnon_draws_an_empty_window() { let root = corpus_or_skip!(); let mut s = seat(&root, Seat::AfterAddons); s.run("ToggleBackpack()").expect("ToggleBackpack"); for _ in 0..3 { s.tick(0.1); } // The two things the director COULD see, exactly as reported. assert!( s.eval::("return Bagnon:IsVisible()").unwrap(), "the window is up" ); assert_eq!( s.eval::("return BagnonTitle:GetText()").unwrap(), "Harness's Inventory", "…with its header" ); // And the thing they could not. assert_eq!( bagnon_slot_quads(&mut s), 0, "no slot is drawn — this is the report, reproduced" ); assert!( s.eval::("return tostring(BagnonItem1)").unwrap() == "nil", "not one item button was even created" ); } /// The **other** fault, pinned on its own so a later regression names itself: the reference's /// `SetItemButton*` family (`assets/ui/ItemButtonTemplate.xml`). /// /// Bagnon ends every slot update with `SetItemButtonDesaturated` / `SetItemButtonTexture` / /// `SetItemButtonCount`. While those were nil the raise landed inside `BagnonFrame_AddBag`'s /// per-slot loop, so the FIRST slot aborted it, `frame.size` never advanced past 0 and the window /// never reached its own `frame:Show()`. That is a different picture from the one above — nothing /// on screen at all — and it is what the identity fix alone would have produced. #[test] fn the_item_button_helpers_paint_a_slots_icon_and_count() { let mut s = UiScript::new().expect("VM"); s.set_screen_size(1024.0, 768.0); let failures = super::load_default_ui(&s); assert!(failures.is_empty(), "our own FrameXML: {failures:#?}"); // A button shaped the way the family's naming contract requires, built the way an addon does. s.run( r#" local b = CreateFrame("Button", "ProbeItemButton", UIParent) b:SetWidth(37) b:SetHeight(37) b:SetPoint("CENTER", UIParent, "CENTER", 0, 0) local icon = b:CreateTexture("ProbeItemButtonIconTexture", "BORDER") icon:SetAllPoints(b) local count = b:CreateFontString("ProbeItemButtonCount", "OVERLAY", "NumberFontNormal") count:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", -5, 2) count:Hide() "#, ) .expect("probe button"); s.run(r#"SetItemButtonTexture(ProbeItemButton, "Interface\\Icons\\INV_Misc_Bag_08")"#) .expect("SetItemButtonTexture"); s.run("SetItemButtonCount(ProbeItemButton, 5)") .expect("SetItemButtonCount"); s.run("SetItemButtonDesaturated(ProbeItemButton, 1, 0.5, 0.5, 0.5)") .expect("SetItemButtonDesaturated"); // Attributed to the probe button, never to the whole screen: the default UI paints its own // "1".."0" action-bar hotkeys, so a bare "is there a quad reading 1 anywhere" would answer yes // whatever these functions did. The `!` case below is the one that would have been fooled. let count_text = |s: &UiScript, want: &str| { s.extract().iter().any(|q| { matches!(&q.content, QuadContent::Text { text, .. } if text.as_deref() == Some(want)) && s.target_owner_name(q.target).as_deref() == Some("ProbeItemButton") }) }; s.resolve(); assert!( s.extract().iter().any(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("INV_Misc_Bag_08"))), "the icon the addon asked for must be on the region" ); assert!( count_text(&s, "5"), "a stack of 5 shows its count (>1 is the reference's own gate)" ); // The no-shader branch: benilla has no `Texture:SetDesaturated`, so a locked slot takes the // reference's own fixed-function fallback — a flat 0.5 grey tint on the icon. let tint = s .eval::>("return {ProbeItemButtonIconTexture:GetVertexColor()}") .expect("GetVertexColor"); assert_eq!( &tint[..3], &[0.5, 0.5, 0.5], "a locked item greys out (ref: `elseif not r or not shaderSupported`)" ); // A count of 1 hides the label again — the reference's gate, not a one-way switch. s.run("SetItemButtonCount(ProbeItemButton, 1)").unwrap(); s.resolve(); assert!( !count_text(&s, "1") && !count_text(&s, "5"), "a single item shows no count" ); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } /// The seat itself, in isolation: what the roster row actually puts in the VM, and the `None` that /// keeps a capture (no pick, no roster) unchanged. #[test] fn the_roster_seat_names_the_character_the_addons_will_meet() { let seat = super::seat_from_roster(&roster()).expect("a pending pick seats a player"); assert_eq!(seat.name.as_deref(), Some("Harness")); assert_eq!(seat.level, 60); assert_eq!(seat.race_file.as_deref(), Some("Human")); assert_eq!(seat.class_file.as_deref(), Some("WARRIOR")); assert_eq!(seat.sex, 2, "the wire's 0 is UnitSex's 2"); assert_eq!( seat.faction_group.as_deref(), Some("Alliance"), "nil here is 24 corpus addons stopping on AceDB-2.0's file-scope concatenation" ); assert!(seat.exists && seat.is_player); // Deliberately NOT invented — the descriptor says these, within the second. assert_eq!((seat.health, seat.max_health), (0, 0)); assert!( super::seat_from_roster(&crate::char_select::Roster::default()).is_none(), "no pick, no seat — a capture must load exactly as it did before" ); } // ───────────────────────────────────────────────────────────────────────────────────────────── // The INTERACTION half — the layer the render half could not see // ───────────────────────────────────────────────────────────────────────────────────────────── // // The tests above prove Bagnon's window is *drawn*. The director's next report was that it is // **dead to the touch**: hover raises, click raises, and the console fills up. Every column of the // addon survey — load, session, probe, and 1230's render column — is blind to that, for one // structural reason: **none of them clicks**. So these do, through // [`UiScript::mouse_move`]/[`UiScript::mouse_button`], which is the whole engine path a player's // mouse takes (hit test → `RegisterForClicks` gate → the frame's own `OnClick`), never a direct // `s.run("Handler(...)")` that would prove only that a function exists. // // `the_whole_bagnon_window_survives_being_used` below is the *enumerator*: it drives every // gesture in one VM and asserts the error list is empty. A scan can only find what it knows to // look for; this finds whatever is actually missing. /// **Bug 1, hover.** `Bagnon_Core/core/Item.lua:136` ends its normal-bag branch in /// `ContainerFrameItemButton_OnEnter(item)` — the reference's own global. It was nil, so every /// hover over every slot raised and no tooltip ever appeared. #[test] fn hovering_a_bagnon_slot_shows_the_items_tooltip() { let root = corpus_and_install_or_skip!(); let mut s = open_bagnon(&root); let occupied = bagnon_button_for_slot(&s, 0, 1); let (x, y) = centre_of(&mut s, &occupied); s.mouse_move(x, y); assert!( s.errors().is_empty(), "a hover must not raise: {:#?}", s.errors() ); assert!( s.eval::(&format!("return GameTooltip:IsOwned({occupied})")) .unwrap(), "the tooltip must belong to the hovered slot ({occupied})" ); assert!( s.eval::("return GameTooltip:IsShown()").unwrap(), "…and be on screen" ); // …and it drops again on the way out, the template's own OnLeave. let (ex, ey) = (x, y + 400.0); s.mouse_move(ex, ey); assert!( !s.eval::("return GameTooltip:IsShown()").unwrap(), "moving off the slot hides the tooltip" ); assert!(s.errors().is_empty(), "…without raising: {:#?}", s.errors()); } /// **Bug 1, left click.** `Item.lua:105` routes a plain click to /// `ContainerFrameItemButton_OnClick(mouseButton, ignoreModifiers)`, whose unmodified left arm is /// `PickupContainerItem(this:GetParent():GetID(), this:GetID())`. The observable is the cursor. #[test] fn left_clicking_a_bagnon_slot_picks_the_item_up() { let root = corpus_and_install_or_skip!(); let mut s = open_bagnon(&root); let occupied = bagnon_button_for_slot(&s, 0, 1); assert!( !s.eval::("return CursorHasItem()").unwrap(), "nothing is held before the click" ); click(&mut s, &occupied, "LeftButton"); assert!( s.errors().is_empty(), "a left click must not raise: {:#?}", s.errors() ); assert!( s.eval::("return CursorHasItem()").unwrap(), "the item is now on the cursor" ); // …and clicking an empty slot puts it back down, through the same one entry point. let empty = bagnon_button_for_slot(&s, 0, 2); click(&mut s, &empty, "LeftButton"); assert_eq!( s.take_container_moves(), vec![benilla_ui::script::ContainerMove { src_bag: 0, src_slot: 1, dst_bag: 0, dst_slot: 2, count: None, }], "the place half of the same gesture" ); assert!(s.errors().is_empty(), "…nor the place: {:#?}", s.errors()); } /// **Bug 1, right click.** The reference's right arm with no merchant open is /// `UseContainerItem(bag, slot)` — use/equip the item. #[test] fn right_clicking_a_bagnon_slot_uses_the_item() { let root = corpus_and_install_or_skip!(); let mut s = open_bagnon(&root); let occupied = bagnon_button_for_slot(&s, 0, 1); click(&mut s, &occupied, "RightButton"); assert!( s.errors().is_empty(), "a right click must not raise: {:#?}", s.errors() ); assert_eq!( s.take_container_uses(), vec![(0, 1)], "right-click uses the slot" ); } /// **Bug 1, drag.** Bagnon registers `OnDragStart`/`OnReceiveDrag` on every item button and routes /// both to `BagnonItem_OnClick("LeftButton", 1)` → `ContainerFrameItemButton_OnClick`'s /// `ignoreModifiers` arm. Driven as a real gesture: press, move past the threshold, release. #[test] fn dragging_a_bagnon_slot_picks_the_item_up() { let root = corpus_and_install_or_skip!(); let mut s = open_bagnon(&root); let occupied = bagnon_button_for_slot(&s, 0, 1); let empty = bagnon_button_for_slot(&s, 0, 2); let (sx, sy) = centre_of(&mut s, &occupied); let (dx, dy) = centre_of(&mut s, &empty); s.mouse_move(sx, sy); s.mouse_button(sx, sy, "LeftButton", true); s.mouse_move(sx + 12.0, sy + 12.0); // past DRAG_START_THRESHOLD ⇒ OnDragStart s.mouse_move(dx, dy); s.mouse_button(dx, dy, "LeftButton", false); assert!( s.errors().is_empty(), "a drag must not raise: {:#?}", s.errors() ); assert_eq!( s.take_container_moves(), vec![benilla_ui::script::ContainerMove { src_bag: 0, src_slot: 1, dst_bag: 0, dst_slot: 2, count: None, }], "drag-and-drop moved the item" ); } /// **Bug 2 — our own handler, called the reference's way.** Bagnon takes /// `MainMenuBarBackpackButton:GetScript("OnEnter")` and calls it back with **no arguments** /// (`Bagnon.lua:87`), because that is the reference's contract: an XML script body takes nothing /// and reads the frame off the `this` global. Ours compiled to `function(self, ...)` and its body /// passed `self` straight into `GameTooltip:SetOwner`, so the hook handed it nil: /// /// ```text /// bad argument #2: error converting Lua nil to table /// BagFrame.xml:789: in function 'BenillaBagToggle_OnEnter' /// [string "MainMenuBarBackpackButton:OnEnter"]:2: in upvalue 'bMainBag_OnEnter' /// ``` #[test] fn the_backpack_button_still_hovers_with_bagnon_holding_its_script() { let root = corpus_or_skip!(); let mut s = open_bagnon(&root); // Bagnon really did take the script over — otherwise this test proves nothing. assert!( s.eval::( "return MainMenuBarBackpackButton:GetScript(\"OnEnter\") == BagnonBlizMainBag_OnEnter" ) .unwrap(), "Bagnon_AddBagHooks must have replaced the backpack button's OnEnter" ); let (x, y) = centre_of(&mut s, "MainMenuBarBackpackButton"); s.mouse_move(x, y); assert!( s.errors().is_empty(), "the hooked hover must not raise: {:#?}", s.errors() ); assert!( s.eval::("return GameTooltip:IsOwned(MainMenuBarBackpackButton)") .unwrap(), "our own tooltip still opens, owned by the button" ); } /// **Bug 3.** `Bagnon_Forever/database/ui.lua:61` sizes its character dropdown from /// `button:GetTextWidth()` on a **CheckButton** — a real 1.12 Button method (wow-re /// `widget-api-batch-benilla.md` Q8 lists it present, and `GetStringWidth` absent, on Button). #[test] fn a_button_reports_its_own_label_width() { let root = corpus_or_skip!(); let mut s = open_bagnon(&root); // Bagnon_Forever's exact shape: a CheckButton built from its own template, with a label. s.run( r#" local b = CreateFrame("CheckButton", "ProbeWidthButton", UIParent, "BagnonDBUINameBox") b:SetPoint("CENTER", UIParent, "CENTER", 0, 0) b:SetText("ProbeLabelText") "#, ) .expect("the probe button"); s.resolve(); // Answer the host measure for the button's OWN label, and nothing else — the assertion below // is then "the button forwarded to the right region and read the right metric", not merely // "some number came back". A headless VM has no font atlas, so an unanswered measure is 0 and // a `> 0` assertion would be untestable here (and unfalsifiable there). let req = s .fontstrings_needing_measure() .into_iter() .find(|r| r.text == "ProbeLabelText") .expect("the label asks to be measured"); s.set_measured_text_unwrapped(&[(req.id, 61.0, 12.0, req.key)]); assert_eq!( s.eval::("return ProbeWidthButton:GetTextWidth()") .expect("Button:GetTextWidth"), 61.0, "the Button reports its LABEL's natural extent" ); assert_eq!( s.eval::("return ProbeWidthButton:GetTextHeight()") .expect("Button:GetTextHeight"), 12.0 ); // A Button with no label at all answers 0 rather than raising — the reference dereferences a // FontString pointer here that a bare `CreateFrame("Button")` leaves null, and what it does // then is not byte-read, so this takes the harmless number. s.run(r#"CreateFrame("Button", "ProbeLabellessButton", UIParent)"#) .unwrap(); assert_eq!( s.eval::("return ProbeLabellessButton:GetTextWidth()") .unwrap(), 0.0 ); // The real consumer, end to end: Bagnon_Forever's own character list. s.run("BagnonDBUI_ShowCharacterList(Bagnon)") .expect("BagnonDBUI_ShowCharacterList"); assert!( s.errors().is_empty(), "the character list must not raise: {:#?}", s.errors() ); } /// **Bug 4.** `Bagnon_Core/core/Item.xml:34` hangs `` off every item button — the reference's own template, which /// we had never declared, so every one of the 36 buttons warned and the child came out bare. #[test] fn the_reference_cooldown_template_resolves() { let root = corpus_or_skip!(); let s = open_bagnon(&root); let occupied = bagnon_button_for_slot(&s, 0, 1); // The child exists and answers as a frame, whatever Bagnon does to it. assert!( s.eval::(&format!("return getglobal(\"{occupied}Cooldown\") ~= nil")) .unwrap(), "the template's $parentCooldown child must exist" ); // …and the one entry point every consumer drives it through does not raise on it. s.run(&format!( "CooldownFrame_SetTimer(getglobal(\"{occupied}Cooldown\"), GetTime(), 30, 1)" )) .expect("CooldownFrame_SetTimer on an addon's cooldown child"); assert!(s.errors().is_empty(), "{:#?}", s.errors()); } /// **The enumerator.** Every gesture a player makes in Bagnon's window, in one VM, with the error /// list asserted empty at the end. /// /// This is the test the arc was missing. Four landings in a row fixed the errors that happened to /// fire and left the next layer to be found by the director playing, because every instrument we /// had asks whether something *raised at load* and none of them touches the window. A scan finds /// what it knows to look for; this finds what is actually missing, because it does what a player /// does. #[test] fn the_whole_bagnon_window_survives_being_used() { let root = corpus_or_skip!(); let mut s = open_bagnon(&root); // 1 · Every slot, hovered and left again — the OnEnter/OnUpdate/OnLeave loop on both an // occupied and an empty slot. for i in 1..=16 { let name = format!("BagnonItem{i}"); if s.eval::(&format!("return getglobal(\"{name}\") ~= nil")) .unwrap_or(false) { let (x, y) = centre_of(&mut s, &name); s.mouse_move(x, y); s.tick(0.4); // Bagnon's own 0.3s OnUpdate tooltip refresh s.mouse_move(x, y + 400.0); } } // 2 · The window's own furniture: the title (a tooltip), the bag row toggle, the close button. for name in ["BagnonTitle", "BagnonShowBags", "BagnonCloseButton"] { if s.eval::(&format!("return getglobal(\"{name}\") ~= nil")) .unwrap_or(false) { let (x, y) = centre_of(&mut s, name); s.mouse_move(x, y); } } click(&mut s, "BagnonShowBags", "LeftButton"); // show the bag row… // 3 · Bagnon's OWN bag buttons (the row it just revealed — `BagnonBags0..4` plus the // keyring's `BagnonBags-2`, from `Bagnon.xml`'s `$parent` naming): hover, click and // drag each. `BagnonBag_OnClick` is `PutItemInBackpack`/`PutItemInBag`, `BagnonBag_OnDrag` // is `PickupBagFromSlot` — three engine verbs, none of which existed here. for id in ["0", "1", "2", "3", "4", "-2"] { let name = format!("BagnonBags{id}"); if !s .eval::(&format!( "local b = getglobal(\"{name}\") return b ~= nil and b:IsVisible()" )) .unwrap_or(false) { continue; } let (x, y) = centre_of(&mut s, &name); s.mouse_move(x, y); s.mouse_button(x, y, "LeftButton", true); s.mouse_button(x, y, "LeftButton", false); s.mouse_button(x, y, "LeftButton", true); s.mouse_move(x + 12.0, y + 12.0); s.mouse_button(x + 12.0, y + 12.0, "LeftButton", false); } // 4 · The money frame's three coin buttons — `OpenCoinPickupFrame`, guarded in our own // MoneyFrame.xml and *not* guarded in Bagnon's copy of the same handler. for coin in ["Gold", "Silver", "Copper"] { let name = format!("BagnonMoneyFrame{coin}Button"); if s.eval::(&format!( "local b = getglobal(\"{name}\") return b ~= nil and b:IsVisible()" )) .unwrap_or(false) { click(&mut s, &name, "LeftButton"); } } // 5 · The bag-bar buttons Bagnon hooked on OUR bar — hover, click, and the keyring. for name in [ "MainMenuBarBackpackButton", "CharacterBag0Slot", "CharacterBag1Slot", "KeyRingButton", ] { if !s .eval::(&format!( "local b = getglobal(\"{name}\") return b ~= nil and b:IsVisible()" )) .unwrap_or(false) { continue; } let (x, y) = centre_of(&mut s, name); s.mouse_move(x, y); s.mouse_button(x, y, "LeftButton", true); s.mouse_button(x, y, "LeftButton", false); s.mouse_move(x, y + 400.0); } // **A CLOSED LIST, not "is it empty".** Everything Bagnon reaches that benilla does not have // is named here, so this test does two jobs at once: it holds the residual honest (each entry // is a gap somebody decided, with a reason, not one nobody noticed), and it fails LOUDLY on // any error class that is not on it. "Assert the list is empty" would have had to be deleted // or `#[ignore]`d while a known gap stands, and then it would be catching nothing at all. // // To retire an entry: implement it and delete the line. To add one: you had better be able to // say why, in the same sentence. const KNOWN_GAPS: &[(&str, &str)] = &[ // The bag-BUTTON cursor trio (`Bag.lua:199-215` — Bagnon's own bag row, click and drag). // Three 1.12 engine bindings benilla does not have: `PutItemInBackpack 0x4c8f70`, // `PutItemInBag 0x4c8f00`, `PickupBagFromSlot 0x4c8fa0` (wow-re `system/ui/ledger.tsv` // l.1962-1964 has the registrations verified; the DECOMPILATION on disk is partial — // Ghidra removed unreachable blocks from two of the three — so what each one actually // DOES is not read). // // **Deliberately not guessed.** The open question is whether `PutItemInBag(inv)` // auto-stores the held item into that bag's CONTENTS or places it onto the equipment slot, // and those are different opcodes. Writing a cursor transition on the wrong one moves a // player's items to the wrong place, which is the most expensive kind of wrong here // (the contract §4), so an RE cross-check is dispatched into wow-5875-re and these land on // its verdict, not before. ("PutItemInBackpack", "Bagnon_Core\\core\\Bag.lua:202"), ("PutItemInBag", "Bagnon_Core\\core\\Bag.lua:204"), ("PickupBagFromSlot", "Bagnon_Core\\core\\Bag.lua:214"), // Money on the cursor. `Frame.lua:537-543` — Bagnon's money frame calls this UNGUARDED // where benilla's own `MoneyFrame.xml:411` guards its copy of the same reference handler. // It is the head of a whole feature, not a missing line: the reference's // `CoinPickupFrame.lua`/`.xml` need `GetCursorMoney`, `PickupPlayerMoney`, a MONEY cursor // payload (wow-re `cursor-dragdrop-payload.md` row 2 — benilla's cursor has Item/Spell/ // Action only) and a keyboard-focused frame with `OnChar`/`OnKeyDown`, which this engine // deliberately does not have (see `EnableKeyboard` in the same landing's report). ("OpenCoinPickupFrame", "Bagnon_Core\\core\\Frame.lua:543"), ]; let raised = s.errors(); let unexplained: Vec<&String> = raised .iter() .filter(|e| !KNOWN_GAPS.iter().any(|(name, _)| e.contains(name))) .collect(); assert!( unexplained.is_empty(), "using Bagnon raised {} error(s) that are NOT on the known-gap list:\n{:#?}", unexplained.len(), unexplained ); // …and the other direction, so a gap that quietly closes does not leave a stale entry behind. for (name, site) in KNOWN_GAPS { assert!( raised.iter().any(|e| e.contains(name)), "`{name}` is listed as a known gap ({site}) but nothing raised on it — if it is \ built now, delete the entry" ); } } /// Bagnon's window with a **stacked** item in the backpack — the count FontString only paints when /// the stack is >1 (`SetItemButtonCount`), so the plain [`open_bagnon`] fixture's single Small /// Brown Pouch can never exercise it. fn open_bagnon_stacked(root: &Path) -> UiScript { let mut s = seat(root, Seat::BeforeAddons); let mut slots = std::collections::HashMap::new(); slots.insert( 1, ContainerSlot { texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()), count: 200, item_id: 4496, quality: Some(1), link: Some("|cffffffff|Hitem:4496:0:0:0|h[Small Brown Pouch]|h|r".into()), ..Default::default() }, ); s.set_container( 0, Some(ContainerState { name: Some("Backpack".into()), num_slots: 16, slots, }), ); s.run("ToggleBackpack()").expect("ToggleBackpack"); for _ in 0..3 { s.tick(0.1); } s.resolve(); s } /// The resolved `QuadContent::Text` for the one named FontString, or a panic naming what was found. fn text_quad(s: &mut UiScript, owner: &str) -> QuadContent { s.resolve(); s.extract() .iter() .find(|q| { matches!(&q.content, QuadContent::Text { text: Some(_), .. }) && s.target_owner_name(q.target).as_deref() == Some(owner) }) .map(|q| q.content.clone()) .unwrap_or_else(|| panic!("no text quad owned by {owner}")) } /// **The director's report: "item stack size text is missing the black bg."** /// /// Bagnon writes `` /// (`Bagnon_Core/core/Item.xml:14`) — the `font=` attribute, which the reference resolves against /// the font-object registry BEFORE the filesystem (`0x783d15 call 0x783870(value, create = 0)`; /// see [`super::super::loader`]'s `apply_fontstring_font`). We read it as a path only, so the /// literal string `"NumberFontNormal"` became the face: no height, no colour, and no /// `outline="NORMAL"` — and the atlas fell back silently to Friz 12. /// /// **The black is the OUTLINE, not a shadow.** `NumberFontNormal` carries no `` in 1.12 /// (`Fonts.xml:226`, matching the install byte for byte), so `shadow: None` here is correct and is /// asserted as such — a future "fix" that adds one would be a divergence, not an improvement. #[test] fn a_stack_count_wears_the_font_object_its_font_attr_names() { let root = corpus_or_skip!(); let mut s = open_bagnon_stacked(&root); let QuadContent::Text { text, font, font_height, outline, shadow, color, .. } = text_quad(&mut s, "BagnonItem1") else { unreachable!("filtered to Text above") }; assert_eq!(text.as_deref(), Some("200"), "the stack count is on screen"); // Every one of these was lost while `font=` was read as a path. The face is the assertion that // the registry was consulted at all; the outline is the one the director can SEE. assert_eq!( font.as_deref(), Some("Fonts\\ARIALN.TTF"), "`font=\"NumberFontNormal\"` must resolve the font OBJECT, not become a font path" ); assert_eq!(font_height, Some(14.0), "NumberFontNormal's height"); assert_eq!( outline, benilla_ui::script::Outline::Normal, "the black ring around a stack count IS `outline=\"NORMAL\"` — this is the reported symptom" ); assert_eq!( color, Some([1.0, 1.0, 1.0, 1.0]), "NumberFontNormal's white" ); assert_eq!( shadow, None, "and NO drop shadow: NumberFontNormal has no in 1.12 either (Fonts.xml:226). \ The readability is the outline; adding a shadow here would be a divergence" ); } /// **The director's report: the character dropdown draws as a solid WHITE box.** /// /// `BagnonPopupFrame` (`Bagnon_Core/core/Frame.xml:23-36`) backs itself with /// `Interface\ChatFrame\ChatFrameBackground` — art that is white by design — and tints it /// black→dark-grey with a ``. The loader parsed `` and `` but never /// ``, and dropped it in silence, so the white art rendered untinted. /// /// `` survives beside a `file=` where `` does not, and that asymmetry is the /// point: they land in different fields (vertex colours `+0xb8` vs the texture `+0xcc` — /// wow-re `texture-color-composition.md` §1-2). #[test] fn a_texture_gradient_tints_the_art_it_sits_on() { let root = corpus_or_skip!(); let mut s = open_bagnon_stacked(&root); s.run("BagnonDBUI_ShowCharacterList(Bagnon)") .expect("BagnonDBUI_ShowCharacterList"); for _ in 0..3 { s.tick(0.1); } s.resolve(); let bg = s .extract() .iter() .find(|q| { matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("ChatFrameBackground")) && s.target_owner_name(q.target).as_deref() == Some("BagnonDBUICharacterList") }) .map(|q| q.content.clone()) .expect("the popup's background art reaches the render list"); let QuadContent::Texture { color, .. } = bg else { unreachable!("filtered to Texture above") }; // The two stops are (0,0,0,0.9) and (0.2,0.2,0.2,0.9); `RegionData::gradient` is folded to its // midpoint by the paint, so this is 0.1 grey at the stops' shared alpha. `None` — or white — // is the bug: untinted ChatFrameBackground is the white slab on the director's screen. assert_eq!( color, Some([0.1, 0.1, 0.1, 0.9]), "the must tint the art; untinted this is the reported white box" ); } /// **The director's report: "when I first open the bags with bagnon the gold numbers are all /// cramped up; if I close and open again it looks good."** /// /// Bagnon's money display is `SmallMoneyFrameTemplate` — OUR `MoneyFrame.xml`. Its `ShowCoin` used /// to size each coin with `label:GetStringWidth()`, which is served from the measure round-trip and /// therefore reads **0 in the tick that set the text**: every coin came out at exactly one icon /// width and the digits overlapped. The second open looked right because the first open's measure /// had landed in the cache by then — the reopen was reading the previous open's numbers. /// /// The fix sums `BENILLA_DIGIT_W`, the app's per-digit advance feed /// ([`benilla_ui::script::UiScript::set_digit_advances`]) — data pushed ahead, so the answer exists /// *in* the tick. That feed's own doc names this frame as what it was built for; only the merchant /// price had ever used it. /// /// The assertion is the FIRST open, which is the half that was broken. #[test] fn a_money_frame_is_the_right_width_on_the_first_open() { let root = corpus_or_skip!(); let mut s = seat(&root, Seat::BeforeAddons); // 12345g 67s 89c — three denominations with different digit counts, so a width that ignored // the digits (the bug) cannot coincide with one that counts them. s.set_money(123_456_789); // The app feeds this once per atlas scale, before any window opens. A flat 8px advance makes // the expected widths exact arithmetic rather than a font-dependent number. s.set_digit_advances(&[8.0; 10]); s.run("ToggleBackpack()").expect("ToggleBackpack"); for _ in 0..3 { s.tick(0.1); } s.resolve(); let widths: Vec = s .eval( "local o = {} \ for _, n in ipairs({\"Gold\", \"Silver\", \"Copper\"}) do \ local b = getglobal(\"BagnonMoneyFrame\" .. n .. \"Button\") \ table.insert(o, b and b:GetWidth() or -1) \ end \ return o", ) .expect("the three coin buttons"); // MONEY_ICON_WIDTH_SMALL is 13; the digits are 8px each. 12345 → 5, 67 → 2, 89 → 2. assert_eq!( widths, vec![5.0 * 8.0 + 13.0, 2.0 * 8.0 + 13.0, 2.0 * 8.0 + 13.0], "each coin must be its digits PLUS its icon on the first open. All three coming back at \ 13.0 — the bare icon width — is the reported cramping: it means the width was taken from \ a text measure that had not landed yet" ); } /// The font a FontString/Button label actually resolved: `(path, height)`. fn resolved_font(s: &UiScript, lua_expr: &str) -> (String, String) { let r: Vec = s .eval(&format!( "local fs = {lua_expr} \ if not fs then return {{ \"MISSING\", \"MISSING\" }} end \ local p, h = fs:GetFont() \ return {{ tostring(p), tostring(h) }}" )) .expect("GetFont"); (r[0].clone(), r[1].clone()) } /// **A Button's ``/``/`` take `font=` as well as /// `inherits=`** — they are ``-TYPED elements, routed by `CSimpleButton::LoadXML 0x7788c0` /// at `0x778bf4` into the same `0x783c30` a top-level `` uses, so `font=` gets the identical /// registry-first treatment (`0x783d15` → `0x783d22 call 0x770c60`). /// /// We read only `inherits=`, so every corpus button declaring `font=` silently kept **no font /// object at all**. Bagnon does it at seven sites; `BagnonDBUINameBox` asks for /// `GameFontNormalLarge` (16px) and got nothing. Real FrameXML always writes `inherits=`, which is /// why nothing we ship ever noticed. /// /// `style=` is deliberately not read — it does not exist in 1.12.1 (an isolated-token scan returns /// zero against nine controls each returning one); it is a later-client idiom. #[test] fn a_button_state_font_takes_the_font_attribute_not_just_inherits() { let root = corpus_or_skip!(); let mut s = open_bagnon_stacked(&root); s.run("BagnonDBUI_ShowCharacterList(Bagnon)") .expect("BagnonDBUI_ShowCharacterList"); for _ in 0..3 { s.tick(0.1); } assert_eq!( resolved_font( &s, "getglobal(\"BagnonDBUICharacterList1\"):GetFontString()" ), ("Fonts\\FRIZQT__.TTF".into(), "16".into()), "`` must link the font object — 16px, not the \ 12px default, and emphatically not the nil this returned while only `inherits=` was read" ); } /// **A `` with no `font=` beside it is dead XML**, and so is a bare `outline=`. /// /// `CSimpleFontString::LoadXML`'s height/outline/monochrome block sits at `[0x77111e, 0x771254)` /// and its ONLY predecessor is the `font=`-names-a-file miss leg. With no `font=` at all, /// `0x7710f1`/`0x7710fa je 0x771254` jump straight past it: the attributes are never parsed. They /// have no independent existence — they are companions of a font FILE path, nothing more. /// /// The reference's own FrameXML contains exactly one such site, and it is the proof: `ZoneText.xml` /// gives `AutoFollowStatusText` `inherits="GameFontNormal"` (12px) **and** a /// ``. The real client draws that line at 12 and Blizzard's 20 never does /// anything. We honoured it — one of the few places benilla was drawing text at a size the /// reference does not. #[test] fn a_fontheight_with_no_font_attr_beside_it_is_never_read() { let root = corpus_or_skip!(); let s = open_bagnon_stacked(&root); assert_eq!( resolved_font(&s, "AutoFollowStatusText"), ("Fonts\\FRIZQT__.TTF".into(), "12".into()), "AutoFollowStatusText inherits GameFontNormal (12) and declares with \ no font= — the 20 is dead XML in the reference, so 20 here means we are reading an \ attribute the client never reaches" ); }