The stock login's Breaking News box (`WOW_GLUE_STOCK=1`): - At glue init, as `0x46a400` does, the client reads the Lua global `SERVER_ALERT_URL`, which the player's own GlueStrings or a server's patch sets. It fetches the URL once on a background thread: plain HTTP, 5 s timeouts, redirects followed. - The body builds the reference's buffer (`0x46a580`: 0x7ff cap, appended chunks). A case-insensitive `SERVERALERT:` prefix fires `SHOW_SERVER_ALERT` once from the update (`0x46b939`). Every failure is silent. - `LaunchURL` opens the browser only for URLs that pass the reference's gate (`0x5abc10`): `http://`, a strict charset and the 10-host whitelist. An over-long host is refused rather than cut. - No fetch or browser ever runs in a test, capture or unattended run. Tests run against a local fake server: prefix, 404, redirects, chunked, timeouts, caps, and the frame shown end to end. Part of #1026 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1568 lines
65 KiB
Rust
1568 lines
65 KiB
Rust
//! 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`), states 2 and 4 complete the operation `RESPONSE_CANCELLED`.
|
|
//!
|
|
//! The realm list, the same machine's other half:
|
|
//!
|
|
//! 1. The connect's first act is a realm list query (`0x5aab20`; after the logon the logon's own
|
|
//! list is the reply). On the reply (`0x5ab800`) a realm already selected this process
|
|
//! (`[0xc28134]`, the record copied to `0xc27fb8`) is dialled; else the `realmName` CVar names
|
|
//! one, first match by name, case folded (`0x5ab6f0`); else the connect fails
|
|
//! `REALM_LIST_REALM_NOT_FOUND`. A query with no answer fails it `REALM_LIST_FAILED`.
|
|
//! 2. State 2's failure arm, for any status but `AUTH_FAILED`, raises the list over the screen
|
|
//! (`0x46bf1f`-`0x46bf96`): `CLOSE_STATUS_DIALOG`, which takes the failure's `OKAY` back down,
|
|
//! then the rebuild (`0x46e510`), whose tail fires `GET_PREFERRED_REALM_INFO` (`GlueParent`'s
|
|
//! `SetGlueScreen("realmwizard")`) for an account with no characters that has chosen no realm
|
|
//! this session, else `OPEN_REALM_LIST`. With no realm held it asks for the list instead
|
|
//! (`0x46c06e`). `RESPONSE_CANCELLED` shows no dialog and drops the world socket alone
|
|
//! (`0x46befd`), so the realmd link stays up for the next pick.
|
|
//! 3. `RequestRealmList` (`0x46b8d0`) is state 4 and a query, with the `CANCEL` dialog
|
|
//! `REALM_LIST_IN_PROGRESS` when asked. State 4's frame (`0x46c0a5`) re-texts the dialog; a
|
|
//! success goes to 0, closes the dialog and rebuilds (`0x46c0d2`-`0x46c0e2`), which is how the
|
|
//! stock list's five-second refresh redraws; a failure opens `OKAY` and goes to 0.
|
|
//! 4. `ChangeRealm` reaches `ConnectToRealm` (`0x46b210`): the realm selected, state 2, the
|
|
//! `CANCEL` dialog `GAME_SERVER_LOGIN`, and the connect. `RealmListDialogCancelled` closes the
|
|
//! realmd link on the login screen only; `CancelRealmListQuery` cancels state 4 only.
|
|
//!
|
|
//! The list itself, its categories, sort and the verbs that read it are the glue VM's
|
|
//! ([`benilla_ui::script::GlueRealm`]); the manager fills its holder and runs its rebuild where
|
|
//! `CGlueMgr::Update` does. `SUGGEST_REALM_WRONG_PVP` and `SUGGEST_REALM_WRONG_CATEGORY` are in the
|
|
//! event table (`0xb41ebc`, `0xb41ec0`) but no glue code fires them: no `SignalEvent`
|
|
//! (`0x703e50`, `0x703f50`) site in `[0x46a400, 0x476020)` passes 19 or 20.
|
|
//!
|
|
//! The character list, the other half of the character screen's show:
|
|
//!
|
|
//! 1. The connect's success tears the roster down (`0x46c014`: `UPDATE_SELECTED_CHARACTER` with 1,
|
|
//! `CHARACTER_LIST_UPDATE`) and puts `charselect` up, whose `OnShow` calls
|
|
//! `GetCharacterListUpdate` (`0x4723d0`). The glue VM clears its records in the call; the tail
|
|
//! (`0x46b880`) is state 3, the `CHAR_LIST_RETRIEVING` dialog and the query (`0x5aabb0`), which
|
|
//! is a `CMSG_CHAR_ENUM` on the parked world session.
|
|
//! 2. The world connect completes with the authentication response (`0x5ab2b8` → `0x5aab80`),
|
|
//! not with a roster; the IO thread fetches none of its own under the stock screen, so every
|
|
//! call sends, in order with the realm requests on the one park channel. A reply from the
|
|
//! current link (the world session's epoch) to that request completes the query, as
|
|
//! `SetStatus` does (`0x5aa920`), so one that lands before the tick that takes a Cancel turns
|
|
//! it into a load. A roster of another cause or link, or one with no query pending, is dropped
|
|
//! ([`GlueMgr::on_roster`]).
|
|
//! 3. State 3's frame (`0x46c03a`) re-texts the dialog and acts on the completed query: success
|
|
//! closes the dialog and loads the roster (`0x4724d0`: the records, `lastCharacterIndex`
|
|
//! selected, `CHARACTER_LIST_UPDATE`); an empty roster with no realm chosen asks for the realm
|
|
//! list instead and fires no update. A cancel (the dialog's button, `0x46b754`) tears the
|
|
//! roster down and raises the realm list; another failure opens `OKAY`. The roster is torn
|
|
//! down on the world connect's failure arm too (`0x46bf1f`).
|
|
//!
|
|
//! 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::{Character, LoginRefusal, LoginStage, RealmInfo, RosterCause};
|
|
use benilla_ui::script::{
|
|
GlueCharacter, GlueNotice, GlueRealm, GlueRealmCells, GlueRequest, GlueSelectCells, GlueServer,
|
|
NoticeState, ScriptValue, UiScript, VmKind,
|
|
};
|
|
|
|
use crate::login::queue::QueueEstimate;
|
|
use crate::login::GlueLogin;
|
|
use crate::net::{
|
|
CharListMessage, CharRequest, DisconnectedMessage, LoginFailedMessage, LoginQueuedMessage,
|
|
LoginStageMessage, ParkRequest, RealmListMessage, RealmRequest, RosterFailedMessage,
|
|
WorldAuthMessage,
|
|
};
|
|
|
|
/// `CGlueMgr`'s `screenState` (`0xb41da0`), named by the functions that set each value: the
|
|
/// states the login, the realm list and the character list reach. The reference's others are
|
|
/// 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 character list query (`0x46b880`, from `GetCharacterListUpdate`).
|
|
CharList = 3,
|
|
/// A realm list query (`RequestRealmList`, `0x46b8d2`).
|
|
RealmList = 4,
|
|
}
|
|
|
|
/// 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_CANCELLED: u8 = 0x02;
|
|
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;
|
|
const STATUS_REALM_LIST_IN_PROGRESS: u8 = 0x22;
|
|
const STATUS_REALM_LIST_SUCCESS: u8 = 0x23;
|
|
const STATUS_REALM_LIST_FAILED: u8 = 0x24;
|
|
const STATUS_REALM_LIST_REALM_NOT_FOUND: u8 = 0x26;
|
|
const STATUS_CHAR_LIST_RETRIEVING: u8 = 0x2a;
|
|
const STATUS_CHAR_LIST_RETRIEVED: u8 = 0x2b;
|
|
const STATUS_CHAR_LIST_FAILED: u8 = 0x2c;
|
|
|
|
/// 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 (`[svc+0x1b10]`).
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
enum OpKind {
|
|
/// `COP_CONNECT` (2, `0x5aab20`).
|
|
#[default]
|
|
Connect,
|
|
/// The authentication (3, `0x5aaba0`).
|
|
Auth,
|
|
/// `COP_GET_REALMS` (9, `0x5ab840`).
|
|
GetRealms,
|
|
/// The character list (6, `0x5aabb0`).
|
|
CharList,
|
|
}
|
|
|
|
/// 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>,
|
|
/// A connect or a query whose realm list reply has not come (`0x5abb90` dispatches the reply
|
|
/// on the operation pending).
|
|
awaiting_list: bool,
|
|
}
|
|
|
|
impl Operation {
|
|
fn begin(kind: OpKind, status: u8) -> Self {
|
|
Operation {
|
|
kind,
|
|
status,
|
|
awaiting_list: matches!(kind, OpKind::Connect | OpKind::GetRealms),
|
|
..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,
|
|
/// `CLOSE_STATUS_DIALOG` (event 5).
|
|
Close,
|
|
/// The realm list rebuild (`0x46e510`) over the holder, which fires `OPEN_REALM_LIST` or
|
|
/// `GET_PREFERRED_REALM_INFO`.
|
|
Rebuild,
|
|
/// The roster teardown (`0x472580`): `UPDATE_SELECTED_CHARACTER` with 1, then
|
|
/// `CHARACTER_LIST_UPDATE`.
|
|
Teardown,
|
|
/// The roster load (`0x4724d0`) over the roster the last reply left.
|
|
Roster,
|
|
/// `SHOW_SERVER_ALERT(text)` (event 21, `0x46b964`): the alert's text after its prefix.
|
|
ServerAlert(String),
|
|
}
|
|
|
|
/// 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>,
|
|
/// The realm holder (`[0xc28118]`): the realms the last list reply carried.
|
|
holder: Vec<RealmInfo>,
|
|
/// The holder changed since the VM was last seeded.
|
|
holder_moved: bool,
|
|
/// `[0xc28134]` and its copy at `0xc27fb8`: the realm the next connect dials, set by the first
|
|
/// selection and kept for the process.
|
|
selected: Option<RealmInfo>,
|
|
/// `[svc+0x1ae4]`: the last roster's character count.
|
|
characters: u16,
|
|
/// The roster the last character list reply left in `ClientServices` (`[svc+0x1ae8]`), which
|
|
/// the roster load (`0x4724d0`) walks.
|
|
roster: Vec<Character>,
|
|
/// The number of the world session the link is (`WorldAuthMessage::epoch`): the roster
|
|
/// replies of any other are not this link's.
|
|
link: u64,
|
|
/// The select verbs' cells, carried from one glue VM to the next.
|
|
select_cells: GlueSelectCells,
|
|
/// The realm list's sort and selection cells, carried from one glue VM to the next.
|
|
realm_cells: GlueRealmCells,
|
|
/// The manager moved [`Self::realm_cells`], for the next seed.
|
|
cells_moved: bool,
|
|
/// Realm and character requests for the IO thread in the order they were made, sent at the end
|
|
/// of the frame on its one ordered channel.
|
|
park_out: Vec<ParkRequest>,
|
|
/// Asks of the login engine the per-frame arms made, sent with the verbs' own.
|
|
login_out: Vec<GlueLogin>,
|
|
/// The server alert's fetch while it runs (`alertPending`, `[0xb41e28]`), started once per
|
|
/// process at the glue init.
|
|
alert_fetch: Option<crossbeam_channel::Receiver<Option<Vec<u8>>>>,
|
|
/// The text a finished fetch left for the next frame (`alertReady`, `[0xb41e2c]`, and the
|
|
/// buffer `0xb414f8` past its prefix).
|
|
alert_ready: Option<String>,
|
|
}
|
|
|
|
impl GlueMgr {
|
|
/// The record the last character list reply carried for `guid`: what the scene drive builds
|
|
/// the selected character from (`0x472950` reads the select record's own copy).
|
|
pub(crate) fn roster_character(&self, guid: u64) -> Option<&Character> {
|
|
self.roster.iter().find(|c| c.guid == guid)
|
|
}
|
|
|
|
/// The alert fetch the glue init began (`0x46a564`).
|
|
pub(super) fn set_alert_fetch(&mut self, fetch: crossbeam_channel::Receiver<Option<Vec<u8>>>) {
|
|
self.alert_fetch = Some(fetch);
|
|
}
|
|
|
|
/// A finished fetch's alert becomes ready (`0x46a5f1`); one that found none, or died, ends the
|
|
/// wait (`0x46a5fb`).
|
|
fn poll_alert(&mut self) {
|
|
let Some(fetch) = &self.alert_fetch else {
|
|
return;
|
|
};
|
|
match fetch.try_recv() {
|
|
Ok(alert) => {
|
|
self.alert_fetch = None;
|
|
self.alert_ready = alert.map(|text| String::from_utf8_lossy(&text).into_owned());
|
|
}
|
|
Err(crossbeam_channel::TryRecvError::Empty) => {}
|
|
Err(crossbeam_channel::TryRecvError::Disconnected) => self.alert_fetch = None,
|
|
}
|
|
}
|
|
|
|
/// The top of `CGlueMgr::Update` (`0x46b939`-`0x46b96c`): a ready alert first runs the status
|
|
/// dialog's button (`0x46b947` → `0x46b720`, the screen state's cancel), fires
|
|
/// `SHOW_SERVER_ALERT` with the text, and is spent.
|
|
fn show_alert(&mut self, asks: &mut Vec<GlueLogin>) {
|
|
if let Some(text) = self.alert_ready.take() {
|
|
self.status_dialog_click(asks);
|
|
self.outbox.push(Outgoing::ServerAlert(text));
|
|
}
|
|
}
|
|
|
|
/// The agreements as read at the startup (`0x46a400`).
|
|
pub(super) fn set_notices(&mut self, notices: [NoticeState; 4]) {
|
|
self.notices = Some(notices);
|
|
}
|
|
|
|
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`, jump table `0x46b7a8`).
|
|
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` (states 2 and 3): the operation completes `RESPONSE_CANCELLED` and the
|
|
// roster is torn down (`0x472580`). A world connect's next tick takes the failure arm
|
|
// without a dialog (`0x46bef2`), dropping the world socket and raising the realm
|
|
// list; a character list's asks for the list again (`0x46c069`).
|
|
ScreenState::Connect | ScreenState::CharList => {
|
|
self.op.complete(false, STATUS_CANCELLED);
|
|
self.outbox.push(Outgoing::Teardown);
|
|
}
|
|
// `0x46b776`: the query cancelled, state 0, nothing sent.
|
|
ScreenState::RealmList => {
|
|
self.op.complete(false, STATUS_CANCELLED);
|
|
self.state = ScreenState::Idle;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The asks the per-frame arms made of the login engine and of the IO thread.
|
|
fn take_asks(&mut self) -> (Vec<GlueLogin>, Vec<ParkRequest>) {
|
|
(
|
|
std::mem::take(&mut self.login_out),
|
|
std::mem::take(&mut self.park_out),
|
|
)
|
|
}
|
|
|
|
/// `0x46b880`, the tail of `GetCharacterListUpdate`: state 3, the `CHAR_LIST_RETRIEVING`
|
|
/// dialog and the query (`0x5aabb0`: operation 6 at status `0x2a`, then `CMSG_CHAR_ENUM` on
|
|
/// the world link). With no link the operation fails `RESPONSE_FAILED_TO_CONNECT` at once
|
|
/// (`0x5aabca`). State 9, a refused character login (`0x46b880`'s first test), returns before
|
|
/// any of it; that state is not reached yet.
|
|
fn get_character_list(&mut self) {
|
|
self.state = ScreenState::CharList;
|
|
self.open("CANCEL", Some(Text::Key("CHAR_LIST_RETRIEVING")));
|
|
self.op = Operation::begin(OpKind::CharList, STATUS_CHAR_LIST_RETRIEVING);
|
|
if !self.connected {
|
|
self.op.complete(false, STATUS_FAILED_TO_CONNECT);
|
|
} else {
|
|
self.park_out.push(CharRequest::Enumerate.into());
|
|
}
|
|
}
|
|
|
|
/// `0x46b8d0`, `RequestRealmList`'s body: state 4, the dialog when asked, and the query
|
|
/// (`0x5ab840`, `COP_GET_REALMS` at `REALM_LIST_IN_PROGRESS`).
|
|
fn request_realm_list(&mut self, dialog: bool) {
|
|
self.state = ScreenState::RealmList;
|
|
if dialog {
|
|
self.open("CANCEL", Some(Text::Key("REALM_LIST_IN_PROGRESS")));
|
|
}
|
|
self.op = Operation::begin(OpKind::GetRealms, STATUS_REALM_LIST_IN_PROGRESS);
|
|
self.park_out.push(RealmRequest::Refresh.into());
|
|
}
|
|
|
|
/// `CancelRealmListQuery` (`0x46b7e0`): in state 4 only, the query completes cancelled and the
|
|
/// state goes to 0; nothing is sent, so a late reply only fills the holder.
|
|
fn cancel_realm_list_query(&mut self) {
|
|
if self.state == ScreenState::RealmList {
|
|
self.op.complete(false, STATUS_CANCELLED);
|
|
self.state = ScreenState::Idle;
|
|
}
|
|
}
|
|
|
|
/// `ConnectToRealm` (`0x46b210`) for the realm `ChangeRealm` named, `realmName` already set to
|
|
/// it: the selection (`0x5ab7b0`'s tail, `0x5ab6f0`), state 2, the `GAME_SERVER_LOGIN` dialog
|
|
/// and the connect. With a world connection up the reference drops it first (`0x46b22b` →
|
|
/// `0x5ab1a0`) and begins the same connect from the disconnect handler (`0x46c5bf`); here the
|
|
/// drop is asked first and the connect follows at once, so a failed query finds no session.
|
|
fn connect_to_realm(&mut self, name: &str) {
|
|
if let Some(r) = select_by_name(&self.holder, name) {
|
|
self.selected = Some(r);
|
|
}
|
|
if self.connected {
|
|
// `0x46b22b`: the world connection drops before anything is asked of realmd.
|
|
self.park_out.push(RealmRequest::Leave.into());
|
|
}
|
|
self.connected = false;
|
|
self.state = ScreenState::Connect;
|
|
self.open("CANCEL", Some(Text::Key("GAME_SERVER_LOGIN")));
|
|
self.op = Operation::begin(OpKind::Connect, STATUS_CONNECTING);
|
|
self.queue = QueueEstimate::default();
|
|
// The connect's own query (`0x5aab20` → `0x5b2220`).
|
|
self.park_out.push(RealmRequest::Refresh.into());
|
|
}
|
|
|
|
/// A realm list reply: the holder filled (`0x5b2230`), then the pending operation's arm of
|
|
/// `0x5abb90`. A query's success is `REALM_LIST_SUCCESS`; a connect's dials the selected realm,
|
|
/// or the one `realm_name` names, and completes only through the dial (`0x5ab823` calls no
|
|
/// `SetStatus`); no answer fails either `REALM_LIST_FAILED`.
|
|
fn on_realm_list(&mut self, realms: Vec<RealmInfo>, failed: bool, realm_name: &str) {
|
|
if !failed {
|
|
self.holder = realms;
|
|
self.holder_moved = true;
|
|
}
|
|
if !self.op.awaiting_list || self.op.done {
|
|
return;
|
|
}
|
|
self.op.awaiting_list = false;
|
|
if failed {
|
|
self.op.complete(false, STATUS_REALM_LIST_FAILED);
|
|
return;
|
|
}
|
|
match self.op.kind {
|
|
OpKind::GetRealms => self.op.complete(true, STATUS_REALM_LIST_SUCCESS),
|
|
OpKind::Connect => {
|
|
if self.selected.is_none() {
|
|
self.selected = select_by_name(&self.holder, realm_name);
|
|
}
|
|
match &self.selected {
|
|
Some(r) => {
|
|
info!("glue: the connect dials {:?}", r.name);
|
|
// Stamped with the abandon generation as it is sent.
|
|
self.park_out.push(
|
|
RealmRequest::Enter {
|
|
realm: r.clone(),
|
|
generation: 0,
|
|
}
|
|
.into(),
|
|
);
|
|
}
|
|
None => {
|
|
info!("glue: realmName {realm_name:?} names no listed realm");
|
|
self.op.complete(false, STATUS_REALM_LIST_REALM_NOT_FOUND);
|
|
}
|
|
}
|
|
}
|
|
OpKind::Auth | OpKind::CharList => {}
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
}
|
|
// The character list query's reply never came: the server refused the roster to a
|
|
// client without Warden (the park's `char_enum`), the one failure it reports. The
|
|
// query completes failed with the words, and the link is gone.
|
|
ScreenState::CharList if self.op.kind != OpKind::CharList || self.op.done => {}
|
|
ScreenState::Connect | ScreenState::CharList => {
|
|
if self.state == ScreenState::CharList {
|
|
self.connected = false;
|
|
}
|
|
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 unless cancelled, state 0, a
|
|
/// cancel's world socket dropped (`0x46befd`); `AUTH_FAILED` from the character screen goes
|
|
/// back to login (`0x46bfa7`); every other status tears the roster down (`0x46bf1f`) and
|
|
/// raises the realm list (`0x46bf24`-`0x46bf96`), or with no realm held asks for one
|
|
/// (`0x46c06e`).
|
|
fn world_failed(&mut self) {
|
|
let status = self.op.status;
|
|
if status != STATUS_CANCELLED {
|
|
let (text, code) = match self.op.text() {
|
|
Some(Text::Raw(t)) => (Text::Raw(t), None),
|
|
_ => status_text(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 status == STATUS_CANCELLED {
|
|
self.login_out.push(GlueLogin::CancelWorld);
|
|
}
|
|
if status == STATUS_AUTH_FAILED {
|
|
if self.screen.eq_ignore_ascii_case("charselect") {
|
|
self.outbox.push(Outgoing::Screen("login"));
|
|
}
|
|
return;
|
|
}
|
|
self.outbox.push(Outgoing::Teardown);
|
|
if self.holder.is_empty() {
|
|
self.request_realm_list(true);
|
|
} else {
|
|
self.outbox.push(Outgoing::Close);
|
|
self.outbox.push(Outgoing::Rebuild);
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
|
|
/// A roster reply from the current link stores the roster and completes the operation
|
|
/// `CHAR_LIST_RETRIEVED` (`0x5ab300` → `SetStatus`, `0x5aa920`), which overwrites the status
|
|
/// whatever it was: a reply that lands between a Cancel and the tick that takes it turns the
|
|
/// cancel into a load, as the reference's does, and a second reply replaces the first.
|
|
///
|
|
/// Deviation: the reference's handler completes whichever operation is current; here only the
|
|
/// character list's does, in state 3, and a reply of another cause (a roster nothing of the
|
|
/// screen asked for) or of another link (before a realm change) completes nothing. Those
|
|
/// replies cannot be told from the operation they would be applied to in the reference, which
|
|
/// has one link and one request in flight; here the IO thread's park serves several.
|
|
fn on_roster(&mut self, roster: Vec<Character>, cause: RosterCause, epoch: u64) {
|
|
if cause != RosterCause::Asked
|
|
|| epoch != self.link
|
|
|| self.state != ScreenState::CharList
|
|
|| self.op.kind != OpKind::CharList
|
|
{
|
|
return;
|
|
}
|
|
self.characters = roster.len().min(usize::from(u16::MAX)) as u16;
|
|
self.roster = roster;
|
|
self.op.complete(true, STATUS_CHAR_LIST_RETRIEVED);
|
|
}
|
|
|
|
/// The roster ask failed on the stream: the query completes `CHAR_LIST_FAILED` (`0x5ab300`'s
|
|
/// failure leg, `SetStatus(0, 0x2c)`), which the tick opens as `OKAY`. The session ends behind
|
|
/// it as a dropped socket does, so the disconnect handler follows (`0x46c540`).
|
|
fn on_roster_failed(&mut self, epoch: u64) {
|
|
if epoch != self.link
|
|
|| self.state != ScreenState::CharList
|
|
|| self.op.kind != OpKind::CharList
|
|
{
|
|
return;
|
|
}
|
|
self.op.complete(false, STATUS_CHAR_LIST_FAILED);
|
|
}
|
|
|
|
/// The world server accepted the authentication: the link is up, and in state 2 the
|
|
/// authentication completes `AUTH_OK` (`0x5aab80`), which the tick shows and takes down the
|
|
/// success arm.
|
|
fn on_world_authenticated(&mut self, realm: benilla_protocol::RealmInfo, epoch: u64) {
|
|
self.connected = true;
|
|
self.link = epoch;
|
|
self.realm = Some(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. A logout or a refused character login ends only the world: the link
|
|
/// is kept (`0x5aaeb0` and `0x5aad70` reach no close), so the screen's roster ask goes out on
|
|
/// it (`IsConnectedToServer`, `0x46d380`). 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. 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, end: benilla_protocol::SessionEnd, session_over: bool) {
|
|
if end == benilla_protocol::SessionEnd::LoggedOut {
|
|
return;
|
|
}
|
|
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`),
|
|
// with no dialog of its own; the logon's list is its query's reply.
|
|
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(),
|
|
ScreenState::CharList => self.tick_char_list(),
|
|
ScreenState::RealmList => self.tick_realm_list(),
|
|
ScreenState::Idle => {}
|
|
}
|
|
}
|
|
|
|
/// State 4's frame (`0x46c0a5`): the dialog re-texted, then a completed query's arm, success
|
|
/// to state 0, the dialog closed and the rebuild (`0x46c0d2`-`0x46c0e2`), failure to `OKAY`
|
|
/// with its text and state 0 (`0x46c0c9` → `0x46c177`).
|
|
fn tick_realm_list(&mut self) {
|
|
if let Some(text) = self.op.text() {
|
|
self.outbox.push(Outgoing::Update { text, button: None });
|
|
}
|
|
if !self.op.done {
|
|
return;
|
|
}
|
|
self.state = ScreenState::Idle;
|
|
if self.op.ok {
|
|
self.outbox.push(Outgoing::Close);
|
|
self.outbox.push(Outgoing::Rebuild);
|
|
} else {
|
|
let text = self.op.text();
|
|
self.open("OKAY", text);
|
|
}
|
|
}
|
|
|
|
/// 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, the roster torn down, `charselect`, the queue ring
|
|
// cleared. The screen's own `GetCharacterListUpdate` loads the roster.
|
|
self.state = ScreenState::Idle;
|
|
self.op = Operation::default();
|
|
self.queue = QueueEstimate::default();
|
|
self.outbox.push(Outgoing::Teardown);
|
|
self.outbox.push(Outgoing::Screen("charselect"));
|
|
}
|
|
}
|
|
|
|
/// State 3's frame (`0x46c03a`-`0x46c0a4`): the dialog re-texted, then a completed query's
|
|
/// arm. A cancel tears the roster down (`0x46c069`) and raises the realm list
|
|
/// (`RequestRealmList(1)`, `0x46c073`); any other failure opens `OKAY` with its text and goes
|
|
/// to state 0 (`0x46c173`); a success goes to state 0, closes the dialog and loads the roster
|
|
/// (`0x46c084`-`0x46c094`).
|
|
fn tick_char_list(&mut self) {
|
|
if let Some(text) = self.op.text() {
|
|
self.outbox.push(Outgoing::Update { text, button: None });
|
|
}
|
|
if !self.op.done {
|
|
return;
|
|
}
|
|
if !self.op.ok {
|
|
if self.op.status == STATUS_CANCELLED {
|
|
self.outbox.push(Outgoing::Teardown);
|
|
self.request_realm_list(true);
|
|
} else {
|
|
let text = self.op.text();
|
|
self.open("OKAY", text);
|
|
self.state = ScreenState::Idle;
|
|
}
|
|
return;
|
|
}
|
|
self.state = ScreenState::Idle;
|
|
self.outbox.push(Outgoing::Close);
|
|
self.outbox.push(Outgoing::Roster);
|
|
}
|
|
|
|
/// `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)));
|
|
}
|
|
}
|
|
|
|
/// `SelectRealmByName` (`0x5ab6f0`): the first realm in the holder whose name matches, case
|
|
/// folded (`0x64a4c0`), whatever its flags.
|
|
fn select_by_name(holder: &[RealmInfo], name: &str) -> Option<RealmInfo> {
|
|
holder
|
|
.iter()
|
|
.find(|r| r.name.eq_ignore_ascii_case(name))
|
|
.cloned()
|
|
}
|
|
|
|
/// A realm record as the glue VM's holder takes it.
|
|
fn glue_realm(r: &RealmInfo) -> GlueRealm {
|
|
GlueRealm {
|
|
name: r.name.clone(),
|
|
realm_type: r.realm_type,
|
|
flags: r.flags,
|
|
population: r.population,
|
|
characters: r.characters,
|
|
category: r.category,
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
world_auths: MessageReader<'w, 's, WorldAuthMessage>,
|
|
roster_failures: MessageReader<'w, 's, RosterFailedMessage>,
|
|
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>,
|
|
launch: MessageWriter<'w, super::server_alert::LaunchUrl>,
|
|
realm: Option<Res<'w, crate::net::RealmChoice>>,
|
|
abandon: Option<Res<'w, crate::net::LoginAbandon>>,
|
|
}
|
|
|
|
/// 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 four agreements' states off the CVar registry: the read `0x46a400` makes at `0x402b36`,
|
|
/// before the first glue screen is set (`0x402b65`), so a returning player's login screen never
|
|
/// sees them unaccepted.
|
|
pub(crate) fn read_agreements(cvars: &crate::cvars::Cvars) -> [NoticeState; 4] {
|
|
let mut n = [NoticeState::default(); 4];
|
|
for notice in GlueNotice::ALL {
|
|
if let Some(value) = cvars.get(notice_cvar(notice)) {
|
|
n[notice as usize] = NoticeState::from_cvar(value);
|
|
}
|
|
}
|
|
n
|
|
}
|
|
|
|
/// 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>>,
|
|
areas: Option<Res<crate::area::AreaTableRes>>,
|
|
create: Option<Res<crate::entities::CharCreate>>,
|
|
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
|
|
creatures: Option<Res<crate::entities::Creatures>>,
|
|
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, and the realm
|
|
// list's C globals carried over, which outlive the Lua state.
|
|
let mut fresh_vm = false;
|
|
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());
|
|
s.set_glue_realm_cells(mgr.realm_cells);
|
|
s.set_glue_select_cells(mgr.select_cells);
|
|
mgr.holder_moved = true;
|
|
fresh_vm = true;
|
|
}
|
|
} 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;
|
|
}
|
|
if let (Some(s), Some(r)) = (script.as_deref_mut(), realms.as_ref()) {
|
|
if fresh_vm || r.is_changed() {
|
|
let (categories, types) = r.glue_setup();
|
|
s.set_glue_realm_setup(categories, types);
|
|
}
|
|
}
|
|
// The name a connect resolves (`0x5ab7d0`).
|
|
let realm_name = script
|
|
.as_deref()
|
|
.and_then(|s| s.cvar(crate::realm_select::CVAR_REALM_NAME))
|
|
.unwrap_or_default();
|
|
|
|
// The alert a finished fetch left, shown before anything else this frame (`0x46b939`).
|
|
let mut logins = Vec::new();
|
|
mgr.poll_alert();
|
|
if glue_up {
|
|
mgr.show_alert(&mut logins);
|
|
}
|
|
|
|
// The verbs the last tick's Lua called.
|
|
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 => {
|
|
// The disconnect handler zeroes the state (`0x46c55d`), the operation in
|
|
// flight with it, so no reply completes on the login screen.
|
|
if mgr.connected || mgr.state == ScreenState::Connect {
|
|
mgr.state = ScreenState::Idle;
|
|
mgr.op = Operation::default();
|
|
}
|
|
mgr.connected = false;
|
|
logins.push(GlueLogin::Disconnect);
|
|
}
|
|
GlueRequest::QuitGame => logins.push(GlueLogin::Quit),
|
|
// `0x5abd00`, past the whitelist the verb applied (`0x5abc10`).
|
|
GlueRequest::LaunchUrl(url) => {
|
|
asks.launch.write(super::server_alert::LaunchUrl(url));
|
|
}
|
|
GlueRequest::RequestRealmList { dialog } => mgr.request_realm_list(dialog),
|
|
GlueRequest::CancelRealmListQuery => mgr.cancel_realm_list_query(),
|
|
// `CLoginMgr::Cancel` (`0x5b3320`): nothing else moves, so no dialog shows.
|
|
GlueRequest::CancelLogon => logins.push(GlueLogin::Abandon),
|
|
GlueRequest::ChangeRealm(name) => mgr.connect_to_realm(&name),
|
|
// `0x46b880`; the VM cleared the roster in the verb.
|
|
GlueRequest::GetCharacterList => mgr.get_character_list(),
|
|
}
|
|
}
|
|
mgr.realm_cells = s.glue_realm_cells();
|
|
}
|
|
|
|
// The native login's news.
|
|
for msg in news.stages.read() {
|
|
mgr.on_stage(msg.stage, glue_up);
|
|
}
|
|
// The logon's list ends the logon, then answers the connect its latch begins.
|
|
let lists: Vec<(Vec<RealmInfo>, bool)> = news
|
|
.realm_lists
|
|
.read()
|
|
.map(|m| (m.realms.clone(), m.failed))
|
|
.collect();
|
|
if !lists.is_empty() {
|
|
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 (realms, failed) in lists {
|
|
mgr.on_realm_list(realms, failed, &realm_name);
|
|
}
|
|
for msg in news.failures.read() {
|
|
mgr.on_failed(msg);
|
|
}
|
|
// A session of an attempt a Cancel or a disconnect has passed is not the screen's.
|
|
let generation = crate::net::LoginAbandon::now(asks.abandon.as_deref());
|
|
for msg in news.world_auths.read() {
|
|
if msg.generation == generation {
|
|
mgr.on_world_authenticated(msg.realm.clone(), msg.epoch);
|
|
}
|
|
}
|
|
for msg in news.roster_failures.read() {
|
|
mgr.on_roster_failed(msg.epoch);
|
|
}
|
|
for msg in news.rosters.read() {
|
|
mgr.on_roster(msg.characters.clone(), msg.cause, msg.epoch);
|
|
}
|
|
for msg in news.disconnects.read() {
|
|
mgr.on_disconnected(msg.end, msg.session_over);
|
|
}
|
|
mgr.tick();
|
|
send_asks(&mut mgr, logins, &mut asks);
|
|
|
|
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(
|
|
&mut mgr,
|
|
&mut script,
|
|
realms.as_deref(),
|
|
realmlist.as_deref(),
|
|
);
|
|
let names = RosterNames {
|
|
areas: areas.as_deref().map(|a| &a.0),
|
|
create: create.as_deref().map(|c| &c.0),
|
|
classes: classes.as_deref().map(|c| &c.0),
|
|
creatures: creatures.as_deref(),
|
|
};
|
|
flush(&mut mgr, &mut script, now_ms, &names);
|
|
mgr.select_cells = script.glue_select_cells();
|
|
// What the outbox asked in turn (the roster load's realm list).
|
|
send_asks(&mut mgr, Vec::new(), &mut asks);
|
|
}
|
|
|
|
/// Send the asks of the frame: the login engine's, and the park requests in the order they were
|
|
/// made, the realm requests stamped with the abandon generation as they go.
|
|
fn send_asks(mgr: &mut GlueMgr, logins: Vec<GlueLogin>, asks: &mut GlueAsks) {
|
|
let (arms, park_asks) = mgr.take_asks();
|
|
for ask in logins.into_iter().chain(arms) {
|
|
asks.login.write(ask);
|
|
}
|
|
let now = crate::net::LoginAbandon::now(asks.abandon.as_deref());
|
|
if let Some(choice) = asks.realm.as_deref() {
|
|
for mut ask in park_asks {
|
|
if let ParkRequest::Realm(RealmRequest::Enter { generation, .. }) = &mut ask {
|
|
*generation = now;
|
|
}
|
|
let _ = choice.0.send_request(ask);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The DBC catalogs the roster's strings come from.
|
|
struct RosterNames<'a> {
|
|
areas: Option<&'a benilla_formats::AreaTableCatalog>,
|
|
create: Option<&'a benilla_formats::CharCreateCatalog>,
|
|
classes: Option<&'a benilla_formats::ChrClasses>,
|
|
creatures: Option<&'a crate::entities::Creatures>,
|
|
}
|
|
|
|
/// A roster entry as the glue VM's records take it (`0x4732a0`). The race and class names are
|
|
/// the `Name_Lang` of the race and class rows (`ChrRaces` `+0x44`, `ChrClasses` `+0x14`) at the
|
|
/// enUS slot (the one locale benilla reads, #429), the file string the race row's (`ChrRaces` column 15), and the model flag
|
|
/// whether the race row's body for the gender resolves to a model. A race, class or zone with no row reads
|
|
/// empty, empty, nil as the reference's does, and so does one whose catalog did not load.
|
|
fn glue_character(c: &Character, names: &RosterNames) -> GlueCharacter {
|
|
let text = |s: Option<&str>| s.unwrap_or_default().to_string();
|
|
GlueCharacter {
|
|
guid: c.guid,
|
|
name: c.name.clone(),
|
|
race: text(names.create.and_then(|r| r.race_name(c.race))),
|
|
class: text(names.classes.and_then(|k| k.name(u32::from(c.class)))),
|
|
level: c.level,
|
|
zone: names.areas.and_then(|a| a.name(c.zone)).map(str::to_string),
|
|
file_string: text(names.create.and_then(|r| r.race_file(c.race))),
|
|
gender: c.gender,
|
|
flags: c.flags,
|
|
// `0x5ec480`: the race row's display for the gender, then its model rows.
|
|
model: names
|
|
.create
|
|
.and_then(|r| r.body_display(c.race, c.gender))
|
|
.is_some_and(|display| names.creatures.is_some_and(|k| k.resolves_model(display))),
|
|
}
|
|
}
|
|
|
|
/// The glue VM's slots, from the manager's state.
|
|
fn seed(
|
|
mgr: &mut 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);
|
|
if std::mem::take(&mut mgr.cells_moved) {
|
|
script.set_glue_realm_cells(mgr.realm_cells);
|
|
}
|
|
if std::mem::take(&mut mgr.holder_moved) {
|
|
script.set_glue_realms(mgr.holder.iter().map(glue_realm).collect());
|
|
}
|
|
script.set_glue_realm_characters(mgr.characters);
|
|
// `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, names: &RosterNames) {
|
|
// The roster load can queue more (the realm list's dialog), which goes out in turn.
|
|
while !mgr.outbox.is_empty() {
|
|
for out in std::mem::take(&mut mgr.outbox) {
|
|
flush_one(mgr, script, now_ms, names, out);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn flush_one(
|
|
mgr: &mut GlueMgr,
|
|
script: &mut UiScript,
|
|
now_ms: u32,
|
|
names: &RosterNames,
|
|
out: Outgoing,
|
|
) {
|
|
let text = |script: &UiScript, t: &Text| match t {
|
|
Text::Key(k) => script.global_text(k),
|
|
Text::Raw(s) => s.clone(),
|
|
};
|
|
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()),
|
|
Outgoing::Close => script.fire_event("CLOSE_STATUS_DIALOG", Vec::new()),
|
|
Outgoing::Rebuild => {
|
|
script.rebuild_glue_realms();
|
|
mgr.realm_cells = script.glue_realm_cells();
|
|
}
|
|
Outgoing::Teardown => script.teardown_glue_roster(),
|
|
Outgoing::ServerAlert(text) => {
|
|
script.fire_event("SHOW_SERVER_ALERT", vec![ScriptValue::Str(text)]);
|
|
}
|
|
Outgoing::Roster => {
|
|
let roster = mgr
|
|
.roster
|
|
.iter()
|
|
.map(|c| glue_character(c, names))
|
|
.collect();
|
|
// `RequestRealmList(1)` at `0x47254b`, when no realm has been chosen.
|
|
if script.load_glue_roster(roster) {
|
|
mgr.request_realm_list(true);
|
|
}
|
|
mgr.realm_cells = script.glue_realm_cells();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "glue_mgr_tests.rs"]
|
|
mod tests;
|