Two addons another player asked for, and the Linux-only gap that killed one

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
`<Binding>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-29 14:15:51 -06:00
co-authored by Claude Opus 5
parent 1b1a90cf13
commit f3fec804eb
14 changed files with 605 additions and 22 deletions
+45 -8
View File
@@ -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<Vec<u8>> {
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 `<Binding>` 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#"<Bindings>
<Binding name="PROBEBIND1">Probe_Fire(1)</Binding>
</Bindings>"#,
)
.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::<Vec<_>>()
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// A `.toc` line resolves against the addon folder, `<Script file=X>` against
/// `dirname(referrer)` (`0x6ee07b`), and both chunks are `"@%s"` of the path (`0x704bc0`).
#[test]