diff --git a/crates/benilla-app/assets/ui/AccountLoginRealmlist.xml b/crates/benilla-app/assets/ui/AccountLoginRealmlist.xml new file mode 100644 index 00000000..3a7c255e --- /dev/null +++ b/crates/benilla-app/assets/ui/AccountLoginRealmlist.xml @@ -0,0 +1,115 @@ + + + + + diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index cc964f8a..462fbdfd 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -112,8 +112,8 @@ pub(crate) use probes::{ fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin, JitterMeterPlugin, LiftCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeClock, ProbeDragPlugin, ProbeExitPlugin, ProbeFocusPlugin, - ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, RevealAuditPlugin, - SchedCensusPlugin, StallPlugin, TrailCensusPlugin, UnitVisualsPlugin, + ProbeGlueLuaPlugin, ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, + RevealAuditPlugin, SchedCensusPlugin, StallPlugin, TrailCensusPlugin, UnitVisualsPlugin, }; pub(crate) use scenarios::ui_opted_in; use scenarios::GlueScreen; diff --git a/crates/benilla-app/src/capture/probe_env.rs b/crates/benilla-app/src/capture/probe_env.rs index 0a11c9a9..0a43c83d 100644 --- a/crates/benilla-app/src/capture/probe_env.rs +++ b/crates/benilla-app/src/capture/probe_env.rs @@ -72,6 +72,26 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[ // First fire at `_AT` seconds, re-armed `_AGAIN` seconds into every later world entry. wall_clock: true, }, + ProbeVar { + name: "WOW_PROBE_GLUE_LUA", + purpose: "\"\" — under WOW_GLUE_STOCK=1, run a Lua chunk once in the stock glue VM (press the login screen's buttons headlessly)", + wall_clock: true, + }, + ProbeVar { + name: "WOW_PROBE_GLUE_LUA_AT", + purpose: " — when the glue chunk runs (default 10)", + wall_clock: false, + }, + ProbeVar { + name: "WOW_PROBE_GLUE_SHOT", + purpose: " — one PNG after the glue chunk runs", + wall_clock: false, + }, + ProbeVar { + name: "WOW_PROBE_GLUE_SHOT_AFTER", + purpose: " — how long after the glue chunk the shot is taken (default 1)", + wall_clock: false, + }, ProbeVar { name: "WOW_PROBE_LUA_AT", purpose: " — when the chunk runs after the first world entry (default 10)", diff --git a/crates/benilla-app/src/capture/probes/act.rs b/crates/benilla-app/src/capture/probes/act.rs index ddc4ea14..8b9367d5 100644 --- a/crates/benilla-app/src/capture/probes/act.rs +++ b/crates/benilla-app/src/capture/probes/act.rs @@ -365,6 +365,97 @@ fn fire_probe_lua( } } +/// `WOW_PROBE_GLUE_LUA=""`: under `WOW_GLUE_STOCK=1`, runs the chunk once in the stock +/// glue VM `WOW_PROBE_GLUE_LUA_AT` seconds (default 10) into the run, to press the login screen's +/// buttons headlessly; `WOW_PROBE_GLUE_SHOT=` then saves one PNG +/// `WOW_PROBE_GLUE_SHOT_AFTER` seconds (default 1) later. The chunk is never logged: it may carry +/// a typed password. +pub(crate) struct ProbeGlueLuaPlugin; + +impl Plugin for ProbeGlueLuaPlugin { + fn build(&self, app: &mut App) { + let secs = |k: &str, d: f32| { + std::env::var(k) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(d) + }; + app.insert_resource(ProbeGlueLua { + chunk: std::env::var("WOW_PROBE_GLUE_LUA").unwrap_or_default(), + at: secs("WOW_PROBE_GLUE_LUA_AT", 10.0), + shot: std::env::var("WOW_PROBE_GLUE_SHOT").ok(), + shot_after: secs("WOW_PROBE_GLUE_SHOT_AFTER", 1.0), + ran_at: None, + shot_done: false, + }) + .add_systems(Update, fire_probe_glue_lua.after(crate::ui_script::UiInput)); + } +} + +#[derive(Resource)] +struct ProbeGlueLua { + chunk: String, + at: f32, + shot: Option, + shot_after: f32, + ran_at: Option, + shot_done: bool, +} + +/// The log line for a failed glue chunk: its kind alone. The error's text can quote the chunk (a +/// syntax error's `near ''`, a chunk named by its own source), and the chunk may carry a +/// typed password. +fn glue_chunk_failure(e: &dyn std::fmt::Display) -> String { + let text = e.to_string(); + let kind = if text.starts_with("syntax error") { + "a syntax error" + } else if text.starts_with("runtime error") { + "a runtime error" + } else if text.starts_with("callback error") { + "an error in a callback" + } else { + "an error" + }; + format!("probe-glue-lua: the chunk failed with {kind}") +} + +/// Runs the chunk once the glue VM stands and its time has come, then the one shot. +fn fire_probe_glue_lua( + mut probe: ResMut, + time: ProbeClock, + script: Option>, + mut commands: Commands, +) { + let now = time.elapsed_secs(); + match probe.ran_at { + None => { + let Some(script) = script.filter(|s| s.vm_kind() == benilla_ui::script::VmKind::Glue) + else { + return; + }; + if now < probe.at { + return; + } + probe.ran_at = Some(now); + info!("probe-glue-lua: running the chunk at {now:.1}s"); + if let Err(e) = script.run(&probe.chunk) { + error!("{}", glue_chunk_failure(&e)); + } + } + Some(ran) if !probe.shot_done && now >= ran + probe.shot_after => { + probe.shot_done = true; + if let Some(out) = probe.shot.clone() { + use bevy::render::view::screenshot::{save_to_disk, Screenshot}; + commands + .spawn(Screenshot::primary_window()) + .observe(save_to_disk(out.clone())); + info!("probe-glue-lua: shot writing {out}"); + } + } + _ => {} + } +} + /// `WOW_PROBE_HOVER="Frame1;Frame2;…"`: moves the pointer across each named frame's centre /// through the real pointer path, pressing nothing, from `WOW_PROBE_HOVER_AT` (default 14 s), /// one step per `WOW_PROBE_HOVER_STEP` (default 0.25 s; ~0.016 moves every frame), until exit. @@ -725,3 +816,28 @@ mod tests { ); } } + +#[cfg(test)] +mod glue_chunk_tests { + /// A failing chunk is logged by its kind, never its text: a syntax error quotes its token. + #[test] + fn a_failed_glue_chunk_never_echoes_its_text() { + let s = benilla_ui::script::UiScript::new_glue().unwrap(); + for (chunk, kind) in [ + ("x = 1 'SECRET'", "a syntax error"), + ("error('SECRET')", "a runtime error"), + ("SECRET()", "a runtime error"), + ] { + let e = s.run(chunk).unwrap_err(); + assert!( + e.to_string().contains("SECRET"), + "the raw error quotes the chunk: {e}" + ); + let line = super::glue_chunk_failure(&e); + assert_eq!( + line, + format!("probe-glue-lua: the chunk failed with {kind}") + ); + } + } +} diff --git a/crates/benilla-app/src/capture/probes/mod.rs b/crates/benilla-app/src/capture/probes/mod.rs index 6a8cfb21..1582b464 100644 --- a/crates/benilla-app/src/capture/probes/mod.rs +++ b/crates/benilla-app/src/capture/probes/mod.rs @@ -9,7 +9,8 @@ mod fx_draw_census; pub(crate) use fx_draw_census::plugin as fx_draw_census_plugin; mod act; pub(crate) use act::{ - ProbeChatPlugin, ProbeDragPlugin, ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, + ProbeChatPlugin, ProbeDragPlugin, ProbeGlueLuaPlugin, ProbeHoverPlugin, ProbeKeyPlugin, + ProbeLuaPlugin, }; /// The probe run's shell: its bounded lifetime and its window's level, parking and size. diff --git a/crates/benilla-app/src/cvars/table.rs b/crates/benilla-app/src/cvars/table.rs index ed9f2b8a..3a62ac1d 100644 --- a/crates/benilla-app/src/cvars/table.rs +++ b/crates/benilla-app/src/cvars/table.rs @@ -720,4 +720,13 @@ pub(crate) const REGISTERED: &[Registered] = &[ // "Last character selected": a 0-based row (the selection cell `[0x83856c]` under `"%d"`), so // "0" is the first character. It mirrors [`crate::char_select::Roster::pending_index`]. same(crate::char_select::CVAR_LAST_CHARACTER, "0"), + // The four user agreements the login screen walks (`0x402db6`, `0x402dd9`, `0x402dfc`, + // `0x402e1f`; "0" `0x82e570`, category 4): "Status of the TOS" (`0x82e8e4`), "... the EULA" + // (`0x82e8c8`), "... the Scanning notice" (`0x82e89c`) and "... the Contest notice" + // (`0x82e86c`). The glue manager reads them once at startup (`0x46a400`) as accepted (1) and + // changed (negative), and its `Accept*` writes "1" (`0x46aef0` and its siblings). + same("readTOS", "0"), + same("readEULA", "0"), + same("readScanning", "0"), + same("readContest", "0"), ]; diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index 053f7a9d..c6ed9c7d 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -101,6 +101,10 @@ impl Plugin for DevProbesPlugin { if std::env::var("WOW_PROBE_LUA").is_ok() { app.add_plugins(crate::capture::ProbeLuaPlugin); } + // `WOW_PROBE_GLUE_LUA`: runs a chunk once in the stock glue VM, then one shot. + if std::env::var("WOW_PROBE_GLUE_LUA").is_ok() { + app.add_plugins(crate::capture::ProbeGlueLuaPlugin); + } // `WOW_PROBE_DRAG="A>B"`: drags frame A onto B through the real pointer path. if std::env::var("WOW_PROBE_DRAG").is_ok() { app.add_plugins(crate::capture::ProbeDragPlugin); diff --git a/crates/benilla-app/src/login/mod.rs b/crates/benilla-app/src/login/mod.rs index 243c8473..07c2acf7 100644 --- a/crates/benilla-app/src/login/mod.rs +++ b/crates/benilla-app/src/login/mod.rs @@ -53,6 +53,7 @@ impl Plugin for LoginPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() + .add_message::() .add_systems(OnEnter(ClientState::Login), enter_login) .add_systems(OnExit(ClientState::Login), screen::exit_login) .add_systems( @@ -60,7 +61,19 @@ impl Plugin for LoginPlugin { ( // Every state: the reconnect resubmit fires while `InWorld`, and the roster // edge lands wherever it lands. - (drive_policy, to_select_on_roster, drive_quit).chain(), + // The stock glue screens' manager first, after the tick whose Lua called its + // verbs, so its asks reach the policy in the same frame. + ( + crate::ui_script::glue_mgr::drive_glue_mgr + .after(crate::ui_script::UiInput) + .after(crate::realm_select::RealmSelectSet) + .run_if(crate::ui_script::glue::stock_glue), + answer_glue, + drive_policy, + to_select_on_roster, + drive_quit, + ) + .chain(), ( screen::materialize_screen, login_input.run_if(crate::ui_script::glue::native_glue), @@ -167,6 +180,69 @@ impl Attempt<'_> { } } +/// What the stock glue screens ask of the login engine, past the glue manager's own guards +/// ([`crate::ui_script::glue_mgr`]). +#[derive(Message)] +pub(crate) enum GlueLogin { + /// `CGlueMgr::Login` (`0x46afb0`) reaching `ClientServices`' logon (`0x5ab4b0`): one attempt, + /// typed, with the screen's credentials. + Submit { account: String, password: String }, + /// The status dialog's Cancel during the logon (`0x5b3320`) or the world connect (`0x5aaa50` + /// with `RESPONSE_CANCELLED`): the attempt in flight is dropped and nothing resubmits. + Abandon, + /// `DisconnectFromServer` (`0x46d340`): the world session dropped if there is one, then the + /// logon cancelled, as select's Back drops the parked session. + Disconnect, + /// `QuitGame` (`0x46cea0` → `0x401560(0)`): the process ends, with no grace for a sound. + Quit, +} + +/// The glue manager's asks: a submit as the screen's own Login press makes it (the account saving +/// is the stock Lua's `SetSavedAccountName`), a cancel as the native Cancel, a disconnect as +/// select's Back, a quit as the native quit with no grace. The screen moves and the quit go +/// through `Commands`, applied before [`drive_quit`] in this chain. +pub(crate) fn answer_glue( + mut asks: MessageReader, + mut attempt: Attempt, + pick: Option>, + state: Res>, + time: Res