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
+8 -1
View File
@@ -3,7 +3,8 @@
//! ```text
//! cargo run -q -p benilla-app --example addon_harness -- <folder> [--verbose] [--why <substr>]
//! [--deep [n]] [--status <file>] [--diff <file>]
//! or: ... -- <folder> --probe <Name> [--eval <lua> | --mouse <x>,<y> | --tick <secs>]...
//! or: ... -- <folder> --probe <Name> [--eval <lua> | --mouse <x>,<y> | --tick <secs>
//! | --fire <EVENT> | --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<addon_harness::probe::Step> {
"--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(
+13 -2
View File
@@ -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/<Folder>`, 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<Vec<u8>> { 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("<the addon's binding>")`
// 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,
+122 -1
View File
@@ -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<benilla_ui::script::PlateFeed> {
(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<ProbeOutcome> {
&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<String> = 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.
@@ -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"
);
}
@@ -217,7 +217,7 @@ fn load_the_players_ui(root: &Path) -> UiScript {
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
let mut pulled: Vec<LoadedDep> = 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(
@@ -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.
+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]
+47
View File
@@ -164,6 +164,53 @@ pub fn apply_template(lua: &mlua::Lua, wrapper: &Table, kind: &str, template: &s
out
}
/// Read `rel` under `root`, resolving each path component **case-insensitively** when the
/// filesystem will not: the one file namespace the reference reads is a Windows one, where
/// `bindings.xml` and `Bindings.xml` name the same file, and a case-sensitive `open` of the
/// spelling the engine asks for is a silent miss.
///
/// Found in play on AlternativeClassicActionBars, which ships its binding table as
/// `bindings.xml`: `ui_script::addons` asks for `Bindings.xml` by the reference's own constant
/// (`0x51f400` reads the fixed name, not a `.toc` entry), the open failed, and the addon's 69
/// `<Binding>` rows never registered — no error, no warning, just an action bar whose keys do
/// nothing. The texture and font side of the same namespace has always resolved this way
/// (`benilla_assets::loose_addon_file`); this is the Lua and XML side of it.
///
/// A component escaping the root (`..`, an absolute path, a prefix) is refused before any
/// filesystem call, so this cannot read outside `root`. The direct open is tried first and is the
/// only cost on a tree whose spelling already matches; the directory walk happens on a miss.
pub fn read_under(root: &std::path::Path, rel: &str) -> Option<Vec<u8>> {
let path = std::path::Path::new(rel);
if path
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
{
return None;
}
if let Ok(bytes) = std::fs::read(root.join(path)) {
return Some(bytes);
}
let mut at = root.to_path_buf();
for comp in path.components() {
let std::path::Component::Normal(comp) = comp else {
return None;
};
let direct = at.join(comp);
if direct.exists() {
at = direct;
continue;
}
let wanted = comp.to_str()?;
let found = std::fs::read_dir(&at).ok()?.flatten().find(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.eq_ignore_ascii_case(wanted))
})?;
at = found.path();
}
std::fs::read(at).ok()
}
/// Join a FrameXML reference to its file's directory, lexically: `\` and `/` both separate, `..`
/// walks up. A `..` above the root is kept, not clamped, so the provider can refuse the escape.
pub fn join_ref(base: &str, path: &str) -> String {
+1 -8
View File
@@ -636,14 +636,7 @@ fn note(lua: &Lua, msg: String) {
/// Read `root/rel`, refusing a path that leaves `root`, lexically before any filesystem call;
/// `join_ref` has already resolved the path, so an escape shows as a leading `..`.
fn read_under(root: &std::path::Path, rel: &str) -> Option<Vec<u8>> {
let rel = std::path::Path::new(rel);
if rel
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
{
return None;
}
std::fs::read(root.join(rel)).ok()
crate::loader::read_under(root, rel)
}
/// Run a chunk read off disk, BOM stripped: [`crate::script::UiScript::run_chunk`] for the
@@ -63,6 +63,7 @@ mod misc;
// `pub(in …compat)` for its `plate_frame`, which SuperWoW's `UnitNameplate` shares.
pub(in crate::script::compat) mod nameplate;
mod new_items;
mod pixel_util;
mod player_cache;
mod player_info;
mod player_util;
@@ -156,6 +157,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
input::install(lua)?;
lua_ext::install(lua)?;
math_ext::install(lua)?;
// After `math_ext`: its `Round` is the rounding `PixelUtil.GetNearestPixelSize` does.
pixel_util::install(lua)?;
pools::install(lua)?;
totem::install(lua)?;
timer::install_globals(lua)?;
@@ -0,0 +1,197 @@
//! `PixelUtil`, the modern client's pixel-snapping helper (`SharedXML/PixelUtil.lua`).
//!
//! Not in ClassicAPI's own `docs/API.md` as of the 2026-10-05 fetch
//! (`docs/classic-api-functions.md`), and not in 1.12's `_G` either — but
//! **AlternativeClassicActionBars calls it unguarded**, in a client it refuses to run on without
//! ClassicAPI (`Core.lua:1658` tests `C_Timer` and `Mixin` and disables itself):
//!
//! ```text
//! Bar.lua:89 return PixelUtil.GetNearestPixelSize(0, scale, 1)
//! DefaultBars.lua:398 screenTop + (1.5 * PixelUtil.GetNearestPixelSize(0, targetScale, 1))
//! DefaultBars.lua:645 return PixelUtil.GetNearestPixelSize(value or 0, UIParent:GetEffectiveScale())
//! DefaultBars.lua:735 local onePixel = PixelUtil.GetNearestPixelSize(0, UIParent:GetEffectiveScale(), 1)
//! ```
//!
//! An absent `PixelUtil` is an `attempt to index global 'PixelUtil' (a nil value)` on the bar
//! geometry path — the three unguarded `GetNearestPixelSize(0, scale, 1)` calls are all asking the
//! same question, "how wide is one screen pixel in UI units", which is what every pixel-perfect
//! border in that addon is drawn from. Its own `ElementEngine.lua:26-35` *does* guard
//! `PixelUtil.SetPoint`/`SetSize`, so those two travel with the one it needs rather than being
//! required; they are here because each has one reading, and leaving them out would mean the
//! addon's guarded path stays on the unsnapped fallback while its unguarded path snaps.
//!
//! Pure arithmetic over the arguments and one widget read, so it is a Lua chunk like
//! [`super::math_ext`] rather than a Rust binding — there is no client state behind it.
//!
//! **One deliberate deviation.** Upstream reads `region:GetEffectiveScale()`. On a 1.12 widget set
//! that method exists on frames and *not* on textures or font strings, and both of the setters
//! here are called with regions (`ElementEngine` passes whatever it is laying out). Upstream can
//! assume it because in the modern client every `Texture` is a `LayoutFrame`. So the scale read
//! falls back to the region's parent, then to `UIParent` — which is the same number in every case
//! that matters, since a texture's effective scale *is* its parent's.
use mlua::Lua;
/// The upstream bodies. `GetNearestPixelSize` is the one that has to be exact, because its answer
/// is a divisor and every other function here routes through it:
///
/// * it rounds the **scaled** size to a whole pixel, then divides back, so the result is a UI
/// unit count that lands on a pixel boundary at that scale;
/// * `minPixels` clamps the **magnitude** and keeps the input's sign, which is what makes
/// `GetNearestPixelSize(0, scale, 1)` answer `1 / scale` — one physical pixel — rather than
/// the 0 a naive `max(0, 1)`-free rounding would give. That call is ACAB's whole use of this
/// file, so a version that answers 0 there would read as "the addon loads and every border it
/// draws is invisible";
/// * a zero or absent scale answers the input unchanged instead of dividing by zero. A region
/// measured before its parent has one is a real state, not a caller error.
const CHUNK: &str = r#"
PixelUtil = PixelUtil or {}
function PixelUtil.GetNearestPixelSize(uiUnitSize, layoutScale, minPixels)
uiUnitSize = uiUnitSize or 0
if not layoutScale or layoutScale == 0 then
return uiUnitSize
end
local numPixels = Round(uiUnitSize * layoutScale)
if minPixels then
local floor = math.abs(minPixels)
if math.abs(numPixels) < floor then
numPixels = floor
if uiUnitSize < 0 then
numPixels = -floor
end
end
end
return numPixels / layoutScale
end
-- The scale to snap against: the region's own if it answers one (a frame), else its parent's, else
-- UIParent's. A 1.12 texture has no `GetEffectiveScale`; its effective scale is its parent's.
local function snapScale(region)
if region and region.GetEffectiveScale then
return region:GetEffectiveScale()
end
local parent = region and region.GetParent and region:GetParent()
if parent and parent.GetEffectiveScale then
return parent:GetEffectiveScale()
end
if UIParent then
return UIParent:GetEffectiveScale()
end
return 1
end
function PixelUtil.GetPixelToUIUnitFactor()
local scale = snapScale(UIParent)
if scale == 0 then
return 1
end
return 1 / scale
end
function PixelUtil.SetPoint(region, point, relativeTo, relativePoint, offsetX, offsetY,
minPixelsX, minPixelsY)
local scale = snapScale(region)
region:SetPoint(point, relativeTo, relativePoint,
PixelUtil.GetNearestPixelSize(offsetX or 0, scale, minPixelsX),
PixelUtil.GetNearestPixelSize(offsetY or 0, scale, minPixelsY))
end
function PixelUtil.SetSize(region, width, height, minPixelsX, minPixelsY)
local scale = snapScale(region)
region:SetSize(PixelUtil.GetNearestPixelSize(width, scale, minPixelsX),
PixelUtil.GetNearestPixelSize(height, scale, minPixelsY))
end
function PixelUtil.SetWidth(region, width, minPixelsX)
region:SetWidth(PixelUtil.GetNearestPixelSize(width, snapScale(region), minPixelsX))
end
function PixelUtil.SetHeight(region, height, minPixelsY)
region:SetHeight(PixelUtil.GetNearestPixelSize(height, snapScale(region), minPixelsY))
end
"#;
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.load(CHUNK).set_name("classic_api/pixel_util").exec()
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// ACAB's own three unguarded calls, which all ask the same question: one screen pixel, in UI
/// units, at this scale. A reading that answers 0 here (`Round(0 * scale)` with the clamp
/// skipped because the input is 0) loads the addon and draws nothing.
#[test]
fn one_pixel_at_a_scale_is_the_question_the_action_bars_ask() {
let s = UiScript::new().unwrap();
for (scale, want) in [("1", 1.0), ("2", 0.5), ("0.8", 1.25)] {
assert_eq!(
s.eval::<f64>(&format!(
"return PixelUtil.GetNearestPixelSize(0, {scale}, 1)"
))
.unwrap(),
want,
"one pixel at scale {scale}"
);
}
}
/// The rounding half, with no `minPixels`: a size is snapped to a whole pixel at that scale
/// and handed back in UI units, and the sign survives.
#[test]
fn a_size_snaps_to_a_whole_pixel_and_keeps_its_sign() {
let s = UiScript::new().unwrap();
// 10.4 UI units at scale 1 is 10 pixels; at scale 0.5 it is 5.2 -> 5 pixels -> 10 units.
assert_eq!(
s.eval::<f64>("return PixelUtil.GetNearestPixelSize(10.4, 1)")
.unwrap(),
10.0
);
assert_eq!(
s.eval::<f64>("return PixelUtil.GetNearestPixelSize(10.4, 0.5)")
.unwrap(),
10.0
);
assert_eq!(
s.eval::<f64>("return PixelUtil.GetNearestPixelSize(-0.2, 1, 1)")
.unwrap(),
-1.0,
"the clamp raises the magnitude and keeps the direction"
);
// No scale is no division, not a nan.
assert_eq!(
s.eval::<f64>("return PixelUtil.GetNearestPixelSize(7, 0)")
.unwrap(),
7.0
);
}
/// The two setters ACAB guards, on a texture — the receiver a 1.12 widget set does not give
/// `GetEffectiveScale` to, and the reason the scale read here has a fallback at all.
#[test]
fn the_setters_snap_a_region_that_cannot_report_its_own_scale() {
let s = UiScript::new().unwrap();
s.run(
r#"UIParent = CreateFrame("Frame", "UIParent")
UIParent:SetWidth(1024) UIParent:SetHeight(768)
Holder = CreateFrame("Frame", "Holder", UIParent)
Holder:SetAllPoints()
Art = Holder:CreateTexture(nil, "ARTWORK")
PixelUtil.SetSize(Art, 10.4, 10.4)
PixelUtil.SetPoint(Art, "TOPLEFT", Holder, "TOPLEFT", 3.7, -3.7)"#,
)
.unwrap();
assert!(
s.eval::<bool>("return Art.GetEffectiveScale == nil")
.unwrap(),
"the premise: a 1.12 texture cannot be asked its scale, so upstream's own read raises"
);
assert_eq!(s.eval::<f64>("return Art:GetWidth()").unwrap(), 10.0);
let (_, _, _, x, y) = s
.eval::<(String, mlua::Value, String, f64, f64)>("return Art:GetPoint(1)")
.unwrap();
assert_eq!((x, y), (4.0, -4.0), "both offsets snapped to whole pixels");
}
}
@@ -174,6 +174,17 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
});
}
Ok(match command.as_str() {
// The presence probe, and the one command whose only job is to answer. Two
// addons in this corpus use it and they read it differently:
// `SuperCleveRoidMacros/Init.lua:274` keeps only `pcall`'s own `ok`, while
// AlternativeClassicActionBars' `HasUnitXPSP3` (`Core.lua:1645-1648`) wants
// `ok and result == true` — so a mod that answers anything else reads as absent
// there, and ACAB prints "UnitXP_SP3 not found - some features may not work"
// over a client that has the whole family. `"nop"` is no unit token, so the
// fall-through could only hand it the stock getter's 0, and that is the wrong
// answer twice over: wrong value, and truthy in Lua, so it passes the loose
// reading and fails the strict one. A real install answers the boolean.
"nop" => Value::Boolean(true),
"distanceBetween" => {
distance_between(lua, &args[1..]).map_or(Value::Nil, Value::Number)
}
@@ -281,6 +292,30 @@ mod tests {
/// the integer 0, so `[insight]` and `[behind]` could never fire while `[noinsight]` and
/// `[nobehind]` always did — and the addon had no way to tell, because it decides the family
/// is present with `pcall(UnitXP, "nop", "nop")` (`Init.lua:274`), which succeeds.
/// The presence probe, in both shapes the corpus reads it: `pcall`'s own `ok`
/// (`SuperCleveRoidMacros/Init.lua:274`) and `ok and result == true`
/// (AlternativeClassicActionBars' `HasUnitXPSP3`, `Core.lua:1645-1648`). The second is the one
/// that caught this: the fall-through answered the stock XP getter's `0`, which passes the
/// first reading and fails the second, so ACAB reported UnitXP_SP3 absent on a client that
/// answers the whole family.
///
/// The stock one-argument getter is untouched beside it — that collision is the reason this
/// dispatcher has a fall-through at all.
#[test]
fn the_presence_probe_answers_the_boolean_both_readers_accept() {
let s = UiScript::new().unwrap();
assert!(
s.eval::<bool>(r#"local ok, v = pcall(UnitXP, "nop", "nop") return ok and v == true"#)
.unwrap(),
"`UnitXP(\"nop\", \"nop\")` has to be `true`, not merely non-raising"
);
assert_eq!(
s.eval::<i64>(r#"return UnitXP("player")"#).unwrap(),
0,
"and the stock getter still answers its own shape"
);
}
#[test]
fn insight_and_behind_answer_the_booleans_the_macro_conditionals_compare_against() {
let mut s = UiScript::new().unwrap();
@@ -389,6 +389,11 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
"ClampedPercentageBetween",
"Square",
"Negate",
// The modern client's pixel-snapping helper (script/compat/classic_api/pixel_util.rs).
// Not in ClassicAPI's own published list, but AlternativeClassicActionBars calls
// `PixelUtil.GetNearestPixelSize` **unguarded** on the bar-geometry path while refusing to
// run at all without ClassicAPI, so on this server's kit it is present by implication.
"PixelUtil",
// ClassicAPI's C_DateAndTime (script/compat/classic_api/date_and_time.rs): a date-aware
// server epoch; 1.12's Lua reaches only `GetGameTime()`'s hour and minute, with no date.
"C_DateAndTime",
+72
View File
@@ -1274,3 +1274,75 @@ either writes the CVar or registers the event.
**ClassicAPI's 70** are the remaining bulk and are tracked in `docs/classic-api-functions.md`
rather than here. The same caution applies twice over: its 999 pointed-at names include a great deal
that is not Lua surface at all, and the corpus intersection is the only part worth reading.
## Two addons asked for by name: AlternativeClassicActionBars and UnitPlates
Not grom's own install — `other-addons/` in the working tree, both for another player on this
fork. Measured with the survey and the read-back probe
(`cargo run -q --release -p benilla-app --example addon_harness -- <root> --verbose`, then
`--probe <Name>`), on a root holding only these two so neither borrows the other's globals:
| Addon | Loads | Session | Drew | What it does with our client |
| --- | --- | --- | --- | --- |
| [AlternativeClassicActionBars](https://github.com/TrusT-Codes/AlternativeClassicActionBars) 1.4.0-rc1 | clean | clean | 175 frames | 9 bars built, buttons shown, 69 of its own bindings registered and settable |
| [UnitPlates](https://github.com/Redbu11dev/UnitPlates) | clean | clean | overlay | adopts **every** seated nameplate and reads its name, health and level off ours |
**Packaging, not a client gap:** a GitHub zip unpacks as `UnitPlates-main`, and the reference
resolves an addon's manifest as `<Folder>/<Folder>.toc` (`manifest_in`, case-insensitively, as on a
Windows filesystem). A folder whose name does not match its `.toc` is invisible to the real client
too, so the folder has to be renamed `UnitPlates`. Nothing to fix here; worth saying once because
the failure is silent on both clients.
### What they needed that we did not have
- **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, since an
absent binding table is the normal case. Its whole keybinding surface was dead: hover-bind,
edit-mode escape, every bar page key. Fixed by giving the Lua/XML side of the file namespace the
same case-insensitive component walk the texture and font side has always had
(`benilla_ui::loader::read_under`, shared by both crates' readers;
`benilla_assets::loose_addon_file` is the asset-side original). Measured: 69 bindings listed by
`GetBinding`, `SetBinding("CTRL-9", "ACABBIND1")` round-trips, and the addon's drawn-frame count
went 42 → 175, because the login sequence had been stopping short of its bars.
- **`PixelUtil`** (`script/compat/classic_api/pixel_util.rs`). Called **unguarded** on ACAB's
bar-geometry path, four sites, three of them the same question — one screen pixel in UI units.
Not in ClassicAPI's published list, but ACAB disables itself without ClassicAPI, so on this
server's kit it is present by implication. `GetNearestPixelSize`, `SetPoint`, `SetSize`,
`SetWidth`, `SetHeight`, `GetPixelToUIUnitFactor`; the scale read falls back to the parent and
then `UIParent`, because upstream's `region:GetEffectiveScale()` does not exist on a 1.12 texture.
- **`UnitXP("nop", "nop")` answering `true`.** 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`, which is 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
- **`IsStackableAction(slot)`** — post-1.12 (not in `reference/1.12-globals.tsv`), not in
ClassicAPI's list, and the stock 1.12 `ActionButton.lua` shows a slot's count off
`IsConsumableAction` alone (`ActionButton.lua:274`, read off the chain). ACAB guards it
(`Button.lua:1560`), so its absence costs exactly what a 1.12 client does: no count on a
stackable non-consumable. Adding it would need a new feed column for a difference no reference
client shows.
- **`CursorHasMacro()`** — also post-1.12, and **redundant here**: ACAB's `ButtonHasCursor`
(`Button.lua:2112`) tests `GetCursorInfo() == "macro"` first, which this client answers, and only
then falls through to the three `CursorHas*` getters.
- **`CleveRoids` / `QuestieOcto` / `ShaguTweaks.libhealth` / `pfDatabase` / `WoWTranslate_API`** —
other addons' tables, every one behind a presence test. UnitPlates' `UPCompat.lua` is a file of
optional integrations; those names are missing because those addons are not installed, which is
the point of the file.
- **69 `Unknown frame type: Binding` log lines** when a `.toc` also lists its binding table, as
ACAB's does. The reference logs one per row the same way (`0x6ee356`), and
`loader/tests.rs`'s `a_bindings_document_loaded_as_framexml_raises_nothing` pins the count
deliberately. Noise, faithfully.
### The harness grew a `--plates` step
A nameplate addon is the one kind the survey could not see working: its subject is a `WorldFrame`
child that `vplates.rs` mints per frame, so with no plates seated the addon's scan finds nothing,
does nothing, and reports a clean load over an empty screen. `Step::Plates(n)` seats `n` live
plates through `sync_nameplates` and counts how many an addon has hung a frame on — which doubles
as the contract check on the plate's own shape, since a plate that stopped being a `Button`, or
stopped putting its border texture first, would read as an addon adopting nothing.