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\…`).