The stock login screen logs in; realmd 0x0E is a success (#970)

Under `WOW_GLUE_STOCK=1` the stock login screen now logs in. Separately, realmd's 0x0E answer was refused by the native login too.

In 1.12.1 the glue manager runs the screen states (`0xb41da0`): `DefaultServerLogin` (`0x46afb0`) needs the agreements accepted and opens the status dialog; each tick re-texts it from the logon's state and result (`0x46b140`, tables `0x836b40`, `0x836b78`); the world connect re-texts it every frame (`0x46be8b`). The agreements seed from `readTOS`/`readEULA` (`0x46a400`). Realmd 0x0E is a success (`0x5b2cd4`); a proof's 0x0A changes nothing (`0x5bada2`).

The glue core verbs, the glue manager over the native login, the agreement CVars, `SimpleHTML file=`, case-insensitive loose files and a benilla Realmlist layer file are built; the protocol accepts 0x0E. A recorded `Deviation:` keeps the interface signature checks off.

Tests: fake-realmd refusals and 0x0E; stock-screen login reaching the native submit.

Part of #856

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 10:28:08 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 133414f5c1
commit 45b7914c9e
39 changed files with 3008 additions and 136 deletions
@@ -0,0 +1,115 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!-- benilla's realmlist control on the stock login screen: a button over the Cinematics one,
the address the next login dials beside it, and the stock GlueDialog with its edit box to
change it. The address is the realmList CVar, written through ConsoleExec; the host keeps
BENILLA_REALMLIST and BENILLA_REALMLIST_PINNED (set while $WOW_HOST fixes the address),
and reopens the dialog with the typed text when it is not an address. -->
<Script>
GlueDialogTypes["BENILLA_REALMLIST"] = {
-- The realmList CVar's registered help text.
text = "Address of realm list server",
button1 = TEXT(OKAY),
button2 = TEXT(CANCEL),
hasEditBox = 1,
maxLetters = 64,
OnShow = function()
BenillaRealmlist_TakeEditBox();
GlueDialogEditBox:SetText(GlueDialog.data or BENILLA_REALMLIST or "");
GlueDialogEditBox:HighlightText();
GlueDialogEditBox:SetFocus();
end,
OnAccept = function()
local typed = GlueDialogEditBox:GetText();
BenillaRealmlist_ReleaseEditBox();
if ( typed and typed ~= "" ) then
ConsoleExec("realmList "..typed);
end
end,
OnCancel = function()
BenillaRealmlist_ReleaseEditBox();
end,
};
GlueDialogTypes["BENILLA_REALMLIST_PINNED"] = {
text = "",
button1 = TEXT(OKAY),
button2 = nil,
};
-- The stock edit box's Enter and Escape call the in-game StaticPopup functions, which the
-- glue does not have: while this dialog owns it, they press its buttons, and it is wide
-- enough for a host name.
function BenillaRealmlist_TakeEditBox()
if ( not BenillaRealmlist_Saved ) then
BenillaRealmlist_Saved = {
width = GlueDialogEditBox:GetWidth(),
enter = GlueDialogEditBox:GetScript("OnEnterPressed"),
escape = GlueDialogEditBox:GetScript("OnEscapePressed"),
};
end
GlueDialogEditBox:SetWidth(300);
GlueDialogEditBox:SetScript("OnEnterPressed", function() GlueDialogButton1:Click(); end);
GlueDialogEditBox:SetScript("OnEscapePressed", function() GlueDialogButton2:Click(); end);
end
function BenillaRealmlist_ReleaseEditBox()
local saved = BenillaRealmlist_Saved;
if ( saved ) then
GlueDialogEditBox:SetWidth(saved.width);
GlueDialogEditBox:SetScript("OnEnterPressed", saved.enter);
GlueDialogEditBox:SetScript("OnEscapePressed", saved.escape);
BenillaRealmlist_Saved = nil;
end
GlueDialogEditBox:ClearFocus();
end
function BenillaRealmlist_OnClick()
if ( GlueDialog:IsVisible() ) then
return;
end
PlaySound("gsLoginNewAccount");
if ( BENILLA_REALMLIST_PINNED ) then
GlueDialog_Show("BENILLA_REALMLIST_PINNED", format(
"$WOW_HOST is set for this session, so the realmlist is fixed at %s.",
BENILLA_REALMLIST or ""));
else
GlueDialog_Show("BENILLA_REALMLIST", nil, BENILLA_REALMLIST);
end
end
</Script>
<Button name="BenillaRealmlistButton" inherits="GlueButtonSmallTemplate" parent="AccountLoginUI" text="Realmlist">
<Anchors>
<Anchor point="BOTTOM" relativeTo="AccountLoginCinematicsButton" relativePoint="TOP">
<Offset>
<AbsDimension x="0" y="-2"/>
</Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString name="BenillaRealmlistText" inherits="GlueFontDisableSmall" justifyH="RIGHT">
<Size>
<AbsDimension x="256" y="0"/>
</Size>
<Anchors>
<Anchor point="RIGHT" relativePoint="LEFT">
<Offset>
<AbsDimension x="-4" y="3"/>
</Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnClick>
BenillaRealmlist_OnClick();
</OnClick>
<OnUpdate>
if ( BenillaRealmlistText:GetText() ~= BENILLA_REALMLIST ) then
BenillaRealmlistText:SetText(BENILLA_REALMLIST);
end
</OnUpdate>
</Scripts>
</Button>
</Ui>
+2 -2
View File
@@ -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;
@@ -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: "\"<chunk>\" — 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: "<secs> — when the glue chunk runs (default 10)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_GLUE_SHOT",
purpose: "<path> — one PNG after the glue chunk runs",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_GLUE_SHOT_AFTER",
purpose: "<secs> — how long after the glue chunk the shot is taken (default 1)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_LUA_AT",
purpose: "<secs> — when the chunk runs after the first world entry (default 10)",
@@ -365,6 +365,97 @@ fn fire_probe_lua(
}
}
/// `WOW_PROBE_GLUE_LUA="<chunk>"`: 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=<path>` 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<String>,
shot_after: f32,
ran_at: Option<f32>,
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 '<token>'`, 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<ProbeGlueLua>,
time: ProbeClock,
script: Option<NonSend<benilla_ui::script::UiScript>>,
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}")
);
}
}
}
+2 -1
View File
@@ -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.
+9
View File
@@ -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"),
];
+4
View File
@@ -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);
+87 -6
View File
@@ -53,6 +53,7 @@ impl Plugin for LoginPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LoginIntent>()
.init_resource::<LoginForm>()
.add_message::<GlueLogin>()
.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<GlueLogin>,
mut attempt: Attempt,
pick: Option<Res<crate::net::CharPick>>,
state: Res<State<ClientState>>,
time: Res<Time>,
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<Res<QuitArm>>, time: Res<Time>, 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);
}
+48 -35
View File
@@ -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<i64>) {
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<i64>) -> 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);
+18 -8
View File
@@ -366,9 +366,14 @@ fn run(
if let Some(dial) = e.downcast_ref::<benilla_protocol::DialFailure>() {
return fail_dial(dial.clone(), format!("{e:#}"));
}
let refusal = e
.downcast_ref::<AuthReject>()
.map(|r| benilla_protocol::LoginRefusal::Logon(r.code));
let refusal = e.downcast_ref::<AuthReject>().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<u32>| {
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(
@@ -274,6 +274,10 @@ pub(crate) fn take_input(mut realms: ResMut<Realms>) {
/// 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::<Realms>()
@@ -304,6 +308,7 @@ impl Plugin for RealmSelectPlugin {
.chain(),
)
.chain()
.in_set(RealmSelectSet)
.after(benilla_world::schedule::WorldStage::Net)
.before(crate::glue::GlueVisuals),
);
+17 -1
View File
@@ -21,7 +21,10 @@ pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut realmlist: ResMut<R
}
match normalize(&ev.new) {
Some(address) => 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<String>,
}
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<String> {
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");
+121 -4
View File
@@ -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<mixer::StreamingSoundHandle<kira::sound::FromFileError>>,
/// 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<GlueMusicRequest>,
mut music: NonSendMut<GlueMusic>,
mut out: NonSendMut<SoundOutput>,
assets: Option<Res<WorldAssets>>,
config: Res<SoundConfig>,
) {
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<GlueMusic>, mut out: NonSendMut<super::So
pub(super) fn plugin(app: &mut App) {
app.add_message::<GlueSound>()
.add_message::<GlueMusicRequest>()
.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);
}
}
+1 -1
View File
@@ -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)]
+40 -7
View File
@@ -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<Res<GlueStock>>) -> 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<Res<GlueStock>>) -> bool {
!stock.is_some_and(|s| s.0)
@@ -112,6 +119,9 @@ pub(super) fn load_glue(script: &mut UiScript) -> Vec<String> {
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<String> {
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<String> {
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 `<H4>`,
/// 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, `<frame>: SetScript("<handler>") 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"))
}) {
@@ -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<String>,
}
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<Text> {
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<Text>,
data: Option<String>,
},
/// `UPDATE_STATUS_DIALOG(text[, button])` (event 4).
Update { text: Text, button: Option<Text> },
/// 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<benilla_protocol::RealmInfo>,
/// State 2's operation.
op: Operation,
queue: QueueEstimate,
queue_realm: Option<String>,
/// The current glue screen's name, as the VM's `SetCurrentScreen` last set it.
screen: String,
outbox: Vec<Outgoing>,
/// The glue VM this manager last served, by session.
vm: Option<u64>,
}
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<Text>) {
self.outbox.push(Outgoing::Open {
which,
text,
data: None,
});
}
/// `CGlueMgr::Login` (`0x46afb0`).
fn login(&mut self, account: String, password: String, asks: &mut Vec<GlueLogin>) {
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<GlueLogin>) {
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<u32>, realm: Option<String>, 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<benilla_protocol::RealmInfo>) {
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<u8>) {
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<GlueMgr>,
script: Option<NonSendMut<UiScript>>,
mut news: LoginNews,
mut asks: GlueAsks,
realms: Option<Res<crate::realm_select::Realms>>,
mut realmlist: Option<ResMut<crate::realmlist::Realmlist>>,
state: Option<Res<State<crate::char_select::ClientState>>>,
time: Res<Time>,
) {
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<Vec<String>> = 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::<Option<String>>("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::<Option<i64>>("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;
@@ -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<LoginRefusal>, 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<LoginRequest>,
Arc<AtomicU64>,
) {
let (tx, rx) = crossbeam_channel::unbounded();
let abandon = Arc::new(AtomicU64::new(0));
let mut app = App::new();
app.init_resource::<Time>()
.init_resource::<GlueMgr>()
.init_resource::<crate::login::LoginIntent>()
.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::<NextState<ClientState>>()
.add_message::<LoginStageMessage>()
.add_message::<LoginQueuedMessage>()
.add_message::<LoginFailedMessage>()
.add_message::<CharListMessage>()
.add_message::<RealmListMessage>()
.add_message::<DisconnectedMessage>()
.add_message::<GlueLogin>()
.add_message::<crate::sound::GlueMusicRequest>()
.add_message::<AppExit>()
.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::<UiScript>()
}
/// 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::<GlueMgr>().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::<GlueMgr>().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::<GlueMgr>().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::<UiScript>();
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::<UiScript>()
.tick(0.05);
assert_eq!(
vm(&app)
.eval::<String>("return BenillaRealmlistText:GetText()")
.unwrap(),
crate::realmlist::DEFAULT_REALMLIST,
"the readout shows the address the next login dials"
);
assert_eq!(
vm(&app)
.eval::<String>("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::<UiScript>()
.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());
}
+8 -1
View File
@@ -167,6 +167,11 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
/// 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<String> {
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<String> = super::super::content::shipped_files()
.filter(|f| f.ends_with(".xml"))
.map(str::to_owned)
+3
View File
@@ -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::<glue_mgr::GlueMgr>()
.init_resource::<UiAutoScaleCache>()
.init_resource::<UiFrameCost>()
.init_resource::<crate::bindings::WheelNotches>()
@@ -102,6 +102,9 @@ pub(super) fn read(req: &str) -> Option<Vec<u8>> {
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<Vec<u8>> {
}
}
/// 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 (`<SimpleHTML file="eula.html">`) 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<Vec<u8>> {
if req.is_empty() || req.contains(['\\', '/']) || req.contains("..") {
return None;
}
static INDEX: OnceLock<std::collections::HashMap<String, std::path::PathBuf>> = 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<std::path::PathBuf> =
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<String, std::path::PathBuf> {
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`,
@@ -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<String> {
let mut names: Vec<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");
@@ -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<String> = 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();
@@ -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),
},
@@ -52,7 +52,9 @@ fn attempt(host: &str, port: u16, user: &str, pass: &str, arm: Arm) -> Result<Op
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
auth::write_logon_challenge(&mut stream, &user.to_uppercase(), CLIENT_BUILD)
.context("sending logon challenge")?;
let reply = auth::read_challenge_reply(&mut stream).context("reading challenge reply")?;
let reply = auth::read_challenge_reply(&mut stream)
.context("reading challenge reply")?
.ok_or_else(|| anyhow!("SUCCESS_SURVEY on the challenge: no SRP inputs"))?;
let b = PublicKey::from_le_bytes(reply.server_public_key)
.map_err(|e| anyhow!("invalid server public key: {e}"))?;
if !b.is_width_stable() {
@@ -182,7 +184,9 @@ fn main() -> 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];
+42 -9
View File
@@ -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<ChallengeReply> {
/// 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<Option<ChallengeReply>> {
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<ChallengeReply> {
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<Option<[u8; 20]>> {
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).
+7 -2
View File
@@ -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,
}
}
}
+24 -10
View File
@@ -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<Logon> {
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<Logon> {
)
.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<Logon> {
auth::write_realm_list_request(&mut stream).context("requesting realm list")?;
let realms = auth::read_realm_list(&mut stream).context("reading realm list")?;
+1 -1
View File
@@ -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.
+17 -3
View File
@@ -73,6 +73,16 @@ pub struct WorldSession {
addon_info: Option<Vec<u8>>,
}
/// 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<u32>),
}
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<u32>) -> bool,
on_event: &mut dyn FnMut(WorldConnectEvent) -> bool,
) -> Result<Self> {
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");
}
}
+85 -2
View File
@@ -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<u8>, proof: Option<Vec<u8>>, 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::<benilla_protocol::AuthReject>()
.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)
);
}
+6
View File
@@ -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,
+38
View File
@@ -1957,6 +1957,44 @@ mod loader_tests {
// ── <SimpleHTML> ─────────────────────────────────────────────────────────────────────────
/// `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#"<Ui>
<Font name="DocFont" font="Fonts\FRIZQT__.TTF"><FontHeight val="12"/></Font>
<SimpleHTML name="Doc" file="doc.html">
<Size><AbsDimension x="270" y="304"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<FontString inherits="DocFont"/>
</SimpleHTML>
<SimpleHTML name="Gone" file="gone.html">
<Size><AbsDimension x="270" y="304"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<FontString inherits="DocFont"/>
</SimpleHTML>
</Ui>"#,
);
let files = |req: &str| -> Option<Vec<u8>> {
(req == "doc.html")
.then(|| b"\xef\xbb\xbf<html><body><p>Hello</p></body></html>".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::<i64>("return Gone:GetNumRegions()").unwrap(), 0);
}
/// A direct-child `<FontString>` declares `elementFont[0]` and `<FontStringHeader1>`
/// `elementFont[1]`, neither a region, and `hyperlinkFormat=` lands at `+0x360`; stock
/// `ItemTextFrame.xml:214` uses the first.
+7 -3
View File
@@ -434,7 +434,9 @@ impl Loader<'_> {
/// `<SimpleHTML>` (`0x78a130`): `font=` for all four element fonts (`0x78a17a`), the
/// `<FontString>` and `<FontStringHeader1..3>` 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);
}
}
}
+3 -3
View File
@@ -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();
+1 -1
View File
@@ -115,5 +115,5 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(model.cursor_pos)
})?,
)?;
Ok(())
super::glue_core::install(lua)
}
+549
View File
@@ -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::<i64>().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", &"<redacted>")
.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<GlueRequest>,
/// 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<GlueRequest> {
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::<Model>()
.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::<Value>(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::<Model>().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::<Model>()
.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::<Model>().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::<Model>().expect("model app_data");
Ok(flag(model.glue.notices[i].accepted))
})?,
)?;
g.set(
accept,
lua.create_function(move |lua, _: MultiValue| {
lua.app_data_mut::<Model>()
.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::<Model>()
.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::<Model>().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::<Model>().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<i64>, Option<i64>, Option<i64>, Option<i64>)>(
"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::<String>("return GetSavedAccountName()").unwrap(),
"PROBE"
);
s.run("SetSavedAccountName('')").unwrap();
assert_eq!(
s.eval::<String>("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::<Vec<String>>("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<i64>, Option<i64>)>("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<i64>, Option<i64>)>("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());
}
}
+2
View File
@@ -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;
+3
View File
@@ -403,6 +403,8 @@ pub(crate) struct Model {
pub(crate) camera_view_requests: Vec<camera_view::CameraViewRequest>,
/// `Logout`, `Quit`, `CancelLogout`, `ForceQuit` calls; Lua sees the countdown only as events.
pub(crate) session_requests: Vec<session::SessionRequest>,
/// 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,
@@ -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",
],
),
(
+5
View File
@@ -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