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:
co-authored by
Claude Opus 5.5
parent
133414f5c1
commit
45b7914c9e
@@ -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>
|
||||
@@ -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}")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -115,5 +115,5 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok(model.cursor_pos)
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
super::glue_core::install(lua)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user