Files
benilla-octo/crates/benilla-app/examples/addon_harness.rs
T
SamandClaude Fable 5 44ff435304 the addon arc: the AddOns folder opens
- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect
- both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list
- the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand
- the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:18:31 +07:00

220 lines
9.4 KiB
Rust

//! `addon_harness` — load a folder of addons, one per VM, and print what happened.
//!
//! ```text
//! cargo run -q -p benilla-app --example addon_harness -- <folder of addons> [--verbose] [--why <substr>]
//! ```
//!
//! The instrument decision 1188 phase 6 asks for: *"which addons work" is a number that can be
//! re-read on any day*. The mechanics, and what the numbers are and are not worth, are in
//! [`benilla_app::addon_harness`]'s module doc — read it before quoting anything from here.
//!
//! **Expect a long tail and do not chase 100 %** (1188's own instruction). The report is a
//! distribution, not a pass/fail: a handful of addons will always want features we have not built.
use benilla_app::addon_harness;
fn main() {
let mut args = std::env::args().skip(1);
let Some(root) = args.next() else {
eprintln!(
"usage: addon_harness <folder of addons> [--verbose] [--why <blocker substring>]"
);
std::process::exit(2);
};
let rest: Vec<String> = args.collect();
let verbose = rest.iter().any(|a| a == "--verbose");
// `--why <substring>` — the addons behind one blocker row, with their verbatim first errors.
// The ranked table collapses quoted names by design (1193); this is the read-back, and two of
// this arc's decisions came from doing it by hand (1206, 1210).
let why = rest
.iter()
.position(|a| a == "--why")
.and_then(|i| rest.get(i + 1))
.cloned();
let root = std::path::PathBuf::from(root);
let reports = addon_harness::survey(&root);
if reports.is_empty() {
eprintln!(
"no addons under {} — is that an AddOns folder?",
root.display()
);
std::process::exit(1);
}
let loaded = reports.iter().filter(|r| r.loaded).count();
let clean = reports
.iter()
.filter(|r| r.loaded && r.missing_globals.is_empty())
.count();
let blocked = reports
.iter()
.filter(|r| !r.missing_deps.is_empty())
.count();
println!("\n{} addon(s) under {}", reports.len(), root.display());
// Which VM the survey ran against. Without an install there is no GlobalStrings.lua, ~5,000
// globals are missing, and every number below is worse for a reason that has nothing to do
// with the client — say so rather than letting two machines' numbers be compared in silence.
println!(
" VM: our whole FrameXML + a seated session{}\n",
if addon_harness::seated_with_global_strings() {
" + the real GlobalStrings.lua"
} else {
" ** no install found: GlobalStrings absent, these numbers are NOT comparable **"
}
);
// The tree these numbers came from. Two runs are comparable only if this matches: in a dev
// build `assets/ui` is read from the SOURCE TREE, so anything else editing the checkout moves
// the headline with no rebuild. Quoting a delta across two different digests is how a wrong
// attribution got into a decision record (1209).
println!(
" FrameXML digest : {}",
addon_harness::framexml_digest()
);
println!(
" loaded without a single load error : {loaded}/{}",
reports.len()
);
println!(
" ...and calling nothing we lack : {clean}/{}",
reports.len()
);
println!(" with a dependency not installed : {blocked}");
// The stricter column, and the one that answers what the survey is really asking. Every other
// number here is LOAD-time; this one drives the client's own session start
// (ADDON_LOADED -> VARIABLES_LOADED -> PLAYER_LOGIN -> PLAYER_ENTERING_WORLD, then a second of
// ticks) and reports what the addon's HANDLERS raised. Four decision records in this arc end
// with "the headline cannot see this"; this is the number that can.
let survived = reports
.iter()
.filter(|r| r.loaded && r.session_errors.is_empty())
.count();
println!(
" ...and survived a session start : {survived}/{}",
reports.len()
);
// What the session start broke, ranked the way `blockers` ranks load failures.
let mut session: std::collections::BTreeMap<String, usize> = Default::default();
for r in reports.iter().filter(|r| r.loaded) {
if let Some(e) = r.session_errors.first() {
*session.entry(addon_harness::normalise(e)).or_default() += 1;
}
}
if !session.is_empty() {
let mut rows: Vec<(String, usize)> = session.into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
println!("\n what broke at SESSION START (addons that loaded clean, first error each):");
for (err, count) in rows.into_iter().take(12) {
println!(" {count:>4} {err}");
}
}
// The distribution, because a mean would hide the shape.
let mut buckets = [0usize; 5];
for r in &reports {
let n = r.missing_globals.len();
buckets[match n {
0 => 0,
1..=2 => 1,
3..=5 => 2,
6..=15 => 3,
_ => 4,
}] += 1;
}
println!("\n missing-global count per addon:");
for (label, n) in ["0", "1-2", "3-5", "6-15", "16+"].iter().zip(buckets) {
println!(" {label:>5} {n:>4} {}", "#".repeat(n.min(60)));
}
// What actually STOPPED them — the ranked first error. Read this before the demand list: a
// wall 60 addons hit is worth more than a verb 60 addons would like (decision 1193).
println!("\n what stopped them (addons whose FIRST load error was each):");
for (err, count) in addon_harness::blockers(&reports).into_iter().take(12) {
println!(" {count:>4} {err}");
}
// Templates an addon names in `CreateFrame(..., "Template")` that we have never declared
// (decision 1203). Its own list because it is invisible to every other number here: an
// unresolved template raises no load error, so the addon scores as a pass and paints nothing.
let templates = addon_harness::template_demand(&reports);
if !templates.is_empty() {
println!("\n most-wanted missing TEMPLATES (addons naming each in CreateFrame):");
for (name, count) in templates.into_iter().take(12) {
println!(" {count:>4} {name}");
}
}
// The same question over the OTHER axis, and the one that actually moves the headline: a
// template named in an addon's own XML `inherits=`. Unlike the list above this failure is
// usually LOUD — the element's `<OnLoad>` fires at load and its first line is normally
// `getglobal(this:GetName().."Text")` — which is why transcribing the reference's shared kit
// was worth twelve addons while the CreateFrame list predicted none of them. Printed second
// and separately because merging the two would hide exactly that difference.
let inherits = addon_harness::inherits_demand(&reports);
if !inherits.is_empty() {
println!("\n most-wanted missing TEMPLATES (addons naming each in an XML inherits=):");
for (name, count) in inherits.into_iter().take(12) {
println!(" {count:>4} {name}");
}
}
// Frames and tables, ranked separately: a missing function is a Rust verb to write, a missing
// frame is FrameXML to transcribe, and the two queues go to different people. The scan was
// blind to this whole shape until 2026-08-11 — a window 86 addons reach scored 0.
let tables = addon_harness::table_demand(&reports);
if !tables.is_empty() {
println!("\n most-wanted missing FRAMES/TABLES (addons indexing each):");
for (name, count) in tables.into_iter().take(16) {
println!(" {count:>4} {name}");
}
}
println!("\n most-wanted missing globals (addons wanting each):");
for (name, count) in addon_harness::demand(&reports).into_iter().take(30) {
println!(" {count:>4} {name}");
}
if let Some(pattern) = &why {
let hits = addon_harness::blocked_by(&reports, pattern);
println!(
"\n addons whose first error matches {pattern:?} ({}):",
hits.len()
);
for (name, err) in &hits {
println!(" {name}");
// Two lines, not one. The first is the message; the SECOND is mlua's first traceback
// frame, and for the row this instrument is most often pointed at that frame is the
// whole answer — `in local '(for generator)'` is what tells a generic-for
// (decision 1202) apart from any other call of a table value.
for line in err.lines().take(2) {
println!(" {}", line.trim());
}
}
if hits.is_empty() {
println!(" (none — the pattern is matched against the NORMALISED row as printed)");
}
}
if verbose {
println!("\n per addon:");
for r in &reports {
let iface = r
.interface
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
println!(
" {:<28} iface={:<12} {:<8} missing={:<4} {}",
r.name,
if iface.is_empty() { "-".into() } else { iface },
if r.loaded { "loaded" } else { "ERRORS" },
r.missing_globals.len(),
r.errors.first().map(String::as_str).unwrap_or("")
);
}
}
println!();
}