Files
benilla-octo/crates/benilla-app/examples/addon_harness.rs
T
Sam SungandClaude Opus 5.5 4f8350c9ed String literals and test names in benilla-app carry no record numbers (#368)
The strings pass for `benilla-app`, the last crate: string literals and test names lose the private record numbers and the workspace's words that a comment pass cannot touch.

Fixes #92. Fixes #36.

- **Messages.** About 230 assertion, panic, `expect` and log messages, printed tool labels, and comments inside embedded Lua fixtures drop their record numbers; the eight that named "the director" keep their fact in plain words. Stale claims in a few messages are corrected on the way (a cast-bar step, a font line, the every-shot recompute).
- **Names.** Seven test and helper names that carried a record number or "director" now say what they check. `git grep` found no other use of any of them.
- **Player files.** The key-bindings file's header and `config.toml`'s header each lose a record citation. Neither header is compared when the file is read back: the bindings reader skips every `#` line, and `config.toml`'s header is a TOML comment.
- **Fixtures.** Test fixtures use neutral character, account and frame names.
- **#36.** The quest-material test labels GameObject type 10 GOOBER (in 1.12, 3 is CHEST, 9 TEXT, 10 GOOBER); the code under test was already right, and the comments were fixed with the comment pass.
- **Verification.** With comments stripped and every literal's contents masked, each file is identical to main and keeps its literals' line counts, apart from the renamed functions and one rustfmt reflow. `cargo fmt --check` is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 01:06:28 +07:00

957 lines
39 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> [--verbose] [--why <substr>]
//! [--deep [n]] [--status <file>] [--diff <file>]
//! or: ... -- <folder> --probe <Name> [--eval <lua> | --mouse <x>,<y> | --tick <secs>]...
//! (one addon, then ask its VM; the steps run in the order given)
//! ```
//!
//! Measures which addons work, as a number that can be re-read any day; what the numbers are
//! worth is in [`benilla_app::addon_harness`]'s module doc. Expect a long tail: the report is a
//! distribution, not a pass/fail.
use benilla_app::addon_harness;
/// How much traceback `--why` prints per addon: the addon, file and line sit below the first frame.
const WHY_TRACEBACK_LINES: usize = 8;
/// Print a ranked table, stating how many rows and addon-mentions fell below the cut; `--deep [n]`
/// overrides every caller's `take` (see [`DEEP`]).
fn ranked(rows: Vec<(String, usize)>, take: usize) {
let take = DEEP.get().copied().flatten().unwrap_or(take);
let total = rows.len();
for (name, count) in rows.iter().take(take) {
println!(" {count:>4} {name}");
}
if total > take {
let tail: usize = rows[take..].iter().map(|(_, c)| c).sum();
println!(
" …{} more rows below the cut ({tail} addon-mentions) — TRUNCATED, not exhausted; \
`--why <name>` opens any row.",
total - take
);
}
}
/// One of the probe's two error lists with its count, printed even when empty, so "loaded clean
/// and died in a handler" reads apart from "never loaded".
fn report_lines(label: &str, lines: &[String]) {
println!(" {label}: {}", lines.len());
for line in lines {
for (n, l) in line.lines().enumerate() {
println!(" {}{}", if n == 0 { "" } else { " " }, l.trim_end());
}
}
}
/// The `--deep` override, set once in `main`: `Some(n)` shows `n` rows of every ranked list,
/// a bare `--deep` all of them; absent, each list keeps its caller's `take`.
static DEEP: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
/// The row's frame names, bounded for the line and saying so when it bounds.
fn render_frames(frames: &[String]) -> String {
use benilla_app::addon_harness::render::MAX_NAMED_FRAMES;
// `--deep` opens this bound too.
let cap = DEEP.get().copied().flatten().unwrap_or(MAX_NAMED_FRAMES);
if frames.len() <= cap {
return frames.join(",");
}
format!("{},+{} more", frames[..cap].join(","), frames.len() - cap)
}
/// Our `_G` against the captured 1.12 `_G` (`reference/1.12-globals.tsv`). Both directions
/// matter: 1.12 addons feature-test with `if SomeName then`, so a name we publish that the
/// reference lacks can send an addon down a path the real client never takes. `lod` rows are the
/// twelve LoadOnDemand `Blizzard_*` addons' names, counted apart as a live capture misses them.
fn surface_report(deep: bool, dump_path: Option<String>) {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-globals.tsv"
);
let Ok(text) = std::fs::read_to_string(tsv) else {
eprintln!("cannot read the reference surface at {tsv}");
std::process::exit(1);
};
let mut reference: std::collections::BTreeMap<String, String> = Default::default();
for line in text.lines().filter(|l| !l.starts_with('#')) {
let mut f = line.split('\t');
if let (Some(name), Some(kind)) = (f.next(), f.next()) {
if !name.is_empty() {
reference.insert(name.to_string(), kind.to_string());
}
}
}
let ours: std::collections::BTreeMap<String, String> =
addon_harness::surface().into_iter().collect();
// `--surface-dump <file>` writes our raw `_G` as `name<TAB>type`, the reference TSV's shape.
if let Some(path) = dump_path {
let body: String = ours
.iter()
.map(|(n, k)| format!("{n}\t{k}\n"))
.collect::<Vec<_>>()
.concat();
match std::fs::write(&path, format!("# our _G — {} names\n{body}", ours.len())) {
Ok(()) => println!(" wrote {} names to {path}", ours.len()),
Err(e) => eprintln!(" could not write {path}: {e}"),
}
}
println!("\n SURFACE DIFF — our _G vs the captured 1.12 _G");
println!(" FrameXML digest : {}", addon_harness::framexml_digest());
println!(" reference names : {}", reference.len());
println!(" ours : {}", ours.len());
// Absent from us, by the reference's own type.
let mut missing_by_kind: std::collections::BTreeMap<&str, Vec<&str>> = Default::default();
for (name, kind) in &reference {
if !ours.contains_key(name) {
missing_by_kind
.entry(kind.as_str())
.or_default()
.push(name.as_str());
}
}
println!("\n ABSENT FROM US, by the reference's type:");
for (kind, names) in &missing_by_kind {
println!(" {:<10} {}", kind, names.len());
}
// Published by us and absent from 1.12, split by our type: most are `table`, our FrameXML's
// own frame names; a `function` is the hazard, since addons feature-test on it.
let extra: Vec<(&str, &str)> = ours
.iter()
.filter(|(n, _)| !reference.contains_key(n.as_str()))
.map(|(n, k)| (n.as_str(), k.as_str()))
.collect();
let mut extra_by_kind: std::collections::BTreeMap<&str, Vec<&str>> = Default::default();
for (name, kind) in &extra {
extra_by_kind.entry(kind).or_default().push(name);
}
println!(
"\n PUBLISHED BY US, ABSENT FROM 1.12 ({}) — a superset is not free:",
extra.len()
);
for (kind, names) in &extra_by_kind {
let note = match *kind {
"function" => " <- the ones an addon feature-tests; read these first",
"table" => " <- mostly our own FrameXML's frame/region names",
_ => "",
};
println!(" {:<10} {}{}", kind, names.len(), note);
}
// Functions in full even without --deep, split on the `Benilla` prefix: no 1.12 addon
// feature-tests our own namespace.
if let Some(fns) = extra_by_kind.get("function") {
let (ours_ns, unprefixed): (Vec<&&str>, Vec<&&str>) = fns
.iter()
.partition(|n| n.starts_with("Benilla") || n.starts_with("BENILLA"));
println!(
"\n ...of those functions: {} are Benilla*-namespaced (cannot collide), {} are NOT:",
ours_ns.len(),
unprefixed.len()
);
for chunk in unprefixed.chunks(4) {
let row: Vec<&str> = chunk.iter().map(|s| **s).collect();
println!(" {}", row.join(" "));
}
println!(
" ^ read these: an unprefixed verb 1.12 lacks is what `if SomeName then` finds.\n \
Most are our own UI's helpers (KeyBindings_*, Options*), which are only\n \
a naming question — but a POST-1.12 API name here is the hazard, because an\n \
addon that feature-tests it takes a branch written for a client we are not."
);
}
if deep {
for (kind, names) in &extra_by_kind {
if *kind == "function" {
continue;
}
println!("\n ...EXTRA {} ({}):", kind, names.len());
for chunk in names.chunks(4) {
println!(" {}", chunk.join(" "));
}
}
}
if deep {
for (kind, names) in &missing_by_kind {
println!("\n ABSENT FROM US — {} ({}):", kind, names.len());
for chunk in names.chunks(4) {
println!(" {}", chunk.join(" "));
}
}
}
}
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>]\n \
or: addon_harness --surface (our _G vs the captured 1.12 surface; takes no corpus)"
);
std::process::exit(2);
};
let rest: Vec<String> = args.collect();
// `--surface` needs no corpus, so it is handled before the root is used.
if root == "--surface" || rest.iter().any(|a| a == "--surface") {
let dump_path = rest
.iter()
.position(|a| a == "--surface-dump")
.and_then(|i| rest.get(i + 1))
.cloned();
surface_report(rest.iter().any(|a| a == "--deep"), dump_path);
return;
}
let verbose = rest.iter().any(|a| a == "--verbose");
// `--why <substring>`: the addons behind one row, with their verbatim errors. Matches the
// normalised row and the raw text, and reads back through the method demand tables too.
let why = rest
.iter()
.position(|a| a == "--why")
.and_then(|i| rest.get(i + 1))
.cloned();
// `--status <file>` writes the per-addon ok/fail roster to a file; `--diff <file>` compares
// against one, since a total hides a fix and a break that cancel out.
let status = rest
.iter()
.position(|a| a == "--status")
.and_then(|i| rest.get(i + 1))
.cloned();
let diff = rest
.iter()
.position(|a| a == "--diff")
.and_then(|i| rest.get(i + 1))
.cloned();
// `--deep [n]`: open the tails. A bare `--deep` (or one followed by the next flag) means all.
let deep = rest.iter().position(|a| a == "--deep").map(|i| {
rest.get(i + 1)
.and_then(|n| n.parse::<usize>().ok())
.unwrap_or(usize::MAX)
});
let _ = DEEP.set(deep);
let root = std::path::PathBuf::from(root);
// `--together`: the whole folder in one VM, the control for the survey's one-VM-per-addon
// bound; `--diff <a survey roster>` names the rows the bound costs.
if rest.iter().any(|a| a == "--together") {
let rows = addon_harness::together::survey_together(&root);
if rows.is_empty() {
eprintln!(
"no addons under {} — is that an AddOns folder?",
root.display()
);
std::process::exit(1);
}
let raised: Vec<&addon_harness::together::TogetherRow> =
rows.iter().filter(|r| r.always_raises()).collect();
let wobbly: Vec<&addon_harness::together::TogetherRow> =
rows.iter().filter(|r| r.order_sensitive()).collect();
println!(
"\n{} addon(s) under {}, ALL IN ONE VM — {} runs",
rows.len(),
root.display(),
addon_harness::together::DEFAULT_RUNS
);
println!(
" raised in EVERY run : {}/{} (clean in every run: {})",
raised.len(),
rows.len(),
rows.len() - raised.len() - wobbly.len()
);
// Named apart: Lua hashes a table key by its pointer, so a registry keyed by objects walks
// in a different order every process.
println!(
" ORDER-SENSITIVE — raised in some runs, not all ({}):",
wobbly.len()
);
for r in &wobbly {
println!(
" {:<28} {}/{} {}",
r.name,
r.raised_in,
r.runs,
r.errors[0].lines().next().unwrap_or("")
);
}
if let Some(path) = &diff {
// `fail` in the survey and clean here is the bound, priced.
let prior: std::collections::BTreeMap<String, bool> = std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.filter_map(|l| l.rsplit_once(' '))
.map(|(n, v)| (n.trim().to_string(), v.trim() == "ok"))
.collect();
let mut freed: Vec<&str> = Vec::new();
let mut only_together: Vec<&str> = Vec::new();
for r in &rows {
// Only rows the same in every run are compared.
match (prior.get(&r.name), r.raised_in) {
(Some(false), 0) => freed.push(&r.name),
(Some(true), n) if n == r.runs => only_together.push(&r.name),
_ => {}
}
}
println!(
"\n FAILS ALONE, CLEAN TOGETHER ({}) — the one-VM bound, priced:",
freed.len()
);
for n in &freed {
println!(" {n}");
}
println!(
"\n CLEAN ALONE, RAISES TOGETHER ({}) — a neighbour's global, or an order the\n \x20 survey never reaches:",
only_together.len()
);
for n in &only_together {
println!(" {n}");
}
}
println!(
"\n STILL RAISING IN EVERY RUN, WITH EVERY NEIGHBOUR PRESENT (first error each):"
);
for r in &raised {
let first = r.errors[0].lines().next().unwrap_or("");
println!(" {:<28} {first}", r.name);
}
return;
}
// `--probe <Name> [--eval <lua> ...]`: one addon, loaded as the survey loads it, then asked.
// Not a measurement (an eval can mutate the VM), so it prints no column.
if let Some(name) = rest
.iter()
.position(|a| a == "--probe")
.and_then(|i| rest.get(i + 1))
{
// `--eval`, `--tick` and `--mouse` are one ordered list, run in the order typed.
let steps: Vec<addon_harness::probe::Step> = rest
.iter()
.enumerate()
.filter_map(|(i, a)| match a.as_str() {
"--eval" => Some(addon_harness::probe::Step::Eval(rest.get(i + 1)?.clone())),
"--tick" => Some(addon_harness::probe::Step::Tick(
rest.get(i + 1)?.parse().ok()?,
)),
"--mouse" => {
let (x, y) = rest.get(i + 1)?.split_once(',')?;
Some(addon_harness::probe::Step::Mouse(
x.trim().parse().ok()?,
y.trim().parse().ok()?,
))
}
_ => None,
})
.collect();
let Some(out) = addon_harness::probe::probe(&root, name, &steps) else {
eprintln!(
"no manifest under {}/{name} — is that an addon folder?",
root.display()
);
std::process::exit(1);
};
println!("\n{} — probed under {}", out.name, root.display());
report_lines("load errors", &out.load_errors);
report_lines("session errors", &out.session_errors);
if out.answers.is_empty() {
println!(" (no --eval/--mouse given — load and session errors only)");
}
for (chunk, answer) in &out.answers {
println!("\n {chunk}");
println!(" {answer}");
}
return;
}
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());
// Without an install there is no GlobalStrings.lua and about 5,000 globals are missing.
println!(
" VM: the stock 1.12 FrameXML off the player's chain + 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 **"
}
);
// Two runs compare only at the same digest: a dev build reads `assets/ui` from the source tree.
println!(
" FrameXML digest : {}",
addon_harness::framexml_digest()
);
println!(
" loaded without a single load error : {loaded}/{}",
reports.len()
);
// `loaded` counts what raised; a manifest entry naming a missing file is not that (the
// reference logs `Couldn't open %s` and carries on), so the stricter count prints beside it.
let strict = reports.iter().filter(|r| r.errors.is_empty()).count();
println!(
" (…{} of those name a file their own package does not contain, which the reference \
logs and carries on from; the strict column counts those as failures: {strict}/{})",
loaded - strict,
reports.len()
);
println!(
" ...and calling nothing we lack : {clean}/{}",
reports.len()
);
println!(" with a dependency not installed : {blocked}");
// The session column: drives ADDON_LOADED -> VARIABLES_LOADED -> PLAYER_LOGIN ->
// PLAYER_ENTERING_WORLD and a second of ticks, and reports what the handlers raised.
let survived = reports
.iter()
.filter(|r| r.loaded && r.session_errors.is_empty())
.count();
println!(
" ...and survived a session start : {survived}/{}",
reports.len()
);
// The UI-probe column: of those, how many survive having their overrides invoked.
let probed = reports
.iter()
.filter(|r| r.loaded && r.session_errors.is_empty() && r.probe_errors.is_empty())
.count();
println!(
" ...and survived a UI probe : {probed}/{}",
reports.len()
);
// The render column, the only one that asks whether anything was drawn; see
// `addon_harness::render`'s header.
let drew = reports
.iter()
.filter(|r| r.render.drew() != addon_harness::Drew::Nothing)
.count();
println!(
" ...and DREW something on screen : {drew}/{}",
reports.len()
);
let (own, overlay) = (
reports
.iter()
.filter(|r| r.render.drew() == addon_harness::Drew::Own)
.count(),
reports
.iter()
.filter(|r| r.render.drew() == addon_harness::Drew::Overlay)
.count(),
);
println!(" of those: {own} drew a window of their own, {overlay} painted onto ours");
// Addons that load, survive a session start and a UI probe, and still draw nothing.
let silent: Vec<&str> = reports
.iter()
.filter(|r| {
r.loaded
&& r.session_errors.is_empty()
&& r.probe_errors.is_empty()
&& r.render.drew() == addon_harness::Drew::Nothing
})
.map(|r| r.name.as_str())
.collect();
if !silent.is_empty() {
// A question, not a defect list: the survey seats a player in an empty world, so an addon
// with nothing to draw there (a buff bar, a pure library) draws nothing correctly.
println!(
"\n DREW NOTHING, and clean on every other column ({}) — a QUESTION, not a defect\n \
list: the seated world has one buff, one target and one running cooldown, so an addon\n \
waiting on combat, a cursor over an item or a slash command belongs here too:",
silent.len()
);
for chunk in silent.chunks(4) {
println!(" {}", chunk.join(" "));
}
}
// The use column, the only one that touches anything: three numbers, because "nothing
// raised" and "nothing was touched" are different answers (`addon_harness::use_probe`).
let (survived_use, raised_use, untouched_use) = (
reports
.iter()
.filter(|r| r.used.verdict() == addon_harness::Used::Survived)
.count(),
reports
.iter()
.filter(|r| r.used.verdict() == addon_harness::Used::Raised)
.count(),
reports
.iter()
.filter(|r| {
r.render.drew() != addon_harness::Drew::Nothing
&& r.used.verdict() == addon_harness::Used::Untouched
})
.count(),
);
println!(
" ...and SURVIVED BEING USED : {survived_use}/{}",
reports.len()
);
println!(
" of the {drew} that drew: {} raised on hover/click/drag, {survived_use} came \
through clean, {untouched_use} had nothing a pointer can reach",
raised_use
);
println!(
" (input driven: hover in+out, left click, right click, drag — up to {} of the \
frames each addon painted itself)",
addon_harness::MAX_USE_TARGETS
);
// Addons whose UI is fully on screen and falls over the moment anyone uses it.
let inert: Vec<String> = reports
.iter()
.filter(|r| r.used.verdict() == addon_harness::Used::Raised)
.map(|r| format!("{}({})", r.name, r.used.errors.len()))
.collect();
if !inert.is_empty() {
println!(
"\n DREW SOMETHING AND IS DEAD TO THE TOUCH ({}) — (n) = raises, `--why <name>` for \
the text:",
inert.len()
);
for chunk in inert.chunks(4) {
println!(" {}", chunk.join(" "));
}
}
// ...and what the input broke, ranked like the session-start table above it.
let mut used_rows: std::collections::BTreeMap<String, usize> = Default::default();
for r in &reports {
if let Some(e) = r.used.errors.first() {
*used_rows.entry(addon_harness::normalise(e)).or_default() += 1;
}
}
if !used_rows.is_empty() {
let mut rows: Vec<(String, usize)> = used_rows.into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
println!("\n what broke when the UI was USED (first error each):");
for (err, count) in rows.into_iter().take(12) {
println!(" {count:>4} {err}");
}
}
// 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}");
}
}
// What was warned about: not blockers, so after the error rankings, ranked by how many addons
// hit each row so one noisy OnUpdate does not bury a warning fifty addons hit once.
let mut warned: std::collections::BTreeMap<String, usize> = Default::default();
for r in &reports {
let mut seen: std::collections::BTreeSet<String> = Default::default();
for w in &r.warnings {
seen.insert(addon_harness::normalise(w));
}
for w in seen {
*warned.entry(w).or_default() += 1;
}
}
if !warned.is_empty() {
let mut rows: Vec<(String, usize)> = warned.into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let addons = reports.iter().filter(|r| !r.warnings.is_empty()).count();
println!(
"\n what was WARNED about ({addons} addons raised at least one, by addon count):"
);
// Through `ranked`, so the cut is stated.
ranked(rows, 12);
}
// 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)));
}
// Whose package is incomplete: a `.toc` entry whose file the addon does not ship is the
// addon's defect, and the reference logs `Couldn't open %s` and carries on (`0x6edaa0`).
// Nothing is subtracted from the headline.
let own: Vec<&addon_harness::AddonReport> = reports
.iter()
.filter(|r| !r.absent_own_files.is_empty())
.collect();
let foreign: Vec<&addon_harness::AddonReport> = reports
.iter()
.filter(|r| !r.absent_foreign_files.is_empty())
.collect();
// "The whole reason" holds only when the absent entries account for every load error.
let sole_cause = |r: &addon_harness::AddonReport| {
!r.loaded && r.errors.len() == r.absent_own_files.len() + r.absent_foreign_files.len()
};
if !own.is_empty() || !foreign.is_empty() {
println!(
"\n MANIFEST ENTRIES WITH NO FILE — counted in the numbers above, listed here so"
);
println!(" the reader can tell a broken package from a broken client:");
for r in &own {
println!(
" {} — its own .toc lists {} file(s) the package does not contain{}",
r.name,
r.absent_own_files.len(),
if sole_cause(r) {
" [and that is its ONLY load error]"
} else {
""
}
);
for f in r.absent_own_files.iter().take(4) {
println!(" missing: {f}");
}
}
for r in &foreign {
println!(
" {} — wants a folder that is not installed{}",
r.name,
if sole_cause(r) {
" [and that is its ONLY load error]"
} else {
""
}
);
for f in r.absent_foreign_files.iter().take(4) {
println!(" missing: {f}");
}
}
let theirs = own.iter().filter(|r| sole_cause(r)).count();
let ours = foreign.iter().filter(|r| sole_cause(r)).count();
println!(
" ({} with an incomplete package of their own, {theirs} of which fail for that \
alone; {} wanting a neighbour, {ours} for that alone)",
own.len(),
foreign.len()
);
}
// What stopped them: the ranked first error.
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 named in `CreateFrame(..., "Template")` that we never declared: an unresolved
// template raises no load error, so the addon passes 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):");
ranked(templates, 12);
}
// Templates named in an addon's own XML `inherits=`: usually loud, since the element's
// `<OnLoad>` fires at load. Kept apart from the list above.
let inherits = addon_harness::inherits_demand(&reports);
if !inherits.is_empty() {
println!("\n most-wanted missing TEMPLATES (addons naming each in an XML inherits=):");
ranked(inherits, 12);
}
// Frames and tables, ranked separately: a missing function is a Rust verb, a missing frame is
// FrameXML.
let tables = addon_harness::table_demand(&reports);
if !tables.is_empty() {
println!("\n most-wanted missing FRAMES/TABLES (addons indexing each):");
ranked(tables, 16);
}
// Widget methods: neither a global nor an indexed table, so only these tables see them.
// Resolved against the live `__index` dispatcher; it over-reports, since a scanner cannot type
// a `:` call's receiver (`AddonReport::missing_methods`).
let methods = addon_harness::method_demand(&reports);
if !methods.is_empty() {
// It counts addons that name the verb, neither addons blocked by it nor call sites: a
// library replicated into many addons inflates a row.
println!("\n most-wanted missing METHODS — addons that NAME each as obj:Name(), and");
println!(" no widget answers. NOT a blocker list and NOT a build queue:");
println!(" · a big number is usually ONE library file replicated, and");
println!(
" · a third-party library's own method on its own object is not ours to write."
);
println!(" Open the call site before ranking it.");
ranked(methods, 16);
}
// Per kind: rows whose receiver the survey could type, from a `CreateFrame("Kind", …)` local
// or our arena's kind for that name, asked against that kind alone, so a verb wired to one
// class and missing on its sibling shows.
let by_kind = addon_harness::kind_method_demand(&reports);
if !by_kind.is_empty() {
println!(
"\n most-wanted missing METHODS BY KIND (receiver typed from the call site; \"on X\" \
= the sibling that does answer it):"
);
ranked(by_kind, 16);
}
// Receivers nothing could type, on a name whose answer depends on the kind: an upper bound
// that shrinks as the scan learns to type receivers.
let ambiguous = addon_harness::ambiguous_method_demand(&reports);
if !ambiguous.is_empty() {
println!(
"\n ...and names an UNTYPABLE receiver may or may not have ({} in all — an upper \
bound, not a queue; NARROWEST first, because a name only a minority of kinds answer \
is the AddMessage shape and one every kind but the two regions answers is not):",
ambiguous.len()
);
ranked(ambiguous, 12);
}
// Methods addons call behind a feature test (`if sliderFrame.SetTopLevel then`): nobody is
// stuck on one, so they are a separate table.
let optional = addon_harness::optional_method_demand(&reports);
if !optional.is_empty() {
println!("\n ...and methods they FEATURE-TEST and work around (not blockers):");
ranked(optional, 8);
}
println!("\n most-wanted missing globals (addons wanting each):");
ranked(addon_harness::demand(&reports), 30);
if let Some(pattern) = &why {
let hits = addon_harness::blocked_by(&reports, pattern);
// Matches the raw text as well as the normalised row, over every error, not just the first.
// A row with no `#` is the addon's first error, the one the tables above rank.
println!(
"\n errors matching {pattern:?} ({}) — a row without a '#' is the FIRST error, i.e. \
the one the tables above rank:",
hits.len()
);
for (name, err) in &hits {
println!(" {name}");
// The message, then mlua's traceback frames; the first tells a generic-for
// (`in local '(for generator)'`) apart from any other call of a table value.
for line in err.lines().take(WHY_TRACEBACK_LINES) {
println!(" {}", line.trim());
}
}
if hits.is_empty() {
println!(
" (none — no load or session error of any addon contains that text, and no \
normalised row does either)"
);
}
// ...and the same question of the method demand tables.
let rows = addon_harness::method_rows_matching(&reports, pattern);
println!(
"\n method-table rows matching {pattern:?} ({}) — which addons carry the row, and \
from which table:",
rows.len()
);
for (name, row) in &rows {
println!(" {name:<36} {row}");
}
if rows.is_empty() {
println!(" (none — no addon's method tables contain that text)");
}
// ...and which addons each demand ranking counted, so a row can be checked against the
// corpus.
for (label, rows) in [
(
"globals",
addon_harness::wanters(&reports, pattern, |r| &r.missing_globals),
),
(
"tables",
addon_harness::wanters(&reports, pattern, |r| &r.missing_tables),
),
(
"methods",
addon_harness::wanters(&reports, pattern, |r| &r.missing_methods),
),
] {
if rows.is_empty() {
continue;
}
println!(
"\n addons whose missing-{label} list matches {pattern:?} ({}) — this is what the \
ranking counted:",
rows.len()
);
for (addon, name) in &rows {
println!(" {addon:<36} {name}");
}
}
}
if verbose {
println!("\n per addon:");
for r in &reports {
let iface = r
.interface
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
// The session column beside the load one: an addon can load clean and die in its
// PLAYER_LOGIN handler.
let session = match r.session_errors.first() {
None if r.loaded => "session=ok".to_string(),
None => "session=-".to_string(),
Some(e) => format!("session: {}", e.lines().next().unwrap_or(e)),
};
// The render verdict, with the quad count and the frames it was charged to.
let drew = format!(
"drew={}({})",
r.render.drew().word(),
r.render.own_quads + r.render.overlay_quads
);
// The use verdict, always with the target count: `used=ok` reads the same whether the
// probe drove eight frames or none.
let used = format!(
"used={}({}/{})",
r.used.verdict().word(),
r.used.driven,
r.used.touchable
);
println!(
" {:<28} iface={:<12} {:<8} missing={:<4} {:<11} {drew:<16} {used:<20} {} {}",
r.name,
if iface.is_empty() { "-".into() } else { iface },
if r.loaded { "loaded" } else { "ERRORS" },
r.missing_globals.len(),
session,
render_frames(&r.render.frames),
r.errors.first().map(String::as_str).unwrap_or("")
);
}
}
// The per-addon roster: `<name> ok|fail`, sorted, one per line. `ok` is the session column
// (loaded and no session error).
let roster: Vec<(String, bool)> = {
let mut v: Vec<(String, bool)> = reports
.iter()
.map(|r| (r.name.clone(), r.loaded && r.session_errors.is_empty()))
.collect();
v.sort();
v
};
if let Some(path) = &status {
let mut out = format!(
"# per-addon status ({} ok / {} fail) — digest {}\n",
roster.iter().filter(|(_, ok)| *ok).count(),
roster.iter().filter(|(_, ok)| !*ok).count(),
addon_harness::framexml_digest()
);
for (name, ok) in &roster {
out.push_str(&format!("{name} {}\n", if *ok { "ok" } else { "fail" }));
}
match std::fs::write(path, out) {
Ok(()) => println!("\n wrote roster to {path} ({} addons)", roster.len()),
Err(e) => println!("\n --status {path}: {e}"),
}
}
if let Some(path) = &diff {
match std::fs::read_to_string(path) {
Ok(text) => {
// The digest gate: a delta is attributable only when this run's FrameXML digest
// matches the roster's, since a dev build reads `assets/ui` from the source tree.
// It refuses rather than warns, so a mismatched number is never printed.
let base_digest = text
.lines()
.find(|l| l.trim_start().starts_with("# per-addon status"))
.and_then(|l| l.split_whitespace().last());
let now = addon_harness::framexml_digest().to_string();
if let Some(was) = base_digest.filter(|d| **d != now) {
println!("\n DIFF vs {path}: REFUSED — the baseline is at a different tree");
println!(" baseline digest {was} this run {now}");
println!(
" A delta across two digests is not attributable \
to your change.\n Re-take the baseline on this tree: \
--status <file>, then re-run --diff."
);
return;
}
let base: std::collections::BTreeMap<&str, bool> = text
.lines()
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
.filter_map(|l| l.rsplit_once(' '))
.map(|(n, v)| (n.trim(), v.trim() == "ok"))
.collect();
let (mut gained, mut lost, mut appeared) = (Vec::new(), Vec::new(), Vec::new());
for (name, ok) in &roster {
match base.get(name.as_str()) {
Some(was) if *was != *ok => {
if *ok {
gained.push(name)
} else {
lost.push(name)
}
}
None => appeared.push(name),
_ => {}
}
}
let vanished: Vec<&&str> = base
.keys()
.filter(|n| !roster.iter().any(|(m, _)| m == **n))
.collect();
println!("\n DIFF vs {path}");
println!(
" net {:+} (gained {}, lost {})",
gained.len() as i64 - lost.len() as i64,
gained.len(),
lost.len()
);
// Both lists always print, even when empty.
for (label, list) in [("GAINED", &gained), ("LOST", &lost)] {
println!(" {label} ({}):", list.len());
for n in list.iter() {
println!(" {n}");
}
}
if !appeared.is_empty() || !vanished.is_empty() {
println!(
" (roster changed: {} not in the baseline, {} baseline rows absent — \
the two runs surveyed different folders)",
appeared.len(),
vanished.len()
);
}
}
Err(e) => println!("\n --diff {path}: {e}"),
}
}
println!();
}