harness: ask the whole interface a question, not one addon at a time

`--probe <Name>` loads one addon and asks it things. What it cannot see is an
interaction between two addons, and a real interface is mostly interactions:
the player's chat frame is docked by pfUI, re-anchored by the shipped
`FCF_DockUpdate`, and read by ChatSuey, and no single-addon probe reproduces
any of that.

`--probe-all` is the same read-back against the walk `--together` already does
— the whole folder in one VM, in load order, each `ADDON_LOADED` at its own
position, then one session start — with the VM left standing for `--eval`,
`--tick` and `--mouse` instead of thrown away. The walk itself is now one
function both callers share, so the survey and the probe cannot drift.

Two things the walk was missing, both of which decide what the interface looks
like:

- **Saved variables.** `register_addons` was told where they live and nobody
  read them, so every `--together` run measured a default-config interface.
  pfUI's panel sizes, module enables and font sizes all live there. With
  `--saved C_Thun-Gromgosh` the probe reproduces the player's own geometry to
  the digit — the chat frame's 25.12 px panel gap matches what was measured in
  play, where a default-config VM says 23.62. Loaded before `ADDON_LOADED`, as
  the reference sets them (`0x51f6c0`), because reading config on that event is
  the usual shape; after it is a default-config interface with extra steps.
  Dependencies get theirs too: a library pulled in under its first dependent
  never gets a turn of its own in this walk, where the live client gives every
  enabled addon one.

- **The screen.** `--screen 2560x1440` instead of a hardcoded 1024x768, because
  pfUI's pixel-perfect scaling reads `GetScreenHeight` and a player's interface
  is not laid out at the harness's resolution.

What it answered first time out: the player's short pfUI chat frame does **not**
reproduce offline. With his real saved variables, at his real resolution, with
all 80 addons in one VM, `ChatFrame1` is docked correctly — `TOPLEFT` and
`BOTTOMRIGHT` into `pfChatLeft`, height 129.76 of a 180 panel — so whatever
shortens it live needs state this walk does not have, which is worth knowing
before reading another line of `layout.rs`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-20 06:19:20 -06:00
co-authored by Claude Opus 5
parent a8b6fc8ad6
commit 3ebf432ebd
3 changed files with 174 additions and 47 deletions
+66 -28
View File
@@ -44,6 +44,40 @@ fn report_lines(label: &str, lines: &[String]) {
}
}
/// `--eval`, `--tick` and `--mouse` are one ordered list, run in the order typed.
fn probe_steps(rest: &[String]) -> 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()
}
/// What a probe found, however it loaded: its failures, then one block per step in order.
fn report_probe(out: &addon_harness::probe::ProbeOutcome) {
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}");
}
}
/// 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();
@@ -334,6 +368,36 @@ fn main() {
return;
}
// `--probe-all [--saved <Realm>-<Character>] [--screen <W>x<H>] [--eval <lua> ...]`: the whole
// folder in one VM, then asked. What `--probe` cannot see is an interaction between two addons,
// which is most of what a real interface is.
if rest.iter().any(|a| a == "--probe-all") {
let steps = probe_steps(&rest);
let screen = rest
.iter()
.position(|a| a == "--screen")
.and_then(|i| rest.get(i + 1))
.and_then(|s| s.split_once('x'))
.and_then(|(w, h)| Some((w.trim().parse().ok()?, h.trim().parse().ok()?)))
.unwrap_or((1024.0, 768.0));
let saved = rest
.iter()
.position(|a| a == "--saved")
.and_then(|i| rest.get(i + 1))
.and_then(|s| s.split_once('-'));
let out = addon_harness::probe::probe_all(&root, saved, screen, &steps);
println!("\n{} — probed under {}", out.name, root.display());
match saved {
Some((realm, character)) => println!(" saved variables: {realm}-{character}"),
None => {
println!(" saved variables: none (--saved <Realm>-<Character> for a player's)")
}
}
println!(" screen: {}x{}", screen.0, screen.1);
report_probe(&out);
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
@@ -341,25 +405,7 @@ fn main() {
.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 steps = probe_steps(&rest);
let Some(out) = addon_harness::probe::probe(&root, name, &steps) else {
eprintln!(
"no manifest under {}/{name} — is that an addon folder?",
@@ -368,15 +414,7 @@ fn main() {
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}");
}
report_probe(&out);
return;
}
+53 -9
View File
@@ -91,7 +91,58 @@ pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option<ProbeOutcome> {
script.mark_addon_loaded(name);
let session_errors = super::drive_session_start(&mut script, name, &installed);
let answers = steps
let answers = run_steps(&mut script, steps);
Some(ProbeOutcome {
name: name.to_string(),
load_errors,
session_errors,
answers,
})
}
/// The whole folder in one VM, as a client loads it, then asked — with the named character's own
/// saved variables, because an interaction between two addons is what a single-addon probe cannot
/// see and a player's config is half of what decides it.
pub fn probe_all(
root: &Path,
saved: Option<(&str, &str)>,
screen: (f32, f32),
steps: &[Step],
) -> ProbeOutcome {
let (account, character) = match saved {
Some((realm, char_name)) => (
crate::local_state::addon_saved_account_dir(),
crate::local_state::addon_saved_character_dir(realm, char_name),
),
None => (None, None),
};
let Some(mut walk) = super::together::walk_folder(root, account, character, screen) else {
return ProbeOutcome {
name: "the whole folder".into(),
load_errors: vec!["VM: could not be created".into()],
..Default::default()
};
};
// One row per folder that failed to load, named, so a probe reports what the survey would.
let load_errors: Vec<String> = walk
.load_errors
.iter()
.flat_map(|(folder, errs)| errs.iter().map(move |e| format!("{folder}: {e}")))
.collect();
let answers = run_steps(&mut walk.script, steps);
ProbeOutcome {
name: format!("the whole folder ({} addons)", walk.names.len()),
load_errors,
// The walk fires the session start itself, so its raises are already in the VM's log.
session_errors: walk.script.errors(),
answers,
}
}
/// Run each [`Step`] against a VM that is already loaded and seated.
fn run_steps(script: &mut UiScript, steps: &[Step]) -> Vec<(String, String)> {
steps
.iter()
.map(|step| match step {
Step::Eval(chunk) => {
@@ -125,12 +176,5 @@ pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option<ProbeOutcome> {
(format!("--mouse {x},{y}"), format!("= over {focus}"))
}
})
.collect();
Some(ProbeOutcome {
name: name.to_string(),
load_errors,
session_errors,
answers,
})
.collect()
}
@@ -86,21 +86,44 @@ pub fn survey_together(root: &Path) -> Vec<TogetherRow> {
merged
}
/// One walk in one VM.
fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
/// One walk of the whole folder into a fresh VM, left standing: the client's load order with each
/// `ADDON_LOADED` at its own position, then one session start and ten ticks. The survey reads its
/// errors and throws it away; a probe asks it questions.
pub(super) struct Walk {
pub script: UiScript,
/// The folders walked, in load order.
pub names: Vec<String>,
/// `(folder, its load failures)`, kept beside the walk: the VM's diagnostics log caps at 256
/// distinct rows and evicts.
pub load_errors: Vec<(String, Vec<String>)>,
}
/// `saved_account`/`saved_character` are the roots a player's saved variables are read from. The
/// survey passes none, so its verdicts are about the packages rather than one player's config; a
/// probe passes the real ones, because pfUI's module enables and panel sizes live there and a
/// default-config VM draws an interface nobody runs.
/// `screen` is the window the interface lays out against; pfUI's pixel-perfect scaling reads it,
/// so a probe of a player's own interface passes the resolution they play at.
pub(super) fn walk_folder(
root: &Path,
saved_account: Option<std::path::PathBuf>,
saved_character: Option<std::path::PathBuf>,
screen: (f32, f32),
) -> Option<Walk> {
let (names, installed, registry) = corpus(root);
let Ok(mut script) = UiScript::new() else {
return Vec::new();
};
script.set_screen_size(1024.0, 768.0);
script.register_addons(registry, Some(root.to_path_buf()), None, None);
let mut script = UiScript::new().ok()?;
script.set_screen_size(screen.0, screen.1);
script.register_addons(
registry,
Some(root.to_path_buf()),
saved_account,
saved_character,
);
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
// `seen` spans the whole walk: a library declared by many dependents loads once.
let mut seen: BTreeSet<String> = BTreeSet::new();
// `(folder, its load failures)`, kept beside the walk: the VM's diagnostics log caps at 256
// distinct rows and evicts.
let mut load_errors: Vec<(String, Vec<String>)> = Vec::new();
for name in &names {
let Some(toc) = manifest_path(root, name)
@@ -117,12 +140,17 @@ fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
let mut pulled: Vec<LoadedDep> = Vec::new();
load_dependencies(&mut script, root, &toc, &installed, &mut seen, &mut pulled);
// A dependency's load failures are collected only here: it loads once, under the first
// dependent to reach it, and never gets a turn of its own.
// dependent to reach it, and never gets a turn of its own — which is also why its saved
// variables are loaded here, where the live walk gives every enabled addon its own turn.
for dep in pulled {
script.load_addon_saved_variables(&dep.name);
load_errors.push((dep.name, dep.files.errors));
}
let files = load_addon_files(&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.
script.load_addon_saved_variables(name);
script.mark_addon_loaded(name);
script.fire_event(
"ADDON_LOADED",
@@ -136,6 +164,23 @@ fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
for _ in 0..10 {
script.tick(0.1);
}
Some(Walk {
script,
names,
load_errors,
})
}
/// One walk in one VM, read for which addon raised.
fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
let Some(Walk {
script,
names,
load_errors,
}) = walk_folder(root, None, None, (1024.0, 768.0))
else {
return Vec::new();
};
// Attribution by the raising chunk, matched as `\<Folder>\` rather than a prefix: Lua
// truncates a long chunk name from the left (`...Ons\FuBar_DakSmak\Libs\…`).