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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BenillaRealmlist_OnClick();
+
+
+ if ( BenillaRealmlistText:GetText() ~= BENILLA_REALMLIST ) then
+ BenillaRealmlistText:SetText(BENILLA_REALMLIST);
+ end
+
+
+
+
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,
+ mut commands: Commands,
+) {
+ for ask in asks.read() {
+ match ask {
+ GlueLogin::Submit { account, password } => {
+ if !attempt.intent.in_flight {
+ attempt.send(account, password, true);
+ }
+ }
+ GlueLogin::Abandon => {
+ attempt.abandon.0.fetch_add(1, Ordering::SeqCst);
+ attempt.intent.in_flight = false;
+ attempt.intent.clear();
+ }
+ GlueLogin::Disconnect => {
+ attempt.abandon.0.fetch_add(1, Ordering::SeqCst);
+ attempt.intent.in_flight = false;
+ attempt.intent.clear();
+ if matches!(
+ state.get(),
+ ClientState::CharSelect | ClientState::CharCreate
+ ) {
+ if let Some(pick) = pick.as_deref() {
+ let _ = pick.0.send(crate::net::CharRequest::Abandon);
+ }
+ commands.set_state(ClientState::Login);
+ }
+ }
+ GlueLogin::Quit => {
+ info!("login: QuitGame");
+ commands.insert_resource(QuitArm(Some(time.elapsed_secs())));
+ }
+ }
+ }
+}
+
/// The queue ring's clock, standing in for the reference's `GetTickCount`: only differences
/// matter, and `u32` keeps the reference's wrapping width.
fn queue_now_ms(time: &Time) -> u32 {
@@ -197,7 +273,10 @@ fn stage_text(strings: &GlueStrings, stage: LoginStage) -> &str {
match stage {
LoginStage::Connecting => strings.text("LOGIN_STATE_CONNECTING", "Connecting"),
LoginStage::Authenticating => strings.text("LOGIN_STATE_AUTHENTICATING", "Authenticating"),
- LoginStage::Handshaking => strings.text("LOGIN_STATE_HANDSHAKING", "Handshaking"),
+ // The native dialog keeps the handshake's text through the world socket's connect.
+ LoginStage::Handshaking | LoginStage::WorldConnected => {
+ strings.text("LOGIN_STATE_HANDSHAKING", "Handshaking")
+ }
}
}
@@ -221,7 +300,9 @@ fn fail_text(
}
let authored = match refusal {
Some(LoginRefusal::World(code)) => world_refusal_text(strings, code),
- Some(LoginRefusal::Logon(code)) => logon_refusal_text(strings, Some(code)),
+ Some(LoginRefusal::Logon(code) | LoginRefusal::LogonProof(code)) => {
+ logon_refusal_text(strings, Some(code))
+ }
None => logon_refusal_text(strings, None),
};
without_dead_url(authored).into_owned()
@@ -862,7 +943,7 @@ fn drive_quit(arm: Option>, time: Res, mut exit: MessageWrite
/// The realmlist dialog's prompt for a value that is not an address; the typed text stays for
/// editing.
-const REALMLIST_BAD: &str =
+pub(crate) const REALMLIST_BAD: &str =
"That is not a server address.\nTry logon.example.org or 127.0.0.1:3724";
/// What a glue-dialog press means on this screen; the widget
@@ -963,14 +1044,14 @@ fn save_account_to(path: &std::path::Path, name: &str) {
}
/// `GetSavedAccountName`, at [`crate::local_state`]'s path.
-fn load_saved_account() -> String {
+pub(crate) fn load_saved_account() -> String {
crate::local_state::saved_account_path()
.map(|p| load_saved_account_from(&p))
.unwrap_or_default()
}
/// `SetSavedAccountName`; empty clears.
-fn save_account(name: &str) {
+pub(crate) fn save_account(name: &str) {
if let Some(path) = crate::local_state::saved_account_path() {
save_account_to(&path, name);
}
diff --git a/crates/benilla-app/src/login/queue.rs b/crates/benilla-app/src/login/queue.rs
index 32b1a8ae..f072e5fc 100644
--- a/crates/benilla-app/src/login/queue.rs
+++ b/crates/benilla-app/src/login/queue.rs
@@ -72,47 +72,60 @@ impl QueueEstimate {
realm: Option<&str>,
strings: &crate::glue_strings::GlueStrings,
) -> String {
- let position = self.position().unwrap_or(0);
- let named = realm.filter(|r| !r.is_empty());
+ let (key, remaining) = self.key(now_ms, realm);
+ let fallback = match key {
+ "QUEUE_NAME_TIME_LEFT_UNKNOWN" => {
+ "%s is Full\nPosition in queue: %d\nEstimated time: Calculating..."
+ }
+ "QUEUE_NAME_TIME_LEFT_SECONDS" => {
+ "%s is Full\nPosition in queue: %d\nEstimated time: < 1 minute"
+ }
+ "QUEUE_NAME_TIME_LEFT" => "%s is Full\nPosition in queue: %d\nEstimated time: %d min",
+ "QUEUE_TIME_LEFT_UNKNOWN" => {
+ "Realm is Full\nPosition in queue: %d\nEstimated time: Calculating..."
+ }
+ "QUEUE_TIME_LEFT_SECONDS" => {
+ "Realm is Full\nPosition in queue: %d\nEstimated time: < 1 minute"
+ }
+ _ => "Realm is Full\nPosition in queue: %d\nEstimated time: %d min",
+ };
+ self.fill(strings.text(key, fallback), realm, remaining)
+ }
+ /// [`Self::text`] with the `QUEUE_*` template looked up by `lookup(key)`: the stock glue
+ /// screens read it off the glue VM's own `GlueStrings.lua`.
+ pub(crate) fn text_by(
+ &self,
+ now_ms: u32,
+ realm: Option<&str>,
+ lookup: impl Fn(&str) -> String,
+ ) -> String {
+ let (key, remaining) = self.key(now_ms, realm);
+ self.fill(&lookup(key), realm, remaining)
+ }
+
+ /// The `QUEUE_*` key for this state, and the wait left in ms once there is an estimate.
+ fn key(&self, now_ms: u32, realm: Option<&str>) -> (&'static str, Option) {
+ let named = realm.is_some_and(|r| !r.is_empty());
let remaining = self.estimate_ms(now_ms).and_then(|est| {
(est > 0).then(|| i64::from(self.tick_ms[0]) + est - i64::from(now_ms))
});
-
- let (key, fallback) = match remaining {
- None => (
- "QUEUE_NAME_TIME_LEFT_UNKNOWN",
- "%s is Full\nPosition in queue: %d\nEstimated time: Calculating...",
- ),
- Some(ms) if ms < MINUTE_MS => (
- "QUEUE_NAME_TIME_LEFT_SECONDS",
- "%s is Full\nPosition in queue: %d\nEstimated time: < 1 minute",
- ),
- Some(_) => (
- "QUEUE_NAME_TIME_LEFT",
- "%s is Full\nPosition in queue: %d\nEstimated time: %d min",
- ),
- };
- let (key, fallback) = match named {
- Some(_) => (key, fallback),
- None => match remaining {
- None => (
- "QUEUE_TIME_LEFT_UNKNOWN",
- "Realm is Full\nPosition in queue: %d\nEstimated time: Calculating...",
- ),
- Some(ms) if ms < MINUTE_MS => (
- "QUEUE_TIME_LEFT_SECONDS",
- "Realm is Full\nPosition in queue: %d\nEstimated time: < 1 minute",
- ),
- Some(_) => (
- "QUEUE_TIME_LEFT",
- "Realm is Full\nPosition in queue: %d\nEstimated time: %d min",
- ),
- },
+ let key = match (named, remaining) {
+ (true, None) => "QUEUE_NAME_TIME_LEFT_UNKNOWN",
+ (true, Some(ms)) if ms < MINUTE_MS => "QUEUE_NAME_TIME_LEFT_SECONDS",
+ (true, Some(_)) => "QUEUE_NAME_TIME_LEFT",
+ (false, None) => "QUEUE_TIME_LEFT_UNKNOWN",
+ (false, Some(ms)) if ms < MINUTE_MS => "QUEUE_TIME_LEFT_SECONDS",
+ (false, Some(_)) => "QUEUE_TIME_LEFT",
};
+ (key, remaining)
+ }
- let mut out = strings.text(key, fallback).to_string();
- if let Some(realm) = named {
+ /// The template's holes: the realm's name, the position, then the minutes.
+ fn fill(&self, template: &str, realm: Option<&str>, remaining: Option) -> String {
+ let position = self.position().unwrap_or(0);
+ let mut out = template.to_string();
+ if let Some(realm) = realm.filter(|r| !r.is_empty()) {
out = out.replacen("%s", realm, 1);
}
out = out.replacen("%d", &position.to_string(), 1);
diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs
index c993d47c..81258673 100644
--- a/crates/benilla-app/src/net/io.rs
+++ b/crates/benilla-app/src/net/io.rs
@@ -366,9 +366,14 @@ fn run(
if let Some(dial) = e.downcast_ref::() {
return fail_dial(dial.clone(), format!("{e:#}"));
}
- let refusal = e
- .downcast_ref::()
- .map(|r| benilla_protocol::LoginRefusal::Logon(r.code));
+ let refusal = e.downcast_ref::().map(|r| match r.reply {
+ benilla_protocol::LogonReply::Challenge => {
+ benilla_protocol::LoginRefusal::Logon(r.code)
+ }
+ benilla_protocol::LogonReply::Proof => {
+ benilla_protocol::LoginRefusal::LogonProof(r.code)
+ }
+ });
return fail(refusal, format!("{e:#}"));
}
}
@@ -451,11 +456,16 @@ fn run(
let realm_name = realm.as_ref().map(|r| r.name.clone());
// A queue can last minutes, so it tests the abandon generation itself; otherwise a Cancel
// would close the dialog while this thread held its place and entered the world anyway.
- let mut on_queue = |position: Option| {
- let _ = events_tx.send(SessionEvent::LoginQueued {
- position,
- realm: realm_name.clone(),
- });
+ let mut on_queue = |event: benilla_protocol::WorldConnectEvent| {
+ match event {
+ benilla_protocol::WorldConnectEvent::Connected => stage(LoginStage::WorldConnected),
+ benilla_protocol::WorldConnectEvent::Queued(position) => {
+ let _ = events_tx.send(SessionEvent::LoginQueued {
+ position,
+ realm: realm_name.clone(),
+ });
+ }
+ }
!canceled()
};
let mut session = match WorldSession::connect_queued(
diff --git a/crates/benilla-app/src/realm_select/mod.rs b/crates/benilla-app/src/realm_select/mod.rs
index cafa2e74..9ae0db7a 100644
--- a/crates/benilla-app/src/realm_select/mod.rs
+++ b/crates/benilla-app/src/realm_select/mod.rs
@@ -274,6 +274,10 @@ pub(crate) fn take_input(mut realms: ResMut) {
/// The realm-selection subsystem: the park's policy plus the screen.
pub(crate) struct RealmSelectPlugin;
+/// The subsystem's `Update` chain, for a reader of [`Realms`] to order itself after.
+#[derive(SystemSet, Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub(crate) struct RealmSelectSet;
+
impl Plugin for RealmSelectPlugin {
fn build(&self, app: &mut App) {
app.init_resource::()
@@ -304,6 +308,7 @@ impl Plugin for RealmSelectPlugin {
.chain(),
)
.chain()
+ .in_set(RealmSelectSet)
.after(benilla_world::schedule::WorldStage::Net)
.before(crate::glue::GlueVisuals),
);
diff --git a/crates/benilla-app/src/realmlist.rs b/crates/benilla-app/src/realmlist.rs
index 2b68d0ec..65f20e77 100644
--- a/crates/benilla-app/src/realmlist.rs
+++ b/crates/benilla-app/src/realmlist.rs
@@ -21,7 +21,10 @@ pub(crate) fn on_cvar(ev: On, mut realmlist: ResMut realmlist.set(&address),
- None => warn!("cvar realmList: unusable realmlist '{}' ignored", ev.new),
+ None => {
+ warn!("cvar realmList: unusable realmlist '{}' ignored", ev.new);
+ realmlist.refused = Some(ev.new.clone());
+ }
}
}
@@ -51,6 +54,9 @@ pub(crate) struct Realmlist {
address: String,
/// `$WOW_HOST` owns this session: the address shows, the control is disabled, nothing persists.
pinned_by_env: bool,
+ /// The last `realmList` value that was not an address, for the stock login screen's
+ /// realmlist dialog to reopen on ([`crate::ui_script::glue_mgr`]).
+ refused: Option,
}
impl Default for Realmlist {
@@ -60,10 +66,12 @@ impl Default for Realmlist {
Ok(host) if !host.trim().is_empty() => Realmlist {
address: host,
pinned_by_env: true,
+ refused: None,
},
_ => Realmlist {
address: DEFAULT_REALMLIST.to_string(),
pinned_by_env: false,
+ refused: None,
},
}
}
@@ -76,6 +84,7 @@ impl Realmlist {
Realmlist {
address: address.to_string(),
pinned_by_env: false,
+ refused: None,
}
}
@@ -87,6 +96,11 @@ impl Realmlist {
self.pinned_by_env
}
+ /// The last value refused as no address, taken once.
+ pub(crate) fn take_refused(&mut self) -> Option {
+ self.refused.take()
+ }
+
/// Points at an already normalized `address`; ignored while pinned by the env.
pub(crate) fn set(&mut self, address: &str) {
if self.pinned_by_env || self.address == address {
@@ -230,6 +244,7 @@ mod tests {
let mut r = Realmlist {
address: "harness.example.org".into(),
pinned_by_env: true,
+ refused: None,
};
r.set("somewhere.else.org");
assert_eq!(r.address(), "harness.example.org");
@@ -241,6 +256,7 @@ mod tests {
let mut r = Realmlist {
address: DEFAULT_REALMLIST.into(),
pinned_by_env: false,
+ refused: None,
};
r.set("logon.example.org");
assert_eq!(r.address(), "logon.example.org");
diff --git a/crates/benilla-app/src/sound/glue.rs b/crates/benilla-app/src/sound/glue.rs
index 047615cd..760ccc1b 100644
--- a/crates/benilla-app/src/sound/glue.rs
+++ b/crates/benilla-app/src/sound/glue.rs
@@ -29,10 +29,27 @@ const GLUE_MUSIC_FADE_OUT_MS: u64 = 3000;
#[derive(Message)]
pub(crate) struct GlueSound(pub(crate) &'static str);
+/// The stock glue screens' music verbs, which drive the theme in place of the native screens'
+/// start-on-entry under `WOW_GLUE_STOCK=1`.
+#[derive(Message, Clone, Debug, PartialEq, Eq)]
+pub(crate) enum GlueMusicRequest {
+ /// `PlayGlueMusic(file)` (`0x45aeb0`): the same file still streaming plays on; another stops
+ /// the old one over 3.0 s and starts at 0.8; an empty name restarts the remembered file if
+ /// nothing streams.
+ Play(String),
+ /// `StopGlueMusic()` (`0x46cf00`): a 1.0 s fade (`0x45b050(1.0)`), the file still remembered.
+ Stop,
+}
+
+/// `StopGlueMusic`'s fade, `0x3f800000` pushed at `0x46cf00`.
+const STOP_GLUE_MUSIC_FADE_MS: u64 = 1000;
+
/// The held glue-music stream and its starvation watch. The handle is kept through the stop-fade
/// so the watch still sees the stream during the world-entry load burst.
struct GlueMusic {
handle: Option>,
+ /// The file `PlayGlueMusic` last started (`0xb060a0`), the stock screens' theme.
+ track: String,
watch: mixer::StreamWatch,
/// The stop-fade is running; the per-frame slider feed stands off so it cannot undo the ramp.
fading: bool,
@@ -42,6 +59,7 @@ impl Default for GlueMusic {
fn default() -> Self {
Self {
handle: None,
+ track: String::new(),
watch: mixer::StreamWatch::new("glue music"),
fading: false,
}
@@ -90,24 +108,35 @@ fn start_glue_music(
// A theme mid-fade restarts at once: the enter-world stop clears the cached track name
// (`0x45afec`), so the next `PlayGlueMusic` opens a fresh stream. The old handle drops below
// with its ramp armed and finishes fading on the backend.
+ open_glue_music(&mut music, &mut out, assets.as_deref(), &config, GLUE_MUSIC);
+}
+
+/// Open `file` off the chain as the held theme at the Music slider times 0.8.
+fn open_glue_music(
+ music: &mut GlueMusic,
+ out: &mut SoundOutput,
+ assets: Option<&WorldAssets>,
+ config: &SoundConfig,
+ file: &str,
+) {
let (Some(assets), Some(mixer_ref)) = (assets, out.mixer.as_mut()) else {
return;
};
let bytes = {
let chain = assets.chain.lock_recover();
- chain.read(GLUE_MUSIC)
+ chain.read(file)
};
let data = match bytes.and_then(mixer::stream_from_bytes) {
Ok(d) => d,
Err(e) => {
- debug!("glue music: {GLUE_MUSIC} — {e:#}");
+ debug!("glue music: {file} — {e:#}");
return;
}
};
let amp = config.category_amp(SoundCategory::Music) * GLUE_MUSIC_VOLUME;
match mixer_ref.play_stream(data.volume(mixer::amp_to_db(amp))) {
Ok(h) => {
- info!("glue music: {GLUE_MUSIC}");
+ info!("glue music: {file}");
music.handle = Some(h);
music.fading = false;
music.watch.reset();
@@ -116,6 +145,69 @@ fn start_glue_music(
}
}
+/// Stop the held theme over `fade_ms`; the handle drops once the watch sees it stopped.
+fn fade_glue_music(music: &mut GlueMusic, fade_ms: u64) {
+ if let Some(h) = music.handle.as_mut() {
+ h.stop(mixer::fade(fade_ms));
+ music.fading = true;
+ }
+}
+
+/// What a `PlayGlueMusic(file)` does against the remembered file and whether a stream is live
+/// (`0x45aef9`-`0x45afdb`).
+#[derive(Debug, PartialEq, Eq)]
+enum PlayAction {
+ Nothing,
+ /// Start the remembered file again (an empty name, nothing streaming).
+ Restart,
+ /// Stop what streams over 3.0 s, remember `file` and start it.
+ Switch,
+}
+
+fn play_action(file: &str, remembered: &str, streaming: bool) -> PlayAction {
+ if file.is_empty() {
+ if !streaming && !remembered.is_empty() {
+ PlayAction::Restart
+ } else {
+ PlayAction::Nothing
+ }
+ } else if !remembered.is_empty() && file == remembered && streaming {
+ PlayAction::Nothing
+ } else {
+ PlayAction::Switch
+ }
+}
+
+/// The stock glue screens' music verbs.
+fn drive_glue_music_requests(
+ mut requests: MessageReader,
+ mut music: NonSendMut,
+ mut out: NonSendMut,
+ assets: Option>,
+ config: Res,
+) {
+ for request in requests.read() {
+ match request {
+ GlueMusicRequest::Play(file) => {
+ let streaming = music.handle.is_some() && !music.fading;
+ match play_action(file, &music.track, streaming) {
+ PlayAction::Nothing => {}
+ PlayAction::Restart => {
+ let track = music.track.clone();
+ open_glue_music(&mut music, &mut out, assets.as_deref(), &config, &track);
+ }
+ PlayAction::Switch => {
+ fade_glue_music(&mut music, GLUE_MUSIC_FADE_OUT_MS);
+ music.track = file.clone();
+ open_glue_music(&mut music, &mut out, assets.as_deref(), &config, file);
+ }
+ }
+ }
+ GlueMusicRequest::Stop => fade_glue_music(&mut music, STOP_GLUE_MUSIC_FADE_MS),
+ }
+ }
+}
+
/// Arm the theme's 3.0 s fade when the world's load drains.
///
/// The reference's trigger: `CGlueMgr::Update` state 8 spins each frame after entry
@@ -188,6 +280,7 @@ fn report_stream_voices(music: NonSend, mut out: NonSendMut()
+ .add_message::()
.insert_non_send_resource(GlueMusic::default())
.add_systems(
Update,
@@ -196,7 +289,12 @@ pub(super) fn plugin(app: &mut App) {
// The theme starts at the login screen (`AccountLogin_OnShow` sets the same
// `wow_main_theme`) and keeps across the glue screens.
start_glue_music
- .run_if(in_state(ClientState::Login).or(in_state(ClientState::CharSelect))),
+ .run_if(in_state(ClientState::Login).or(in_state(ClientState::CharSelect)))
+ .run_if(crate::ui_script::glue::native_glue),
+ // The stock screens' `PlayGlueMusic`/`StopGlueMusic` drive it instead.
+ drive_glue_music_requests
+ .after(crate::ui_script::glue_mgr::drive_glue_mgr)
+ .before(watch_glue_music),
// No run condition: the theme plays into `InWorld`, and the entry load burst is
// the window the watch exists for.
hand_off_glue_music,
@@ -207,3 +305,22 @@ pub(super) fn plugin(app: &mut App) {
),
);
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// `0x45aeb0`'s three legs: an empty name restarts the remembered file only when nothing
+ /// streams; the remembered file still streaming plays on; anything else switches.
+ #[test]
+ fn play_glue_music_restarts_keeps_or_switches() {
+ let theme = "Sound\\Music\\GlueScreenMusic\\wow_main_theme.mp3";
+ assert_eq!(play_action("", theme, false), PlayAction::Restart);
+ assert_eq!(play_action("", theme, true), PlayAction::Nothing);
+ assert_eq!(play_action("", "", false), PlayAction::Nothing);
+ assert_eq!(play_action(theme, theme, true), PlayAction::Nothing);
+ assert_eq!(play_action(theme, theme, false), PlayAction::Switch);
+ assert_eq!(play_action(theme, "", false), PlayAction::Switch);
+ assert_eq!(play_action("other.mp3", theme, true), PlayAction::Switch);
+ }
+}
diff --git a/crates/benilla-app/src/sound/mod.rs b/crates/benilla-app/src/sound/mod.rs
index b50d946e..474a2547 100644
--- a/crates/benilla-app/src/sound/mod.rs
+++ b/crates/benilla-app/src/sound/mod.rs
@@ -48,7 +48,7 @@ pub(crate) use emote::EmoteSounds;
pub(crate) fn kit_decodes() -> u32 {
kit::DECODES.load(std::sync::atomic::Ordering::Relaxed)
}
-pub(crate) use glue::GlueSound;
+pub(crate) use glue::{GlueMusicRequest, GlueSound};
pub(crate) use greeting::NpcGreetingRequest;
/// Named by the schedule tests' class table (`game_plugins::schedule_tests::Classes`).
#[cfg(test)]
diff --git a/crates/benilla-app/src/ui_script/glue.rs b/crates/benilla-app/src/ui_script/glue.rs
index 11748d87..a2747e24 100644
--- a/crates/benilla-app/src/ui_script/glue.rs
+++ b/crates/benilla-app/src/ui_script/glue.rs
@@ -12,9 +12,11 @@
//! results 0–2 (the table at `0x46aaac`) log their line, missing, corrupt or modified (`0x836fb8`,
//! `0x836f98`, `0x836f78`), and load nothing; an MD5 mismatch logs no line, its frames already
//! built, and skips `FRAMES_LOADED`. Either way the client quits with
-//! error 9 (`0x401560`: the exit code, then the quit post `ForceQuit` makes with code 0). benilla
-//! checks no signature, as for `FrameXML.toc` (`0x48ff4d`): the chain's toc always loads and
-//! `FRAMES_LOADED` follows.
+//! error 9 (`0x401560`: the exit code, then the quit post `ForceQuit` makes with code 0).
+//! Deviation: no signature or digest check (`0x46a90b`, `0x46a9d3`, `0x401560(9)`), so the chain's
+//! toc always loads and `FRAMES_LOADED` follows, because the check verifies Blizzard's signature
+//! with a key carried in the client binary, which benilla does not ship, and a modified interface
+//! file is loaded rather than refused.
//!
//! The screen comes after the build: at startup the reference fires `SET_GLUE_SCREEN` with
//! `"movie"` while the `movie` CVar is 1 (clearing it), else `"login"` (`0x402b3b`–`0x402b65`);
@@ -58,6 +60,11 @@ pub(crate) fn glue_ui_up(
&& state.is_none_or(|s| *s.get() != crate::char_select::ClientState::InWorld)
}
+/// Run condition: the switch is on, whether or not a character is in the world.
+pub(crate) fn stock_glue(stock: Option>) -> bool {
+ stock.is_some_and(|s| s.0)
+}
+
/// Run condition: the native glue screens draw and take input, the switch off.
pub(crate) fn native_glue(stock: Option>) -> bool {
!stock.is_some_and(|s| s.0)
@@ -112,6 +119,9 @@ pub(super) fn load_glue(script: &mut UiScript) -> Vec {
vec![e]
}
};
+ if super::manifest::layer_enabled() {
+ failures.extend(load_glue_layer(script));
+ }
script.fire_event("FRAMES_LOADED", vec![]);
failures.extend(
script
@@ -122,6 +132,25 @@ pub(super) fn load_glue(script: &mut UiScript) -> Vec {
failures
}
+/// benilla's one glue file, its realmlist control over the stock `AccountLogin`, which loads after
+/// every `GlueXML.toc` row and before `FRAMES_LOADED`, as the in-game layer loads after
+/// `FrameXML.toc` ([`super::manifest::LAYER_MANIFEST`]); `WOW_STOCK_UI=1` drops it with that layer.
+pub(super) const GLUE_LAYER: &str = "AccountLoginRealmlist.xml";
+
+/// Load [`GLUE_LAYER`] off the shipped tree; returns its failures.
+fn load_glue_layer(script: &UiScript) -> Vec {
+ let files = vec![GLUE_LAYER.to_string()];
+ let layer = super::addons::Addon::new(
+ "benilla".to_string(),
+ benilla_ui::toc::Toc {
+ directives: Vec::new(),
+ files: files.clone(),
+ },
+ super::addons::Source::Builtin,
+ );
+ layer.load_files(script, &files)
+}
+
/// Drain the glue load's record into `Logs\GlueXML.log`, as the builder does after
/// `FRAMES_LOADED` (`0x46aa61`-`0x46aa94`).
pub(super) fn finish_glue_load_log(script: &mut UiScript) {
@@ -147,15 +176,16 @@ mod tests {
/// raises "attempt to call global `X' (a nil value)", at the load or on the login screen. The
/// list only shrinks: a name built, or no longer reached, comes off it.
const UNBUILT_CALLED: &[&str] = &[
- "EULAAccepted",
- "GetBuildInfo",
"GetNumCharacters",
- "GetSavedAccountName",
"SetCharCustomizeFrame",
"SetCharSelectModelFrame",
- "SetCurrentScreen",
];
+ /// What the player's own files make the reference report too: their `tos.html` uses ``,
+ /// which 1.12's SimpleHTML does not know (`"Frame %s: Unknown element type: %s"`,
+ /// `0x87a95c`, and the walk goes on).
+ const INSTALL_WARNINGS: &[&str] = &["Frame TOSText: Unknown element type: H4"];
+
/// The movie frame's three handlers, which benilla's `MovieFrame` does not take: it plays no
/// movie. Each is a loader warning, ` : SetScript("") unsupported`; the list only
/// shrinks.
@@ -251,6 +281,9 @@ mod tests {
called.insert(name);
return false;
}
+ if INSTALL_WARNINGS.contains(&e.as_str()) {
+ return false;
+ }
if let Some(h) = UNBUILT_HANDLERS.iter().find(|(frame, handler)| {
e.contains(&format!("{frame}: SetScript(\"{handler}\") unsupported"))
}) {
diff --git a/crates/benilla-app/src/ui_script/glue_mgr.rs b/crates/benilla-app/src/ui_script/glue_mgr.rs
new file mode 100644
index 00000000..efb30589
--- /dev/null
+++ b/crates/benilla-app/src/ui_script/glue_mgr.rs
@@ -0,0 +1,971 @@
+//! The glue manager, `CGlueMgr` host side, under `WOW_GLUE_STOCK=1`: the screen-state machine of
+//! `CGlueMgr::Update` (`0x46b930`, `screenState` at `0xb41da0`) over the native login engine. The
+//! IO thread's parks and [`crate::login`]'s policy still do the network; this maps their messages
+//! onto the reference's screen states and fires the glue events the stock GlueXML answers: the
+//! status dialog (`OPEN_STATUS_DIALOG`, `UPDATE_STATUS_DIALOG`, `CLOSE_STATUS_DIALOG`),
+//! `SET_GLUE_SCREEN` and `DISCONNECTED_FROM_SERVER`, and it serves the glue core's verbs
+//! ([`benilla_ui::script::GlueRequest`]).
+//!
+//! The login, in the reference's order:
+//!
+//! 1. `DefaultServerLogin` reaches `CGlueMgr::Login` (`0x46afb0`). With the TOS and the EULA
+//! accepted (`[0xb41dfc]`, `[0xb41e04]`) and the state idle, an empty account or password
+//! opens an `OKAY` dialog (`LOGIN_ENTER_NAME` `0x83708c`, `LOGIN_ENTER_PASSWORD` `0x8370a0`);
+//! otherwise the logon's cells reset to -1 (`0x46b009`-`0x46b01b`), the state goes to 1
+//! (`0x46b028`), a `CANCEL` dialog opens with no text (`0x46b04d`) and the logon starts.
+//! 2. Every frame in state 1 the logon's `(state, result)` pair raises a dialog when it changes
+//! (`0x46b140`): a failure (login state 5) opens `OKAY` with the result's `LOGIN_*` text
+//! (`0x836b78`), `CONNECTION_HELP` for `LOGIN_FAILED`; any other state but 11 and 12 opens
+//! `CANCEL` with its `LOGIN_STATE_*` text (`0x836b40`). Authenticated (login state 4) sets the
+//! final latch (`0x46b12c`), on which the state goes 0 then 2 (`0x46ba8d`, `0x46b1f0`); a
+//! failure cancels the logon and goes to 0 (`0x46bbbf`).
+//! 3. State 2, the world connect, runs `ClientServices` operations: the connect (`0x5aab20`,
+//! which fetches the realm list and dials the realm `realmName` names), then the authentication
+//! (`0x5aaba0`, begun at `0x46bfd9` once the connect completes `RESPONSE_CONNECTED`). Every frame
+//! re-texts the dialog with the operation's status text (`0x46be8b`), empty while it runs
+//! (`0x5aaa35`, which `GlueDialog_OnEvent` ignores), so each completion shows; in the queue
+//! (`AUTH_WAIT_QUEUE`) the text is the queue's, with the button relabelled `CHANGE_REALM`
+//! (`0x46bdfc`). The authentication's success puts `charselect` up (`0x46c00e`-`0x46c01e`); a
+//! failure opens `OKAY` with the status's text (`0x85cae8`), or `OKAY_WITH_URL` with the URL key
+//! for the five codes of `0x803740` (`0x46bf63`), and goes to 0.
+//!
+//! `StatusDialogClick` (`0x46b720`) is the dialog button's verb, a jump on the state: state 1
+//! cancels the logon (`0x5b3320`), state 2 cancels the world operation (`RESPONSE_CANCELLED`).
+//!
+//! The native login reports a logon as one blocking step, so of the logon's states only
+//! `CONNECTING` and `AUTHENTICATED` are seen; its refusals map through the reference's own
+//! byte-to-result table ([`realmd_result`]).
+
+use bevy::prelude::*;
+
+use benilla_protocol::{LoginRefusal, LoginStage};
+use benilla_ui::script::{
+ GlueNotice, GlueRequest, GlueServer, NoticeState, ScriptValue, UiScript, VmKind,
+};
+
+use crate::login::queue::QueueEstimate;
+use crate::login::GlueLogin;
+use crate::net::{
+ CharListMessage, DisconnectedMessage, LoginFailedMessage, LoginQueuedMessage,
+ LoginStageMessage, RealmListMessage,
+};
+
+/// `CGlueMgr`'s `screenState` (`0xb41da0`), named by the functions that set each value: the
+/// states the login reaches. The reference's others are the character list (3, `0x46b893`), the
+/// realm list (4, `0x46b8d2`), create, delete and rename (5-7, `0x46b29e`, `0x46b2f8`,
+/// `0x46b3a4`), `EnterWorld` (8, `0x46b711`), a refused character login (9, `0x402075`) and the
+/// patch and survey downloads (10 and 11, `0x473c40`, `0x473da8`), each set by a verb these screens
+/// do not have yet.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub(crate) enum ScreenState {
+ /// No operation (`0x46a7ce`, and every terminal arm).
+ #[default]
+ Idle = 0,
+ /// The realmd logon (`CGlueMgr::Login`, `0x46b028`).
+ Logon = 1,
+ /// The world connect: realm list, dial and authentication (`0x46b1f9`, `0x46b250`).
+ Connect = 2,
+}
+
+/// The logon's `LOGIN_STATE_*` keys by login state (`0x836b40`, 13 entries).
+const LOGIN_STATE_KEYS: [&str; 13] = [
+ "LOGIN_STATE_INITIALIZED",
+ "LOGIN_STATE_CONNECTING",
+ "LOGIN_STATE_HANDSHAKING",
+ "LOGIN_STATE_AUTHENTICATING",
+ "LOGIN_STATE_AUTHENTICATED",
+ "LOGIN_STATE_FAILED",
+ "LOGIN_STATE_DOWNLOADFILE",
+ "LOGIN_STATE_PIN",
+ "LOGIN_STATE_PIN_WAIT",
+ "LOGIN_STATE_CHECKINGVERSIONS",
+ "RESPONSE_CONNECTED",
+ "LOGIN_STATE_DISCONNECTED",
+ "LOGIN_STATE_SURVEY",
+];
+
+/// The logon's `LOGIN_*` keys by result (`0x836b78`, 21 entries).
+const LOGIN_RESULT_KEYS: [&str; 21] = [
+ "LOGIN_OK",
+ "LOGIN_INVALID_CHALLENGE_MESSAGE",
+ "LOGIN_SRP_ERROR",
+ "LOGIN_INVALID_PROOF_MESSAGE",
+ "LOGIN_BAD_SERVER_PROOF",
+ "LOGIN_INVALID_RECODE_MESSAGE",
+ "LOGIN_BAD_SERVER_RECODE_PROOF",
+ "LOGIN_UNKNOWN_ACCOUNT",
+ "LOGIN_UNKNOWN_ACCOUNT_PIN",
+ "LOGIN_UNKNOWN_ACCOUNT_CALL",
+ "LOGIN_INCORRECT_PASSWORD",
+ "LOGIN_FAILED",
+ "LOGIN_SERVER_DOWN",
+ "LOGIN_BANNED",
+ "LOGIN_BADVERSION",
+ "LOGIN_ALREADYONLINE",
+ "LOGIN_NOTIME",
+ "LOGIN_DBBUSY",
+ "LOGIN_SUSPENDED",
+ "LOGIN_PARENTALCONTROL",
+ "DISCONNECTED",
+];
+
+/// The `ClientServices` status keys (`0x85cae8`, 83 entries), the `ResponseCodes` the world
+/// server's `SMSG_AUTH_RESPONSE` byte indexes directly.
+const STATUS_KEYS: [&str; 0x53] = [
+ "RESPONSE_SUCCESS",
+ "RESPONSE_FAILURE",
+ "RESPONSE_CANCELLED",
+ "RESPONSE_DISCONNECTED",
+ "RESPONSE_FAILED_TO_CONNECT",
+ "RESPONSE_CONNECTED",
+ "RESPONSE_VERSION_MISMATCH",
+ "CSTATUS_CONNECTING",
+ "CSTATUS_NEGOTIATING_SECURITY",
+ "CSTATUS_NEGOTIATION_COMPLETE",
+ "CSTATUS_NEGOTIATION_FAILED",
+ "CSTATUS_AUTHENTICATING",
+ "AUTH_OK",
+ "AUTH_FAILED",
+ "AUTH_REJECT",
+ "AUTH_BAD_SERVER_PROOF",
+ "AUTH_UNAVAILABLE",
+ "AUTH_SYSTEM_ERROR",
+ "AUTH_BILLING_ERROR",
+ "AUTH_BILLING_EXPIRED",
+ "AUTH_VERSION_MISMATCH",
+ "AUTH_UNKNOWN_ACCOUNT",
+ "AUTH_INCORRECT_PASSWORD",
+ "AUTH_SESSION_EXPIRED",
+ "AUTH_SERVER_SHUTTING_DOWN",
+ "AUTH_ALREADY_LOGGING_IN",
+ "AUTH_LOGIN_SERVER_NOT_FOUND",
+ "AUTH_WAIT_QUEUE",
+ "AUTH_BANNED",
+ "AUTH_ALREADY_ONLINE",
+ "AUTH_NO_TIME",
+ "AUTH_DB_BUSY",
+ "AUTH_SUSPENDED",
+ "AUTH_PARENTAL_CONTROL",
+ "REALM_LIST_IN_PROGRESS",
+ "REALM_LIST_SUCCESS",
+ "REALM_LIST_FAILED",
+ "REALM_LIST_INVALID",
+ "REALM_LIST_REALM_NOT_FOUND",
+ "ACCOUNT_CREATE_IN_PROGRESS",
+ "ACCOUNT_CREATE_SUCCESS",
+ "ACCOUNT_CREATE_FAILED",
+ "CHAR_LIST_RETRIEVING",
+ "CHAR_LIST_RETRIEVED",
+ "CHAR_LIST_FAILED",
+ "CHAR_CREATE_IN_PROGRESS",
+ "CHAR_CREATE_SUCCESS",
+ "CHAR_CREATE_ERROR",
+ "CHAR_CREATE_FAILED",
+ "CHAR_CREATE_NAME_IN_USE",
+ "CHAR_CREATE_DISABLED",
+ "CHAR_CREATE_PVP_TEAMS_VIOLATION",
+ "CHAR_CREATE_SERVER_LIMIT",
+ "CHAR_CREATE_ACCOUNT_LIMIT",
+ "CHAR_CREATE_SERVER_QUEUE",
+ "CHAR_CREATE_ONLY_EXISTING",
+ "CHAR_DELETE_IN_PROGRESS",
+ "CHAR_DELETE_SUCCESS",
+ "CHAR_DELETE_FAILED",
+ "CHAR_DELETE_FAILED_LOCKED_FOR_TRANSFER",
+ "CHAR_LOGIN_IN_PROGRESS",
+ "CHAR_LOGIN_SUCCESS",
+ "CHAR_LOGIN_NO_WORLD",
+ "CHAR_LOGIN_DUPLICATE_CHARACTER",
+ "CHAR_LOGIN_NO_INSTANCES",
+ "CHAR_LOGIN_FAILED",
+ "CHAR_LOGIN_DISABLED",
+ "CHAR_LOGIN_NO_CHARACTER",
+ "CHAR_LOGIN_LOCKED_FOR_TRANSFER",
+ "CHAR_NAME_NO_NAME",
+ "CHAR_NAME_TOO_SHORT",
+ "CHAR_NAME_TOO_LONG",
+ "CHAR_NAME_INVALID_CHARACTER",
+ "CHAR_NAME_MIXED_LANGUAGES",
+ "CHAR_NAME_PROFANE",
+ "CHAR_NAME_RESERVED",
+ "CHAR_NAME_INVALID_APOSTROPHE",
+ "CHAR_NAME_MULTIPLE_APOSTROPHES",
+ "CHAR_NAME_THREE_CONSECUTIVE",
+ "CHAR_NAME_INVALID_SPACE",
+ "CHAR_NAME_CONSECUTIVE_SPACES",
+ "CHAR_NAME_FAILURE",
+ "CHAR_NAME_SUCCESS",
+];
+
+/// The five world statuses whose failure dialog carries a help URL, `{code, urlKey}` records at
+/// `0x803740` (stride 0x24), walked at `0x46bec0`.
+const URL_STATUSES: [(u8, &str); 5] = [
+ (0x1c, "AUTH_BANNED_URL"),
+ (0x1f, "AUTH_DB_BUSY_URL"),
+ (0x1e, "AUTH_NO_TIME_URL"),
+ (0x20, "AUTH_SUSPENDED_URL"),
+ (0x21, "AUTH_PARENTAL_CONTROL_URL"),
+];
+
+/// Login states, the reference's numbers.
+const LOGIN_CONNECTING: i32 = 1;
+const LOGIN_AUTHENTICATED: i32 = 4;
+const LOGIN_FAILED_STATE: i32 = 5;
+const LOGIN_DOWNLOADFILE: i32 = 6;
+const LOGIN_PIN_WAIT: i32 = 8;
+
+/// Results.
+const RESULT_LOGIN_FAILED: i32 = 0x0b;
+const RESULT_DISCONNECTED: i32 = 0x14;
+
+/// World statuses.
+const STATUS_RESPONSE_FAILURE: u8 = 0x01;
+const STATUS_FAILED_TO_CONNECT: u8 = 0x04;
+const STATUS_RESPONSE_CONNECTED: u8 = 0x05;
+const STATUS_CONNECTING: u8 = 0x07;
+const STATUS_AUTHENTICATING: u8 = 0x0b;
+const STATUS_AUTH_OK: u8 = 0x0c;
+const STATUS_AUTH_FAILED: u8 = 0x0d;
+const STATUS_WAIT_QUEUE: u8 = 0x1b;
+
+/// What a realmd result byte becomes, `(login state, result)`: `Logon::OnAuthResult`'s
+/// dispatch (`0x5b2c90`, byte index `0x5b2ea4`, jumps `0x5b2e78`) after the handlers' clamp of a
+/// byte past `0x0f` to `0xff` (`0x5bac36`, `0x5bad8d`). 0x00 and 0x0E take the success arm
+/// (`0x5b2cc9`, `0x5b2cd4` to `0x5b2e16`), authenticated; the unknown account and the wrong
+/// password share one arm (`0x5b2d00`, result 7 with no PIN); a challenge's 0x0A is the patch state
+/// (`0x5b2d3b`). A proof's 0x0A never gets here: `Grunt::OnLogonProof` returns on it (`0x5bada2`),
+/// so the logon reports nothing and its dialog stands (`None`).
+pub(crate) fn realmd_result(byte: u8, reply: benilla_protocol::LogonReply) -> Option<(i32, i32)> {
+ if byte == 0x0a && reply == benilla_protocol::LogonReply::Proof {
+ return None;
+ }
+ let byte = if byte > 0x0f { 0xff } else { byte };
+ let result = match byte {
+ 0x00 | 0x0e => return Some((LOGIN_AUTHENTICATED, 0)),
+ 0x03 => 0x0d,
+ 0x04 | 0x05 => 0x07,
+ 0x06 => 0x0f,
+ 0x07 => 0x10,
+ 0x08 => 0x11,
+ 0x09 => 0x0e,
+ 0x0a => return Some((LOGIN_DOWNLOADFILE, 0)),
+ 0x0c => 0x12,
+ 0x0f => 0x13,
+ 0xff => RESULT_DISCONNECTED,
+ // 0x01, 0x02, 0x0b, 0x0d: the default arm (`0x5b2e00`).
+ _ => RESULT_LOGIN_FAILED,
+ };
+ Some((LOGIN_FAILED_STATE, result))
+}
+
+/// A `ClientServices` operation of state 2 (`[svc+0x1b10]`).
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+enum OpKind {
+ /// `COP_CONNECT` (2, `0x5aab20`).
+ #[default]
+ Connect,
+ /// The authentication (3, `0x5aaba0`).
+ Auth,
+}
+
+/// The operation record (`BeginOperation` `0x5aa400`, `SetStatus` `0x5aa920`): its status code
+/// (`[svc+0x1b14]`), completed (`[svc+0x1b08]`) and succeeded (`[svc+0x1b0c]`).
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+struct Operation {
+ kind: OpKind,
+ status: u8,
+ done: bool,
+ ok: bool,
+ /// The text of a failure the reference has no status for, shown in place of the status's.
+ raw: Option,
+}
+
+impl Operation {
+ fn begin(kind: OpKind, status: u8) -> Self {
+ Operation {
+ kind,
+ status,
+ ..Default::default()
+ }
+ }
+
+ fn complete(&mut self, ok: bool, status: u8) {
+ self.status = status;
+ self.ok = ok;
+ self.done = true;
+ }
+
+ /// `GetStatus`'s text: the status's key once completed, none while it runs (`0x5aaa35`).
+ fn text(&self) -> Option {
+ if !self.done {
+ return None;
+ }
+ Some(match &self.raw {
+ Some(t) => Text::Raw(t.clone()),
+ None => status_text(self.status).0,
+ })
+ }
+}
+
+/// A glue string by key or as given.
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum Text {
+ Key(&'static str),
+ Raw(String),
+}
+
+/// One glue event the manager fires into the glue VM.
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum Outgoing {
+ /// `OPEN_STATUS_DIALOG(which[, text[, data]])` (event 3).
+ Open {
+ which: &'static str,
+ text: Option,
+ data: Option,
+ },
+ /// `UPDATE_STATUS_DIALOG(text[, button])` (event 4).
+ Update { text: Text, button: Option },
+ /// The queue's per-frame re-text, composed at the fire from the ring (`0x46bcb8`-`0x46bdfc`).
+ Queue,
+ /// `SET_GLUE_SCREEN(name)` (event 0, `0x46b840`).
+ Screen(&'static str),
+ /// `DISCONNECTED_FROM_SERVER` (event 2, `0x46c62e`).
+ Disconnected,
+}
+
+/// The logon's notification cells: the latest `(state, result)` (`0x836b28`, `0x836b24`) and the
+/// pair last shown (`0x836b30`, `0x836b2c`), -1 when none.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+struct LoginCells {
+ state: i32,
+ result: i32,
+ shown_state: i32,
+ shown_result: i32,
+}
+
+impl Default for LoginCells {
+ fn default() -> Self {
+ LoginCells {
+ state: -1,
+ result: -1,
+ shown_state: -1,
+ shown_result: -1,
+ }
+ }
+}
+
+/// The host side of `CGlueMgr`.
+#[derive(Resource, Default)]
+pub(crate) struct GlueMgr {
+ pub(crate) state: ScreenState,
+ cells: LoginCells,
+ /// Authenticated, waiting for the next tick (`[0xb41e18]`).
+ auth_final: bool,
+ /// The four agreements, read once from their CVars (`0x46a400`).
+ notices: Option<[NoticeState; 4]>,
+ /// The world connection is up (`[svc+0x1b00]`).
+ connected: bool,
+ /// The realm this session connected to.
+ realm: Option,
+ /// State 2's operation.
+ op: Operation,
+ queue: QueueEstimate,
+ queue_realm: Option,
+ /// The current glue screen's name, as the VM's `SetCurrentScreen` last set it.
+ screen: String,
+ outbox: Vec,
+ /// The glue VM this manager last served, by session.
+ vm: Option,
+}
+
+impl GlueMgr {
+ fn notice(&self, notice: GlueNotice) -> NoticeState {
+ self.notices.map(|n| n[notice as usize]).unwrap_or_default()
+ }
+
+ fn open(&mut self, which: &'static str, text: Option) {
+ self.outbox.push(Outgoing::Open {
+ which,
+ text,
+ data: None,
+ });
+ }
+
+ /// `CGlueMgr::Login` (`0x46afb0`).
+ fn login(&mut self, account: String, password: String, asks: &mut Vec) {
+ if !self.notice(GlueNotice::Tos).accepted
+ || !self.notice(GlueNotice::Eula).accepted
+ || self.state != ScreenState::Idle
+ {
+ return;
+ }
+ if account.is_empty() {
+ self.open("OKAY", Some(Text::Key("LOGIN_ENTER_NAME")));
+ return;
+ }
+ if password.is_empty() {
+ self.open("OKAY", Some(Text::Key("LOGIN_ENTER_PASSWORD")));
+ return;
+ }
+ self.begin_logon();
+ asks.push(GlueLogin::Submit { account, password });
+ }
+
+ /// Login's own state edge: the cells cleared, the latch down, state 1, and the `CANCEL`
+ /// dialog with no text (`0x46b009`-`0x46b04d`).
+ fn begin_logon(&mut self) {
+ self.cells = LoginCells::default();
+ self.auth_final = false;
+ self.state = ScreenState::Logon;
+ self.open("CANCEL", None);
+ }
+
+ /// `StatusDialogClick` (`0x46b720`).
+ fn status_dialog_click(&mut self, asks: &mut Vec) {
+ match self.state {
+ // `0x46b731`: the pending operation's callback; none is pending outside an operation.
+ ScreenState::Idle => {}
+ // `0x46b73d`: the logon cancelled, state 0.
+ ScreenState::Logon => {
+ asks.push(GlueLogin::Abandon);
+ self.state = ScreenState::Idle;
+ }
+ // `0x46b754`: the operation completes as `RESPONSE_CANCELLED`, which the next tick's
+ // failure arm takes without a dialog (`0x46bef2`), dropping the connection; the realm
+ // list it then raises (`0x46bf8c`, `0x46c073`) is not built on these screens yet.
+ ScreenState::Connect => {
+ asks.push(GlueLogin::Abandon);
+ self.op = Operation::default();
+ self.connected = false;
+ self.state = ScreenState::Idle;
+ }
+ }
+ }
+
+ /// A login stage off the IO thread. `glue_up` is whether the stock screens stand: a stage of
+ /// an attempt the native policy made on its own (the `WOW_USER` fast path, a resubmit) shows
+ /// as a login only there.
+ fn on_stage(&mut self, stage: LoginStage, glue_up: bool) {
+ match stage {
+ LoginStage::Connecting => {
+ if self.state == ScreenState::Idle && glue_up {
+ self.begin_logon();
+ }
+ if self.state == ScreenState::Logon {
+ self.cells.state = LOGIN_CONNECTING;
+ self.cells.result = 0;
+ }
+ }
+ // The SRP exchange is inside the native logon's one blocking call.
+ LoginStage::Authenticating => {}
+ // The world dial begins: a logon with no realm list to publish is done.
+ LoginStage::Handshaking => self.on_logon_done(),
+ // The connect completes `RESPONSE_CONNECTED` (`0x5aaa90`).
+ LoginStage::WorldConnected => {
+ if self.state == ScreenState::Connect && self.op.kind == OpKind::Connect {
+ self.op.complete(true, STATUS_RESPONSE_CONNECTED);
+ }
+ }
+ }
+ }
+
+ /// The logon authenticated: login state 4, which arms the final latch (`0x46b0f0`).
+ fn on_logon_done(&mut self) {
+ if self.state == ScreenState::Logon {
+ self.cells.state = LOGIN_AUTHENTICATED;
+ self.cells.result = 0;
+ self.auth_final = true;
+ }
+ }
+
+ /// A failure before the roster.
+ fn on_failed(&mut self, msg: &LoginFailedMessage) {
+ match self.state {
+ ScreenState::Logon => {
+ let cells = match msg.refusal {
+ Some(LoginRefusal::Logon(byte)) => {
+ realmd_result(byte, benilla_protocol::LogonReply::Challenge)
+ }
+ Some(LoginRefusal::LogonProof(byte)) => {
+ realmd_result(byte, benilla_protocol::LogonReply::Proof)
+ }
+ // A dial that never connected reports `LOGIN_SERVER_DOWN` (`0x5b1bb2`) and then
+ // the unexpected disconnect (`0x5b1bb7` → `0x5b1c30`, result `0xff`), so the
+ // dialog the next tick shows is the last, `DISCONNECTED`; a transport failure
+ // after the connect is that disconnect alone.
+ _ => Some((LOGIN_FAILED_STATE, RESULT_DISCONNECTED)),
+ };
+ if let Some((state, result)) = cells {
+ self.cells.state = state;
+ self.cells.result = result;
+ }
+ }
+ ScreenState::Connect => {
+ let status = match msg.refusal {
+ Some(LoginRefusal::World(code)) => code,
+ Some(LoginRefusal::Logon(_) | LoginRefusal::LogonProof(_)) => {
+ STATUS_RESPONSE_FAILURE
+ }
+ None if msg.terminal => STATUS_RESPONSE_FAILURE,
+ None => STATUS_FAILED_TO_CONNECT,
+ };
+ self.op.complete(false, status);
+ // A server refusing a client without Warden has no reference status: its own
+ // words.
+ if msg.terminal && msg.refusal.is_none() {
+ self.op.raw = Some(msg.reason.clone());
+ }
+ }
+ _ => {}
+ }
+ }
+
+ /// State 2's failure arm (`0x46bea7`-`0x46bfc8`): the dialog, state 0, and `AUTH_FAILED` from
+ /// the character screen goes back to login. The realm list the arm raises for every other
+ /// code (`0x46bf1f`-`0x46bf96`, `0x46c073`) is not built on these screens yet, so the dialog
+ /// stays.
+ fn world_failed(&mut self) {
+ let (text, code) = match self.op.text() {
+ Some(Text::Raw(t)) => (Text::Raw(t), None),
+ _ => status_text(self.op.status),
+ };
+ match code.and_then(|c| URL_STATUSES.iter().find(|(s, _)| *s == c)) {
+ Some((_, url)) => self.outbox.push(Outgoing::Open {
+ which: "OKAY_WITH_URL",
+ text: Some(text),
+ data: Some(url.to_string()),
+ }),
+ None => self.open("OKAY", Some(text)),
+ }
+ self.state = ScreenState::Idle;
+ self.connected = false;
+ if code == Some(STATUS_AUTH_FAILED) && self.screen.eq_ignore_ascii_case("charselect") {
+ self.outbox.push(Outgoing::Screen("login"));
+ }
+ }
+
+ /// One queue packet: the operation is re-armed as the queue's, status `AUTH_WAIT_QUEUE` and
+ /// not complete (`0x5ab280`), and the ring takes a sample.
+ fn on_queued(&mut self, position: Option, realm: Option, now_ms: u32) {
+ if self.state != ScreenState::Connect {
+ return;
+ }
+ self.op = Operation::begin(OpKind::Auth, STATUS_WAIT_QUEUE);
+ self.queue_realm = realm;
+ if let Some(p) = position {
+ self.queue.sample(p, now_ms);
+ }
+ }
+
+ /// The roster: in state 2, the authentication completes `AUTH_OK` (`0x5aab80`), which the tick
+ /// shows and takes down the success arm.
+ fn on_roster(&mut self, realm: Option) {
+ self.connected = true;
+ if realm.is_some() {
+ self.realm = realm;
+ }
+ if self.state == ScreenState::Logon {
+ // A logon whose world half finished inside one frame.
+ self.on_logon_done();
+ self.tick();
+ }
+ if self.state != ScreenState::Connect {
+ return;
+ }
+ self.op = Operation::begin(OpKind::Auth, STATUS_AUTHENTICATING);
+ self.op.complete(true, STATUS_AUTH_OK);
+ }
+
+ /// The session ended. Over (`session_over`), the disconnect handler (`0x46c540`): state 0,
+ /// and `DISCONNECTED_FROM_SERVER` (`0x46c62e`), which the stock screens answer with the login
+ /// screen and the `DISCONNECTED` dialog. A logout's relist or an unattended reconnect is no
+ /// disconnect to the screens.
+ ///
+ /// Out of state 2 it fires the event (`0x46c618`); in state 2 a completed, failed operation
+ /// opens `OKAY` with its text instead (`0x46c671`-`0x46c68f`). The handler's first test, that
+ /// the dropped connection is `ClientServices`' current one (`0x5abd40`, else "Invalid
+ /// disconnect"), has no native counterpart: the IO thread holds one session and reports only
+ /// its own end.
+ fn on_disconnected(&mut self, session_over: bool) {
+ self.connected = false;
+ if !session_over {
+ return;
+ }
+ let was_connect = self.state == ScreenState::Connect;
+ self.state = ScreenState::Idle;
+ let op = std::mem::take(&mut self.op);
+ match op.text() {
+ Some(text) if was_connect && !op.ok => self.open("OKAY", Some(text)),
+ _ => self.outbox.push(Outgoing::Disconnected),
+ }
+ }
+
+ /// The per-frame arms of `CGlueMgr::Update` that run without a new message.
+ fn tick(&mut self) {
+ match self.state {
+ ScreenState::Logon => {
+ // `0x46ba7c`: every login state but PIN_WAIT reaches the dialog arm.
+ if self.cells.state != LOGIN_PIN_WAIT {
+ self.show_login_dialog();
+ }
+ if self.auth_final {
+ // `0x46ba8d`-`0x46ba98`, then `0x46b1f0`'s idle test passes and the connect
+ // begins at `CSTATUS_CONNECTING` (`0x5aab4a`), its ring cleared (`0x5aab30`).
+ self.state = ScreenState::Connect;
+ self.auth_final = false;
+ self.op = Operation::begin(OpKind::Connect, STATUS_CONNECTING);
+ self.queue = QueueEstimate::default();
+ return;
+ }
+ // `0x46bbbf`: a failed logon is cancelled and the state goes to 0. The patch
+ // state (`0x46bb64`) waits on a downloader benilla does not have; its dialog's
+ // Cancel ends it.
+ if self.cells.state == LOGIN_FAILED_STATE {
+ self.state = ScreenState::Idle;
+ }
+ }
+ ScreenState::Connect => self.tick_connect(),
+ _ => {}
+ }
+ }
+
+ /// State 2's frame (`0x46bc49`-`0x46c02e`): the dialog re-texted from the operation, then a
+ /// completed operation's arm.
+ fn tick_connect(&mut self) {
+ if self.op.status == STATUS_WAIT_QUEUE {
+ self.outbox.push(Outgoing::Queue);
+ } else if let Some(text) = self.op.text() {
+ self.outbox.push(Outgoing::Update { text, button: None });
+ }
+ if !self.op.done {
+ return;
+ }
+ if !self.op.ok {
+ self.world_failed();
+ } else if self.op.kind == OpKind::Connect {
+ // `0x46bfdd`: the connect done, the authentication begins (`0x5aaba0`).
+ self.op = Operation::begin(OpKind::Auth, STATUS_AUTHENTICATING);
+ } else {
+ // `0x46c00e`-`0x46c023`: state 0, `charselect`, the queue ring cleared.
+ self.state = ScreenState::Idle;
+ self.op = Operation::default();
+ self.queue = QueueEstimate::default();
+ self.outbox.push(Outgoing::Screen("charselect"));
+ }
+ }
+
+ /// `0x46b140`: a dialog for the logon's `(state, result)` when it differs from the pair last
+ /// shown.
+ fn show_login_dialog(&mut self) {
+ let c = self.cells;
+ if c.state == c.shown_state && c.result == c.shown_result {
+ return;
+ }
+ self.cells.shown_state = c.state;
+ self.cells.shown_result = c.result;
+ if c.state == LOGIN_FAILED_STATE {
+ let Some(key) = usize::try_from(c.result)
+ .ok()
+ .and_then(|r| LOGIN_RESULT_KEYS.get(r))
+ else {
+ return;
+ };
+ let which = if c.result == RESULT_LOGIN_FAILED {
+ "CONNECTION_HELP"
+ } else {
+ "OKAY"
+ };
+ self.open(which, Some(Text::Key(key)));
+ return;
+ }
+ // `0x46b1ae`-`0x46b1c7`: no dialog outside the table, for DISCONNECTED (11) or SURVEY (12).
+ let Some(key) = usize::try_from(c.state)
+ .ok()
+ .and_then(|s| LOGIN_STATE_KEYS.get(s))
+ else {
+ return;
+ };
+ if c.state == 0x0b || c.state == 0x0c {
+ return;
+ }
+ self.open("CANCEL", Some(Text::Key(key)));
+ }
+}
+
+/// A world status's text and its code for the URL walk; a code past the table reads
+/// `RESPONSE_FAILURE`.
+fn status_text(code: u8) -> (Text, Option) {
+ match STATUS_KEYS.get(usize::from(code)) {
+ Some(key) => (Text::Key(key), Some(code)),
+ None => (
+ Text::Key(STATUS_KEYS[usize::from(STATUS_RESPONSE_FAILURE)]),
+ Some(STATUS_RESPONSE_FAILURE),
+ ),
+ }
+}
+
+/// The native login's messages the manager reads, as one parameter.
+#[derive(bevy::ecs::system::SystemParam)]
+pub(crate) struct LoginNews<'w, 's> {
+ stages: MessageReader<'w, 's, LoginStageMessage>,
+ failures: MessageReader<'w, 's, LoginFailedMessage>,
+ queued: MessageReader<'w, 's, LoginQueuedMessage>,
+ rosters: MessageReader<'w, 's, CharListMessage>,
+ realm_lists: MessageReader<'w, 's, RealmListMessage>,
+ disconnects: MessageReader<'w, 's, DisconnectedMessage>,
+}
+
+/// What the manager asks of the rest of the app.
+#[derive(bevy::ecs::system::SystemParam)]
+pub(crate) struct GlueAsks<'w> {
+ login: MessageWriter<'w, GlueLogin>,
+ music: MessageWriter<'w, crate::sound::GlueMusicRequest>,
+}
+
+/// The CVars each agreement is read from and written to.
+fn notice_cvar(notice: GlueNotice) -> &'static str {
+ match notice {
+ GlueNotice::Tos => "readTOS",
+ GlueNotice::Eula => "readEULA",
+ GlueNotice::Scanning => "readScanning",
+ GlueNotice::Contest => "readContest",
+ }
+}
+
+/// The glue manager's frame: the VM's requests, the native login's news, the per-frame arms, then
+/// the events into the glue VM and its slots seeded. Runs under `WOW_GLUE_STOCK=1` only.
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn drive_glue_mgr(
+ mut mgr: ResMut,
+ script: Option>,
+ mut news: LoginNews,
+ mut asks: GlueAsks,
+ realms: Option>,
+ mut realmlist: Option>,
+ state: Option>>,
+ time: Res,
+) {
+ let now_ms = time.elapsed().as_millis() as u32;
+ let mut script = script.filter(|s| s.vm_kind() == VmKind::Glue);
+ let in_world = state.is_some_and(|s| *s.get() == crate::char_select::ClientState::InWorld);
+ let glue_up = script.is_some() && !in_world;
+
+ // The agreements, read once (`0x46a400`) off the VM's CVar mirror once the sync has seeded
+ // it.
+ if mgr.notices.is_none() {
+ if let Some(s) = script.as_deref() {
+ let values: Option> = GlueNotice::ALL
+ .iter()
+ .map(|n| s.cvar(notice_cvar(*n)))
+ .collect();
+ if let Some(values) = values {
+ let mut n = [NoticeState::default(); 4];
+ for (notice, value) in GlueNotice::ALL.iter().zip(&values) {
+ n[*notice as usize] = NoticeState::from_cvar(value);
+ }
+ mgr.notices = Some(n);
+ }
+ }
+ }
+
+ // A new glue VM is the glue build (`0x46a7b0`): state 0, its slots seeded.
+ if let Some(s) = script.as_deref_mut() {
+ if mgr.vm != Some(s.session()) {
+ mgr.vm = Some(s.session());
+ mgr.state = ScreenState::Idle;
+ mgr.cells = LoginCells::default();
+ mgr.auth_final = false;
+ s.set_glue_saved_account(&crate::login::load_saved_account());
+ }
+ } else if in_world {
+ // In the world the glue is torn down, and the entry's second phase leaves state 0
+ // (`0x46c252`).
+ mgr.state = ScreenState::Idle;
+ }
+
+ // The verbs the last tick's Lua called.
+ let mut logins = Vec::new();
+ if let Some(s) = script.as_deref_mut() {
+ mgr.screen = s.glue_screen();
+ for request in s.take_glue_requests() {
+ match request {
+ GlueRequest::Login(c) => mgr.login(c.account, c.password, &mut logins),
+ GlueRequest::StatusDialogClick => mgr.status_dialog_click(&mut logins),
+ GlueRequest::SetSavedAccountName(name) => crate::login::save_account(&name),
+ GlueRequest::Accept(notice) => {
+ let n = mgr.notices.get_or_insert([NoticeState::default(); 4]);
+ n[notice as usize] = NoticeState {
+ accepted: true,
+ show_notice: false,
+ };
+ // Through the VM's mirror, whose writes the CVar sync persists.
+ s.set_cvar_engine(notice_cvar(notice), "1");
+ }
+ GlueRequest::PlayGlueMusic(file) => {
+ asks.music.write(crate::sound::GlueMusicRequest::Play(file));
+ }
+ GlueRequest::StopGlueMusic => {
+ asks.music.write(crate::sound::GlueMusicRequest::Stop);
+ }
+ GlueRequest::DisconnectFromServer => {
+ mgr.connected = false;
+ logins.push(GlueLogin::Disconnect);
+ }
+ GlueRequest::QuitGame => logins.push(GlueLogin::Quit),
+ }
+ }
+ }
+
+ // The native login's news.
+ for msg in news.stages.read() {
+ mgr.on_stage(msg.stage, glue_up);
+ }
+ for _ in news.realm_lists.read() {
+ mgr.on_logon_done();
+ }
+ for msg in news.queued.read() {
+ mgr.on_queued(msg.position, msg.realm.clone(), now_ms);
+ }
+ // An authenticated logon moves to the world connect before the world's own news.
+ if mgr.auth_final {
+ mgr.tick();
+ }
+ for msg in news.failures.read() {
+ mgr.on_failed(msg);
+ }
+ for msg in news.rosters.read() {
+ mgr.on_roster(msg.realm.clone());
+ }
+ for msg in news.disconnects.read() {
+ mgr.on_disconnected(msg.session_over);
+ }
+ mgr.tick();
+ for ask in logins {
+ asks.login.write(ask);
+ }
+
+ let Some(mut script) = script else {
+ return;
+ };
+ // The realmlist control's typed value that was not an address reopens its dialog.
+ if let Some(typed) = realmlist.as_deref_mut().and_then(|r| r.take_refused()) {
+ mgr.outbox.push(Outgoing::Open {
+ which: "BENILLA_REALMLIST",
+ text: Some(Text::Raw(crate::login::REALMLIST_BAD.to_string())),
+ data: Some(typed),
+ });
+ }
+ seed(&mgr, &mut script, realms.as_deref(), realmlist.as_deref());
+ flush(&mut mgr, &mut script, now_ms);
+}
+
+/// The glue VM's slots, from the manager's state.
+fn seed(
+ mgr: &GlueMgr,
+ script: &mut UiScript,
+ realms: Option<&crate::realm_select::Realms>,
+ realmlist: Option<&crate::realmlist::Realmlist>,
+) {
+ for notice in GlueNotice::ALL {
+ let want = mgr.notice(notice);
+ if script.glue_notice(notice) != want {
+ script.set_glue_notice(notice, want);
+ }
+ }
+ script.set_glue_connected(mgr.connected);
+ // `realmName` names the realm the session dials (`0x5ab7b0` writes it on the connect); the
+ // flags come off the connected realm's `Cfg_Configs` row.
+ let (name, pvp, rp) = match &mgr.realm {
+ Some(r) if mgr.connected => {
+ let (pvp, rp) = realms.map_or((false, false), |x| x.pvp_rp(r.realm_type));
+ (r.name.clone(), pvp, rp)
+ }
+ _ => (
+ script
+ .cvar(crate::realm_select::CVAR_REALM_NAME)
+ .unwrap_or_default(),
+ false,
+ false,
+ ),
+ };
+ script.set_glue_server(GlueServer { name, pvp, rp });
+ if let Some(r) = realmlist {
+ seed_realmlist(script, r);
+ }
+}
+
+/// The realmlist control's two globals (`AccountLoginRealmlist.xml`): the address the next logon
+/// dials and whether `$WOW_HOST` fixes it for the session.
+fn seed_realmlist(script: &UiScript, realmlist: &crate::realmlist::Realmlist) {
+ let g = script.lua().globals();
+ let address = realmlist.address();
+ if g.get::>("BENILLA_REALMLIST")
+ .ok()
+ .flatten()
+ .as_deref()
+ != Some(address)
+ {
+ let _ = g.set("BENILLA_REALMLIST", address);
+ }
+ let pinned = realmlist.pinned_by_env().then_some(1);
+ if g.get:: >("BENILLA_REALMLIST_PINNED")
+ .ok()
+ .flatten()
+ != pinned
+ {
+ let _ = g.set("BENILLA_REALMLIST_PINNED", pinned);
+ }
+}
+
+/// Fire the outbox into the glue VM, the texts resolved through its own strings (`0x703bf0`).
+fn flush(mgr: &mut GlueMgr, script: &mut UiScript, now_ms: u32) {
+ let text = |script: &UiScript, t: &Text| match t {
+ Text::Key(k) => script.global_text(k),
+ Text::Raw(s) => s.clone(),
+ };
+ for out in std::mem::take(&mut mgr.outbox) {
+ match out {
+ Outgoing::Open {
+ which,
+ text: t,
+ data,
+ } => {
+ let mut args = vec![ScriptValue::Str(which.to_string())];
+ if t.is_some() || data.is_some() {
+ args.push(t.map_or(ScriptValue::Nil, |t| ScriptValue::Str(text(script, &t))));
+ }
+ if let Some(d) = data {
+ args.push(ScriptValue::Str(d));
+ }
+ script.fire_event("OPEN_STATUS_DIALOG", args);
+ }
+ Outgoing::Update { text: t, button } => {
+ let mut args = vec![ScriptValue::Str(text(script, &t))];
+ if let Some(b) = button {
+ args.push(ScriptValue::Str(text(script, &b)));
+ }
+ script.fire_event("UPDATE_STATUS_DIALOG", args);
+ }
+ Outgoing::Queue => {
+ let body = mgr
+ .queue
+ .text_by(now_ms, mgr.queue_realm.as_deref(), |key| {
+ script.global_text(key)
+ });
+ let button = script.global_text("CHANGE_REALM");
+ script.fire_event(
+ "UPDATE_STATUS_DIALOG",
+ vec![ScriptValue::Str(body), ScriptValue::Str(button)],
+ );
+ }
+ Outgoing::Screen(name) => {
+ script.fire_event("SET_GLUE_SCREEN", vec![ScriptValue::Str(name.to_string())]);
+ }
+ Outgoing::Disconnected => script.fire_event("DISCONNECTED_FROM_SERVER", Vec::new()),
+ }
+ }
+}
+
+#[cfg(test)]
+#[path = "glue_mgr_tests.rs"]
+mod tests;
diff --git a/crates/benilla-app/src/ui_script/glue_mgr_tests.rs b/crates/benilla-app/src/ui_script/glue_mgr_tests.rs
new file mode 100644
index 00000000..848d5c8a
--- /dev/null
+++ b/crates/benilla-app/src/ui_script/glue_mgr_tests.rs
@@ -0,0 +1,527 @@
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::Arc;
+
+use super::*;
+use crate::char_select::ClientState;
+use crate::net::{LoginAbandon, LoginRequest, LoginSubmit};
+
+/// The manager with both agreements accepted, the way a returning player's CVars seed it.
+fn accepted() -> GlueMgr {
+ let mut n = [NoticeState::default(); 4];
+ n[GlueNotice::Tos as usize] = NoticeState::from_cvar("1");
+ n[GlueNotice::Eula as usize] = NoticeState::from_cvar("1");
+ GlueMgr {
+ notices: Some(n),
+ ..GlueMgr::default()
+ }
+}
+
+fn open(which: &'static str, key: Option<&'static str>) -> Outgoing {
+ Outgoing::Open {
+ which,
+ text: key.map(Text::Key),
+ data: None,
+ }
+}
+
+fn failed(refusal: Option, terminal: bool) -> LoginFailedMessage {
+ LoginFailedMessage {
+ refusal,
+ reason: "test".into(),
+ terminal,
+ dial: None,
+ }
+}
+
+/// `0x5b2c90`'s arms, after the handlers' clamp past `0x0f`: 0x0E is a success (`0x5b2cd4`), and
+/// a proof's 0x0A reports nothing (`0x5bada2`).
+#[test]
+fn a_realmd_refusal_maps_through_the_references_table() {
+ use benilla_protocol::LogonReply::{Challenge, Proof};
+ let cases = [
+ (0x00, (4, 0x00)),
+ (0x01, (5, 0x0b)),
+ (0x02, (5, 0x0b)),
+ (0x03, (5, 0x0d)),
+ (0x04, (5, 0x07)),
+ (0x05, (5, 0x07)),
+ (0x06, (5, 0x0f)),
+ (0x07, (5, 0x10)),
+ (0x08, (5, 0x11)),
+ (0x09, (5, 0x0e)),
+ (0x0a, (6, 0x00)),
+ (0x0b, (5, 0x0b)),
+ (0x0c, (5, 0x12)),
+ (0x0d, (5, 0x0b)),
+ (0x0e, (4, 0x00)),
+ (0x0f, (5, 0x13)),
+ (0x10, (5, 0x14)),
+ (0xff, (5, 0x14)),
+ ];
+ for (byte, want) in cases {
+ assert_eq!(realmd_result(byte, Challenge), Some(want), "{byte:#04x}");
+ }
+ assert_eq!(
+ realmd_result(0x0a, Proof),
+ None,
+ "a proof's 0x0A is swallowed"
+ );
+ assert_eq!(realmd_result(0x04, Proof), Some((5, 0x07)));
+}
+
+/// A proof's 0x0A leaves the logon's dialog as it stands.
+#[test]
+fn a_proof_0x0a_changes_no_dialog() {
+ let mut m = accepted();
+ m.login("a".into(), "b".into(), &mut Vec::new());
+ m.on_stage(LoginStage::Connecting, true);
+ m.tick();
+ m.outbox.clear();
+ m.on_failed(&failed(Some(LoginRefusal::LogonProof(0x0a)), false));
+ m.tick();
+ assert!(m.outbox.is_empty(), "{:?}", m.outbox);
+ assert_eq!(m.state, ScreenState::Logon);
+}
+
+/// `CGlueMgr::Login`'s guards: nothing without both agreements or outside state 0, an `OKAY`
+/// dialog for an empty box, else the `CANCEL` dialog with no text and the attempt.
+#[test]
+fn login_guards_then_starts_the_logon() {
+ let mut asks = Vec::new();
+ let mut m = GlueMgr::default();
+ m.login("a".into(), "b".into(), &mut asks);
+ assert!(asks.is_empty() && m.outbox.is_empty(), "no TOS, no login");
+
+ let mut m = accepted();
+ m.login(String::new(), "b".into(), &mut asks);
+ assert_eq!(m.outbox, [open("OKAY", Some("LOGIN_ENTER_NAME"))]);
+ m.outbox.clear();
+ m.login("a".into(), String::new(), &mut asks);
+ assert_eq!(m.outbox, [open("OKAY", Some("LOGIN_ENTER_PASSWORD"))]);
+ assert!(asks.is_empty());
+ m.outbox.clear();
+
+ m.login("a".into(), "b".into(), &mut asks);
+ assert_eq!(m.state, ScreenState::Logon);
+ assert_eq!(m.outbox, [open("CANCEL", None)]);
+ assert!(matches!(
+ asks.as_slice(),
+ [GlueLogin::Submit { account, password }] if account == "a" && password == "b"
+ ));
+ m.login("a".into(), "b".into(), &mut asks);
+ assert_eq!(asks.len(), 1, "a second login waits for state 0");
+}
+
+/// The logon's dialogs come off its `(state, result)` pair once per change (`0x46b140`); the
+/// authenticated state moves to the world connect on the same tick.
+#[test]
+fn the_logon_shows_each_state_once_then_connects() {
+ let mut m = accepted();
+ m.login("a".into(), "b".into(), &mut Vec::new());
+ m.outbox.clear();
+ m.tick();
+ assert!(m.outbox.is_empty(), "the cleared cells show nothing");
+ m.on_stage(LoginStage::Connecting, true);
+ m.tick();
+ m.tick();
+ assert_eq!(m.outbox, [open("CANCEL", Some("LOGIN_STATE_CONNECTING"))]);
+ m.outbox.clear();
+ m.on_logon_done();
+ m.tick();
+ assert_eq!(
+ m.outbox,
+ [open("CANCEL", Some("LOGIN_STATE_AUTHENTICATED"))]
+ );
+ assert_eq!(m.state, ScreenState::Connect);
+}
+
+/// A logon refusal raises its `LOGIN_*` text, `CONNECTION_HELP` for `LOGIN_FAILED`, and state 0;
+/// a dial that never connected ends on the disconnect it reports last.
+#[test]
+fn a_logon_refusal_opens_its_result_and_ends_the_logon() {
+ for (refusal, which, key) in [
+ (
+ Some(LoginRefusal::Logon(0x04)),
+ "OKAY",
+ "LOGIN_UNKNOWN_ACCOUNT",
+ ),
+ (
+ Some(LoginRefusal::Logon(0x05)),
+ "OKAY",
+ "LOGIN_UNKNOWN_ACCOUNT",
+ ),
+ (
+ Some(LoginRefusal::Logon(0x01)),
+ "CONNECTION_HELP",
+ "LOGIN_FAILED",
+ ),
+ (Some(LoginRefusal::Logon(0x03)), "OKAY", "LOGIN_BANNED"),
+ (None, "OKAY", "DISCONNECTED"),
+ ] {
+ let mut m = accepted();
+ m.login("a".into(), "b".into(), &mut Vec::new());
+ m.outbox.clear();
+ m.on_failed(&failed(refusal, false));
+ m.tick();
+ assert_eq!(m.outbox, [open(which, Some(key))], "{refusal:?}");
+ assert_eq!(m.state, ScreenState::Idle);
+ }
+}
+
+/// The world connect's failure arm: the status text, `OKAY_WITH_URL` with the URL key for the
+/// five codes of `0x803740`, and `AUTH_FAILED` from the character screen back to login.
+#[test]
+fn a_world_refusal_opens_its_status_and_url() {
+ let connected = || {
+ let mut m = accepted();
+ m.state = ScreenState::Connect;
+ m
+ };
+ let update = |key| Outgoing::Update {
+ text: Text::Key(key),
+ button: None,
+ };
+ let mut m = connected();
+ m.on_failed(&failed(Some(LoginRefusal::World(0x1c)), false));
+ m.tick();
+ assert_eq!(
+ m.outbox,
+ [
+ update("AUTH_BANNED"),
+ Outgoing::Open {
+ which: "OKAY_WITH_URL",
+ text: Some(Text::Key("AUTH_BANNED")),
+ data: Some("AUTH_BANNED_URL".into()),
+ }
+ ]
+ );
+ assert_eq!(m.state, ScreenState::Idle);
+
+ let mut m = connected();
+ m.screen = "charselect".into();
+ m.on_failed(&failed(Some(LoginRefusal::World(0x0d)), false));
+ m.tick();
+ assert_eq!(
+ m.outbox,
+ [
+ update("AUTH_FAILED"),
+ open("OKAY", Some("AUTH_FAILED")),
+ Outgoing::Screen("login")
+ ]
+ );
+
+ let mut m = connected();
+ m.on_failed(&failed(None, false));
+ m.tick();
+ assert_eq!(
+ m.outbox,
+ [
+ update("RESPONSE_FAILED_TO_CONNECT"),
+ open("OKAY", Some("RESPONSE_FAILED_TO_CONNECT"))
+ ]
+ );
+}
+
+/// The world connect re-texts the dialog from its operation each frame: nothing while the connect
+/// runs, `RESPONSE_CONNECTED` when it completes (`0x5aaa90`), nothing while the authentication runs,
+/// then `AUTH_OK` and the character screen.
+#[test]
+fn the_world_connect_shows_each_completion() {
+ let mut m = accepted();
+ m.login("a".into(), "b".into(), &mut Vec::new());
+ m.on_logon_done();
+ m.tick();
+ assert_eq!(m.state, ScreenState::Connect);
+ m.outbox.clear();
+ m.tick();
+ assert!(m.outbox.is_empty(), "the running connect's text is empty");
+ m.on_stage(LoginStage::WorldConnected, true);
+ m.tick();
+ assert_eq!(
+ m.outbox,
+ [Outgoing::Update {
+ text: Text::Key("RESPONSE_CONNECTED"),
+ button: None
+ }]
+ );
+ m.outbox.clear();
+ m.tick();
+ assert!(
+ m.outbox.is_empty(),
+ "the running authentication's text is empty"
+ );
+ m.on_roster(None);
+ m.tick();
+ assert_eq!(
+ m.outbox,
+ [
+ Outgoing::Update {
+ text: Text::Key("AUTH_OK"),
+ button: None
+ },
+ Outgoing::Screen("charselect")
+ ]
+ );
+}
+
+/// The roster ends the world connect: the status text, state 0, the character screen.
+#[test]
+fn the_roster_puts_the_character_screen_up() {
+ let mut m = accepted();
+ m.state = ScreenState::Connect;
+ m.on_roster(None);
+ m.tick();
+ assert_eq!(
+ m.outbox,
+ [
+ Outgoing::Update {
+ text: Text::Key("AUTH_OK"),
+ button: None
+ },
+ Outgoing::Screen("charselect")
+ ]
+ );
+ assert_eq!(m.state, ScreenState::Idle);
+ assert!(m.connected);
+}
+
+/// The queue re-texts the dialog every frame while the world connect waits.
+#[test]
+fn the_queue_retexts_every_frame() {
+ let mut m = accepted();
+ m.state = ScreenState::Connect;
+ m.on_queued(Some(40), Some("Realm".into()), 1000);
+ m.tick();
+ m.tick();
+ assert_eq!(m.outbox, [Outgoing::Queue, Outgoing::Queue]);
+}
+
+/// `StatusDialogClick` by state: the logon and the world connect are abandoned; idle does nothing.
+#[test]
+fn status_dialog_click_cancels_by_state() {
+ let mut asks = Vec::new();
+ let mut m = accepted();
+ m.status_dialog_click(&mut asks);
+ assert!(asks.is_empty());
+ for state in [ScreenState::Logon, ScreenState::Connect] {
+ m.state = state;
+ m.status_dialog_click(&mut asks);
+ assert_eq!(m.state, ScreenState::Idle);
+ }
+ assert!(matches!(
+ asks.as_slice(),
+ [GlueLogin::Abandon, GlueLogin::Abandon]
+ ));
+}
+
+/// A session that is over fires `DISCONNECTED_FROM_SERVER`; a logout's relist does not; in the
+/// world connect, a completed failed operation opens `OKAY` with its text instead
+/// (`0x46c671`-`0x46c68f`), and a running one still fires the event.
+#[test]
+fn a_lost_session_fires_disconnected() {
+ let mut m = accepted();
+ m.on_disconnected(false);
+ assert!(m.outbox.is_empty());
+ m.on_disconnected(true);
+ assert_eq!(m.outbox, [Outgoing::Disconnected]);
+
+ let mut m = accepted();
+ m.state = ScreenState::Connect;
+ m.op = Operation::begin(OpKind::Connect, STATUS_CONNECTING);
+ m.on_disconnected(true);
+ assert_eq!(m.outbox, [Outgoing::Disconnected]);
+
+ let mut m = accepted();
+ m.state = ScreenState::Connect;
+ m.op.complete(false, STATUS_FAILED_TO_CONNECT);
+ m.on_disconnected(true);
+ assert_eq!(m.outbox, [open("OKAY", Some("RESPONSE_FAILED_TO_CONNECT"))]);
+ assert_eq!(m.state, ScreenState::Idle);
+}
+
+// ── The stock screens over the native login, end to end ─────────────────────────────────────────
+
+/// An app with the manager, the native login's glue asks and the stock glue VM on the login
+/// screen. Returns it, the credentials channel and the abandon generation.
+fn stock_app() -> (
+ App,
+ crossbeam_channel::Receiver,
+ Arc,
+) {
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let abandon = Arc::new(AtomicU64::new(0));
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .insert_resource(crate::realmlist::Realmlist::unpinned(
+ crate::realmlist::DEFAULT_REALMLIST,
+ ))
+ .insert_resource(LoginSubmit(tx))
+ .insert_resource(LoginAbandon(abandon.clone()))
+ .insert_resource(State::new(ClientState::Login))
+ .init_resource::>()
+ .add_message::()
+ .add_message::()
+ .add_message::()
+ .add_message::()
+ .add_message::()
+ .add_message::()
+ .add_message::()
+ .add_message::()
+ .add_message::()
+ .add_systems(Update, (drive_glue_mgr, crate::login::answer_glue).chain());
+ let mut s = UiScript::new_glue().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ let _ = super::super::glue::load_glue(&mut s);
+ super::super::glue::set_glue_screen(&mut s, super::super::glue::GlueScreen::Login);
+ let _ = s.take_errors();
+ app.insert_non_send_resource(s);
+ app.update();
+ (app, rx, abandon)
+}
+
+fn vm(app: &App) -> &UiScript {
+ app.world().non_send_resource::()
+}
+
+/// The dialog the stock `GlueDialog` shows: its type and text, or `None` when hidden.
+fn dialog(app: &App) -> Option<(String, String)> {
+ let (shown, which, text) = vm(app)
+ .eval::<(bool, String, String)>(
+ "return GlueDialog:IsShown() == 1, GlueDialog.which or '', GlueDialogText:GetText() or ''",
+ )
+ .unwrap();
+ shown.then_some((which, text))
+}
+
+/// Type into the stock boxes and press Login, as `AccountLogin_Login` does.
+fn type_and_login(app: &mut App) {
+ vm(app)
+ .run(
+ "AccountLoginAccountEdit:SetText('probe') AccountLoginPasswordEdit:SetText('secret') \
+ AccountLogin_Login()",
+ )
+ .unwrap();
+ app.update();
+}
+
+/// The stock login screen's Login reaches the native login with the typed credentials, under the
+/// `CANCEL` dialog; the logon's stage and a refusal re-text it with the reference's keys; and the
+/// agreements gate it.
+#[test]
+fn the_stock_login_reaches_the_native_login_and_shows_its_status() {
+ let _ = benilla_formats::wow_data_or_skip!();
+ let (mut app, rx, _) = stock_app();
+
+ // A first launch: the EULA stands, and a login before it is accepted does nothing.
+ assert_eq!(
+ vm(&app)
+ .eval::<(bool, bool)>("return TOSFrame:IsShown() == 1, AccountLoginUI:IsShown() == 1")
+ .unwrap(),
+ (true, false),
+ "readEULA 0 puts the EULA up"
+ );
+ type_and_login(&mut app);
+ assert!(rx.try_recv().is_err(), "no login before the agreements");
+ vm(&app).run("AcceptEULA() AcceptTOS()").unwrap();
+ app.update();
+
+ type_and_login(&mut app);
+ let req = rx.try_recv().expect("the native login gets the attempt");
+ assert_eq!((req.user.as_str(), req.pass.as_str()), ("probe", "secret"));
+ let (which, _) = dialog(&app).expect("the status dialog is up");
+ assert_eq!(which, "CANCEL");
+ assert_eq!(app.world().resource::().state, ScreenState::Logon);
+
+ app.world_mut().write_message(LoginStageMessage {
+ stage: LoginStage::Connecting,
+ });
+ app.update();
+ let connecting = vm(&app).global_text("LOGIN_STATE_CONNECTING");
+ assert_eq!(dialog(&app), Some(("CANCEL".into(), connecting)));
+
+ app.world_mut()
+ .write_message(failed(Some(LoginRefusal::Logon(0x04)), false));
+ app.update();
+ let unknown = vm(&app).global_text("LOGIN_UNKNOWN_ACCOUNT");
+ assert!(!unknown.is_empty());
+ assert_eq!(dialog(&app), Some(("OKAY".into(), unknown)));
+ assert_eq!(app.world().resource::().state, ScreenState::Idle);
+}
+
+/// The status dialog's Cancel is `StatusDialogClick`, which abandons the native attempt.
+#[test]
+fn the_status_dialogs_cancel_abandons_the_attempt() {
+ let _ = benilla_formats::wow_data_or_skip!();
+ let (mut app, rx, abandon) = stock_app();
+ vm(&app).run("AcceptEULA() AcceptTOS()").unwrap();
+ app.update();
+ type_and_login(&mut app);
+ assert!(rx.try_recv().is_ok());
+ vm(&app).run("GlueDialogButton1:Click()").unwrap();
+ app.update();
+ assert_eq!(
+ abandon.load(Ordering::SeqCst),
+ 1,
+ "Cancel bumps the abandon generation"
+ );
+ assert_eq!(app.world().resource::().state, ScreenState::Idle);
+ assert_eq!(dialog(&app), None);
+}
+
+/// benilla's realmlist control on the stock screen: its button opens the stock dialog on the
+/// current address, and Okay writes `realmList`.
+#[test]
+fn the_realmlist_button_writes_realmlist() {
+ let _ = benilla_formats::wow_data_or_skip!();
+ let (mut app, _rx, _) = stock_app();
+ {
+ let s = app.world_mut().non_send_resource_mut::();
+ s.register_cvars(crate::cvars::registered_pairs());
+ s.run("AcceptEULA() AcceptTOS() AccountLogin_ShowUserAgreements()")
+ .unwrap();
+ }
+ app.update();
+ // The readout follows the host's global on the frame's `OnUpdate`.
+ app.world_mut()
+ .non_send_resource_mut::()
+ .tick(0.05);
+ assert_eq!(
+ vm(&app)
+ .eval::("return BenillaRealmlistText:GetText()")
+ .unwrap(),
+ crate::realmlist::DEFAULT_REALMLIST,
+ "the readout shows the address the next login dials"
+ );
+ assert_eq!(
+ vm(&app)
+ .eval::("return BenillaRealmlistButton:GetParent():GetName()")
+ .unwrap(),
+ "AccountLoginUI"
+ );
+ vm(&app).run("BenillaRealmlistButton:Click()").unwrap();
+ assert_eq!(
+ vm(&app)
+ .eval::<(String, String)>("return GlueDialog.which, GlueDialogEditBox:GetText()")
+ .unwrap(),
+ (
+ "BENILLA_REALMLIST".to_string(),
+ crate::realmlist::DEFAULT_REALMLIST.to_string()
+ )
+ );
+ vm(&app)
+ .run("GlueDialogEditBox:SetText('logon.example.org') GlueDialogButton1:Click()")
+ .unwrap();
+ let changes = app
+ .world_mut()
+ .non_send_resource_mut::()
+ .take_cvar_changes();
+ assert!(
+ changes
+ .iter()
+ .any(|(n, v)| n.eq_ignore_ascii_case("realmList") && v == "logon.example.org"),
+ "{changes:?}"
+ );
+ assert!(vm(&app).errors().is_empty(), "{:?}", vm(&app).errors());
+}
diff --git a/crates/benilla-app/src/ui_script/manifest.rs b/crates/benilla-app/src/ui_script/manifest.rs
index 3a1da8b8..c1af1216 100644
--- a/crates/benilla-app/src/ui_script/manifest.rs
+++ b/crates/benilla-app/src/ui_script/manifest.rs
@@ -167,6 +167,11 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec {
/// reports `"Couldn't open %s"` (`0x846ff4`) at severity 2 (`0x6edc3f`) and returns 0, which
/// `UI_Init` never reads (`0x48fff2`), going on to `Bindings.xml` and the addons. benilla goes on
/// the same way and reports it as a load failure, an `ERROR` line and a row in `/errors`.
+///
+/// Deviation: no `FrameXML.toc` signature check, where `UI_Init` loads on a failed one and then
+/// quits (`0x401560(0xa)` at `0x48ff90`), because the check verifies Blizzard's signature with a key
+/// carried in the client binary, which benilla does not ship, and a modified interface file is
+/// loaded rather than refused.
pub(super) fn load_core(script: &UiScript) -> Vec {
let Some(core) = reference_ui::core() else {
let e = format!(
@@ -485,10 +490,12 @@ mod tests {
assert_eq!(order, ["ExhaustionTick", "PlayerFrame"]);
}
- /// A shipped `.xml` the layer does not list never loads, with no error to say so.
+ /// A shipped `.xml` the layer does not list never loads, with no error to say so; the one
+ /// glue file loads by name ([`super::super::glue::GLUE_LAYER`]).
#[test]
fn the_manifest_lists_every_shipped_file_and_nothing_else() {
let mut listed = shipped_manifest_files();
+ listed.push(super::super::glue::GLUE_LAYER.to_string());
let mut shipped: Vec = super::super::content::shipped_files()
.filter(|f| f.ends_with(".xml"))
.map(str::to_owned)
diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs
index cdb17c7f..7ed8274f 100644
--- a/crates/benilla-app/src/ui_script/mod.rs
+++ b/crates/benilla-app/src/ui_script/mod.rs
@@ -18,6 +18,8 @@ mod content;
pub(crate) mod extract;
/// The stock glue screens behind `WOW_GLUE_STOCK=1`.
pub(crate) mod glue;
+/// `CGlueMgr`'s screen states over the native login, for the stock glue screens.
+pub(crate) mod glue_mgr;
mod input;
#[cfg(test)]
mod layer_tests;
@@ -292,6 +294,7 @@ impl Plugin for UiScriptPlugin {
crate::shutdown::on_app_exit(app, shutdown_on_exit.into_configs());
app.insert_resource(UiScaleCvar(default_ui_scale()))
.insert_resource(glue::GlueStock::from_env())
+ .init_resource::()
.init_resource::()
.init_resource::()
.init_resource::()
diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs
index 9c55306f..9ff8922c 100644
--- a/crates/benilla-app/src/ui_script/reference_ui.rs
+++ b/crates/benilla-app/src/ui_script/reference_ui.rs
@@ -102,6 +102,9 @@ pub(super) fn read(req: &str) -> Option> {
if let Some(overlaid) = fixture::read(req) {
return overlaid;
}
+ if let Some(bytes) = read_loose(req) {
+ return Some(bytes);
+ }
let chain = chain()?;
match chain.read(req) {
Ok(bytes) => Some(bytes),
@@ -112,6 +115,53 @@ pub(super) fn read(req: &str) -> Option> {
}
}
+/// A bare file name off the install's own folders, read-only: Storm's open tries the install
+/// directory (its base path `0xc52418`) and its `Data\` before the archives, which is where the
+/// login screen's agreements (``) are found, loose in `Data\`. The
+/// lookup ignores case, as the reference's file system does, so `credits.html` finds
+/// `Data/Credits.html` on a case-sensitive one. Only a bare name looks on disk; every chain path
+/// keeps reading the archives alone.
+fn read_loose(req: &str) -> Option> {
+ if req.is_empty() || req.contains(['\\', '/']) || req.contains("..") {
+ return None;
+ }
+ static INDEX: OnceLock> = OnceLock::new();
+ let index = INDEX.get_or_init(|| {
+ benilla_formats::wow_data()
+ .map(|data| {
+ let install = data.parent().map(std::path::Path::to_path_buf);
+ let dirs: Vec =
+ install.into_iter().chain(std::iter::once(data)).collect();
+ loose_index(&dirs)
+ })
+ .unwrap_or_default()
+ });
+ std::fs::read(index.get(&req.to_ascii_lowercase())?).ok()
+}
+
+/// The files directly in `dirs` by lowercased name, read once; an earlier directory wins a name
+/// two share.
+fn loose_index(
+ dirs: &[std::path::PathBuf],
+) -> std::collections::HashMap {
+ let mut index = std::collections::HashMap::new();
+ for dir in dirs {
+ let Ok(entries) = std::fs::read_dir(dir) else {
+ continue;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if !path.is_file() {
+ continue;
+ }
+ if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
+ index.entry(name.to_ascii_lowercase()).or_insert(path);
+ }
+ }
+ }
+ index
+}
+
/// The player's patch chain, opened once per process and shared by every VM. Process-local, not
/// the one [`benilla_assets`] holds: tests, the addon harness and a bare `UiScript` load the
/// interface with no Bevy world to ask.
@@ -189,6 +239,23 @@ fn is_word(c: char) -> bool {
mod tests {
use benilla_ui::script::UiScript;
+ /// A bare name finds its loose file whatever the case on disk, the install root ahead of
+ /// `Data`.
+ #[test]
+ fn the_loose_index_ignores_case_and_prefers_the_install_root() {
+ let tmp = std::env::temp_dir().join(format!("benilla-loose-{}", std::process::id()));
+ let data = tmp.join("Data");
+ std::fs::create_dir_all(&data).unwrap();
+ std::fs::write(data.join("Credits.html"), b"data").unwrap();
+ std::fs::write(data.join("eula.html"), b"data").unwrap();
+ std::fs::write(tmp.join("EULA.html"), b"root").unwrap();
+ let index = super::loose_index(&[tmp.clone(), data.clone()]);
+ let read = |name: &str| std::fs::read(&index[name]).unwrap();
+ assert_eq!(read("credits.html"), b"data");
+ assert_eq!(read("eula.html"), b"root");
+ let _ = std::fs::remove_dir_all(&tmp);
+ }
+
/// Every `RegisterForSave`'d UI global defaults to the value the reference's own
/// `UIOptionsFrame_Init` assigns, the FrameXML half of the rule `cvars::REGISTERED` holds for
/// CVars. A name the reference declares elsewhere (`SHOW_OFFLINE_GUILD_MEMBERS`,
diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
index 800d1b74..a71a40c2 100644
--- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
+++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
@@ -1065,7 +1065,13 @@ fn shipped_frame_parents() -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
let path = entry.path();
- if !path.extension().is_some_and(|e| e == "xml") {
+ // The glue file loads over the stock glue screens, not this in-game load; its own test
+ // reads its parent (`glue_mgr`'s realmlist test).
+ if !path.extension().is_some_and(|e| e == "xml")
+ || path
+ .file_name()
+ .is_some_and(|n| n == super::glue::GLUE_LAYER)
+ {
continue;
}
let text = std::fs::read_to_string(&path).expect("read");
@@ -1099,7 +1105,13 @@ pub(super) fn shipped_frame_names() -> Vec {
let mut names: Vec = Vec::new();
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
let path = entry.path();
- if !path.extension().is_some_and(|e| e == "xml") {
+ // The glue file loads over the stock glue screens, not this in-game load; its own test
+ // reads its parent (`glue_mgr`'s realmlist test).
+ if !path.extension().is_some_and(|e| e == "xml")
+ || path
+ .file_name()
+ .is_some_and(|n| n == super::glue::GLUE_LAYER)
+ {
continue;
}
let text = std::fs::read_to_string(&path).expect("read");
@@ -1924,7 +1936,8 @@ fn the_stock_options_windows_load_and_save_are_reachable_for_addons() {
}
/// benilla's own interface is one layer, and it does not grow: `layer.toc` names exactly these
-/// files, all ours (none off the chain), and `assets/ui` holds nothing else but that manifest. The
+/// files, all ours (none off the chain), and `assets/ui` holds nothing else but that manifest and
+/// the one glue file ([`super::glue::GLUE_LAYER`]). The
/// core loads every stock window the player's `FrameXML.toc` lists; what changes the stock UI goes
/// in one of these files.
#[test]
@@ -1953,9 +1966,13 @@ fn the_layer_does_not_grow() {
.filter(|n| !n.starts_with('.'))
.collect();
found.sort();
+ // Deviation: one glue file beside the in-game layer, `AccountLoginRealmlist.xml`, the
+ // realmlist control on the stock login screen, because benilla is a client for any server and
+ // 1.12.1 has no interface for the realmlist, only `realmlist.wtf`.
+ assert_eq!(super::glue::GLUE_LAYER, "AccountLoginRealmlist.xml");
let mut shipped: Vec = LAYER
.iter()
- .chain(&["layer.toc"])
+ .chain(&["layer.toc", super::glue::GLUE_LAYER])
.map(|s| s.to_string())
.collect();
shipped.sort();
@@ -1987,8 +2004,9 @@ fn the_layer_speaks_the_1_12_dialect() {
}
}
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
- let files = super::addons::Addon::layer().toc.files;
+ let mut files = super::addons::Addon::layer().toc.files;
assert!(files.len() >= 8, "the layer's toc lists {files:?}");
+ files.push(super::glue::GLUE_LAYER.to_string());
let word = |text: &str, w: &str| {
text.match_indices(w).any(|(i, _)| {
let before = text[..i].chars().next_back();
diff --git a/crates/benilla-protocol/examples/srp_encoding_probe.rs b/crates/benilla-protocol/examples/srp_encoding_probe.rs
index 2a47dad1..25c74731 100644
--- a/crates/benilla-protocol/examples/srp_encoding_probe.rs
+++ b/crates/benilla-protocol/examples/srp_encoding_probe.rs
@@ -264,7 +264,9 @@ fn handshake(
.with_context(|| format!("connecting to {host}:{AUTH_PORT}"))?;
s.set_read_timeout(Some(Duration::from_secs(10)))?;
auth::write_logon_challenge(&mut s, user, BUILD).context("sending logon challenge")?;
- let reply = auth::read_challenge_reply(&mut s).context("reading logon challenge reply")?;
+ let reply = auth::read_challenge_reply(&mut s)
+ .context("reading logon challenge reply")?
+ .ok_or_else(|| anyhow!("SUCCESS_SURVEY on the challenge: no SRP inputs"))?;
// A wrong-shape `B` drops the connection before any proof, so realmd records no failure.
if want != Want::Any && (reply.server_public_key[31] == 0) != (want == Want::ShortB) {
@@ -277,7 +279,8 @@ fn handshake(
auth::write_logon_proof(&mut s, &attempt.a_pub, &attempt.m1, &reply.crc_salt)
.context("sending logon proof")?;
let outcome = match auth::read_proof_reply(&mut s) {
- Ok(m2) => Outcome::Accepted {
+ Ok(None) => Outcome::Error("SUCCESS_SURVEY: accepted with no M2".into()),
+ Ok(Some(m2)) => Outcome::Accepted {
m2_fixed: m2 == attempt.expected_m2(Enc::Fixed),
m2_minimal: m2 == attempt.expected_m2(Enc::Minimal),
},
diff --git a/crates/benilla-protocol/examples/version_check_probe.rs b/crates/benilla-protocol/examples/version_check_probe.rs
index f157c884..4069661d 100644
--- a/crates/benilla-protocol/examples/version_check_probe.rs
+++ b/crates/benilla-protocol/examples/version_check_probe.rs
@@ -52,7 +52,9 @@ fn attempt(host: &str, port: u16, user: &str, pass: &str, arm: Arm) -> Result Result<()> {
TcpStream::connect((host, port)).with_context(|| format!("connecting to {host}:{port}"))?;
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
auth::write_logon_challenge(&mut stream, &user.to_uppercase(), CLIENT_BUILD)?;
- let crc_salt = auth::read_challenge_reply(&mut stream)?.crc_salt;
+ let crc_salt = auth::read_challenge_reply(&mut stream)?
+ .ok_or_else(|| anyhow!("SUCCESS_SURVEY on the challenge: no SRP inputs"))?
+ .crc_salt;
drop(stream); // no proof follows, so realmd records nothing for this dial
let hex: String = crc_salt.iter().map(|b| format!("{b:02x}")).collect();
let known = auth::version_proof(&crc_salt, &[0u8; 32]) != [0u8; 20];
diff --git a/crates/benilla-protocol/src/auth.rs b/crates/benilla-protocol/src/auth.rs
index 552ad544..1177192e 100644
--- a/crates/benilla-protocol/src/auth.rs
+++ b/crates/benilla-protocol/src/auth.rs
@@ -43,8 +43,25 @@ const LOCALE_EN_US: u32 = 0x656e_5553; // "enUS"
pub struct AuthReject {
/// The grunt result byte (`WOW_FAIL_*`).
pub code: u8,
+ /// Which reply carried it: the reference handles a byte by its reply, swallowing a proof's
+ /// `0x0A` (`0x5bada2`) where a challenge's reaches the patch state.
+ pub reply: LogonReply,
}
+/// The two realmd replies a logon reads a result byte from.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LogonReply {
+ /// `CMD_AUTH_LOGON_CHALLENGE` (`Grunt::OnLogonChallenge`, `0x5baa20`).
+ Challenge,
+ /// `CMD_AUTH_LOGON_PROOF` (`Grunt::OnLogonProof`, `0x5bacb0`).
+ Proof,
+}
+
+/// `WOW_SUCCESS_SURVEY`: a success. Both reply handlers take it down their failure branch
+/// (`0x5baa77`, `0x5bace5` test the byte for zero), reading nothing after it, and
+/// `Logon::OnAuthResult` sends it down 0x00's success arm (`0x5b2cd4` `cmp eax,0xe; je 0x5b2e16`).
+pub const SUCCESS_SURVEY: u8 = 0x0e;
+
impl std::fmt::Display for AuthReject {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "server rejected logon: result {:#04x}", self.code)
@@ -93,16 +110,24 @@ pub fn write_logon_challenge(
w.write_all(&packet)
}
-/// Read the challenge reply; a non-success result is an [`AuthReject`].
-pub fn read_challenge_reply(r: &mut impl Read) -> Result {
+/// Read the challenge reply: the SRP6 inputs, `None` for [`SUCCESS_SURVEY`] (a success that
+/// carries none), or an [`AuthReject`].
+pub fn read_challenge_reply(r: &mut impl Read) -> Result> {
let opcode = read_u8(r)?;
if opcode != CMD_AUTH_LOGON_CHALLENGE {
bail!("expected CMD_AUTH_LOGON_CHALLENGE (0x00), got {opcode:#x}");
}
let _protocol_version = read_u8(r)?;
let result = read_u8(r)?;
+ if result == SUCCESS_SURVEY {
+ return Ok(None);
+ }
if result != 0 {
- return Err(AuthReject { code: result }.into());
+ return Err(AuthReject {
+ code: result,
+ reply: LogonReply::Challenge,
+ }
+ .into());
}
let server_public_key = read_array::<32>(r)?;
let generator_len = read_u8(r)?;
@@ -129,13 +154,13 @@ pub fn read_challenge_reply(r: &mut impl Read) -> Result {
let _pin_grid_seed = read_u32_le(r)?;
let _pin_salt = read_array::<16>(r)?;
}
- Ok(ChallengeReply {
+ Ok(Some(ChallengeReply {
server_public_key,
generator,
large_safe_prime,
salt,
crc_salt,
- })
+ }))
}
// --- the version (client-integrity) proof --------------------------------------------------------
@@ -209,20 +234,28 @@ pub fn write_logon_proof(
w.write_all(&packet)
}
-/// Read the proof reply: the server's `M2`, or an [`AuthReject`].
-pub fn read_proof_reply(r: &mut impl Read) -> Result<[u8; 20]> {
+/// Read the proof reply: the server's `M2`, `None` for [`SUCCESS_SURVEY`] (a success with no `M2`
+/// read), or an [`AuthReject`].
+pub fn read_proof_reply(r: &mut impl Read) -> Result> {
let opcode = read_u8(r)?;
if opcode != CMD_AUTH_LOGON_PROOF {
bail!("expected CMD_AUTH_LOGON_PROOF (0x01), got {opcode:#x}");
}
let result = read_u8(r)?;
+ if result == SUCCESS_SURVEY {
+ return Ok(None);
+ }
if result != 0 {
// A wrong password fails here, where `M1` does not verify: vmangos answers 0x04.
- return Err(AuthReject { code: result }.into());
+ return Err(AuthReject {
+ code: result,
+ reply: LogonReply::Proof,
+ }
+ .into());
}
let server_proof = read_array::<20>(r)?;
let _hardware_survey_id = read_u32_le(r)?;
- Ok(server_proof)
+ Ok(Some(server_proof))
}
/// Send `CMD_REALM_LIST_Client` (opcode + a `u32` padding of 0).
diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs
index 6a1a0d2f..43105ee2 100644
--- a/crates/benilla-protocol/src/events.rs
+++ b/crates/benilla-protocol/src/events.rs
@@ -71,6 +71,8 @@ pub enum LoginStage {
Authenticating,
/// Realm picked; the world-socket handshake and char enum (`LOGIN_STATE_HANDSHAKING`).
Handshaking,
+ /// The world socket connected, before its authentication (`RESPONSE_CONNECTED`, `0x5aaa90`).
+ WorldConnected,
}
/// How a world session ended. On a loss the reference fires `DISCONNECTED_FROM_SERVER` and
@@ -88,8 +90,11 @@ pub enum SessionEnd {
/// `AUTH_LOGON_FAILED_SUSPENDED` to realmd but `AUTH_OK` to the world server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginRefusal {
- /// realmd's logon-proof `AuthLogonResult` (`crate::AuthReject`).
+ /// realmd's `AuthLogonResult` in its logon-challenge reply (`crate::AuthReject`).
Logon(u8),
+ /// realmd's `AuthLogonResult` in its logon-proof reply, where the reference swallows `0x0A`
+ /// (`0x5bada2`).
+ LogonProof(u8),
/// The world server's `SMSG_AUTH_RESPONSE` code (`crate::WorldAuthReject`).
World(u8),
}
@@ -98,7 +103,7 @@ impl LoginRefusal {
/// The raw byte, for logging only: without its server it is ambiguous.
pub fn byte(self) -> u8 {
match self {
- LoginRefusal::Logon(b) | LoginRefusal::World(b) => b,
+ LoginRefusal::Logon(b) | LoginRefusal::LogonProof(b) | LoginRefusal::World(b) => b,
}
}
}
diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs
index 23deefb5..ad97e3d6 100644
--- a/crates/benilla-protocol/src/lib.rs
+++ b/crates/benilla-protocol/src/lib.rs
@@ -7,7 +7,7 @@ pub mod guid;
pub mod messages;
pub mod wire;
pub mod world;
-pub use auth::AuthReject;
+pub use auth::{AuthReject, LogonReply};
pub use events::{
decode, CharAction, EntityKind, LoginRefusal, LoginStage, MoveSpeeds, Poll, SessionEnd,
SessionEvent, SessionEventKind,
@@ -20,8 +20,8 @@ pub use messages::{
CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME,
};
pub use world::{
- observe_sends, SendObserver, WardenRequired, WorldAuthReject, WorldReader, WorldSession,
- WorldWriter, WORLD_PORT,
+ observe_sends, SendObserver, WardenRequired, WorldAuthReject, WorldConnectEvent, WorldReader,
+ WorldSession, WorldWriter, WORLD_PORT,
};
use std::net::TcpStream;
@@ -181,8 +181,13 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result {
let mut stream = dial(host, port)?;
auth::write_logon_challenge(&mut stream, &username.to_uppercase(), CLIENT_BUILD)
.context("sending logon challenge")?;
- let reply =
- auth::read_challenge_reply(&mut stream).context("reading logon challenge reply")?;
+ let Some(reply) =
+ auth::read_challenge_reply(&mut stream).context("reading logon challenge reply")?
+ else {
+ // `SUCCESS_SURVEY` on the challenge authenticates with no SRP exchange; the
+ // reference's success arm copies no key (length 0 at `0x5b2e16`).
+ return finish_logon(stream, [0u8; SESSION_KEY_LENGTH]);
+ };
let server_public_key = PublicKey::from_le_bytes(reply.server_public_key)
.map_err(|e| anyhow!("invalid server public key: {e}"))?;
let stable = server_public_key.is_width_stable();
@@ -212,12 +217,21 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result {
)
.context("sending logon proof")?;
- let server_proof = auth::read_proof_reply(&mut stream).context("reading logon proof reply")?;
- let client = challenge
- .verify_server_proof(server_proof)
- .map_err(|e| anyhow!("server proof mismatch (wrong password?): {e}"))?;
- let session_key = *client.session_key();
+ let session_key =
+ match auth::read_proof_reply(&mut stream).context("reading logon proof reply")? {
+ Some(server_proof) => *challenge
+ .verify_server_proof(server_proof)
+ .map_err(|e| anyhow!("server proof mismatch (wrong password?): {e}"))?
+ .session_key(),
+ // `SUCCESS_SURVEY` on the proof authenticates with no `M2` to check; the key is the one
+ // this exchange derived.
+ None => challenge.into_unverified_session_key(),
+ };
+ finish_logon(stream, session_key)
+}
+/// An authenticated logon's tail: the realm list over the same socket.
+fn finish_logon(mut stream: TcpStream, session_key: [u8; SESSION_KEY_LENGTH]) -> Result {
auth::write_realm_list_request(&mut stream).context("requesting realm list")?;
let realms = auth::read_realm_list(&mut stream).context("reading realm list")?;
diff --git a/crates/benilla-protocol/src/world/mod.rs b/crates/benilla-protocol/src/world/mod.rs
index 2654cac9..f5eacae8 100644
--- a/crates/benilla-protocol/src/world/mod.rs
+++ b/crates/benilla-protocol/src/world/mod.rs
@@ -19,7 +19,7 @@ mod session;
mod writer;
pub use reader::WorldReader;
-pub use session::{WardenRequired, WorldAuthReject, WorldSession};
+pub use session::{WardenRequired, WorldAuthReject, WorldConnectEvent, WorldSession};
pub use writer::WorldWriter;
/// The stock `mangosd` port, for probes that dial the world server without a realm list.
diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs
index c1b82db3..e0361f77 100644
--- a/crates/benilla-protocol/src/world/session.rs
+++ b/crates/benilla-protocol/src/world/session.rs
@@ -73,6 +73,16 @@ pub struct WorldSession {
addon_info: Option>,
}
+/// What a world connect reports on its way to `SMSG_AUTH_RESPONSE`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WorldConnectEvent {
+ /// The socket connected: the reference's connect operation completes `RESPONSE_CONNECTED`
+ /// here (`0x5aaa90`) and its authentication begins.
+ Connected,
+ /// A queue position (`AUTH_WAIT_QUEUE`), or `None` when the body carried none.
+ Queued(Option),
+}
+
impl WorldSession {
/// Connect and complete the auth handshake with the logon's account and session key.
pub fn connect(
@@ -83,17 +93,21 @@ impl WorldSession {
Self::connect_queued(addr, username, session_key, &mut |_| true)
}
- /// [`Self::connect`], calling `on_queue` with each queue position; `false` abandons the queue.
+ /// [`Self::connect`], calling `on_event` once the socket connects and with each queue position;
+ /// `false` abandons the connect.
/// Deviation: checked per queue packet where the reference checks each tick, so a cancel can
/// lag one server update.
pub fn connect_queued(
addr: impl ToSocketAddrs,
username: &str,
session_key: [u8; SESSION_KEY_LENGTH],
- on_queue: &mut dyn FnMut(Option) -> bool,
+ on_event: &mut dyn FnMut(WorldConnectEvent) -> bool,
) -> Result {
let mut queued = false;
let mut stream = TcpStream::connect(addr).context("connecting to world server")?;
+ if !on_event(WorldConnectEvent::Connected) {
+ bail!("world connect abandoned");
+ }
// Nagle off: the reference sets `TCP_NODELAY` on its game socket (`0x5bca60`).
stream
.set_nodelay(true)
@@ -161,7 +175,7 @@ impl WorldSession {
.set_read_timeout(Some(QUEUE_READ_TIMEOUT))
.context("relaxing the read timeout for the login queue")?;
}
- if !on_queue(queue_position) {
+ if !on_event(WorldConnectEvent::Queued(queue_position)) {
bail!("login queue abandoned");
}
}
diff --git a/crates/benilla-protocol/tests/auth.rs b/crates/benilla-protocol/tests/auth.rs
index 10622fc0..ee7b26d4 100644
--- a/crates/benilla-protocol/tests/auth.rs
+++ b/crates/benilla-protocol/tests/auth.rs
@@ -67,7 +67,9 @@ fn logon_read_sequence_stays_aligned() {
let mut stream: &[u8] = &buf;
// The challenge read must consume the `crc_salt` + `security_flag` tail to land on opcode 0x01.
- let reply = auth::read_challenge_reply(&mut stream).expect("challenge reply");
+ let reply = auth::read_challenge_reply(&mut stream)
+ .expect("challenge reply")
+ .expect("a challenge with SRP inputs");
assert_eq!(reply.server_public_key, server_public_key);
assert_eq!(reply.generator, 7);
assert_eq!(reply.large_safe_prime, N);
@@ -76,7 +78,7 @@ fn logon_read_sequence_stays_aligned() {
let proof =
auth::read_proof_reply(&mut stream).expect("proof reply (stream desynced if this fails)");
- assert_eq!(proof, server_proof);
+ assert_eq!(proof, Some(server_proof));
let realms =
auth::read_realm_list(&mut stream).expect("realm list (stream desynced if this fails)");
@@ -91,3 +93,84 @@ fn logon_read_sequence_stays_aligned() {
stream.len()
);
}
+
+// ── `logon` against a fake realmd ────────────────────────────────────────────────────────────────
+
+/// A realmd that answers the challenge with `challenge`, then, unless it was the bare 3-byte
+/// survey or a refusal, reads the client's proof and answers `proof`; after a success it serves
+/// the one-realm list. Returns its address.
+fn fake_realmd(challenge: Vec, proof: Option>, then_list: bool) -> String {
+ use std::io::{Read, Write};
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap().to_string();
+ std::thread::spawn(move || {
+ let (mut s, _) = listener.accept().unwrap();
+ let mut head = [0u8; 4];
+ s.read_exact(&mut head).unwrap();
+ let mut body = vec![0u8; usize::from(u16::from_le_bytes([head[2], head[3]]))];
+ s.read_exact(&mut body).unwrap();
+ s.write_all(&challenge).unwrap();
+ if let Some(proof) = proof {
+ let mut client_proof = [0u8; 75];
+ s.read_exact(&mut client_proof).unwrap();
+ s.write_all(&proof).unwrap();
+ }
+ if then_list {
+ s.read_exact(&mut [0u8; 5]).unwrap();
+ s.write_all(&realm_list_packet()).unwrap();
+ }
+ let _ = s.read(&mut [0u8; 1]);
+ });
+ addr
+}
+
+/// A width-stable `B` (top byte non-zero), so the client does not redial.
+const B: [u8; 32] = [9u8; 32];
+
+/// `WOW_SUCCESS_SURVEY` (0x0E) in the proof reply logs in: `Logon::OnAuthResult` sends it down
+/// 0x00's success arm (`0x5b2cd4`), with no `M2` read after it.
+#[test]
+fn a_survey_success_in_the_proof_reply_logs_in() {
+ let addr = fake_realmd(
+ challenge_packet(&B, &[3u8; 32]),
+ Some(vec![0x01, 0x0e]),
+ true,
+ );
+ let logon = benilla_protocol::logon(&addr, "probe", "secret").expect("0x0E is a success");
+ assert_eq!(logon.realms.len(), 1);
+}
+
+/// 0x0E in the challenge reply logs in with no SRP exchange at all.
+#[test]
+fn a_survey_success_in_the_challenge_reply_logs_in() {
+ let addr = fake_realmd(vec![0x00, 0x00, 0x0e], None, true);
+ let logon = benilla_protocol::logon(&addr, "probe", "secret").expect("0x0E is a success");
+ assert_eq!(logon.realms.len(), 1);
+}
+
+/// A refusal says which reply carried it, since the reference swallows a proof's 0x0A
+/// (`0x5bada2`) where a challenge's reaches the patch state.
+#[test]
+fn a_refusal_names_its_reply() {
+ let refused = |addr: String| {
+ let e = benilla_protocol::logon(&addr, "probe", "secret")
+ .err()
+ .expect("refused");
+ *e.downcast_ref::()
+ .expect("an AuthReject")
+ };
+ let r = refused(fake_realmd(vec![0x00, 0x00, 0x0a], None, false));
+ assert_eq!(
+ (r.code, r.reply),
+ (0x0a, benilla_protocol::LogonReply::Challenge)
+ );
+ let r = refused(fake_realmd(
+ challenge_packet(&B, &[3u8; 32]),
+ Some(vec![0x01, 0x0a]),
+ false,
+ ));
+ assert_eq!(
+ (r.code, r.reply),
+ (0x0a, benilla_protocol::LogonReply::Proof)
+ );
+}
diff --git a/crates/benilla-srp/src/lib.rs b/crates/benilla-srp/src/lib.rs
index 094d83fa..6fee3189 100644
--- a/crates/benilla-srp/src/lib.rs
+++ b/crates/benilla-srp/src/lib.rs
@@ -369,6 +369,12 @@ impl SrpClientChallenge {
&self.client_public_key
}
+ /// The session key this exchange derived, for a logon the server accepted without an `M2`
+ /// (realmd's `SUCCESS_SURVEY`), which leaves nothing to verify.
+ pub fn into_unverified_session_key(self) -> [u8; 40] {
+ self.session_key
+ }
+
/// Verify the server's proof `M2`; the returned [`SrpClient`] holds the session key.
pub fn verify_server_proof(
self,
diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs
index f1ff1575..8d08c78a 100644
--- a/crates/benilla-ui/src/loader/tests.rs
+++ b/crates/benilla-ui/src/loader/tests.rs
@@ -1957,6 +1957,44 @@ mod loader_tests {
// ── ─────────────────────────────────────────────────────────────────────────
+ /// `file=` names a document read verbatim, never joined to the XML's own directory, whose text
+ /// becomes the frame's (`0x78a2a0`, `0x78a2b2`), as the login screen's agreements load
+ /// `eula.html`; a file that does not open leaves the frame empty.
+ #[test]
+ fn simplehtml_file_sets_the_documents_text() {
+ let mut s = UiScript::new().unwrap();
+ s.set_screen_size(800.0, 600.0);
+ let doc = parse(
+ r#"
+
+
+
+
+
+
+
+
+
+
+
+ "#,
+ );
+ let files = |req: &str| -> Option> {
+ (req == "doc.html")
+ .then(|| b"\xef\xbb\xbfHello
".to_vec())
+ };
+ let report = load_in(&s, &doc, "Interface/GlueXML/Doc.xml", &files);
+ assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
+ assert_eq!(
+ s.eval::<(i64, String)>(
+ "local r = Doc:GetRegions(); return Doc:GetNumRegions(), r:GetText()"
+ )
+ .unwrap(),
+ (1, "Hello".to_string())
+ );
+ assert_eq!(s.eval::("return Gone:GetNumRegions()").unwrap(), 0);
+ }
+
/// A direct-child `` declares `elementFont[0]` and ``
/// `elementFont[1]`, neither a region, and `hyperlinkFormat=` lands at `+0x360`; stock
/// `ItemTextFrame.xml:214` uses the first.
diff --git a/crates/benilla-ui/src/loader/widgets.rs b/crates/benilla-ui/src/loader/widgets.rs
index b82ae91a..0df7605f 100644
--- a/crates/benilla-ui/src/loader/widgets.rs
+++ b/crates/benilla-ui/src/loader/widgets.rs
@@ -434,7 +434,9 @@ impl Loader<'_> {
/// `` (`0x78a130`): `font=` for all four element fonts (`0x78a17a`), the
/// `` and `` children one each (`0x78a1fe`, through
- /// `0x783c30`), `hyperlinkFormat` (`0x78a540`), and `file=`, a global-string key for `SetText`.
+ /// `0x783c30`), `hyperlinkFormat` (`0x78a540`), and `file=`, a document whose whole text is
+ /// set as the frame's (`0x78a2a0` loads it through Storm, the attribute verbatim; `0x78a2b2`
+ /// sets it). A file that does not open leaves the frame empty.
pub(super) fn apply_simplehtml(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
if !el.tag.eq_ignore_ascii_case("SimpleHTML") {
return;
@@ -463,8 +465,10 @@ impl Loader<'_> {
self.apply_element_font(child, wrapper, elem, dbg);
}
if let Some(raw) = el.attr("file") {
- let text = self.resolve_text(raw, dbg);
- self.call(wrapper, "SetText", text, dbg);
+ if let Some(bytes) = (self.files)(raw) {
+ let text = crate::source::decode(&bytes).into_owned();
+ self.call(wrapper, "SetText", text, dbg);
+ }
}
}
diff --git a/crates/benilla-ui/src/script/client.rs b/crates/benilla-ui/src/script/client.rs
index 41d63671..ff0802df 100644
--- a/crates/benilla-ui/src/script/client.rs
+++ b/crates/benilla-ui/src/script/client.rs
@@ -7,9 +7,9 @@ use mlua::{Lua, MultiValue, Value};
use super::Model;
/// The 1.12.1 client's build identity, as the strings in its binary spell it.
-const VERSION: &str = "1.12.1";
-const BUILD: &str = "5875";
-const BUILD_DATE: &str = "Sep 19 2006";
+pub(super) const VERSION: &str = "1.12.1";
+pub(super) const BUILD: &str = "5875";
+pub(super) const BUILD_DATE: &str = "Sep 19 2006";
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
diff --git a/crates/benilla-ui/src/script/glue.rs b/crates/benilla-ui/src/script/glue.rs
index 54aa83da..35b75a53 100644
--- a/crates/benilla-ui/src/script/glue.rs
+++ b/crates/benilla-ui/src/script/glue.rs
@@ -115,5 +115,5 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(model.cursor_pos)
})?,
)?;
- Ok(())
+ super::glue_core::install(lua)
}
diff --git a/crates/benilla-ui/src/script/glue_core.rs b/crates/benilla-ui/src/script/glue_core.rs
new file mode 100644
index 00000000..f9160842
--- /dev/null
+++ b/crates/benilla-ui/src/script/glue_core.rs
@@ -0,0 +1,549 @@
+//! The glue core's login-screen verbs (registrar `0x46ddf0`, table `0x8373b8`): the login, the
+//! status dialog's button, the saved account, the four user agreements, the build, the locale,
+//! the server, the screen name, the glue music and the quit. Each reads a slot the host seeds or
+//! queues a [`GlueRequest`] the host's glue manager drains; the screen state machine they drive
+//! (`CGlueMgr::Update`, `0x46b930`) is the host's.
+
+use mlua::{Lua, MultiValue, Value};
+
+use super::binding_abi::{flag, string_arg};
+use super::Model;
+
+/// One of the four user agreements the login screen walks before it shows the login boxes
+/// (`AccountLogin_ShowUserAgreements`), each a CVar the glue manager reads once at startup
+/// (`0x46a400`): `readTOS`, `readEULA`, `readScanning`, `readContest`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum GlueNotice {
+ Tos,
+ Eula,
+ Scanning,
+ Contest,
+}
+
+impl GlueNotice {
+ /// All four, in the glue manager's seeding order (`0x46a406`-`0x46a48e`).
+ pub const ALL: [GlueNotice; 4] = [
+ GlueNotice::Tos,
+ GlueNotice::Eula,
+ GlueNotice::Scanning,
+ GlueNotice::Contest,
+ ];
+
+ fn index(self) -> usize {
+ self as usize
+ }
+}
+
+/// One agreement's two flags, as the glue manager seeds them from its CVar's integer value
+/// (`0x46a41a`-`0x46a42e` for `readTOS`): accepted is the value being 1, the notice (the agreement
+/// changed since it was read) is the value being negative.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct NoticeState {
+ pub accepted: bool,
+ pub show_notice: bool,
+}
+
+impl NoticeState {
+ /// The flags for a CVar value, read as `atoi` reads it: anything unparseable is 0.
+ pub fn from_cvar(value: &str) -> Self {
+ let n = leading_int(value);
+ NoticeState {
+ accepted: n == 1,
+ show_notice: n < 0,
+ }
+ }
+}
+
+/// `atoi`: an optional sign and the leading digits, 0 when there are none.
+fn leading_int(s: &str) -> i64 {
+ let s = s.trim_start();
+ let (sign, digits) = match s.as_bytes().first() {
+ Some(b'-') => (-1, &s[1..]),
+ Some(b'+') => (1, &s[1..]),
+ _ => (1, s),
+ };
+ let end = digits
+ .find(|c: char| !c.is_ascii_digit())
+ .unwrap_or(digits.len());
+ sign * digits[..end].parse::().unwrap_or(0)
+}
+
+/// `DefaultServerLogin`'s two strings. Never logged: the `Debug` form names the account only.
+pub struct GlueCredentials {
+ pub account: String,
+ pub password: String,
+}
+
+impl std::fmt::Debug for GlueCredentials {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("GlueCredentials")
+ .field("account", &self.account)
+ .field("password", &"")
+ .finish()
+ }
+}
+
+/// A glue verb's effect on the host, drained by its glue manager.
+#[derive(Debug)]
+pub enum GlueRequest {
+ /// `DefaultServerLogin(account, password)` (`0x46d160`), handed to `CGlueMgr::Login`
+ /// (`0x46afb0`), whose guards and dialogs are the host's.
+ Login(GlueCredentials),
+ /// `StatusDialogClick()` (`0x46d270` → `0x46b720`): the status dialog's button, per screen
+ /// state.
+ StatusDialogClick,
+ /// `SetSavedAccountName(name)` (`0x46cdf0`): the `accountName` CVar's new value.
+ SetSavedAccountName(String),
+ /// `AcceptTOS` and its three siblings (`0x46d000`, `0x46d070`, `0x46d0e0`, `0x46d150`): the
+ /// agreement's CVar set to `"1"` (`0x46aef0` and its siblings).
+ Accept(GlueNotice),
+ /// `PlayGlueMusic(file)` (`0x46ceb0` → `0x45aeb0`).
+ PlayGlueMusic(String),
+ /// `StopGlueMusic()` (`0x46cf00`): `0x45b050(1.0)`, a one-second fade.
+ StopGlueMusic,
+ /// `DisconnectFromServer()` (`0x46d340`): the world connection dropped if up, then the logon
+ /// cancelled.
+ DisconnectFromServer,
+ /// `QuitGame()` (`0x46cea0` → `0x46b870` → `0x401560(0)`).
+ QuitGame,
+}
+
+/// The realm `GetServerName` (`0x46d280`) answers: the `realmName` CVar's text, and the
+/// selected realm's PvP and RP flags off its `Cfg_Configs` row, nil without a selected realm.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct GlueServer {
+ pub name: String,
+ pub pvp: bool,
+ pub rp: bool,
+}
+
+/// The glue core's slots and queue in the VM's model.
+#[derive(Default)]
+pub(crate) struct GlueCore {
+ requests: Vec,
+ /// The `accountName` CVar (`[0x8826c8]`), which `GetSavedAccountName` (`0x46cdd0`) reads.
+ saved_account: String,
+ notices: [NoticeState; 4],
+ /// ClientServices' world-connection flag (`[svc+0x1b00]`), which `IsConnectedToServer`
+ /// (`0x46d380`) reads.
+ connected: bool,
+ server: GlueServer,
+ /// The current glue screen's name (`[0xb41478]`), which `SetCurrentScreen` (`0x46ce60` →
+ /// `0x46b860`) copies into a 0x40-byte buffer.
+ screen: String,
+}
+
+/// `SetCurrentScreen`'s buffer, 0x40 bytes with its terminator (`0x46b860`, `SStrCopy`).
+const SCREEN_NAME_MAX: usize = 0x3f;
+
+impl super::UiScript {
+ /// Drain the glue verbs' requests since the last call.
+ pub fn take_glue_requests(&mut self) -> Vec {
+ std::mem::take(&mut self.model_mut().glue.requests)
+ }
+
+ /// Seed the saved account name `GetSavedAccountName` answers.
+ pub fn set_glue_saved_account(&mut self, name: &str) {
+ self.model_mut().glue.saved_account = name.to_string();
+ }
+
+ /// Seed one agreement's flags.
+ pub fn set_glue_notice(&mut self, notice: GlueNotice, state: NoticeState) {
+ self.model_mut().glue.notices[notice.index()] = state;
+ }
+
+ /// One agreement's flags as the VM holds them.
+ pub fn glue_notice(&self, notice: GlueNotice) -> NoticeState {
+ self.model_ref().glue.notices[notice.index()]
+ }
+
+ /// Seed whether the world connection is up.
+ pub fn set_glue_connected(&mut self, connected: bool) {
+ self.model_mut().glue.connected = connected;
+ }
+
+ /// Seed the realm `GetServerName` answers.
+ pub fn set_glue_server(&mut self, server: GlueServer) {
+ self.model_mut().glue.server = server;
+ }
+
+ /// The current glue screen's name, as `SetCurrentScreen` last set it.
+ pub fn glue_screen(&self) -> String {
+ self.model_ref().glue.screen.clone()
+ }
+
+ /// A string global's text, `GetGlobalString`'s answer (`0x703bf0` with no gender and no
+ /// ordinal takes `0x704350`): the global's string (a number stringified), else the empty
+ /// string (`0x882748`).
+ pub fn global_text(&self, key: &str) -> String {
+ global_text(&self.lua, key)
+ }
+}
+
+fn queue(lua: &Lua, request: GlueRequest) {
+ lua.app_data_mut::()
+ .expect("model app_data")
+ .glue
+ .requests
+ .push(request);
+}
+
+/// The text of a string global, as [`super::UiScript::global_text`] reads it.
+fn global_text(lua: &Lua, key: &str) -> String {
+ match lua.globals().get::(key) {
+ Ok(v @ (Value::String(_) | Value::Integer(_) | Value::Number(_))) => lua
+ .coerce_string(v)
+ .ok()
+ .flatten()
+ .map(|s| s.to_string_lossy())
+ .unwrap_or_default(),
+ _ => String::new(),
+ }
+}
+
+pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
+ let g = lua.globals();
+
+ // Both arguments `is-number-or-string` (`0x46d168`, `0x46d178`), else the usage error
+ // (`0x837cbc`); then `CGlueMgr::Login(account, password)` (`0x46d1a0`).
+ g.set(
+ "DefaultServerLogin",
+ lua.create_function(|lua, (account, password): (Value, Value)| {
+ const USAGE: &str = "Usage: DefaultServerLogin(\"accountName\", \"password\")";
+ let ok =
+ |v: &Value| matches!(v, Value::String(_) | Value::Integer(_) | Value::Number(_));
+ if !ok(&account) || !ok(&password) {
+ return Err(mlua::Error::RuntimeError(USAGE.into()));
+ }
+ let account = string_arg(lua, account, USAGE)?;
+ let password = string_arg(lua, password, USAGE)?;
+ queue(
+ lua,
+ GlueRequest::Login(GlueCredentials { account, password }),
+ );
+ Ok(())
+ })?,
+ )?;
+
+ g.set(
+ "StatusDialogClick",
+ lua.create_function(|lua, _: MultiValue| {
+ queue(lua, GlueRequest::StatusDialogClick);
+ Ok(())
+ })?,
+ )?;
+
+ // The `accountName` CVar's string (`0x46cdd5`, `[cvar+0x20]`).
+ g.set(
+ "GetSavedAccountName",
+ lua.create_function(|lua, ()| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ lua.create_string(&model.glue.saved_account)
+ })?,
+ )?;
+ // A string argument (`0x46cdf8`) or `Usage: SetSavedAccountName("accountName")` (`0x837c28`),
+ // then `CVar::Set` (`0x46ce2e`), so the next `GetSavedAccountName` reads it back at once.
+ g.set(
+ "SetSavedAccountName",
+ lua.create_function(|lua, name: Value| {
+ let name = string_arg(lua, name, "Usage: SetSavedAccountName(\"accountName\")")?;
+ lua.app_data_mut::()
+ .expect("model app_data")
+ .glue
+ .saved_account = name.clone();
+ queue(lua, GlueRequest::SetSavedAccountName(name));
+ Ok(())
+ })?,
+ )?;
+
+ // Each agreement: `Show*Notice` and `*Accepted` push 1 or nil off the flag pair the glue
+ // manager seeded (`[0xb41df8]`/`[0xb41dfc]` for the TOS, `[0xb41e00]`/`[0xb41e04]` the EULA,
+ // `[0xb41e08]`/`[0xb41e0c]` scanning, `[0xb41e10]`/`[0xb41e14]` the contest); `Accept*` sets
+ // accepted, clears the notice and sets the CVar to "1" (`0x46aef0`, `0x46af20`, `0x46af50`,
+ // `0x46af80`).
+ for (notice, show, accepted, accept) in [
+ (GlueNotice::Tos, "ShowTOSNotice", "TOSAccepted", "AcceptTOS"),
+ (
+ GlueNotice::Eula,
+ "ShowEULANotice",
+ "EULAAccepted",
+ "AcceptEULA",
+ ),
+ (
+ GlueNotice::Scanning,
+ "ShowScanningNotice",
+ "ScanningAccepted",
+ "AcceptScanning",
+ ),
+ (
+ GlueNotice::Contest,
+ "ShowContestNotice",
+ "ContestAccepted",
+ "AcceptContest",
+ ),
+ ] {
+ let i = notice.index();
+ g.set(
+ show,
+ lua.create_function(move |lua, _: MultiValue| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ Ok(flag(model.glue.notices[i].show_notice))
+ })?,
+ )?;
+ g.set(
+ accepted,
+ lua.create_function(move |lua, _: MultiValue| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ Ok(flag(model.glue.notices[i].accepted))
+ })?,
+ )?;
+ g.set(
+ accept,
+ lua.create_function(move |lua, _: MultiValue| {
+ lua.app_data_mut::()
+ .expect("model app_data")
+ .glue
+ .notices[i] = NoticeState {
+ accepted: true,
+ show_notice: false,
+ };
+ queue(lua, GlueRequest::Accept(notice));
+ Ok(())
+ })?,
+ )?;
+ }
+
+ // Five strings (`0x46cd70`): the version type and build type through `GetGlobalString`
+ // (`BETA_BUILD` `0x837c1c`, `RELEASE_BUILD` `0x837c0c`), then the version, the build and the
+ // date as the binary spells them. The in-game binding (`0x4884a0`) has the last three only.
+ g.set(
+ "GetBuildInfo",
+ lua.create_function(|lua, _: MultiValue| {
+ Ok(MultiValue::from_vec(vec![
+ Value::String(lua.create_string(global_text(lua, "BETA_BUILD"))?),
+ Value::String(lua.create_string(global_text(lua, "RELEASE_BUILD"))?),
+ Value::String(lua.create_string(super::client::VERSION)?),
+ Value::String(lua.create_string(super::client::BUILD)?),
+ Value::String(lua.create_string(super::client::BUILD_DATE)?),
+ ]))
+ })?,
+ )?;
+
+ // The locale name table `0x8558a4` at the locale index `[0xc0e080]` (`0x46ce40`), the same
+ // answer as the in-game binding: benilla is enUS-only.
+ g.set(
+ "GetLocale",
+ lua.create_function(|lua, _: MultiValue| lua.create_string("enUS"))?,
+ )?;
+
+ // A string argument (`0x46ce68`) or `Usage: SetCurrentScreen("screen")` (`0x837c54`), copied
+ // into the 0x40-byte screen buffer (`0x46b860`).
+ g.set(
+ "SetCurrentScreen",
+ lua.create_function(|lua, name: Value| {
+ let mut name = string_arg(lua, name, "Usage: SetCurrentScreen(\"screen\")")?;
+ if name.len() > SCREEN_NAME_MAX {
+ let mut end = SCREEN_NAME_MAX;
+ while !name.is_char_boundary(end) {
+ end -= 1;
+ }
+ name.truncate(end);
+ }
+ lua.app_data_mut::()
+ .expect("model app_data")
+ .glue
+ .screen = name;
+ Ok(())
+ })?,
+ )?;
+
+ // Three values (`0x46d280`): the `realmName` CVar's string, always a string, then PvP and RP
+ // as 1 or nil.
+ g.set(
+ "GetServerName",
+ lua.create_function(|lua, _: MultiValue| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ let server = &model.glue.server;
+ Ok(MultiValue::from_vec(vec![
+ Value::String(lua.create_string(&server.name)?),
+ flag(server.pvp),
+ flag(server.rp),
+ ]))
+ })?,
+ )?;
+
+ g.set(
+ "IsConnectedToServer",
+ lua.create_function(|lua, _: MultiValue| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ Ok(flag(model.glue.connected))
+ })?,
+ )?;
+ g.set(
+ "DisconnectFromServer",
+ lua.create_function(|lua, _: MultiValue| {
+ queue(lua, GlueRequest::DisconnectFromServer);
+ Ok(())
+ })?,
+ )?;
+ g.set(
+ "QuitGame",
+ lua.create_function(|lua, _: MultiValue| {
+ queue(lua, GlueRequest::QuitGame);
+ Ok(())
+ })?,
+ )?;
+
+ // A string argument (`0x46ceb8`) or `Usage: PlayGlueMusic("filename")` (`0x837c78`).
+ g.set(
+ "PlayGlueMusic",
+ lua.create_function(|lua, file: Value| {
+ let file = string_arg(lua, file, "Usage: PlayGlueMusic(\"filename\")")?;
+ queue(lua, GlueRequest::PlayGlueMusic(file));
+ Ok(())
+ })?,
+ )?;
+ g.set(
+ "StopGlueMusic",
+ lua.create_function(|lua, _: MultiValue| {
+ queue(lua, GlueRequest::StopGlueMusic);
+ Ok(())
+ })?,
+ )?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::script::UiScript;
+
+ #[test]
+ fn a_notice_cvar_reads_as_atoi() {
+ let s = |v| NoticeState::from_cvar(v);
+ assert_eq!(s("0"), NoticeState::default());
+ assert!(s("1").accepted && !s("1").show_notice);
+ assert!(s("-1").show_notice && !s("-1").accepted);
+ assert!(!s("2").accepted && !s("2").show_notice);
+ assert!(s("1x").accepted, "atoi stops at the first non-digit");
+ assert_eq!(s("x"), NoticeState::default());
+ }
+
+ #[test]
+ fn default_server_login_queues_the_strings_or_raises_its_usage() {
+ let mut s = UiScript::new_glue().unwrap();
+ s.run("DefaultServerLogin('probe', 1234)").unwrap();
+ let reqs = s.take_glue_requests();
+ match reqs.as_slice() {
+ [GlueRequest::Login(c)] => {
+ assert_eq!((c.account.as_str(), c.password.as_str()), ("probe", "1234"));
+ assert!(
+ !format!("{c:?}").contains("1234"),
+ "the password is never printed"
+ );
+ }
+ other => panic!("{other:?}"),
+ }
+ let err = s
+ .run("DefaultServerLogin('probe')")
+ .unwrap_err()
+ .to_string();
+ assert!(
+ err.contains("Usage: DefaultServerLogin(\"accountName\", \"password\")"),
+ "{err}"
+ );
+ assert!(s.take_glue_requests().is_empty());
+ }
+
+ #[test]
+ fn the_agreements_answer_their_seeded_flags_and_accept_sets_them() {
+ let mut s = UiScript::new_glue().unwrap();
+ s.set_glue_notice(
+ GlueNotice::Eula,
+ NoticeState {
+ accepted: false,
+ show_notice: true,
+ },
+ );
+ s.set_glue_notice(GlueNotice::Tos, NoticeState::from_cvar("1"));
+ let read = |s: &UiScript| {
+ s.eval::<(Option, Option, Option, Option)>(
+ "return EULAAccepted(), ShowEULANotice(), TOSAccepted(), ScanningAccepted()",
+ )
+ .unwrap()
+ };
+ assert_eq!(read(&s), (None, Some(1), Some(1), None));
+ s.run("AcceptEULA()").unwrap();
+ assert_eq!(read(&s), (Some(1), None, Some(1), None));
+ assert!(matches!(
+ s.take_glue_requests().as_slice(),
+ [GlueRequest::Accept(GlueNotice::Eula)]
+ ));
+ }
+
+ #[test]
+ fn the_saved_account_reads_back_what_was_set() {
+ let mut s = UiScript::new_glue().unwrap();
+ s.set_glue_saved_account("PROBE");
+ assert_eq!(
+ s.eval::("return GetSavedAccountName()").unwrap(),
+ "PROBE"
+ );
+ s.run("SetSavedAccountName('')").unwrap();
+ assert_eq!(
+ s.eval::("return GetSavedAccountName()").unwrap(),
+ ""
+ );
+ assert!(matches!(
+ s.take_glue_requests().as_slice(),
+ [GlueRequest::SetSavedAccountName(n)] if n.is_empty()
+ ));
+ let err = s.run("SetSavedAccountName()").unwrap_err().to_string();
+ assert!(
+ err.contains("Usage: SetSavedAccountName(\"accountName\")"),
+ "{err}"
+ );
+ }
+
+ #[test]
+ fn the_glue_build_info_is_five_strings() {
+ let s = UiScript::new_glue().unwrap();
+ s.run("BETA_BUILD = 'Version' RELEASE_BUILD = 'Release'")
+ .unwrap();
+ assert_eq!(
+ s.eval::>("return { GetBuildInfo() }").unwrap(),
+ ["Version", "Release", "1.12.1", "5875", "Sep 19 2006"]
+ );
+ }
+
+ #[test]
+ fn the_server_name_is_always_a_string_with_two_flags() {
+ let mut s = UiScript::new_glue().unwrap();
+ assert_eq!(
+ s.eval::<(String, Option, Option)>("return GetServerName()")
+ .unwrap(),
+ (String::new(), None, None)
+ );
+ s.set_glue_server(GlueServer {
+ name: "Nostalrius".into(),
+ pvp: true,
+ rp: false,
+ });
+ assert_eq!(
+ s.eval::<(String, Option, Option)>("return GetServerName()")
+ .unwrap(),
+ ("Nostalrius".to_string(), Some(1), None)
+ );
+ }
+
+ #[test]
+ fn set_current_screen_keeps_the_name_in_its_buffer() {
+ let s = UiScript::new_glue().unwrap();
+ s.run("SetCurrentScreen('login')").unwrap();
+ assert_eq!(s.glue_screen(), "login");
+ s.run(&format!("SetCurrentScreen('{}')", "x".repeat(100)))
+ .unwrap();
+ assert_eq!(s.glue_screen().len(), 0x3f);
+ assert!(s.run("SetCurrentScreen()").is_err());
+ }
+}
diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs
index d82225ad..0ffe0327 100644
--- a/crates/benilla-ui/src/script/mod.rs
+++ b/crates/benilla-ui/src/script/mod.rs
@@ -65,7 +65,9 @@ pub(crate) mod font;
mod font_block;
mod frame_enum;
mod glue;
+mod glue_core;
pub use glue::{VmKind, GLUE_EVENTS};
+pub use glue_core::{GlueCredentials, GlueNotice, GlueRequest, GlueServer, NoticeState};
mod gm_ticket;
mod gossip;
mod guild;
diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs
index 38c441b0..168d23be 100644
--- a/crates/benilla-ui/src/script/model.rs
+++ b/crates/benilla-ui/src/script/model.rs
@@ -403,6 +403,8 @@ pub(crate) struct Model {
pub(crate) camera_view_requests: Vec,
/// `Logout`, `Quit`, `CancelLogout`, `ForceQuit` calls; Lua sees the countdown only as events.
pub(crate) session_requests: Vec,
+ /// The glue core's slots and request queue; empty in an in-game VM.
+ pub(crate) glue: super::glue_core::GlueCore,
/// `PLAYER_LOGIN`'s one-shot, the reference's `[0xb4e260]`: armed by the UI load, spent by
/// the next world-enter cascade ([`super::UiScript::fire_world_enter`]).
pub(crate) player_login_armed: bool,
@@ -1244,6 +1246,7 @@ impl Model {
follow_requests: Vec::new(),
camera_view_requests: Vec::new(),
session_requests: Vec::new(),
+ glue: Default::default(),
player_login_armed: false,
pvp_toggles: 0,
honor: None,
diff --git a/crates/benilla-ui/src/script/tests/glue_surface.rs b/crates/benilla-ui/src/script/tests/glue_surface.rs
index 59332379..d1eaf463 100644
--- a/crates/benilla-ui/src/script/tests/glue_surface.rs
+++ b/crates/benilla-ui/src/script/tests/glue_surface.rs
@@ -139,19 +139,12 @@ const NOT_YET: &[(&str, &[&str])] = &[
&["ReadFile", "DeleteFile", "AppendToFile"],
),
(
- "the glue core (0x46ddf0, table 0x8373b8): login, the notices, the addon list, the cursor \
- and the glue music; each a binding of its own, the 14 with an in-game namesake included",
+ "the glue core (0x46ddf0, table 0x8373b8): the addon list, the movie, the patch and survey, \
+ the billing, the cursor, the URLs and the credits music; each a binding of its own, the \
+ ones with an in-game namesake included",
&[
- "AcceptContest",
- "AcceptEULA",
- "AcceptScanning",
- "AcceptTOS",
- "ContestAccepted",
- "DefaultServerLogin",
"DisableAddOn",
"DisableAllAddOns",
- "DisconnectFromServer",
- "EULAAccepted",
"EnableAddOn",
"EnableAllAddOns",
"EnterWorld",
@@ -161,17 +154,12 @@ const NOT_YET: &[(&str, &[&str])] = &[
"GetBillingPlan",
"GetBillingTimeRemaining",
"GetBillingTimeRested",
- "GetBuildInfo",
- "GetLocale",
"GetMovieResolution",
"GetMovieSubtitles",
"GetNumAddOns",
- "GetSavedAccountName",
"GetScriptMemory",
- "GetServerName",
"HideCursor",
"IsAddonVersionCheckEnabled",
- "IsConnectedToServer",
"LaunchAddOnURL",
"LaunchURL",
"PINEntered",
@@ -179,26 +167,14 @@ const NOT_YET: &[(&str, &[&str])] = &[
"PatchDownloadCancel",
"PatchDownloadProgress",
"PlayCreditsMusic",
- "PlayGlueMusic",
- "QuitGame",
"ResetAddOns",
"SaveAddOns",
- "ScanningAccepted",
"Screenshot",
"SetAddonVersionCheck",
- "SetCurrentScreen",
"SetMovieSubtitles",
- "SetSavedAccountName",
"SetScriptMemory",
- "ShowContestNotice",
"ShowCursor",
- "ShowEULANotice",
- "ShowScanningNotice",
- "ShowTOSNotice",
- "StatusDialogClick",
- "StopGlueMusic",
"SurveyNotificationDone",
- "TOSAccepted",
],
),
(
diff --git a/docs/MAP.md b/docs/MAP.md
index 20b4acc1..13d511cc 100644
--- a/docs/MAP.md
+++ b/docs/MAP.md
@@ -51,6 +51,7 @@
- ProbeFocusPlugin
- ProbeChatPlugin
- ProbeLuaPlugin
+- ProbeGlueLuaPlugin
- ProbeDragPlugin
- ProbeHoverPlugin
- ProbeKeyPlugin
@@ -413,6 +414,10 @@
- `WOW_PROBE_DRAG_LUA` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_DRAG_STEP` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_EXIT_AT` — benilla-app/src/capture/probes/run.rs, benilla-app/src/dev.rs
+- `WOW_PROBE_GLUE_LUA` — benilla-app/src/capture/probes/act.rs, benilla-app/src/dev.rs
+- `WOW_PROBE_GLUE_LUA_AT` — benilla-app/src/capture/probes/act.rs
+- `WOW_PROBE_GLUE_SHOT` — benilla-app/src/capture/probes/act.rs
+- `WOW_PROBE_GLUE_SHOT_AFTER` — benilla-app/src/capture/probes/act.rs
- `WOW_PROBE_GMTICKET` — benilla-app/src/dev.rs
- `WOW_PROBE_GOQUEST` — benilla-app/src/capture/probe_goquest.rs, benilla-app/src/dev.rs
- `WOW_PROBE_HOVER` — benilla-app/src/capture/probes/act.rs, benilla-app/src/dev.rs