From 3ebf432ebd93e77126cab4372be4fe0ebab84404 Mon Sep 17 00:00:00 2001 From: grom Date: Tue, 20 Oct 2026 06:19:20 -0600 Subject: [PATCH] harness: ask the whole interface a question, not one addon at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--probe ` 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 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/examples/addon_harness.rs | 94 +++++++++++++------ crates/benilla-app/src/addon_harness/probe.rs | 62 ++++++++++-- .../benilla-app/src/addon_harness/together.rs | 65 +++++++++++-- 3 files changed, 174 insertions(+), 47 deletions(-) diff --git a/crates/benilla-app/examples/addon_harness.rs b/crates/benilla-app/examples/addon_harness.rs index 0a34bd7e..be113e05 100644 --- a/crates/benilla-app/examples/addon_harness.rs +++ b/crates/benilla-app/examples/addon_harness.rs @@ -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 { + 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> = std::sync::OnceLock::new(); @@ -334,6 +368,36 @@ fn main() { return; } + // `--probe-all [--saved -] [--screen x] [--eval ...]`: 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 - for a player's)") + } + } + println!(" screen: {}x{}", screen.0, screen.1); + report_probe(&out); + return; + } + // `--probe [--eval ...]`: 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 = 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; } diff --git a/crates/benilla-app/src/addon_harness/probe.rs b/crates/benilla-app/src/addon_harness/probe.rs index 6fb9dd62..0d90da21 100644 --- a/crates/benilla-app/src/addon_harness/probe.rs +++ b/crates/benilla-app/src/addon_harness/probe.rs @@ -91,7 +91,58 @@ pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option { 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 = 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 { (format!("--mouse {x},{y}"), format!("= over {focus}")) } }) - .collect(); - - Some(ProbeOutcome { - name: name.to_string(), - load_errors, - session_errors, - answers, - }) + .collect() } diff --git a/crates/benilla-app/src/addon_harness/together.rs b/crates/benilla-app/src/addon_harness/together.rs index bb8112cd..7fe481bb 100644 --- a/crates/benilla-app/src/addon_harness/together.rs +++ b/crates/benilla-app/src/addon_harness/together.rs @@ -86,21 +86,44 @@ pub fn survey_together(root: &Path) -> Vec { merged } -/// One walk in one VM. -fn survey_together_once(root: &Path) -> Vec<(String, Vec)> { +/// 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, + /// `(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)>, +} + +/// `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, + saved_character: Option, + screen: (f32, f32), +) -> Option { 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 = 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)> = 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)> { let mut pulled: Vec = 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)> { 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)> { + 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 `\\` rather than a prefix: Lua // truncates a long chunk name from the left (`...Ons\FuBar_DakSmak\Libs\…`).