From f3fec804eb68b93edeb3db69757884ae54aa1782 Mon Sep 17 00:00:00 2001 From: grom Date: Thu, 29 Oct 2026 14:15:51 -0600 Subject: [PATCH] Two addons another player asked for, and the Linux-only gap that killed one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AlternativeClassicActionBars and UnitPlates, both now in `other-addons/` (untracked — third-party source, not ours to vendor). Measured with the survey and the read-back probe on a root holding only those two, so neither borrows the other's globals. Both already loaded clean; three things were missing, and the first was costing an entire addon's keybinding surface. **A binding table spelled `bindings.xml`.** `ui_script::addons` asks for `Bindings.xml` by the reference's own fixed name — `0x51f400` reads the name, not a `.toc` entry — and the open was case-sensitive, so on Linux ACAB's 69 `` rows never registered. Silently: an absent binding table is the normal case, so nothing raised and nothing warned. Hover-bind, edit-mode escape and every bar page key were dead. The texture and font side of the same file namespace has always resolved case-insensitively (`loose_addon_file`, which walks the path a component at a time and enumerates a directory when the spelling does not match); the Lua and XML side now shares one resolver with it, and both crates' private `read_under` copies delegate to it. Measured after: 69 bindings listed by `GetBinding`, `SetBinding("CTRL-9", "ACABBIND1")` round-trips, and the addon's drawn-frame count went 42 -> 175, because its login sequence had been stopping short of its own bars. **`PixelUtil`.** Called unguarded on ACAB's bar-geometry path at four sites, three of them the same question — one screen pixel in UI units, which is what every pixel-perfect border in that addon is drawn from. Not in ClassicAPI's published list, but ACAB disables itself without ClassicAPI, so on this server's kit it is present by implication. A Lua chunk beside `math_ext`, whose `Round` it uses. One deviation, written down where it lives: upstream reads `region:GetEffectiveScale()`, which a 1.12 texture does not answer, so the scale read falls back to the parent and then `UIParent` — the same number in every case that matters. **`UnitXP("nop", "nop")` answering the boolean.** The presence probe. SuperCleveRoidMacros keeps only `pcall`'s `ok`, but ACAB's `HasUnitXPSP3` wants `ok and result == true`, and the fall-through handed it the stock XP getter's `0` — truthy, so it passed the loose reading and failed the strict one. ACAB reported UnitXP_SP3 absent on a client that answers the whole family. Left alone with the reason in `docs/ADDON_COMPAT.md`: `IsStackableAction` (post-1.12, guarded, and stock 1.12's own ActionButton.lua shows a count off `IsConsumableAction` alone), `CursorHasMacro` (post-1.12 and redundant — ACAB tests `GetCursorInfo() == "macro"` first), and five third-party tables that are missing because those addons are not installed. The harness grew a `--plates [n]` step, because a nameplate addon is the one kind it could not see working: the subject is a `WorldFrame` child `vplates.rs` mints per frame, so with none seated the addon's scan finds nothing, does nothing, and reports a clean load over an empty screen. It seats n live plates and counts how many an addon hung a frame on, which doubles as the contract check on the plate's own shape — a plate that stopped being a `Button`, or stopped putting its border texture first, would read as an addon adopting nothing. UnitPlates adopts every plate and reads its name, health and level off ours. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/examples/addon_harness.rs | 9 +- crates/benilla-app/src/addon_harness/mod.rs | 15 +- crates/benilla-app/src/addon_harness/probe.rs | 123 ++++++++++- .../src/addon_harness/probe_tests.rs | 55 +++++ .../src/addon_harness/tick_bench_tests.rs | 2 +- .../benilla-app/src/addon_harness/together.rs | 2 +- crates/benilla-app/src/ui_script/addons.rs | 53 ++++- crates/benilla-ui/src/loader/mod.rs | 47 +++++ crates/benilla-ui/src/script/addon.rs | 9 +- .../src/script/compat/classic_api/mod.rs | 3 + .../script/compat/classic_api/pixel_util.rs | 197 ++++++++++++++++++ crates/benilla-ui/src/script/compat/unitxp.rs | 35 ++++ .../src/script/tests/reference_surface.rs | 5 + docs/ADDON_COMPAT.md | 72 +++++++ 14 files changed, 605 insertions(+), 22 deletions(-) create mode 100644 crates/benilla-ui/src/script/compat/classic_api/pixel_util.rs diff --git a/crates/benilla-app/examples/addon_harness.rs b/crates/benilla-app/examples/addon_harness.rs index 22f7c3d1..ce4f752e 100644 --- a/crates/benilla-app/examples/addon_harness.rs +++ b/crates/benilla-app/examples/addon_harness.rs @@ -3,7 +3,8 @@ //! ```text //! cargo run -q -p benilla-app --example addon_harness -- [--verbose] [--why ] //! [--deep [n]] [--status ] [--diff ] -//! or: ... -- --probe [--eval | --mouse , | --tick ]... +//! or: ... -- --probe [--eval | --mouse , | --tick +//! | --fire | --plates [n]]... //! (one addon, then ask its VM; the steps run in the order given) //! ``` //! @@ -54,6 +55,12 @@ fn probe_steps(rest: &[String]) -> Vec { "--tick" => Some(addon_harness::probe::Step::Tick( rest.get(i + 1)?.parse().ok()?, )), + // `--plates [n]`: seat n live nameplates (default 3) and count how many an addon took + // over. The one thing a nameplate addon needs that the rest of the harness never + // provides, since its subject is a `WorldFrame` child the app's feed mints. + "--plates" => Some(addon_harness::probe::Step::Plates( + rest.get(i + 1).and_then(|n| n.parse().ok()).unwrap_or(3), + )), "--mouse" => { let (x, y) = rest.get(i + 1)?.split_once(',')?; Some(addon_harness::probe::Step::Mouse( diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index 4126bc83..801efd17 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -295,7 +295,7 @@ fn survey_one( // high-water mark is a valid cut of a log that evicts and dedupes. let warn_mark = script.diagnostics().last().map_or(0, |d| d.seq); - let files = load_addon_files(&script, root, name, &toc); + let files = load_addon_files(&mut script, root, name, &toc); // The live walk stamps each addon as loaded just before its `ADDON_LOADED` (`0x51f5ad`); // without it `IsAddOnLoaded` answers nil and a LoadOnDemand dependent gets // `DEP_NOT_DEMAND_LOADED`. The dependencies stamped themselves in `load_dependencies`. @@ -1610,7 +1610,7 @@ struct FileLoad { /// Run the addon's manifest through the loader's two arms, `.lua` as a chunk and anything else as /// FrameXML, in the install-relative path space (`Interface/AddOns/`, as /// `ui_script::addons::Addon::prefix`), so chunk names match a live session's. -fn load_addon_files(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> FileLoad { +fn load_addon_files(script: &mut UiScript, root: &Path, name: &str, toc: &Toc) -> FileLoad { let provider = |req: &str| -> Option> { read_under(root, req) }; let base = addon_base(name); let mut errors = Vec::new(); @@ -1686,6 +1686,17 @@ fn load_addon_files(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> Fi } } } + // `Bindings.xml` (`0x51f400`), after the manifest's own files as the live walk reads it + // (`ui_script::addons`): a fixed name, not a `.toc` entry, so a package that does not list it + // still has it read. Without this leg a probe's `GetBindingKey("")` + // answers nil and its hotkey text measures blank, which is a harness artefact and not what + // the client does. + if let Some(bytes) = read_under(root, &benilla_ui::loader::join_ref(&base, "Bindings.xml")) { + match benilla_ui::bindings_xml::parse(&benilla_ui::source::decode(&bytes)) { + Ok(bindings) => script.register_addon_bindings(name, &bindings), + Err(e) => errors.push(format!("Bindings.xml: {e}")), + } + } FileLoad { errors, absent, diff --git a/crates/benilla-app/src/addon_harness/probe.rs b/crates/benilla-app/src/addon_harness/probe.rs index 493ca5e0..eac54878 100644 --- a/crates/benilla-app/src/addon_harness/probe.rs +++ b/crates/benilla-app/src/addon_harness/probe.rs @@ -49,6 +49,101 @@ pub enum Step { /// turns "did this event reach the addon that acts on it" into something a probe can see /// without the app, the server or a player walking across a border. Fire(String), + /// Seat `n` live nameplates on the `WorldFrame`, as `vplates.rs` does every frame, and report + /// how many of them a plate addon has taken over. + /// + /// A nameplate addon is the one kind the rest of this harness cannot see working: it finds + /// its subject by walking `WorldFrame:GetChildren()` for a `Button` whose first region carries + /// `Interface\Tooltips\Nameplate-Border` (pfUI's own 1.12 path and UnitPlates' both), and + /// with no plates seated that walk finds nothing, the addon does nothing, and the survey + /// reports a clean load with an empty screen. The count of taken-over plates is the whole + /// answer: 0 against a non-zero seat is the addon failing to recognise ours. + Plates(u32), +} + +/// The census [`Step::Plates`] reads back: how many plates are on the `WorldFrame`, and how many +/// of those an addon has hung something on. +/// +/// A plate is counted as a `Button` child of the `WorldFrame`, which is what the reference's +/// `CGNamePlateFrame` is and the only kind of Button that is ever parented there — not by its +/// border texture, because taking a plate over usually means blanking that texture (UnitPlates' +/// `borderRegion:SetTexture(nil)`), and a census that stopped counting the plates an addon had +/// adopted would read as a failure at exactly the moment of success. +/// +/// "Decorated" is a second child: the engine gives a plate exactly one (its health bar), so +/// anything past that is an addon's, and every `WorldFrame`-scanning plate addon works by +/// parenting its own frame to the plate it adopts. +const PLATE_CENSUS: &str = r#" +local found, taken = 0, 0 +local kids = {WorldFrame:GetChildren()} +for i = 1, table.getn(kids) do + local f = kids[i] + if f and f.GetObjectType and f:GetObjectType() == "Button" then + found = found + 1 + if f:GetNumChildren() > 1 then + taken = taken + 1 + end + end +end +return found, taken +"#; + +/// The plate geometry the app feeds, in FrameXML units: `vplates.rs`'s own numbers at a 1.0 UI +/// scale. Only the shape matters to an addon, not the exact sizes. +const PLATE_GEOMETRY: benilla_ui::script::PlateGeometry = benilla_ui::script::PlateGeometry { + width: 110.0, + height: 36.0, + bar_off_x: 4.0, + bar_off_y: 4.0, + bar_width: 102.0, + bar_height: 10.0, + level_off_x: 4.0, + level_off_y: 4.0, + skull_size: 14.0, + raid_size: 20.0, + name_height: 10.0, + level_height: 9.0, + shadow_offset: 1.0, +}; + +/// `n` plates spread across the screen with a unit each, the per-frame feed `vplates.rs` builds. +fn plate_feeds(n: u32) -> Vec { + (1..=n) + .map(|i| { + let key = 0x3000 + u64::from(i); + benilla_ui::script::PlateFeed { + state: benilla_ui::script::PlateState { + key, + top_centre: ( + 200.0 + f32::from(u16::try_from(i).unwrap_or(1)) * 60.0, + 500.0, + ), + health: 2400.0, + max_health: 3000.0, + bar_colour: [1.0, 0.0, 0.0], + name: format!("Probe Target {i}"), + level: Some(58), + skull: false, + level_colour: [1.0, 1.0, 1.0], + raid_icon: None, + alpha: 1.0, + lit: false, + hovered: false, + }, + unit: benilla_ui::script::UnitState { + exists: true, + has_object: true, + name: Some(format!("Probe Target {i}")), + health: 2400, + max_health: 3000, + level: 58, + guid: key, + ..Default::default() + }, + unit_target: None, + } + }) + .collect() } /// Load `name` out of `root` the way the survey does, drive the session start, then run each @@ -90,7 +185,7 @@ pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option { &mut dep_order, ); - let load_errors = super::load_addon_files(&script, root, name, &toc).errors; + let load_errors = super::load_addon_files(&mut script, root, name, &toc).errors; // The registry must agree with the VM, or `IsAddOnLoaded` answers for another session; the // dependency chain stamped itself as it loaded. script.mark_addon_loaded(name); @@ -195,6 +290,32 @@ fn run_steps(script: &mut UiScript, steps: &[Step]) -> Vec<(String, String)> { ), ) } + Step::Plates(n) => { + let before = script.errors().len(); + let feeds = plate_feeds(*n); + // Three passes: the first mints the plates, and an addon's own `OnUpdate` scan is + // throttled (UnitPlates checks every 50 ms), so one tick is not enough for it to + // have looked. Each pass re-pushes, as the app's per-frame feed does. + for _ in 0..3 { + script.sync_nameplates(PLATE_GEOMETRY, &feeds); + script.tick(1.0 / 20.0); + script.resolve(); + } + let raised: Vec = script.errors().into_iter().skip(before).collect(); + let (found, taken) = script.eval::<(i64, i64)>(PLATE_CENSUS).unwrap_or((-1, -1)); + ( + format!("--plates {n}"), + format!( + "= {} \u{b7} {found}/{n} plate(s) on the WorldFrame, {taken} of them \ + decorated by an addon", + if raised.is_empty() { + "no errors".to_string() + } else { + format!("RAISED: {}", raised.join(" | ")) + }, + ), + ) + } Step::Mouse(x, y) => { // Resolve first: the hit-test reads resolved rects, which a new or moved frame // lacks until then. diff --git a/crates/benilla-app/src/addon_harness/probe_tests.rs b/crates/benilla-app/src/addon_harness/probe_tests.rs index da073966..c28e5381 100644 --- a/crates/benilla-app/src/addon_harness/probe_tests.rs +++ b/crates/benilla-app/src/addon_harness/probe_tests.rs @@ -130,3 +130,58 @@ fn a_folder_without_a_manifest_is_refused() { assert!(probe(fx.root(), "Backup", &[]).is_none()); assert!(probe(fx.root(), "NotThereAtAll", &[]).is_none()); } + +/// [`Step::Plates`] seats real nameplates and counts the ones an addon adopted — the one thing the +/// rest of this harness cannot show, because a plate addon's subject is a `WorldFrame` child the +/// app's own feed mints and no amount of loading or ticking creates. +/// +/// The fixture is the real idiom, the one both pfUI's 1.12 path and UnitPlates use: walk +/// `WorldFrame:GetChildren()` for a `Button` whose first region carries the plate border, then +/// parent a frame to it. So this test is also the contract check on the plate's own shape — a +/// plate that stopped being a `Button`, or stopped putting its border first, would read here as +/// an addon that adopted nothing. +#[test] +fn the_plate_step_seats_plates_and_counts_the_ones_an_addon_adopted() { + let fx = Fixtures::new("plates"); + fx.addon( + "Plater", + r#" + PlaterSeen = 0 + local function isPlate(f) + if not f.GetObjectType or f:GetObjectType() ~= "Button" then return nil end + local r = f:GetRegions() + if not r or not r.GetTexture then return nil end + return r:GetTexture() == "Interface\\Tooltips\\Nameplate-Border" + end + local scan = CreateFrame("Frame") + scan:SetScript("OnUpdate", function() + local kids = {WorldFrame:GetChildren()} + for i = 1, table.getn(kids) do + local f = kids[i] + if isPlate(f) and not f.plater then + f.plater = CreateFrame("Frame", nil, f) + PlaterSeen = PlaterSeen + 1 + end + end + end) + "#, + ); + + let out = probe( + fx.root(), + "Plater", + &[Step::Plates(3), Step::Eval("return PlaterSeen".to_string())], + ) + .expect("the fixture has a manifest"); + + assert!(out.load_errors.is_empty(), "{:?}", out.load_errors); + assert_eq!( + out.answers[0].1, + "= no errors \u{b7} 3/3 plate(s) on the WorldFrame, 3 of them decorated by an addon", + "the seat has to reach the WorldFrame and the census has to see the adoption" + ); + assert_eq!( + out.answers[1].1, "= 3", + "and the addon's own count agrees, so the census is not counting its own doing" + ); +} diff --git a/crates/benilla-app/src/addon_harness/tick_bench_tests.rs b/crates/benilla-app/src/addon_harness/tick_bench_tests.rs index c6e170d7..e5741cd1 100644 --- a/crates/benilla-app/src/addon_harness/tick_bench_tests.rs +++ b/crates/benilla-app/src/addon_harness/tick_bench_tests.rs @@ -217,7 +217,7 @@ fn load_the_players_ui(root: &Path) -> UiScript { script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET); let mut pulled: Vec = Vec::new(); load_dependencies(&mut script, root, &toc, &installed, &mut seen, &mut pulled); - let _ = load_addon_files(&script, root, name, &toc); + let _ = load_addon_files(&mut script, root, name, &toc); script.load_addon_saved_variables(name); script.mark_addon_loaded(name); script.fire_event( diff --git a/crates/benilla-app/src/addon_harness/together.rs b/crates/benilla-app/src/addon_harness/together.rs index 7fe481bb..4304c544 100644 --- a/crates/benilla-app/src/addon_harness/together.rs +++ b/crates/benilla-app/src/addon_harness/together.rs @@ -146,7 +146,7 @@ pub(super) fn walk_folder( script.load_addon_saved_variables(&dep.name); load_errors.push((dep.name, dep.files.errors)); } - let files = load_addon_files(&script, root, name, &toc); + let files = load_addon_files(&mut script, root, name, &toc); load_errors.push((name.clone(), files.errors)); // Before `ADDON_LOADED`, as the reference sets them (`0x51f6c0`): a handler that reads its // config on that event is the usual shape, so after it is a default-config interface. diff --git a/crates/benilla-app/src/ui_script/addons.rs b/crates/benilla-app/src/ui_script/addons.rs index ee67c767..d418245b 100644 --- a/crates/benilla-app/src/ui_script/addons.rs +++ b/crates/benilla-app/src/ui_script/addons.rs @@ -283,14 +283,7 @@ fn is_lua(entry: &str) -> bool { /// [`benilla_ui::loader::join_ref`], so an escape shows as a leading `..`, refused with anything /// absolute before any filesystem call; a symlinked addon folder still works. fn read_under(root: &Path, rel: &str) -> Option> { - let rel = Path::new(rel); - if rel - .components() - .any(|c| !matches!(c, std::path::Component::Normal(_))) - { - return None; - } - std::fs::read(root.join(rel)).ok() + benilla_ui::loader::read_under(root, rel) } /// The one file namespace, install-relative, as in the reference: `0x647e60` answers a name from @@ -1345,6 +1338,50 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } + /// The binding table is found whatever case the addon spelled its file in: the reference's + /// one file namespace is a Windows one, and `0x51f400` asks for the fixed name `Bindings.xml` + /// rather than reading a `.toc` entry, so a package shipping `bindings.xml` has to resolve. + /// + /// AlternativeClassicActionBars is that package. On a case-sensitive filesystem the open + /// missed and its 69 `` rows never registered — with nothing raised anywhere, since + /// an absent binding table is the normal case. The whole addon's keybinding surface was dead: + /// its hover-bind, its edit-mode escape and every bar page key. + #[test] + fn a_binding_table_resolves_whatever_case_the_package_spelled_it_in() { + let tmp = + std::env::temp_dir().join(format!("benilla-addon-lcbindings-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let home = tmp.join("state"); + let dir = home.join("AddOns/Probe"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Probe.toc"), "## Interface: 11200\nProbe.lua\n").unwrap(); + std::fs::write(dir.join("Probe.lua"), "PROBE_RAN = 1").unwrap(); + // Lower-case `b`, as shipped. + std::fs::write( + dir.join("bindings.xml"), + r#" + Probe_Fire(1) +"#, + ) + .unwrap(); + + let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); + let _h = + crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap()); + + let mut script = UiScript::new().unwrap(); + script.set_screen_size(1024.0, 768.0); + let failures = load_third_party(&mut script, None, &[], true); + assert!(failures.is_empty(), "load errors: {failures:?}"); + let bodies = script.addon_binding_bodies(); + assert!( + bodies.iter().any(|b| b.name == "PROBEBIND1"), + "the binding table did not register: {:?}", + bodies.iter().map(|b| &b.name).collect::>() + ); + let _ = std::fs::remove_dir_all(&tmp); + } + /// A `.toc` line resolves against the addon folder, `