Stock realm list and wizard; realm park cancels cleanly (#985)

Part 3b of the stock GlueXML (`WOW_GLUE_STOCK=1`): the stock `RealmList` and `RealmWizard` run on the reference's ten realm verbs (`0x46f480`).

- The realm park is now a cancellable operation. Each dial carries the cancel generation it was asked under, so no Cancel is lost, and a status Cancel closes the logon (`0x5b3320`).
- Change Realm with the world up drops the session first (`0x46b22b`).
- The roster arm (`0x4724d0`) sets the chosen realm, or asks for the list when the roster is empty.
- An empty realm list is shown, never dialled at the world port.
- The native type sort puts PvP before RP.

Deviation lines cover the re-dial's full logon, comparator ties, a missing Region row and the native close.

Part of #856 and #834

Fixes #833
Fixes #974
Fixes #982

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 12:46:31 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0c58d85cf9
commit b2926edf53
21 changed files with 3326 additions and 582 deletions
+4 -1
View File
@@ -47,7 +47,10 @@ pub(super) fn seed_realm_list(
}
match *step {
0 => {
lists.write(crate::net::RealmListMessage { realms: fixture() });
lists.write(crate::net::RealmListMessage {
realms: fixture(),
failed: false,
});
}
2 => crate::realm_select::open_over_char_select(&mut realms, &choice),
3.. => return,
+13 -2
View File
@@ -198,6 +198,7 @@ mod tests {
.insert_resource(CharPick(pick_tx))
.insert_resource(RealmChoice(realm_tx))
.add_message::<GlueSound>()
.add_message::<crate::login::GlueLogin>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_systems(PreUpdate, take_input);
if list_first {
@@ -226,11 +227,21 @@ mod tests {
} else {
"screen first"
};
assert!(
!app.world().resource::<Realms>().shown,
"{order}: the list's Cancel ran"
);
assert!(
realm_rx
.try_iter()
.any(|r| matches!(r, RealmRequest::Abandon)),
"{order}: the list's Cancel ran"
.all(|r| matches!(r, RealmRequest::Refresh)),
"{order}: and over character select it closes nothing"
);
assert!(
app.world()
.resource::<bevy::ecs::message::Messages<crate::login::GlueLogin>>()
.is_empty(),
"{order}: nor ends the login"
);
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
+144 -23
View File
@@ -180,16 +180,22 @@ impl Attempt<'_> {
}
}
/// What the stock glue screens ask of the login engine, past the glue manager's own guards
/// ([`crate::ui_script::glue_mgr`]).
/// What the glue screens ask of the login engine: the stock ones past the glue manager's own
/// guards ([`crate::ui_script::glue_mgr`]), and the native realm list's Cancel over login.
#[derive(Message)]
pub(crate) enum GlueLogin {
/// `CGlueMgr::Login` (`0x46afb0`) reaching `ClientServices`' logon (`0x5ab4b0`): one attempt,
/// typed, with the screen's credentials.
/// typed, with the screen's credentials. It supersedes a logon still parked at the realm list,
/// as the reference starts the next logon whatever link the last one left.
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.
/// `CLoginMgr::Cancel` (`0x5b3320`): the status dialog's Cancel during the logon, and
/// `RealmListDialogCancelled` on the login screen. The attempt in flight is dropped, the
/// realmd link closes, and nothing resubmits.
Abandon,
/// The status dialog's Cancel during the world connect, which completes it
/// `RESPONSE_CANCELLED` (`0x46b754`); the failure arm then drops the world socket alone
/// (`0x46befd`, `0x5ab1a0`), so the attempt in flight ends and the realmd link stays up.
CancelWorld,
/// `DisconnectFromServer` (`0x46d340`): the world session dropped if there is one, then the
/// logon cancelled, as select's Back drops the parked session.
Disconnect,
@@ -205,26 +211,22 @@ pub(crate) fn answer_glue(
mut asks: MessageReader<GlueLogin>,
mut attempt: Attempt,
pick: Option<Res<crate::net::CharPick>>,
realm: Option<Res<crate::net::RealmChoice>>,
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::Submit { account, password } => attempt.send(account, password, true),
GlueLogin::Abandon => {
close_logon(&attempt.abandon, &mut attempt.intent, realm.as_deref());
}
GlueLogin::CancelWorld => {
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();
close_logon(&attempt.abandon, &mut attempt.intent, realm.as_deref());
if matches!(
state.get(),
ClientState::CharSelect | ClientState::CharCreate
@@ -243,6 +245,22 @@ pub(crate) fn answer_glue(
}
}
/// `CLoginMgr::Cancel` (`0x5b3320`): the attempt in flight is dropped at its next stage boundary
/// (a dial asked for under the old generation included), the realmd link closes, and nothing
/// resubmits.
fn close_logon(
abandon: &LoginAbandon,
intent: &mut LoginIntent,
realm: Option<&crate::net::RealmChoice>,
) {
abandon.0.fetch_add(1, Ordering::SeqCst);
intent.in_flight = false;
intent.clear();
if let Some(realm) = realm {
let _ = realm.0.send(crate::net::RealmRequest::Close);
}
}
/// 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 {
@@ -956,6 +974,7 @@ fn answer_dialog(
mut realmlist: ResMut<crate::realmlist::Realmlist>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
abandon: Res<LoginAbandon>,
realm: Option<Res<crate::net::RealmChoice>>,
) {
for answer in answers.read() {
match answer.kind {
@@ -975,11 +994,9 @@ fn answer_dialog(
dialog.dismiss(&mut commands);
}
}
// Cancel, as the reference's state-1 Cancel (`0x46b73d` → `CLoginMgr::Cancel`).
DialogKind::Status | DialogKind::Queued => {
// Cancel: the next stage boundary discards the attempt, and nothing resubmits.
abandon.0.fetch_add(1, Ordering::SeqCst);
intent.in_flight = false;
intent.clear();
close_logon(&abandon, &mut intent, realm.as_deref());
dialog.dismiss(&mut commands);
}
}
@@ -1117,6 +1134,7 @@ mod tests {
.insert_resource(crate::net::RealmChoice(realm_tx))
.add_message::<KeyboardInput>()
.add_message::<GlueSound>()
.add_message::<GlueLogin>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_systems(PreUpdate, crate::realm_select::take_input);
let list = crate::realm_select::list_keys;
@@ -1143,11 +1161,17 @@ mod tests {
!app.world().resource::<crate::realm_select::Realms>().shown,
"{order}: the list's Cancel closed it"
);
let asks = app
.world()
.resource::<bevy::ecs::message::Messages<GlueLogin>>();
assert!(
realm_rx
.try_iter()
.any(|r| matches!(r, crate::net::RealmRequest::Abandon)),
"{order}: and answered the park"
asks.iter_current_update_messages()
.any(|a| matches!(a, GlueLogin::Abandon)),
"{order}: and ended the attempt, which closes the logon"
);
assert!(
realm_rx.try_recv().is_err(),
"{order}: the login engine sends the close"
);
assert!(
!app.world().contains_resource::<QuitArm>(),
@@ -1156,6 +1180,103 @@ mod tests {
}
}
/// The status dialog's Cancel is `CLoginMgr::Cancel` (`0x46b73d` → `0x5b3320`): the attempt
/// ends at its next boundary and the logon park closes, so a logon that already finished is
/// not left parked.
#[test]
fn the_status_cancel_closes_the_logon() {
let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
let abandon = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let mut app = App::new();
app.init_resource::<GlueDialog>()
.init_resource::<LoginIntent>()
.insert_resource(crate::realmlist::Realmlist::unpinned(
crate::realmlist::DEFAULT_REALMLIST,
))
.insert_resource(LoginAbandon(abandon.clone()))
.insert_resource(crate::net::RealmChoice(realm_tx))
.add_message::<crate::glue::dialog::GlueDialogAnswer>()
.add_systems(Update, answer_dialog);
app.world_mut().resource_mut::<LoginIntent>().in_flight = true;
app.world_mut()
.write_message(crate::glue::dialog::GlueDialogAnswer {
kind: DialogKind::Status,
button1: true,
button2: false,
});
app.update();
assert_eq!(abandon.load(Ordering::SeqCst), 1);
assert!(matches!(
realm_rx.try_recv(),
Ok(crate::net::RealmRequest::Close)
));
assert!(!app.world().resource::<LoginIntent>().in_flight);
}
/// The realm list's Cancel over the login screen ends the attempt (#833): the logon closes
/// and the next Login is sent, where the attempt left in flight refused it.
#[test]
fn the_realm_lists_cancel_lets_the_next_login_through() {
let (tx, requests) = crossbeam_channel::unbounded();
let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<crate::glue::GlueClicks>()
.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<crate::bindings::LayoutNames>()
.insert_non_send_resource(HostClipboard::default())
.init_resource::<LoginForm>()
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.init_resource::<crate::realm_select::Realms>()
.insert_resource(crate::realmlist::Realmlist::unpinned(
crate::realmlist::DEFAULT_REALMLIST,
))
.insert_resource(LoginSubmit(tx))
.insert_resource(LoginAbandon(std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(0),
)))
.insert_resource(crate::net::RealmChoice(realm_tx))
.insert_resource(State::new(ClientState::Login))
.add_message::<KeyboardInput>()
.add_message::<GlueSound>()
.add_message::<GlueLogin>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_systems(PreUpdate, crate::realm_select::take_input)
.add_systems(
Update,
(crate::realm_select::list_keys, answer_glue, login_input).chain(),
);
{
let w = app.world_mut();
let mut form = w.resource_mut::<LoginForm>();
form.account.text = "probe".into();
form.password.text = "secret".into();
// The attempt reached the realm park, which raised the list over this screen.
w.resource_mut::<LoginIntent>().in_flight = true;
w.resource_mut::<crate::realm_select::Realms>().shown = true;
}
let press = |app: &mut App, key: KeyCode| {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(key);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
};
press(&mut app, KeyCode::Escape);
assert!(
matches!(realm_rx.try_recv(), Ok(crate::net::RealmRequest::Close)),
"Cancel closes the logon"
);
assert!(!app.world().resource::<LoginIntent>().in_flight);
app.update(); // the list's input ownership settles
press(&mut app, KeyCode::Enter);
let req = requests.try_recv().expect("the next Login is sent");
assert_eq!(req.user, "probe");
}
/// A lost session does not log itself back in: a displaced client's resubmit would kick the
/// client that displaced it. `GlueParent.lua` retries nothing.
#[test]
+30 -9
View File
@@ -401,16 +401,28 @@ pub(crate) enum CharRequest {
#[derive(Resource)]
pub(crate) struct RealmChoice(pub(crate) Sender<RealmRequest>);
/// One request to the IO thread parked at the realm list.
/// One request to the IO thread about the realm list, served at the logon park and at character
/// select; at the login park a query fails and the rest are dropped.
#[derive(Debug)]
pub(crate) enum RealmRequest {
/// Enter this realm, by name: the list refreshes while open, so an index could shift.
Enter(String),
/// Re-request the realm list (`RequestRealmList`, every 5 s from `RealmList_OnUpdate`).
/// Dial this realm (`ConnectToRealm`, `0x46b210`), the record as the list held it, as the
/// reference dials its copy (`0xc27fb8`) whatever a later list says. `generation` is the
/// abandon generation the app held when it asked: a later Cancel ends the dial however early
/// it lands.
Enter {
realm: benilla_protocol::RealmInfo,
generation: u64,
},
/// Query the realm list (`RequestRealmList`, every 5 s from `RealmList_OnUpdate`); the answer
/// is a [`RealmListMessage`], failed when the link gave none.
Refresh,
/// Cancel (`RealmList_OnCancel` only hides the frame): the screen underneath stays, login or
/// character select.
Abandon,
/// `CLoginMgr::Cancel` (`0x5b3320`, from `RealmListDialogCancelled` on the login screen, the
/// logon's Cancel and `DisconnectFromServer`): the realmd link closes and the thread parks for
/// credentials. At character select, where `RealmListDialogCancelled` is a no-op, nothing.
Close,
/// `ConnectToRealm` with the world up (`0x46b22b` → `0x5ab1a0`): the session at character
/// select drops and the thread returns to the logon park, realmd still up. Elsewhere, nothing.
Leave,
}
/// The credentials channel to the IO thread's pre-logon park, sent by [`crate::login`].
@@ -418,10 +430,17 @@ pub(crate) enum RealmRequest {
pub(crate) struct LoginSubmit(pub(crate) Sender<io::LoginRequest>);
/// The login abandon generation: Cancel bumps it, and the IO thread drops an
/// [`io::LoginRequest`] submitted under an older value.
/// [`io::LoginRequest`] or a [`RealmRequest::Enter`] asked under an older value.
#[derive(Resource)]
pub(crate) struct LoginAbandon(pub(crate) std::sync::Arc<std::sync::atomic::AtomicU64>);
impl LoginAbandon {
/// The generation an ask is stamped with now.
pub(crate) fn now(abandon: Option<&LoginAbandon>) -> u64 {
abandon.map_or(0, |a| a.0.load(std::sync::atomic::Ordering::SeqCst))
}
}
/// Guid to entity for every kind, items included, like the reference's one `ClntObjMgr` index.
/// Written only by `net::objects`.
#[derive(Resource, Default)]
@@ -1978,10 +1997,12 @@ pub(crate) enum ClientCommand {
},
}
/// The realm list (`CMD_REALM_LIST`); the IO thread waits for [`crate::realm_select`] to pick.
/// The realm list (`CMD_REALM_LIST`); the IO thread waits for a [`RealmRequest`]. `failed` is a
/// query that got no answer (`REALM_LIST_FAILED`), `realms` then the list last held.
#[derive(Message)]
pub(crate) struct RealmListMessage {
pub(crate) realms: Vec<benilla_protocol::RealmInfo>,
pub(crate) failed: bool,
}
/// The character roster (`SMSG_CHAR_ENUM`); the IO thread waits for [`crate::char_select`] to
+319 -162
View File
@@ -1,7 +1,9 @@
//! The background networking threads, the socket half of the net bridge.
//!
//! [`spawn_net`]'s read thread parks pre-logon for a [`LoginRequest`], walks logon, realm and
//! world handshake, then parks at character select until the app picks a guid. The pick sends
//! [`spawn_net`]'s read thread parks pre-logon for a [`LoginRequest`], logs on to realmd, then
//! holds the realmd link at the logon park, serving realm list queries, until the app names a realm
//! or closes the logon; a world attempt that fails returns there. Past the world handshake it
//! parks at character select until the app picks a guid; a new login supersedes either park. The pick sends
//! `CMSG_PLAYER_LOGIN` and announces the connection without waiting for the verdict, as the
//! reference's world is already loading when it sends the login (`0x46c272`), so the destination's
//! tiles stream a round trip early; a later `SMSG_CHARACTER_LOGIN_FAILED` ends the cycle like a
@@ -20,8 +22,8 @@ use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use benilla_assets::LockRecover;
use benilla_protocol::{
host_port, messages, AuthReject, CharAction, LoginStage, Poll, SessionEnd, SessionEvent,
WardenRequired, WorldSession, WorldWriter, WORLD_PORT,
messages, AuthReject, CharAction, LoginStage, Poll, SessionEnd, SessionEvent, WardenRequired,
WorldSession, WorldWriter,
};
use crossbeam_channel::{Receiver, Sender};
@@ -74,7 +76,7 @@ pub(super) struct Parks {
pub(super) pick_rx: Receiver<CharRequest>,
}
/// How long a realm-list refresh waits for realmd; the park serving the list is deaf that long.
/// How long a realm-list query waits for realmd; the park serving it is deaf that long.
const REALM_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
/// The reference's 30 s ping timer (`0x537ff0`). vmangos kicks a socket whose pings come faster
@@ -153,10 +155,15 @@ enum Parked {
Acted(CharAction, u8),
/// Stay parked: a realm-list refresh, or a Cancel over this screen.
StayPut,
/// Drop the parked world session and dial this realm.
Realm(benilla_protocol::RealmInfo),
/// Drop the parked world session and dial this realm under the abandon generation its ask
/// carried.
Realm(benilla_protocol::RealmInfo, u64),
/// Drop the parked world session and return to the logon park.
Leave,
/// Select's Back: return to the pre-logon park.
Repark,
/// A new login: drop the session and the logon, and run it.
Login(LoginRequest),
/// The app dropped a channel end.
Exit,
}
@@ -293,10 +300,147 @@ pub(super) fn spawn_net(connect: bool) -> NetHandles {
}
}
/// One connection cycle: park for credentials, logon, park at the realm list, world handshake,
/// roster, park at character select, enter the world and stream [`SessionEvent`]s until the socket
/// dies, the character logs out or the app exits. A pre-roster failure emits
/// [`SessionEvent::LoginFailed`] and re-parks; any retry is the app's.
/// What the logon park's wait ended with.
enum LogonAnswer {
/// `ChangeRealm` (`0x46f0e0`): dial this realm with the logon's session key, under the abandon
/// generation the ask carried.
Enter(benilla_protocol::RealmInfo, u64),
/// A new login supersedes the logon, as `CGlueMgr::Login` (`0x46afb0`) starts the next logon
/// whatever link the last one left.
Login(LoginRequest),
/// `CLoginMgr::Cancel` (`0x5b3320`): the realmd link closes and the thread parks for
/// credentials.
Close,
/// The app dropped a channel end.
Exit,
}
/// How one world attempt ended.
enum WorldEnd {
/// The cycle is over.
Cycle(Cycle),
/// It failed before the roster, reported, or was cancelled, or Change Realm left the session at
/// character select: the logon park resumes with the realmd link up, as the reference's
/// state-2 failure arm (`0x46bea7`-`0x46bf96`) and `ConnectToRealm`'s disconnect (`0x46b22b`)
/// drop only the world socket.
Failed,
/// A new login superseded the session at character select.
Login(LoginRequest),
}
/// Publish a realm list: a fresh one, or with `failed` the last one held, after a query that got
/// no answer (`REALM_LIST_FAILED`). False once the app is gone.
fn publish(
events_tx: &Sender<SessionEvent>,
realms: &[benilla_protocol::RealmInfo],
failed: bool,
) -> bool {
events_tx
.send(SessionEvent::RealmList {
realms: realms.to_vec(),
failed,
})
.is_ok()
}
/// A realm list query on the realmd link, `RequestRealmList`'s (`0x5ab840`) and the connect's
/// own (`0x5aab6d`), then the answer published. A request that fails for any reason (the link
/// realmd closed, the 5 s timeout, a reply that does not parse) dials realmd again and asks once
/// more; vmangos closes every link after `MaxSessionDuration`, 300 s by default, while a list may
/// stand longer. False once the app is gone.
///
/// Deviation: the reference re-dials only from `RequestRealmList`, and only when the link is down
/// (`[CLoginMgr+5]` clear, `0x5ab857` → `0x5b2200`), with the reconnect challenge (`0x5b2134`,
/// `[Logon+0xc]`); the connect's query sends only on a link in state 6 (`0x5bc14c`) and otherwise
/// nothing, leaving the screen on its connecting dialog for good. Here both queries re-dial on
/// any failure, with a full logon from the cycle's credentials, because benilla's logon has no
/// reconnect exchange and a connect that can never complete is a dead end, not a behaviour; the
/// new session key serves the next world dial as the reconnect's would.
fn query(
logon: &mut benilla_protocol::Logon,
req: &LoginRequest,
events_tx: &Sender<SessionEvent>,
) -> bool {
let mut ok = logon.refresh_realms(REALM_REFRESH_TIMEOUT);
if !ok {
match benilla_protocol::logon(&req.host, &req.user, &req.pass) {
Ok(fresh) => {
*logon = fresh;
ok = true;
}
Err(e) => bevy::log::warn!("net: realm list re-dial failed: {e:#}"),
}
}
publish(events_tx, &logon.realms, !ok)
}
/// The pre-logon park: waits for credentials. With no realmd link, a realm list query fails at
/// once and a dial or a close has nothing to act on. `None` once the app is gone.
fn login_park(parks: &Parks, events_tx: &Sender<SessionEvent>) -> Option<LoginRequest> {
bevy::log::info!("net: parked at the login screen — waiting for credentials");
let Parks {
login_rx, realm_rx, ..
} = parks;
loop {
// No `break` or `continue` inside the `select!` arms: the macro expands into a loop of its
// own, which they would bind to.
let answer = crossbeam_channel::select! {
recv(login_rx) -> req => Some(req.ok()),
recv(realm_rx) -> msg => match msg {
Err(_) => Some(None),
Ok(RealmRequest::Refresh) => (!publish(events_tx, &[], true)).then_some(None),
Ok(_) => None,
},
};
if let Some(req) = answer {
return req;
}
}
}
/// The logon park: the realmd link is up and the list published. Waits for the app's realm,
/// answering list queries in place on the link (`RequestRealmList`, `0x5ab840`); a new login
/// supersedes it and a close ends it.
fn logon_park(
logon: &mut benilla_protocol::Logon,
req: &LoginRequest,
parks: &Parks,
events_tx: &Sender<SessionEvent>,
) -> LogonAnswer {
bevy::log::info!(
"net: parked at the realm list — {} realm(s) published",
logon.realms.len()
);
let Parks {
login_rx, realm_rx, ..
} = parks;
loop {
let answer = crossbeam_channel::select! {
recv(realm_rx) -> msg => match msg {
Err(_) => Some(LogonAnswer::Exit),
Ok(RealmRequest::Refresh) => {
(!query(logon, req, events_tx)).then_some(LogonAnswer::Exit)
}
Ok(RealmRequest::Enter { realm, generation }) => {
Some(LogonAnswer::Enter(realm, generation))
}
Ok(RealmRequest::Close) => Some(LogonAnswer::Close),
// No world session here to leave.
Ok(RealmRequest::Leave) => None,
},
recv(login_rx) -> msg => Some(msg.map_or(LogonAnswer::Exit, LogonAnswer::Login)),
};
if let Some(answer) = answer {
return answer;
}
}
}
/// One connection cycle: park for credentials, logon, the logon park over the realm list, world
/// handshake, roster, park at character select, enter the world and stream [`SessionEvent`]s
/// until the socket dies, the character logs out or the app exits. A logon failure emits
/// [`SessionEvent::LoginFailed`] and re-parks for credentials; a world attempt's failure emits it
/// and returns to the logon park; any retry is the app's.
fn run(
events_tx: &Sender<SessionEvent>,
writer_tx: &Sender<WorldWriter>,
@@ -305,20 +449,121 @@ fn run(
ping_clock: &Mutex<PingClock>,
tails_announced: &mut std::collections::HashSet<u16>,
) -> Result<Cycle> {
let Some(mut req) = login_park(parks, events_tx) else {
return Ok(Cycle::Exit);
};
'logon: loop {
// Checked at every stage boundary; a blocking dial's result is discarded.
let generation = req.generation;
let canceled = || abandon.load(Ordering::SeqCst) != generation;
let stage = |s: LoginStage| {
let _ = events_tx.send(SessionEvent::LoginStage { stage: s });
};
// Logon: the dial and the SRP6 exchange against realmd.
stage(LoginStage::Connecting);
let mut logon = match benilla_protocol::logon(&req.host, &req.user, &req.pass) {
Ok(l) => l,
Err(e) => {
if canceled() {
return Ok(Cycle::Repark);
}
// A dial that never opened a socket, the one failure the screen can advise on.
let dial = e.downcast_ref::<benilla_protocol::DialFailure>().cloned();
// A server refusal carries its auth result byte for the `AUTH_*` string; a
// transport failure carries none.
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)
}
});
let _ = events_tx.send(SessionEvent::LoginFailed {
refusal: if dial.is_some() { None } else { refusal },
reason: format!("{e:#}"),
terminal: false,
dial,
});
return Ok(Cycle::Repark);
}
};
if canceled() {
return Ok(Cycle::Repark);
}
// ── The logon park ─────────────────────────────────────────────────────────────────────
//
// The login-side list, an empty one included: `RealmList` is a `frameStrata="DIALOG"`
// frame over the current glue screen. Change Realm is served at the character park.
//
// An answer queued during a dead cycle must not answer this list.
while parks.realm_rx.try_recv().is_ok() {}
if !publish(events_tx, &logon.realms, false) {
return Ok(Cycle::Exit);
}
let mut pick = match logon_park(&mut logon, &req, parks, events_tx) {
LogonAnswer::Enter(realm, generation) => (realm, generation),
LogonAnswer::Login(next) => {
req = next;
continue 'logon;
}
LogonAnswer::Close => return Ok(Cycle::Repark),
LogonAnswer::Exit => return Ok(Cycle::Exit),
};
loop {
let ended = world(
&req,
&mut logon,
pick.clone(),
events_tx,
writer_tx,
parks,
abandon,
ping_clock,
tails_announced,
)?;
match ended {
WorldEnd::Cycle(cycle) => return Ok(cycle),
WorldEnd::Login(next) => {
req = next;
continue 'logon;
}
WorldEnd::Failed => match logon_park(&mut logon, &req, parks, events_tx) {
LogonAnswer::Enter(realm, generation) => pick = (realm, generation),
LogonAnswer::Login(next) => {
req = next;
continue 'logon;
}
LogonAnswer::Close => return Ok(Cycle::Repark),
LogonAnswer::Exit => return Ok(Cycle::Exit),
},
}
}
}
}
/// One world attempt and, past the roster, its session: the dial to `pick`'s realm, the roster,
/// the character park, then the world stream. Change Realm at the character park dials again from here with the same
/// session key. A cancel is an abandon generation past the one the pick carried.
#[allow(clippy::too_many_arguments)]
fn world(
req: &LoginRequest,
logon: &mut benilla_protocol::Logon,
pick: (benilla_protocol::RealmInfo, u64),
events_tx: &Sender<SessionEvent>,
writer_tx: &Sender<WorldWriter>,
parks: &Parks,
abandon: &AtomicU64,
ping_clock: &Mutex<PingClock>,
tails_announced: &mut std::collections::HashSet<u16>,
) -> Result<WorldEnd> {
let Parks {
login_rx,
realm_rx,
pick_rx,
} = parks;
// ── The pre-logon park ──────────────────────────────────────────────────────
bevy::log::info!("net: parked at the login screen — waiting for credentials");
let req = match login_rx.recv() {
Ok(req) => req,
Err(_) => return Ok(Cycle::Exit),
};
// Checked at every stage boundary; a blocking dial's result is discarded.
let generation = req.generation;
let canceled = move || abandon.load(Ordering::SeqCst) != generation;
let stage = |s: LoginStage| {
let _ = events_tx.send(SessionEvent::LoginStage { stage: s });
};
@@ -329,17 +574,7 @@ fn run(
terminal: false,
dial: None,
});
Ok(Cycle::Repark)
};
// A dial that never opened a socket, the one failure the screen can advise on.
let fail_dial = |dial: benilla_protocol::DialFailure, reason: String| {
let _ = events_tx.send(SessionEvent::LoginFailed {
refusal: None,
reason,
terminal: false,
dial: Some(dial),
});
Ok(Cycle::Repark)
Ok(WorldEnd::Failed)
};
// A failure resubmitting cannot fix: the app shows it and does not retry.
let fail_terminal = |reason: String| {
@@ -349,111 +584,32 @@ fn run(
terminal: true,
dial: None,
});
Ok(Cycle::Repark)
};
// Logon: the dial and the SRP6 exchange against realmd.
let mut logon = {
stage(LoginStage::Connecting);
match benilla_protocol::logon(&req.host, &req.user, &req.pass) {
Ok(l) => l,
Err(e) => {
if canceled() {
return Ok(Cycle::Repark);
}
// A server refusal carries its auth result byte for the `AUTH_*` string; a
// transport failure carries none.
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| 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:#}"));
}
}
};
if canceled() {
return Ok(Cycle::Repark);
}
// ── The realm park ─────────────────────────────────────────────────────────────────────────
//
// The login-side list: `RealmList` is a `frameStrata="DIALOG"` frame over the current glue
// screen and `RealmList_OnCancel` only hides it, so Cancel here returns to the login screen.
// Change Realm is served at the character park. A server with no realms skips the park and
// dials the fallback address.
let mut realm = if logon.realms.is_empty() {
None
} else {
// An answer queued during a dead cycle must not answer this list.
while realm_rx.try_recv().is_ok() {}
loop {
if events_tx
.send(SessionEvent::RealmList {
realms: logon.realms.clone(),
})
.is_err()
{
return Ok(Cycle::Exit);
}
bevy::log::info!(
"net: parked at the realm list — {} realm(s) published",
logon.realms.len()
);
let Ok(req) = realm_rx.recv() else {
return Ok(Cycle::Exit);
};
if canceled() {
return Ok(Cycle::Repark);
}
match req {
// Cancel over the login screen.
RealmRequest::Abandon => return Ok(Cycle::Repark),
// The reference re-requests the list every 5 s while it is up; a failed refresh
// keeps the list on screen (`Logon::refresh_realms`).
RealmRequest::Refresh => {
logon.refresh_realms(REALM_REFRESH_TIMEOUT);
}
// By name, so a list that changed under the click cannot enter the wrong realm;
// a vanished name re-publishes the list.
RealmRequest::Enter(name) => {
if let Some(realm) = logon.realms.iter().find(|r| r.name == name) {
break Some(realm.clone());
}
}
}
}
Ok(WorldEnd::Failed)
};
// ── The world half, once per realm ─────────────────────────────────────────────────────────
//
// Change Realm re-enters this loop: the SRP6 session key authenticates against any world
// server on the account's list, so a switch costs one world dial and no re-authentication.
let (mut realm, mut generation) = pick;
'realm: loop {
// Named, since the address alone cannot tell a chosen realm from the fallback.
if let Some(r) = &realm {
bevy::log::info!(
"net: realm {:?} ({} of {})",
r.name,
r.address,
logon.realms.len()
);
// The generation the app held when it asked for this dial: a Cancel after the ask ends
// the attempt, however early it lands, and an earlier attempt's cannot.
let canceled = || abandon.load(Ordering::SeqCst) != generation;
if canceled() {
return Ok(WorldEnd::Failed);
}
let world_addr = realm
.as_ref()
.map(|r| r.address.clone())
// The fallback drops any auth `:port` from the realmlist for the world port.
.unwrap_or_else(|| format!("{}:{}", host_port(&req.host, WORLD_PORT).0, WORLD_PORT));
bevy::log::info!(
"net: realm {:?} ({} of {})",
realm.name,
realm.address,
logon.realms.len()
);
let world_addr = realm.address.clone();
stage(LoginStage::Handshaking);
// For the queue dialog to name; the roster that carries it comes after the queue.
let realm_name = realm.as_ref().map(|r| r.name.clone());
let realm_name = Some(realm.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 = |event: benilla_protocol::WorldConnectEvent| {
@@ -477,7 +633,7 @@ fn run(
Ok(s) => s,
Err(e) => {
if canceled() {
return Ok(Cycle::Repark);
return Ok(WorldEnd::Failed);
}
// A Warden refusal is the server's answer, not a transport fault.
if let Some(w) = e.downcast_ref::<WardenRequired>() {
@@ -495,7 +651,7 @@ fn run(
};
// A cancel during the queue must not be overtaken by the roster.
if canceled() {
return Ok(Cycle::Repark);
return Ok(WorldEnd::Failed);
}
// The roster as the account holds it, an empty one included: the reference builds
@@ -505,7 +661,7 @@ fn run(
Ok(c) => c,
Err(e) => {
if canceled() {
return Ok(Cycle::Repark);
return Ok(WorldEnd::Failed);
}
if let Some(w) = e.downcast_ref::<WardenRequired>() {
return fail_terminal(w.to_string());
@@ -514,7 +670,7 @@ fn run(
}
};
if canceled() {
return Ok(Cycle::Repark);
return Ok(WorldEnd::Failed);
}
// A pick or realm answer queued during a dead cycle must not answer this roster: a logout
// must land on the list, not bounce back in off a stale pick. The app re-sends its wants.
@@ -523,11 +679,11 @@ fn run(
if events_tx
.send(SessionEvent::CharacterList {
characters: characters.clone(),
realm: realm.clone(),
realm: Some(realm.clone()),
})
.is_err()
{
return Ok(Cycle::Exit);
return Ok(WorldEnd::Cycle(Cycle::Exit));
}
// Park at character select until the app answers. Create and delete are serviced in place.
@@ -541,31 +697,26 @@ fn run(
// No `break` or `continue` inside the `select!` arms: the macro expands into a loop of
// its own, which they would bind to. The arms answer with a [`Parked`].
let answer = crossbeam_channel::select! {
recv(realm_rx) -> req => match req {
recv(realm_rx) -> msg => match msg {
Err(_) => Parked::Exit,
// `RequestRealmList`: refresh on the realmd socket this cycle holds, then
// republish; also Change Realm's first list.
// `RequestRealmList`: a query on the realmd link this cycle holds, then the
// list; also Change Realm's first list.
Ok(RealmRequest::Refresh) => {
logon.refresh_realms(REALM_REFRESH_TIMEOUT);
if events_tx
.send(SessionEvent::RealmList { realms: logon.realms.clone() })
.is_err()
{
Parked::Exit
} else {
if query(logon, req, events_tx) {
Parked::StayPut
} else {
Parked::Exit
}
}
// `RealmList_OnCancel` over character select: only the dialog hides.
Ok(RealmRequest::Abandon) => Parked::StayPut,
// `RealmListDialogCancelled` over character select is a no-op (`0x46b810`).
Ok(RealmRequest::Close) => Parked::StayPut,
// `ChangeRealm(category, index)`: dial the chosen realm with the session key
// we hold; a vanished name changes nothing.
Ok(RealmRequest::Enter(name)) => logon
.realms
.iter()
.find(|r| r.name == name)
.cloned()
.map_or(Parked::StayPut, Parked::Realm),
// we hold.
Ok(RealmRequest::Enter { realm, generation }) => {
Parked::Realm(realm, generation)
}
// `ConnectToRealm` with the world up drops it first (`0x46b22b`).
Ok(RealmRequest::Leave) => Parked::Leave,
},
recv(pick_rx) -> pick => match pick {
Err(_) => Parked::Exit,
@@ -579,16 +730,19 @@ fn run(
Parked::Acted(CharAction::Delete, session.delete_character(target)?)
}
},
recv(login_rx) -> msg => msg.map_or(Parked::Exit, Parked::Login),
};
let (action, code) = match answer {
Parked::Exit => return Ok(Cycle::Exit),
Parked::Repark => return Ok(Cycle::Repark),
Parked::Exit => return Ok(WorldEnd::Cycle(Cycle::Exit)),
Parked::Repark => return Ok(WorldEnd::Cycle(Cycle::Repark)),
Parked::Login(next) => return Ok(WorldEnd::Login(next)),
Parked::StayPut => continue,
Parked::Play(guid) => break guid,
Parked::Realm(chosen) => {
realm = Some(chosen);
Parked::Realm(chosen, asked) => {
(realm, generation) = (chosen, asked);
continue 'realm;
}
Parked::Leave => return Ok(WorldEnd::Failed),
Parked::Acted(action, code) => (action, code),
};
// A success changed the roster: re-emit it before the result, so the screen has the
@@ -602,18 +756,18 @@ fn run(
if events_tx
.send(SessionEvent::CharacterList {
characters: characters.clone(),
realm: realm.clone(),
realm: Some(realm.clone()),
})
.is_err()
{
return Ok(Cycle::Exit);
return Ok(WorldEnd::Cycle(Cycle::Exit));
}
}
if events_tx
.send(SessionEvent::CharActionResult { action, code })
.is_err()
{
return Ok(Cycle::Exit);
return Ok(WorldEnd::Cycle(Cycle::Exit));
}
};
let name = characters
@@ -657,11 +811,11 @@ fn run(
})
.is_err()
{
return Ok(Cycle::Exit);
return Ok(WorldEnd::Cycle(Cycle::Exit));
}
if writer_tx.send(writer).is_err() {
// The writer thread ends only on app exit.
return Ok(Cycle::Exit);
return Ok(WorldEnd::Cycle(Cycle::Exit));
}
bevy::log::info!("net: connected to {world_addr}");
@@ -714,10 +868,10 @@ fn run(
};
// Receiver dropped: the app exited.
if events_tx.send(ev).is_err() {
return Ok(Cycle::Exit);
return Ok(WorldEnd::Cycle(Cycle::Exit));
}
if let Some(cycle) = ends {
return Ok(cycle);
return Ok(WorldEnd::Cycle(cycle));
}
}
}
@@ -748,7 +902,7 @@ fn run(
})
.is_err()
{
return Ok(Cycle::Exit);
return Ok(WorldEnd::Cycle(Cycle::Exit));
}
if skip_run > 1024 {
return Err(anyhow!("world stream desynced after 1024 skipped packets"));
@@ -1487,6 +1641,9 @@ mod wire_trace;
#[cfg(test)]
mod cycle_tests;
#[cfg(test)]
pub(crate) mod fake;
#[cfg(test)]
mod rtt_tests {
use super::{PingClock, RTT_RING};
+205 -206
View File
@@ -1,206 +1,22 @@
//! The login cycle, [`super::run`], against a fake realmd and a fake world server on loopback: the
//! real logon, realm park, world handshake and roster, driven through the parks as the app does.
//! The login cycle, [`super::run`], against a fake realmd and a fake world server on loopback
//! ([`super::fake`]): the real logon, the logon park, world handshake and roster, driven through
//! the parks as the app does.
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, Mutex};
use std::sync::Mutex;
use std::thread;
use std::time::Duration;
use benilla_protocol::messages::{self, opcode};
use benilla_protocol::{SessionEvent, WorldWriter};
use benilla_srp::vanilla_header::HeaderCrypto;
use benilla_srp::{
password_verifier, NormalizedString, GENERATOR, LARGE_SAFE_PRIME_LITTLE_ENDIAN,
SESSION_KEY_LENGTH,
};
use crossbeam_channel::{Receiver, Sender};
use num_bigint::BigUint;
use sha1::{Digest, Sha1};
use benilla_protocol::messages::opcode;
use benilla_protocol::{RealmInfo, SessionEvent, WorldWriter};
use super::fake::{self, FakeRealm, WAIT};
use super::{run, Cycle, LoginRequest, Parks, PingClock};
use crate::net::{CharRequest, RealmRequest};
const USER: &str = "empty";
const PASS: &str = "secret";
const REALM: &str = "Fake";
const SALT: [u8; 32] = [0x5a; 32];
/// Every wait in the test: long enough for a loaded machine, short of a hung gate.
const WAIT: Duration = Duration::from_secs(20);
fn sha1(parts: &[&[u8]]) -> [u8; 20] {
let mut h = Sha1::new();
for p in parts {
h.update(p);
}
h.finalize().into()
}
fn le32(v: &BigUint) -> [u8; 32] {
let raw = v.to_bytes_le();
let mut out = [0u8; 32];
out[..raw.len()].copy_from_slice(&raw);
out
}
/// `SHA1_Interleave` over all 32 bytes of `S`, as vmangos's `SRP6::HashSessionKey` does.
fn interleave(s: &[u8; 32]) -> [u8; SESSION_KEY_LENGTH] {
let even: Vec<u8> = s.iter().step_by(2).copied().collect();
let odd: Vec<u8> = s.iter().skip(1).step_by(2).copied().collect();
let (g, h) = (sha1(&[&even]), sha1(&[&odd]));
let mut out = [0u8; SESSION_KEY_LENGTH];
for (i, (gi, hi)) in g.iter().zip(&h).enumerate() {
out[i * 2] = *gi;
out[i * 2 + 1] = *hi;
}
out
}
/// A realmd that logs [`USER`] in with the server half of SRP6 and lists one realm, [`REALM`] at
/// `world`. The session key goes to the fake world server, which needs it for the header cipher.
fn fake_realmd(world: String, keys: Sender<[u8; SESSION_KEY_LENGTH]>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let n = BigUint::from_bytes_le(&LARGE_SAFE_PRIME_LITTLE_ENDIAN);
let g = BigUint::from(GENERATOR);
let user = NormalizedString::new(USER).unwrap();
let pass = NormalizedString::new(PASS).unwrap();
let v = BigUint::from_bytes_le(&password_verifier(&user, &pass, &SALT));
// CMD_AUTH_LOGON_CHALLENGE_Client: opcode, protocol, u16 size, then the body.
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();
// B = k·v + g^b, with k = 3; `b` walks until `B`'s top byte is non-zero, since the client
// redials on a zero one.
let (b, b_pub) = (1u32..)
.map(|i| {
let b = BigUint::from_bytes_le(&[0x3c; 32]) + i;
let b_pub = le32(&((BigUint::from(3u8) * &v + g.modpow(&b, &n)) % &n));
(b, b_pub)
})
.find(|(_, b_pub)| b_pub[31] != 0)
.unwrap();
let mut reply = vec![0x00, 0x00, 0x00];
reply.extend_from_slice(&b_pub);
reply.extend_from_slice(&[1, GENERATOR, 32]);
reply.extend_from_slice(&LARGE_SAFE_PRIME_LITTLE_ENDIAN);
reply.extend_from_slice(&SALT);
reply.extend_from_slice(&[0u8; 16]); // crc_salt
reply.push(0); // security_flag
s.write_all(&reply).unwrap();
// CMD_AUTH_LOGON_PROOF_Client: opcode, A, M1, crc_hash, key count, security flag.
let mut proof = [0u8; 75];
s.read_exact(&mut proof).unwrap();
let (a_pub, m1) = (&proof[1..33], &proof[33..53]);
let u = BigUint::from_bytes_le(&sha1(&[a_pub, &b_pub]));
let a = BigUint::from_bytes_le(a_pub);
let big_s = (a * v.modpow(&u, &n) % &n).modpow(&b, &n);
let key = interleave(&le32(&big_s));
let mut reply = vec![0x01, 0x00];
reply.extend_from_slice(&sha1(&[a_pub, m1, &key]));
reply.extend_from_slice(&0u32.to_le_bytes()); // hardware_survey_id
s.write_all(&reply).unwrap();
keys.send(key).unwrap();
// CMD_REALM_LIST_Client, answered with the one realm.
s.read_exact(&mut [0u8; 5]).unwrap();
let mut realm = Vec::new();
realm.extend_from_slice(&0u32.to_le_bytes()); // realm_type
realm.push(0); // flags
realm.extend_from_slice(format!("{REALM}\0{world}\0").as_bytes());
realm.extend_from_slice(&0f32.to_le_bytes()); // population
realm.extend_from_slice(&[0, 1, 1]); // characters, category, id
let mut list = vec![0x10];
list.extend_from_slice(&((4 + 1 + realm.len() + 2) as u16).to_le_bytes());
list.extend_from_slice(&0u32.to_le_bytes());
list.push(1);
list.extend_from_slice(&realm);
list.extend_from_slice(&0u16.to_le_bytes());
s.write_all(&list).unwrap();
// Held until the cycle drops the logon, as realmd keeps it for the list refresh.
let _ = s.read(&mut [0u8; 1]);
});
addr
}
/// A server packet: `u16` BE size counting the opcode, `u16` LE opcode, the body.
fn send(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>, op: u16, body: &[u8]) {
let size = ((body.len() + 2) as u16).to_be_bytes();
let op = op.to_le_bytes();
let mut header = [size[0], size[1], op[0], op[1]];
if let Some(c) = crypto {
c.encrypter().encrypt(&mut header);
}
s.write_all(&header).unwrap();
s.write_all(body).unwrap();
}
/// A client packet's opcode, reading past its body: `u16` BE size counting the 4-byte opcode.
fn recv(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>) -> Option<u16> {
let mut header = [0u8; 6];
s.read_exact(&mut header).ok()?;
if let Some(c) = crypto {
c.decrypter().decrypt(&mut header);
}
let size = usize::from(u16::from_be_bytes([header[0], header[1]]));
s.read_exact(&mut vec![0u8; size.saturating_sub(4)]).ok()?;
Some(u16::from_le_bytes([header[2], header[3]]))
}
/// A world server whose account has no characters: it admits the session under realmd's key,
/// answers every `CMSG_CHAR_ENUM` with an empty roster and a `CMSG_CHAR_CREATE` with success, and
/// logs every opcode the client sends.
fn fake_world(
listener: TcpListener,
keys: Receiver<[u8; SESSION_KEY_LENGTH]>,
) -> Arc<Mutex<Vec<u16>>> {
let sent = Arc::new(Mutex::new(Vec::new()));
let log = Arc::clone(&sent);
thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
send(
&mut s,
None,
opcode::SMSG_AUTH_CHALLENGE,
&0xDEAD_BEEFu32.to_le_bytes(),
);
assert_eq!(recv(&mut s, None), Some(opcode::CMSG_AUTH_SESSION));
let mut crypto = HeaderCrypto::from_session_key(keys.recv_timeout(WAIT).unwrap());
send(
&mut s,
Some(&mut crypto),
opcode::SMSG_AUTH_RESPONSE,
&[messages::AUTH_OK],
);
while let Some(op) = recv(&mut s, Some(&mut crypto)) {
log.lock().unwrap().push(op);
match op {
opcode::CMSG_CHAR_ENUM => {
send(&mut s, Some(&mut crypto), opcode::SMSG_CHAR_ENUM, &[0]);
}
opcode::CMSG_CHAR_CREATE => send(
&mut s,
Some(&mut crypto),
opcode::SMSG_CHAR_CREATE,
&[messages::CHAR_CREATE_SUCCESS],
),
_ => {}
}
}
});
sent
}
/// The next event, skipping the stage announcements; a login failure fails the test by name.
fn next_event(events: &Receiver<SessionEvent>) -> SessionEvent {
fn next_event(events: &crossbeam_channel::Receiver<SessionEvent>) -> SessionEvent {
loop {
match events.recv_timeout(WAIT).expect("the cycle went quiet") {
SessionEvent::LoginStage { .. } => continue,
@@ -210,16 +26,28 @@ fn next_event(events: &Receiver<SessionEvent>) -> SessionEvent {
}
}
/// A dial of `realm` asked under generation 0, the cycles' start.
fn enter(realm: RealmInfo) -> RealmRequest {
RealmRequest::Enter {
realm,
generation: 0,
}
}
/// The next realm list, its realms and whether the query failed.
fn next_list(events: &crossbeam_channel::Receiver<SessionEvent>) -> (Vec<RealmInfo>, bool) {
match next_event(events) {
SessionEvent::RealmList { realms, failed } => (realms, failed),
other => panic!("expected the realm list, got {other:?}"),
}
}
/// The reference builds `CMSG_CHAR_CREATE` in one place (`0x5aac50`), reached only from the glue's
/// `CreateCharacter`, which the stock glue calls only from the create screen's Okay; so a login to
/// an account with no characters parks at select on the empty list and creates nothing.
#[test]
fn an_empty_account_parks_at_select_without_creating_a_character() {
let world_listener = TcpListener::bind("127.0.0.1:0").unwrap();
let world = world_listener.local_addr().unwrap().to_string();
let (keys_tx, keys_rx) = crossbeam_channel::unbounded();
let sent = fake_world(world_listener, keys_rx);
let realmd = fake_realmd(world, keys_tx);
let (realmd, world) = fake::servers(|world| vec![FakeRealm::new(REALM, world)]);
let (events_tx, events) = crossbeam_channel::unbounded();
let (writer_tx, _writer_rx) = crossbeam_channel::unbounded::<WorldWriter>();
@@ -245,17 +73,16 @@ fn an_empty_account_parks_at_select_without_creating_a_character() {
login_tx
.send(LoginRequest {
user: USER.into(),
pass: PASS.into(),
host: realmd,
user: fake::USER.into(),
pass: fake::PASS.into(),
host: realmd.addr.clone(),
generation: 0,
})
.unwrap();
match next_event(&events) {
SessionEvent::RealmList { realms } => assert_eq!(realms[0].name, REALM),
other => panic!("expected the realm list, got {other:?}"),
}
realm_tx.send(RealmRequest::Enter(REALM.into())).unwrap();
let (realms, failed) = next_list(&events);
assert!(!failed);
assert_eq!(realms[0].name, REALM);
realm_tx.send(enter(realms[0].clone())).unwrap();
match next_event(&events) {
SessionEvent::CharacterList { characters, .. } => assert!(
characters.is_empty(),
@@ -266,9 +93,181 @@ fn an_empty_account_parks_at_select_without_creating_a_character() {
// Select's Back ends the cycle, so the socket is done and its log complete.
pick_tx.send(CharRequest::Abandon).unwrap();
assert!(cycle.join().unwrap(), "Back returns to the login park");
let sent = sent.lock().unwrap().clone();
let sent = world.sent.lock().unwrap().clone();
assert!(
!sent.contains(&opcode::CMSG_CHAR_CREATE),
"the login sent CMSG_CHAR_CREATE on its own: {sent:#06x?}"
);
}
/// The logon park takes the next login (#833): closing the list ends the logon and the thread
/// parks for credentials; a login while the list stands supersedes it. Either way the next login
/// reaches realmd and its list.
#[test]
fn the_next_login_is_accepted_after_the_realm_list_closes_or_stands() {
let (realmd, _) = fake::servers(|world| vec![FakeRealm::new(REALM, world)]);
let cycles = fake::spawn_cycles();
fake::login(&cycles, &realmd.addr);
assert!(!next_list(&cycles.events).1);
// `RealmListDialogCancelled` on the login screen: `CLoginMgr::Cancel`.
cycles.realm.send(RealmRequest::Close).unwrap();
fake::login(&cycles, &realmd.addr);
let (realms, failed) = next_list(&cycles.events);
assert!(!failed && realms[0].name == REALM);
assert_eq!(realmd.logons(), 2, "the second login logged on");
// The close button leaves the list's link up; Login again supersedes it.
fake::login(&cycles, &realmd.addr);
assert!(!next_list(&cycles.events).1);
assert_eq!(realmd.logons(), 3);
let deadline = std::time::Instant::now() + WAIT;
while realmd.open() > 1 {
assert!(
std::time::Instant::now() < deadline,
"the superseded links stay open: {}",
realmd.open()
);
thread::sleep(std::time::Duration::from_millis(10));
}
}
/// A world attempt that fails returns to the logon park with the realmd link up, as the
/// reference's state-2 failure arm drops only the world socket: the list is queried on the same
/// link and another realm dialled, with no second logon.
#[test]
fn a_failed_world_dial_keeps_the_logon_for_the_next_realm() {
let (realmd, _) = fake::servers(|world| {
vec![
FakeRealm::new("Down", &fake::dead_address()),
FakeRealm::new(REALM, world),
]
});
let cycles = fake::spawn_cycles();
fake::login(&cycles, &realmd.addr);
let (realms, _) = next_list(&cycles.events);
let down = realms.iter().find(|r| r.name == "Down").unwrap().clone();
let up = realms.iter().find(|r| r.name == REALM).unwrap().clone();
cycles.realm.send(enter(down)).unwrap();
loop {
match cycles
.events
.recv_timeout(WAIT)
.expect("the dial went quiet")
{
SessionEvent::LoginFailed { refusal, dial, .. } => {
assert!(
refusal.is_none() && dial.is_none(),
"a world transport failure"
);
break;
}
SessionEvent::LoginStage { .. } => {}
other => panic!("expected the dial to fail, got {other:?}"),
}
}
cycles.realm.send(RealmRequest::Refresh).unwrap();
assert!(!next_list(&cycles.events).1, "the link answers the query");
assert_eq!(realmd.lists(), 2);
cycles.realm.send(enter(up)).unwrap();
assert!(matches!(
next_event(&cycles.events),
SessionEvent::CharacterList { .. }
));
assert_eq!(realmd.logons(), 1, "one logon throughout");
}
/// With no logon a realm list query fails at once (`REALM_LIST_FAILED`); the dial and the close
/// have nothing to act on.
#[test]
fn a_query_without_a_logon_fails_at_once() {
let cycles = fake::spawn_cycles();
cycles.realm.send(RealmRequest::Close).unwrap();
cycles.realm.send(RealmRequest::Refresh).unwrap();
let (realms, failed) = next_list(&cycles.events);
assert!(failed && realms.is_empty());
}
/// A query on a link realmd has closed dials again and answers (`0x5b2200`), so a list left up
/// past vmangos's `MaxSessionDuration` still refreshes.
#[test]
fn a_query_on_a_closed_link_dials_again() {
let (realmd, _) = fake::servers(|world| vec![FakeRealm::new(REALM, world)]);
let cycles = fake::spawn_cycles();
fake::login(&cycles, &realmd.addr);
assert!(!next_list(&cycles.events).1);
realmd.drop_links();
cycles.realm.send(RealmRequest::Refresh).unwrap();
let (realms, failed) = next_list(&cycles.events);
assert!(!failed, "the re-dial answers");
assert_eq!(realms[0].name, REALM);
assert_eq!(realmd.logons(), 2);
}
/// A Cancel after the dial was asked ends it, however early it lands: the dial carries the
/// generation the app held when it asked, so the thread drops it and stays at the logon park.
#[test]
fn a_cancel_after_the_dial_was_asked_drops_it() {
let (realmd, _) = fake::servers(|world| vec![FakeRealm::new(REALM, world)]);
let cycles = fake::spawn_cycles();
fake::login(&cycles, &realmd.addr);
let (realms, _) = next_list(&cycles.events);
// The dial was asked under 0, and the Cancel's bump landed before the thread read it.
cycles
.abandon
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
cycles.realm.send(enter(realms[0].clone())).unwrap();
cycles.realm.send(RealmRequest::Refresh).unwrap();
assert!(
!next_list(&cycles.events).1,
"the logon park answers, with no roster before it"
);
}
/// A server that lists no realm gets its empty list published and the logon parked, and the
/// screens show it empty, as the reference does.
#[test]
fn an_empty_realm_list_is_published_and_parked() {
let (realmd, _) = fake::servers(|_| Vec::new());
let cycles = fake::spawn_cycles();
fake::login(&cycles, &realmd.addr);
let (realms, failed) = next_list(&cycles.events);
assert!(realms.is_empty() && !failed);
cycles.realm.send(RealmRequest::Refresh).unwrap();
assert!(!next_list(&cycles.events).1, "parked at the logon park");
assert_eq!(realmd.lists(), 2);
}
/// Change Realm with the world up drops the session first (`0x46b22b`) and returns to the logon
/// park, realmd still up: the world socket closes before any query.
#[test]
fn leave_drops_the_world_session_and_keeps_the_logon() {
let (realmd, world) = fake::servers(|world| vec![FakeRealm::new(REALM, world)]);
let cycles = fake::spawn_cycles();
fake::login(&cycles, &realmd.addr);
let (realms, _) = next_list(&cycles.events);
cycles.realm.send(enter(realms[0].clone())).unwrap();
assert!(matches!(
next_event(&cycles.events),
SessionEvent::CharacterList { .. }
));
assert_eq!(world.open(), 1);
cycles.realm.send(RealmRequest::Leave).unwrap();
let deadline = std::time::Instant::now() + WAIT;
while world.open() != 0 {
assert!(
std::time::Instant::now() < deadline,
"the world session is still open"
);
thread::sleep(std::time::Duration::from_millis(10));
}
cycles.realm.send(RealmRequest::Refresh).unwrap();
assert!(!next_list(&cycles.events).1);
cycles.realm.send(enter(realms[0].clone())).unwrap();
assert!(matches!(
next_event(&cycles.events),
SessionEvent::CharacterList { .. }
));
assert_eq!(realmd.logons(), 1, "one logon throughout");
}
+443
View File
@@ -0,0 +1,443 @@
//! A fake realmd and a fake world server on loopback, for the login cycle's tests: the real SRP6
//! logon, the realm list as often as it is asked for, the world handshake and the roster. Both take
//! any number of connections, so a test can log in again.
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use benilla_protocol::messages::{self, opcode};
use benilla_protocol::{SessionEnd, SessionEvent, WorldWriter};
use benilla_srp::vanilla_header::HeaderCrypto;
use benilla_srp::{
password_verifier, NormalizedString, GENERATOR, LARGE_SAFE_PRIME_LITTLE_ENDIAN,
SESSION_KEY_LENGTH,
};
use crossbeam_channel::{Receiver, Sender};
use num_bigint::BigUint;
use sha1::{Digest, Sha1};
use super::{run, Cycle, LoginRequest, Parks, PingClock};
use crate::net::{CharRequest, RealmRequest};
pub(crate) const USER: &str = "empty";
pub(crate) const PASS: &str = "secret";
const SALT: [u8; 32] = [0x5a; 32];
/// Every wait in the tests: long enough for a loaded machine, short of a hung gate.
pub(crate) const WAIT: Duration = Duration::from_secs(20);
/// A session key, realmd to the world server.
type Key = [u8; SESSION_KEY_LENGTH];
fn sha1(parts: &[&[u8]]) -> [u8; 20] {
let mut h = Sha1::new();
for p in parts {
h.update(p);
}
h.finalize().into()
}
fn le32(v: &BigUint) -> [u8; 32] {
let raw = v.to_bytes_le();
let mut out = [0u8; 32];
out[..raw.len()].copy_from_slice(&raw);
out
}
/// `SHA1_Interleave` over all 32 bytes of `S`, as vmangos's `SRP6::HashSessionKey` does.
fn interleave(s: &[u8; 32]) -> Key {
let even: Vec<u8> = s.iter().step_by(2).copied().collect();
let odd: Vec<u8> = s.iter().skip(1).step_by(2).copied().collect();
let (g, h) = (sha1(&[&even]), sha1(&[&odd]));
let mut out = [0u8; SESSION_KEY_LENGTH];
for (i, (gi, hi)) in g.iter().zip(&h).enumerate() {
out[i * 2] = *gi;
out[i * 2 + 1] = *hi;
}
out
}
/// One realm the fake realmd lists.
#[derive(Clone)]
pub(crate) struct FakeRealm {
pub(crate) name: String,
/// `host:port` the realm's world server answers on.
pub(crate) address: String,
pub(crate) realm_type: u32,
pub(crate) flags: u8,
pub(crate) characters: u8,
pub(crate) category: u8,
}
impl FakeRealm {
/// A Normal realm in category 1 at `address`.
pub(crate) fn new(name: &str, address: &str) -> Self {
FakeRealm {
name: name.into(),
address: address.into(),
realm_type: 0,
flags: 0,
characters: 0,
category: 1,
}
}
}
/// A realmd that logs [`USER`] in with the server half of SRP6 and answers every
/// `CMD_REALM_LIST` with its realms, on every connection, until the client closes it.
pub(crate) struct Realmd {
pub(crate) addr: String,
logons: Arc<AtomicUsize>,
lists: Arc<AtomicUsize>,
open: Arc<AtomicUsize>,
links: Arc<Mutex<Vec<TcpStream>>>,
}
impl Realmd {
/// Listen on loopback; each session key goes to `keys`, for the world server's header cipher.
pub(crate) fn start(realms: Vec<FakeRealm>, keys: Sender<Key>) -> Realmd {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let logons = Arc::new(AtomicUsize::new(0));
let lists = Arc::new(AtomicUsize::new(0));
let open = Arc::new(AtomicUsize::new(0));
let links = Arc::new(Mutex::new(Vec::new()));
let (l, n, o) = (logons.clone(), lists.clone(), open.clone());
let held = links.clone();
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(s) = stream else { return };
if let Ok(c) = s.try_clone() {
held.lock().unwrap().push(c);
}
let (realms, keys) = (realms.clone(), keys.clone());
let (l, n, o) = (l.clone(), n.clone(), o.clone());
thread::spawn(move || {
o.fetch_add(1, Ordering::SeqCst);
serve_realmd(s, &realms, &keys, &l, &n);
o.fetch_sub(1, Ordering::SeqCst);
});
}
});
Realmd {
addr,
logons,
lists,
open,
links,
}
}
/// Close every link from the server's side, as vmangos does at `MaxSessionDuration`.
pub(crate) fn drop_links(&self) {
for s in self.links.lock().unwrap().drain(..) {
let _ = s.shutdown(std::net::Shutdown::Both);
}
}
/// Logons completed so far.
pub(crate) fn logons(&self) -> usize {
self.logons.load(Ordering::SeqCst)
}
/// Realm lists sent so far.
pub(crate) fn lists(&self) -> usize {
self.lists.load(Ordering::SeqCst)
}
/// Connections the client still holds.
pub(crate) fn open(&self) -> usize {
self.open.load(Ordering::SeqCst)
}
}
/// One realmd connection: the logon, then the realm list for every request until EOF.
fn serve_realmd(
mut s: TcpStream,
realms: &[FakeRealm],
keys: &Sender<Key>,
logons: &AtomicUsize,
lists: &AtomicUsize,
) {
let n = BigUint::from_bytes_le(&LARGE_SAFE_PRIME_LITTLE_ENDIAN);
let g = BigUint::from(GENERATOR);
let user = NormalizedString::new(USER).unwrap();
let pass = NormalizedString::new(PASS).unwrap();
let v = BigUint::from_bytes_le(&password_verifier(&user, &pass, &SALT));
// CMD_AUTH_LOGON_CHALLENGE_Client: opcode, protocol, u16 size, then the body.
let mut head = [0u8; 4];
if s.read_exact(&mut head).is_err() {
return;
}
let mut body = vec![0u8; usize::from(u16::from_le_bytes([head[2], head[3]]))];
if s.read_exact(&mut body).is_err() {
return;
}
// B = k·v + g^b, with k = 3; `b` walks until `B`'s top byte is non-zero, since the client
// redials on a zero one.
let (b, b_pub) = (1u32..)
.map(|i| {
let b = BigUint::from_bytes_le(&[0x3c; 32]) + i;
let b_pub = le32(&((BigUint::from(3u8) * &v + g.modpow(&b, &n)) % &n));
(b, b_pub)
})
.find(|(_, b_pub)| b_pub[31] != 0)
.unwrap();
let mut reply = vec![0x00, 0x00, 0x00];
reply.extend_from_slice(&b_pub);
reply.extend_from_slice(&[1, GENERATOR, 32]);
reply.extend_from_slice(&LARGE_SAFE_PRIME_LITTLE_ENDIAN);
reply.extend_from_slice(&SALT);
reply.extend_from_slice(&[0u8; 16]); // crc_salt
reply.push(0); // security_flag
if s.write_all(&reply).is_err() {
return;
}
// CMD_AUTH_LOGON_PROOF_Client: opcode, A, M1, crc_hash, key count, security flag.
let mut proof = [0u8; 75];
if s.read_exact(&mut proof).is_err() {
return;
}
let (a_pub, m1) = (&proof[1..33], &proof[33..53]);
let u = BigUint::from_bytes_le(&sha1(&[a_pub, &b_pub]));
let a = BigUint::from_bytes_le(a_pub);
let big_s = (a * v.modpow(&u, &n) % &n).modpow(&b, &n);
let key = interleave(&le32(&big_s));
let mut reply = vec![0x01, 0x00];
reply.extend_from_slice(&sha1(&[a_pub, m1, &key]));
reply.extend_from_slice(&0u32.to_le_bytes()); // hardware_survey_id
if s.write_all(&reply).is_err() {
return;
}
let _ = keys.send(key);
logons.fetch_add(1, Ordering::SeqCst);
// CMD_REALM_LIST_Client (0x10 and a u32), answered every time, until the client closes the
// link.
let mut request = [0u8; 5];
while s.read_exact(&mut request).is_ok() {
assert_eq!(request[0], 0x10, "a realm list request");
let mut records = Vec::new();
for r in realms {
records.extend_from_slice(&r.realm_type.to_le_bytes());
records.push(r.flags);
records.extend_from_slice(format!("{}\0{}\0", r.name, r.address).as_bytes());
records.extend_from_slice(&1f32.to_le_bytes()); // population
records.extend_from_slice(&[r.characters, r.category, 1]); // characters, category, id
}
let mut list = vec![0x10];
list.extend_from_slice(&((4 + 1 + records.len() + 2) as u16).to_le_bytes());
list.extend_from_slice(&0u32.to_le_bytes());
list.push(realms.len() as u8);
list.extend_from_slice(&records);
// vmangos's trailer (`AuthSocket.cpp:1088`).
list.extend_from_slice(&0x0002u16.to_le_bytes());
if s.write_all(&list).is_err() {
return;
}
lists.fetch_add(1, Ordering::SeqCst);
}
}
/// A server packet: `u16` BE size counting the opcode, `u16` LE opcode, the body.
fn send(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>, op: u16, body: &[u8]) {
let size = ((body.len() + 2) as u16).to_be_bytes();
let op = op.to_le_bytes();
let mut header = [size[0], size[1], op[0], op[1]];
if let Some(c) = crypto {
c.encrypter().encrypt(&mut header);
}
let _ = s.write_all(&header);
let _ = s.write_all(body);
}
/// A client packet's opcode, reading past its body: `u16` BE size counting the 4-byte opcode.
fn recv(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>) -> Option<u16> {
let mut header = [0u8; 6];
s.read_exact(&mut header).ok()?;
if let Some(c) = crypto {
c.decrypter().decrypt(&mut header);
}
let size = usize::from(u16::from_be_bytes([header[0], header[1]]));
s.read_exact(&mut vec![0u8; size.saturating_sub(4)]).ok()?;
Some(u16::from_le_bytes([header[2], header[3]]))
}
/// The fake world server's record: every opcode the client sent, and the sessions still open.
pub(crate) struct World {
pub(crate) sent: Arc<Mutex<Vec<u16>>>,
open: Arc<AtomicUsize>,
}
impl World {
/// Sessions the client still holds.
pub(crate) fn open(&self) -> usize {
self.open.load(Ordering::SeqCst)
}
}
/// A world server whose account has no characters: it admits each session under the latest key
/// realmd made, answers every `CMSG_CHAR_ENUM` with an empty roster and a `CMSG_CHAR_CREATE` with
/// success, and logs every opcode the client sends.
pub(crate) fn world(listener: TcpListener, keys: Receiver<Key>) -> World {
let sent = Arc::new(Mutex::new(Vec::new()));
let log = Arc::clone(&sent);
let open = Arc::new(AtomicUsize::new(0));
let sessions = Arc::clone(&open);
thread::spawn(move || {
let mut last: Option<Key> = None;
for stream in listener.incoming() {
let Ok(mut s) = stream else { return };
send(
&mut s,
None,
opcode::SMSG_AUTH_CHALLENGE,
&0xDEAD_BEEFu32.to_le_bytes(),
);
if recv(&mut s, None) != Some(opcode::CMSG_AUTH_SESSION) {
continue;
}
// The account's latest session key, as realmd stores it for the world server: a newer
// logon's replaces it, and a second dial on the same logon reuses it.
while let Ok(k) = keys.try_recv() {
last = Some(k);
}
if last.is_none() {
last = keys.recv_timeout(WAIT).ok();
}
let Some(key) = last else {
return;
};
let mut crypto = HeaderCrypto::from_session_key(key);
send(
&mut s,
Some(&mut crypto),
opcode::SMSG_AUTH_RESPONSE,
&[messages::AUTH_OK],
);
let log = Arc::clone(&log);
let sessions = Arc::clone(&sessions);
sessions.fetch_add(1, Ordering::SeqCst);
thread::spawn(move || {
while let Some(op) = recv(&mut s, Some(&mut crypto)) {
log.lock().unwrap().push(op);
match op {
opcode::CMSG_CHAR_ENUM => {
send(&mut s, Some(&mut crypto), opcode::SMSG_CHAR_ENUM, &[0]);
}
opcode::CMSG_CHAR_CREATE => send(
&mut s,
Some(&mut crypto),
opcode::SMSG_CHAR_CREATE,
&[messages::CHAR_CREATE_SUCCESS],
),
_ => {}
}
}
sessions.fetch_sub(1, Ordering::SeqCst);
});
}
});
World { sent, open }
}
/// A fake realmd listing `realms` and the world server they point at. `realms` takes the world's
/// address.
pub(crate) fn servers(realms: impl FnOnce(&str) -> Vec<FakeRealm>) -> (Realmd, World) {
let world_listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = world_listener.local_addr().unwrap().to_string();
let (keys_tx, keys_rx) = crossbeam_channel::unbounded();
let sent = world(world_listener, keys_rx);
(Realmd::start(realms(&addr), keys_tx), sent)
}
/// An address nothing answers on: a port bound and released.
pub(crate) fn dead_address() -> String {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().to_string()
}
/// The app's ends of the IO thread's channels.
pub(crate) struct Cycles {
pub(crate) events: Receiver<SessionEvent>,
pub(crate) login: Sender<LoginRequest>,
pub(crate) realm: Sender<RealmRequest>,
pub(crate) pick: Sender<CharRequest>,
pub(crate) abandon: Arc<AtomicU64>,
}
/// The read thread's cycle loop, as [`super::spawn_net`] runs it, with the world writer dropped.
pub(crate) fn spawn_cycles() -> Cycles {
let (events_tx, events) = crossbeam_channel::unbounded();
let (login, login_rx) = crossbeam_channel::unbounded();
let (realm, realm_rx) = crossbeam_channel::unbounded();
let (pick, pick_rx) = crossbeam_channel::unbounded();
let abandon = Arc::new(AtomicU64::new(0));
let thread_abandon = Arc::clone(&abandon);
thread::spawn(move || {
let (writer_tx, _writer_rx) = crossbeam_channel::unbounded::<WorldWriter>();
let parks = Parks {
login_rx,
realm_rx,
pick_rx,
};
let clock = Mutex::new(PingClock::default());
let mut tails = Default::default();
loop {
match run(
&events_tx,
&writer_tx,
&parks,
&thread_abandon,
&clock,
&mut tails,
) {
Ok(Cycle::Exit) => return,
Ok(Cycle::Repark) => {}
Ok(Cycle::LoggedOut | Cycle::LoginRefused) => {
let _ = events_tx.send(SessionEvent::Disconnected {
reason: "logged out".into(),
end: SessionEnd::LoggedOut,
});
}
Err(e) => {
if events_tx
.send(SessionEvent::Disconnected {
reason: format!("disconnected: {e:#}"),
end: SessionEnd::Lost,
})
.is_err()
{
return;
}
}
}
}
});
Cycles {
events,
login,
realm,
pick,
abandon,
}
}
/// A login attempt for [`USER`] at `host` under the current abandon generation.
pub(crate) fn login(cycles: &Cycles, host: &str) {
cycles
.login
.send(LoginRequest {
user: USER.into(),
pass: PASS.into(),
host: host.into(),
generation: cycles.abandon.load(Ordering::SeqCst),
})
.unwrap();
}
+2 -2
View File
@@ -108,8 +108,8 @@ fn on_login_edge(In(ev): In<SessionEvent>, mut e: Edges) {
terminal,
dial,
} => login_failed(refusal, reason, terminal, dial, &mut e.login_failures),
SessionEvent::RealmList { realms } => {
e.realm_lists.write(RealmListMessage { realms });
SessionEvent::RealmList { realms, failed } => {
e.realm_lists.write(RealmListMessage { realms, failed });
}
SessionEvent::CharActionResult { action, code } => {
char_action_result(action, code, &mut e.char_actions)
+44 -15
View File
@@ -6,7 +6,9 @@ use bevy::input::mouse::MouseWheel;
use bevy::prelude::*;
use crate::bindings::WheelNotches;
use crate::net::{RealmChoice, RealmRequest};
use crate::char_select::ClientState;
use crate::login::GlueLogin;
use crate::net::RealmChoice;
use crate::sound::GlueSound;
use super::screen::{RealmAction, MAX_ROWS};
@@ -17,12 +19,14 @@ const DOUBLE_CLICK_SECS: f32 = 0.4;
/// Clicks: a row selects (a second one enters), a tab fronts its category, the column headers
/// sort, Okay enters, Cancel and the close X leave.
#[allow(clippy::too_many_arguments)]
pub(super) fn clicks(
buttons: Query<(Entity, &RealmAction)>,
hits: Res<crate::glue::GlueClicks>,
mut realms: ResMut<Realms>,
choice: Res<RealmChoice>,
mut sounds: MessageWriter<GlueSound>,
mut cancels: Cancels,
time: Res<Time>,
mut last_click: Local<Option<(String, f32)>>,
) {
@@ -60,13 +64,22 @@ pub(super) fn clicks(
}
}
if enter {
try_enter(&mut realms, &choice, &mut sounds);
try_enter(&mut realms, &choice, &mut sounds, &cancels);
}
if let Some(with_sound) = leave {
do_cancel(&mut realms, &choice, &mut sounds, with_sound);
do_cancel(&mut realms, &mut cancels, &mut sounds, with_sound);
}
}
/// What Okay and Cancel need beyond the list: the screen under it, the login engine's ask, and
/// the abandon generation a dial is stamped with.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct Cancels<'w> {
state: Option<Res<'w, State<ClientState>>>,
login: MessageWriter<'w, GlueLogin>,
abandon: Option<Res<'w, crate::net::LoginAbandon>>,
}
/// `RealmList_OnKeyDown` (ESCAPE / ENTER), plus arrow-key row cycling and the wheel.
pub(crate) fn keys(
keys: Res<ButtonInput<KeyCode>>,
@@ -74,13 +87,14 @@ pub(crate) fn keys(
mut realms: ResMut<Realms>,
choice: Res<RealmChoice>,
mut sounds: MessageWriter<GlueSound>,
mut cancels: Cancels,
) {
if keys.just_pressed(KeyCode::Escape) {
do_cancel(&mut realms, &choice, &mut sounds, true);
do_cancel(&mut realms, &mut cancels, &mut sounds, true);
return;
}
if keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter) {
try_enter(&mut realms, &choice, &mut sounds);
try_enter(&mut realms, &choice, &mut sounds, &cancels);
return;
}
@@ -146,16 +160,16 @@ fn scroll_into_view(realms: &mut Realms, row: usize, total: usize) {
}
/// `RealmList_OnCancel`, and the close X without the sound. `RealmListDialogCancelled`
/// (`0x46ed20` -> `0x46b810`) does nothing off the login screen and there closes the realmd
/// socket (`0x5b3320`) with no screen change; the character park ignores `Abandon` and the login
/// park re-parks, dropping the socket.
/// (`0x46ed20` -> `0x46b810`) does nothing off the login screen and there closes the realmd link
/// (`CLoginMgr::Cancel`, `0x5b3320`) with no screen change, which ends the attempt: the login
/// engine closes the logon park and takes the next Login.
///
/// Deviation: the reference's X only cancels the pending realm query (`0x46ed10` -> `0x46b7e0`)
/// and leaves the realmd link up; ours abandons, because the IO thread blocks at the realm park
/// until answered.
/// Deviation: the reference's X only cancels a pending realm query (`0x46ed10` -> `0x46b7e0`) and
/// leaves the realmd link up; ours closes it as Cancel does, because the native login screen takes
/// a new Login only once the attempt has ended.
fn do_cancel(
realms: &mut Realms,
choice: &RealmChoice,
cancels: &mut Cancels,
sounds: &mut MessageWriter<GlueSound>,
with_sound: bool,
) {
@@ -163,14 +177,25 @@ fn do_cancel(
sounds.write(GlueSound("gsLoginChangeRealmCancel"));
}
realms.hide();
let _ = choice.0.send(RealmRequest::Abandon);
if cancels
.state
.as_ref()
.is_none_or(|s| *s.get() == ClientState::Login)
{
cancels.login.write(GlueLogin::Abandon);
}
}
/// `RealmList_OnOk`: play the click, hide the frame, answer the park.
///
/// Not built: the reference's `REALM_IS_FULL` Yes/No confirm for a `Full` realm with no characters
/// on it; ours enters directly. `Full` is the `0x80` flag, which vmangos never sets.
fn try_enter(realms: &mut Realms, choice: &RealmChoice, sounds: &mut MessageWriter<GlueSound>) {
fn try_enter(
realms: &mut Realms,
choice: &RealmChoice,
sounds: &mut MessageWriter<GlueSound>,
cancels: &Cancels,
) {
let Some(realm) = realms.selected() else {
return; // the reference disables Okay with nothing highlighted
};
@@ -181,7 +206,11 @@ fn try_enter(realms: &mut Realms, choice: &RealmChoice, sounds: &mut MessageWrit
sounds.write(GlueSound("gsLoginChangeRealmOK"));
realms.hide();
realms.store_front_category();
realms.enter(choice, name);
realms.enter(
choice,
name,
crate::net::LoginAbandon::now(cancels.abandon.as_deref()),
);
}
#[cfg(test)]
+4 -52
View File
@@ -2,8 +2,8 @@
//!
//! The load word is a band, not the population the auth server sent: the reference takes the
//! mean and a scaled standard deviation over every realm it knows (all categories) and places each
//! realm in that distribution. Ports of `CGlue::RealmLoadStats` `0x46e510` and
//! `CGlue::RealmLoadClassify` `0x46ec60`, with their x87 rounding, since the band edges are where a
//! realm in that distribution: `realm_load_stats` `0x46e510` and `realm_load_classify` `0x46ec60`,
//! ported with their x87 rounding in [`benilla_ui::script`], since the band edges are where a
//! rounding difference flips the word. The band to string to colour mapping is `RealmListUpdate`
//! in `GlueXML/RealmList.lua`.
@@ -27,56 +27,8 @@ pub(super) const RED: Color = Color::srgb(1.0, 0.1, 0.1);
/// `BLUE_FONT_COLOR`.
pub(super) const BLUE: Color = Color::srgb(0.0, 0.749, 0.953);
/// The std-dev scale, 0.6 (`[0x8038d8]`).
const STDDEV_MULT: f32 = f32::from_bits(0x3f19_999a);
/// `CGlue::RealmLoadStats` (`0x46e510`): `(mean, sqrt(Σ(pop − mean)² / (n − 1)) · 0.6)` over every
/// realm's population. Both accumulators round to `f32` each iteration; the divide, sqrt and scale
/// run at 53 bits. `n <= 1` gives `(1.0, 0.0)`, so a lone realm is banded against the literal 1.0.
pub(super) fn realm_load_stats(populations: &[f32]) -> (f32, f32) {
let n = populations.len();
if n <= 1 {
return (1.0, 0.0);
}
// `fld sum; fadd pop; fstp sum`: stored as f32 each step.
let mut sum: f32 = 0.0;
for &pop in populations {
sum = (f64::from(sum) + f64::from(pop)) as f32;
}
// `fild n; fdivr sum; fstp mean`.
let mean = (f64::from(sum) / n as f64) as f32;
// Stored as f32 each step.
let mut acc: f32 = 0.0;
for &pop in populations {
let d = f64::from(pop) - f64::from(mean);
acc = (d * d + f64::from(acc)) as f32;
}
// `fild n-1; fdivr acc; fsqrt; fmul 0.6; fstp stddev`.
let stddev = ((f64::from(acc) / (n - 1) as f64).sqrt() * f64::from(STDDEV_MULT)) as f32;
(mean, stddev)
}
/// `CGlue::RealmLoadClassify` (`0x46ec60`): the load indicator `GetRealmInfo` returns. The three
/// sentinel bits, synthesized by the parser from magic populations as the reference's does, win in
/// this order; then the band, whose 53-bit comparisons are strict, so an edge reads normal.
pub(super) fn realm_load_classify(flags: u8, population: f32, mean: f32, stddev: f32) -> f32 {
if flags & 0x20 != 0 {
return -3.0;
}
if flags & 0x40 != 0 {
return -2.0;
}
if flags & 0x80 != 0 {
return 2.0;
}
if (f64::from(mean) - f64::from(stddev)) > f64::from(population) {
return -1.0;
}
if (f64::from(mean) + f64::from(stddev)) < f64::from(population) {
return 1.0;
}
0.0
}
/// The load band's two primitives, shared with the stock glue's `GetRealmInfo`.
pub(super) use benilla_ui::script::{realm_load_classify, realm_load_stats};
/// The Population column's key and colour: `RealmListUpdate`'s ladder, offline first, then the
/// sentinels by exact comparison, then the sign.
+131 -12
View File
@@ -5,8 +5,8 @@
//! `GlueScreenInfo` has no entry for it): it is shown over the current screen and hidden again,
//! and that screen is where the player returns.
//!
//! The IO thread publishes the list from two parks (`crate::net::io`), the login-side realm park
//! and the character park, and takes one answer. The policy, in order:
//! The IO thread publishes the list from two parks (`crate::net::io`), the logon park and the
//! character park, and takes one answer. The policy, in order:
//!
//! 1. `WOW_REALM`, matched case-insensitively; a name not on the list falls through.
//! 2. The realm this session is on, so a logout or a dropped connection returns to it.
@@ -18,6 +18,9 @@
//! begins a realm query on the realmd socket, which stays open until world entry (`0x46b70c`), and
//! names no screen; only `ConnectToRealm` (`0x46b210`) drops the world connection, once a realm is
//! chosen. So the character park serves the list in place and Cancel there does nothing.
//!
//! Under `WOW_GLUE_STOCK=1` the stock `RealmList` and its verbs take the list instead
//! ([`crate::ui_script::glue_mgr`]), and this policy stands down.
mod category;
mod input;
@@ -94,7 +97,8 @@ pub(super) enum SortKey {
Load,
/// `SortRealms("name")`, case 2: a case-folding compare.
Name,
/// `SortRealms("mode")`, case 3: the `(pvp, rp)` pair.
/// `SortRealms("mode")`, case 3: the `Cfg_Configs` pair, RP before PvP, so Normal, PvP, RP,
/// RP-PvP ascending.
Type,
}
@@ -164,7 +168,14 @@ impl Realms {
la.total_cmp(&lb)
}
SortKey::Name => ra.name.to_lowercase().cmp(&rb.name.to_lowercase()),
SortKey::Type => self.pvp_rp(ra.realm_type).cmp(&self.pvp_rp(rb.realm_type)),
// RP decides before PvP (`0x46e92c`-`0x46e978`).
SortKey::Type => {
let rp_pvp = |t: u32| {
let (pvp, rp) = self.pvp_rp(t);
(rp, pvp)
};
rp_pvp(ra.realm_type).cmp(&rp_pvp(rb.realm_type))
}
};
if ord != std::cmp::Ordering::Equal {
return if descending { ord.reverse() } else { ord };
@@ -177,6 +188,36 @@ impl Realms {
rows
}
/// The client Region's categories and the realm types, as the stock glue's realm verbs read
/// them; no categories until the DBC is read.
pub(crate) fn glue_setup(
&self,
) -> (
Vec<benilla_ui::script::GlueRealmCategory>,
Vec<benilla_ui::script::GlueRealmType>,
) {
let categories = self
.categories
.iter()
.flatten()
.map(|c| benilla_ui::script::GlueRealmCategory {
id: c.id,
name: c.name.clone(),
})
.collect();
let types = self
.types
.rows()
.iter()
.map(|t| benilla_ui::script::GlueRealmType {
realm_type: t.realm_type,
pvp: t.pvp,
rp: t.rp,
})
.collect();
(categories, types)
}
/// A realm type's `(pvp, rp)` in `Cfg_Configs.dbc`, `(false, false)` for a type with no row.
pub(crate) fn pvp_rp(&self, realm_type: u32) -> (bool, bool) {
load::pvp_rp(&self.types, realm_type)
@@ -240,10 +281,16 @@ impl Realms {
self.offset = self.offset.min(max);
}
/// Answer a park with a realm and remember it as [`Self::current`].
pub(super) fn enter(&mut self, choice: &RealmChoice, name: String) {
self.current = Some(name.clone());
let _ = choice.0.send(RealmRequest::Enter(name));
/// Answer a park with a realm, by the record the list holds, stamped with the abandon
/// generation `generation` the app holds now, and remember it as [`Self::current`].
pub(super) fn enter(&mut self, choice: &RealmChoice, name: String, generation: u64) {
if let Some(realm) = self.realms.iter().find(|r| r.name == name) {
let _ = choice.0.send(RealmRequest::Enter {
realm: realm.clone(),
generation,
});
}
self.current = Some(name);
}
/// `RealmList:Hide()`, and nothing else.
@@ -252,8 +299,33 @@ impl Realms {
}
}
/// `GetRealmInfo`'s `realmDown`: flag bit 0x02, greyed and not enterable; the reference's
/// auto-pick (`0x46ea20`) skips a realm on these low bits.
#[cfg(test)]
impl Realms {
/// A US client's state at startup, read without an install: Region 1's two categories and the
/// shipped `Cfg_Configs.dbc` types 0, 1, 6 and 8.
pub(crate) fn us_client() -> Realms {
let ty = |realm_type, pvp, rp| benilla_formats::RealmConfig {
realm_type,
pvp,
rp,
};
Realms {
categories: Some(category::tests::us()),
types: RealmConfigs::from_rows(vec![
ty(0, false, false),
ty(1, true, false),
ty(6, false, true),
ty(8, true, true),
]),
..Realms::default()
}
}
}
/// `GetRealmInfo`'s `realmDown`: flag bit 0x02 of the record's flags byte (`+0x08`, `0x46ef83`),
/// greyed and not enterable. The reference's own pick, `SetPreferredInfo`'s `0x46ea20`, skips only
/// bit 0x01 (`0x46ea7c`), so it can suggest a realm that is down; `auto_answer` skipping one is
/// benilla's policy ladder, not the reference's.
pub(crate) fn is_down(realm: &RealmInfo) -> bool {
realm.flags & 0x02 != 0
}
@@ -291,7 +363,7 @@ impl Plugin for RealmSelectPlugin {
.add_systems(
Update,
(
apply_realm_policy,
apply_realm_policy.run_if(crate::ui_script::glue::native_glue),
// Ungated by state: the dialog stands over login and character select alike.
screen::drive_screen,
// Input before the row refresh, so a click shows on the frame it landed.
@@ -321,12 +393,18 @@ fn apply_realm_policy(
mut realms: ResMut<Realms>,
choice: Res<RealmChoice>,
cvars: Res<crate::cvars::Cvars>,
abandon: Option<Res<crate::net::LoginAbandon>>,
) {
let generation = crate::net::LoginAbandon::now(abandon.as_deref());
if !realms.env_read {
realms.env_read = true;
realms.env_realm = std::env::var("WOW_REALM").ok().filter(|s| !s.is_empty());
}
for msg in msgs.read() {
// A query that got no answer leaves the list as it stands.
if msg.failed {
continue;
}
realms.realms = msg.realms.clone();
realms.refresh_in = REFRESH_SECS;
// The registered default is empty: a client that has never connected.
@@ -345,6 +423,9 @@ fn apply_realm_policy(
if realms.shown {
continue;
}
// A server that lists no realm gets the empty list, as the reference shows it once the
// connect fails `REALM_LIST_REALM_NOT_FOUND` with no realm held (`0x46bf86` → `0x46c06e`):
// no realm answers it.
match auto_answer(
&realms,
remembered.as_deref(),
@@ -352,7 +433,7 @@ fn apply_realm_policy(
) {
Some(name) => {
realms.select(&name);
realms.enter(&choice, name);
realms.enter(&choice, name, generation);
}
None => realms.open(),
}
@@ -675,6 +756,44 @@ mod tests {
);
}
/// A server that lists no realm: nothing is dialled, and the list goes up empty, as the
/// reference shows it (`0x46bf86` → `0x46c06e`).
#[test]
fn an_empty_list_dials_nothing_and_shows_the_list() {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<Realms>()
.init_resource::<crate::cvars::Cvars>()
.insert_resource(RealmChoice(tx))
.add_message::<RealmListMessage>()
.add_systems(Update, apply_realm_policy);
app.world_mut().write_message(RealmListMessage {
realms: Vec::new(),
failed: false,
});
app.update();
assert!(rx.try_recv().is_err(), "no dial");
assert!(
app.world().resource::<Realms>().shown,
"the empty list is up"
);
assert_eq!(auto_answer(&list(Vec::new()), None, true), None);
}
/// Mode ascending is Normal, PvP, RP, RP-PvP: RP decides before PvP (`0x46e92c`-`0x46e978`).
#[test]
fn the_mode_sort_puts_rp_after_pvp() {
let mut r = Realms::us_client();
r.realms = vec![
realm("RpPvp", 1, 8, 0, 0, 1.0),
realm("Rp", 1, 6, 0, 0, 1.0),
realm("Pvp", 1, 1, 0, 0, 1.0),
realm("Normal", 1, 0, 0, 0, 1.0),
];
r.sort.click(SortKey::Type);
assert_eq!(names(&r), ["Normal", "Pvp", "Rp", "RpPvp"]);
}
/// The reference disables an offline realm's Okay.
#[test]
fn an_offline_realm_cannot_be_entered() {
@@ -852,6 +852,7 @@ mod tests {
.init_resource::<GlueClicks>()
.insert_resource(crate::net::RealmChoice(tx))
.add_message::<crate::sound::GlueSound>()
.add_message::<crate::login::GlueLogin>()
.insert_resource(Realms {
realms: list.iter().map(|&(n, c)| realm(n, c)).collect(),
categories: Some(super::super::category::tests::us()),
+17 -9
View File
@@ -12,7 +12,7 @@
use bevy::prelude::*;
use crate::char_select::{ClientState, Roster};
use crate::net::{RealmChoice, RealmRequest};
use crate::net::RealmChoice;
use super::Realms;
@@ -41,6 +41,7 @@ pub(super) fn debug_realm_smoke(
roster: Res<Roster>,
state: Res<State<ClientState>>,
choice: Res<RealmChoice>,
abandon: Option<Res<crate::net::LoginAbandon>>,
time: Res<Time>,
mut walk: Local<Walk>,
mut exit: MessageWriter<AppExit>,
@@ -110,7 +111,11 @@ pub(super) fn debug_realm_smoke(
return;
}
info!("realm-smoke: dialog up, character select still underneath → Okay");
okay(realms, &choice);
okay(
realms,
&choice,
crate::net::LoginAbandon::now(abandon.as_deref()),
);
(walk.phase, walk.mark, walk.rosters) = (2, now, walk.seen + 1);
}
// ── 2 · Okay has to land: a fresh roster, dialog down, still at select. ──────────────
@@ -122,7 +127,7 @@ pub(super) fn debug_realm_smoke(
// ── 3 · Cancel changes nothing. ──────────────────────────────────────────────────────
3 if realms.shown && now - walk.mark > DWELL => {
info!("realm-smoke: dialog up → Cancel");
cancel(realms, &choice);
cancel(realms);
(walk.phase, walk.mark) = (4, now);
}
4 if !realms.shown && now - walk.mark > DWELL => {
@@ -137,7 +142,11 @@ pub(super) fn debug_realm_smoke(
(walk.phase, walk.mark) = (5, now);
}
5 if realms.shown && now - walk.mark > DWELL => {
okay(realms, &choice);
okay(
realms,
&choice,
crate::net::LoginAbandon::now(abandon.as_deref()),
);
(walk.phase, walk.mark, walk.rosters) = (6, now, walk.seen + 1);
}
6 if walk.seen >= walk.rosters && !realms.shown && at_select => {
@@ -159,7 +168,7 @@ fn open(mut realms: ResMut<Realms>, choice: &RealmChoice) {
}
/// `RealmList_OnOk` on whatever the dialog opened with highlighted, or the first row.
fn okay(mut realms: ResMut<Realms>, choice: &RealmChoice) {
fn okay(mut realms: ResMut<Realms>, choice: &RealmChoice, generation: u64) {
let name = realms.selected().map(|r| r.name.clone()).or_else(|| {
realms
.rows()
@@ -170,11 +179,10 @@ fn okay(mut realms: ResMut<Realms>, choice: &RealmChoice) {
return;
};
realms.hide();
let _ = choice.0.send(RealmRequest::Enter(name));
realms.enter(choice, name, generation);
}
/// `RealmList_OnCancel`.
fn cancel(mut realms: ResMut<Realms>, choice: &RealmChoice) {
/// `RealmList_OnCancel` over character select, where it only hides the list.
fn cancel(mut realms: ResMut<Realms>) {
realms.hide();
let _ = choice.0.send(RealmRequest::Abandon);
}
+332 -42
View File
@@ -30,7 +30,35 @@
//! 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`).
//! 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 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
@@ -38,24 +66,24 @@
use bevy::prelude::*;
use benilla_protocol::{LoginRefusal, LoginStage};
use benilla_protocol::{LoginRefusal, LoginStage, RealmInfo};
use benilla_ui::script::{
GlueNotice, GlueRequest, GlueServer, NoticeState, ScriptValue, UiScript, VmKind,
GlueNotice, GlueRealm, GlueRealmCells, GlueRequest, GlueServer, NoticeState, ScriptValue,
UiScript, VmKind,
};
use crate::login::queue::QueueEstimate;
use crate::login::GlueLogin;
use crate::net::{
CharListMessage, DisconnectedMessage, LoginFailedMessage, LoginQueuedMessage,
LoginStageMessage, RealmListMessage,
LoginStageMessage, RealmListMessage, RealmRequest,
};
/// `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.
/// states the login and the realm list reach. The reference's others are the character list (3,
/// `0x46b893`), 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).
@@ -65,6 +93,8 @@ pub(crate) enum ScreenState {
Logon = 1,
/// The world connect: realm list, dial and authentication (`0x46b1f9`, `0x46b250`).
Connect = 2,
/// A realm list query (`RequestRealmList`, `0x46b8d2`).
RealmList = 4,
}
/// The logon's `LOGIN_STATE_*` keys by login state (`0x836b40`, 13 entries).
@@ -220,6 +250,7 @@ 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;
@@ -227,6 +258,10 @@ 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;
/// 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
@@ -258,7 +293,7 @@ pub(crate) fn realmd_result(byte: u8, reply: benilla_protocol::LogonReply) -> Op
Some((LOGIN_FAILED_STATE, result))
}
/// A `ClientServices` operation of state 2 (`[svc+0x1b10]`).
/// A `ClientServices` operation (`[svc+0x1b10]`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum OpKind {
/// `COP_CONNECT` (2, `0x5aab20`).
@@ -266,6 +301,8 @@ enum OpKind {
Connect,
/// The authentication (3, `0x5aaba0`).
Auth,
/// `COP_GET_REALMS` (9, `0x5ab840`).
GetRealms,
}
/// The operation record (`BeginOperation` `0x5aa400`, `SetStatus` `0x5aa920`): its status code
@@ -278,6 +315,9 @@ struct Operation {
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 {
@@ -285,6 +325,7 @@ impl Operation {
Operation {
kind,
status,
awaiting_list: matches!(kind, OpKind::Connect | OpKind::GetRealms),
..Default::default()
}
}
@@ -331,6 +372,11 @@ enum Outgoing {
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 logon's notification cells: the latest `(state, result)` (`0x836b28`, `0x836b24`) and the
@@ -376,6 +422,23 @@ pub(crate) struct GlueMgr {
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 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 requests for the IO thread, sent at the end of the frame.
realm_out: Vec<RealmRequest>,
/// Asks of the login engine the per-frame arms made, sent with the verbs' own.
login_out: Vec<GlueLogin>,
}
impl GlueMgr {
@@ -420,7 +483,7 @@ impl GlueMgr {
self.open("CANCEL", None);
}
/// `StatusDialogClick` (`0x46b720`).
/// `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.
@@ -430,18 +493,111 @@ impl GlueMgr {
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;
// `0x46b754`: the operation completes `RESPONSE_CANCELLED`, which the next tick's
// failure arm takes without a dialog (`0x46bef2`), dropping the world socket and
// raising the realm list. Its `0x472580` tears the glue character list down, which
// these screens do not have yet.
ScreenState::Connect => self.op.complete(false, STATUS_CANCELLED),
// `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<RealmRequest>) {
(
std::mem::take(&mut self.login_out),
std::mem::take(&mut self.realm_out),
)
}
/// `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.realm_out.push(RealmRequest::Refresh);
}
/// `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.realm_out.push(RealmRequest::Leave);
}
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.realm_out.push(RealmRequest::Refresh);
}
/// 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.realm_out.push(RealmRequest::Enter {
realm: r.clone(),
generation: 0,
});
}
None => {
info!("glue: realmName {realm_name:?} names no listed realm");
self.op.complete(false, STATUS_REALM_LIST_REALM_NOT_FOUND);
}
}
}
OpKind::Auth => {}
}
}
/// 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.
@@ -520,27 +676,43 @@ impl GlueMgr {
}
}
/// 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.
/// 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 raises the realm list (`0x46bf1f`-`0x46bf96`),
/// or with no realm held asks for one (`0x46c06e`). The arm's `0x472580` tears the glue
/// character list down, which these screens do not have yet.
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)),
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 code == Some(STATUS_AUTH_FAILED) && self.screen.eq_ignore_ascii_case("charselect") {
self.outbox.push(Outgoing::Screen("login"));
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;
}
if self.holder.is_empty() {
self.request_realm_list(true);
} else {
self.outbox.push(Outgoing::Close);
self.outbox.push(Outgoing::Rebuild);
}
}
@@ -610,7 +782,8 @@ impl GlueMgr {
}
if self.auth_final {
// `0x46ba8d`-`0x46ba98`, then `0x46b1f0`'s idle test passes and the connect
// begins at `CSTATUS_CONNECTING` (`0x5aab4a`), its ring cleared (`0x5aab30`).
// 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);
@@ -625,7 +798,28 @@ impl GlueMgr {
}
}
ScreenState::Connect => self.tick_connect(),
_ => {}
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);
}
}
@@ -651,6 +845,20 @@ impl GlueMgr {
self.op = Operation::default();
self.queue = QueueEstimate::default();
self.outbox.push(Outgoing::Screen("charselect"));
self.roster_arm();
}
}
/// The roster's arm (`0x4724d0`), which the reference reaches from the character list query
/// that `charselect` asks for (`0x46c094`); benilla's roster comes with the authentication.
/// Characters mark a realm chosen (`0x472556`); an empty roster with no realm chosen asks for
/// the list with its dialog (`0x47253d` → `0x46b8d0`), skipping `CHARACTER_LIST_UPDATE`.
fn roster_arm(&mut self) {
if self.characters != 0 {
self.realm_cells.chosen = Some(0);
self.cells_moved = true;
} else if self.realm_cells.chosen.is_none() {
self.request_realm_list(true);
}
}
@@ -692,6 +900,27 @@ impl GlueMgr {
}
}
/// `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>) {
@@ -720,6 +949,8 @@ pub(crate) struct LoginNews<'w, 's> {
pub(crate) struct GlueAsks<'w> {
login: MessageWriter<'w, GlueLogin>,
music: MessageWriter<'w, crate::sound::GlueMusicRequest>,
realm: Option<Res<'w, crate::net::RealmChoice>>,
abandon: Option<Res<'w, crate::net::LoginAbandon>>,
}
/// The CVars each agreement is read from and written to.
@@ -768,7 +999,9 @@ pub(crate) fn drive_glue_mgr(
}
}
// A new glue VM is the glue build (`0x46a7b0`): state 0, its slots seeded.
// 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());
@@ -776,12 +1009,26 @@ pub(crate) fn drive_glue_mgr(
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);
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 verbs the last tick's Lua called.
let mut logins = Vec::new();
@@ -812,15 +1059,27 @@ pub(crate) fn drive_glue_mgr(
logins.push(GlueLogin::Disconnect);
}
GlueRequest::QuitGame => logins.push(GlueLogin::Quit),
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),
}
}
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);
}
for _ in news.realm_lists.read() {
// 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() {
@@ -830,19 +1089,33 @@ pub(crate) fn drive_glue_mgr(
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);
}
for msg in news.rosters.read() {
mgr.characters = msg.characters.len().min(usize::from(u16::MAX)) as u16;
mgr.on_roster(msg.realm.clone());
}
for msg in news.disconnects.read() {
mgr.on_disconnected(msg.session_over);
}
mgr.tick();
for ask in logins {
let (arms, realm_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 realm_asks {
if let RealmRequest::Enter { generation, .. } = &mut ask {
*generation = now;
}
let _ = choice.0.send(ask);
}
}
let Some(mut script) = script else {
return;
@@ -855,13 +1128,18 @@ pub(crate) fn drive_glue_mgr(
data: Some(typed),
});
}
seed(&mgr, &mut script, realms.as_deref(), realmlist.as_deref());
seed(
&mut 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,
mgr: &mut GlueMgr,
script: &mut UiScript,
realms: Option<&crate::realm_select::Realms>,
realmlist: Option<&crate::realmlist::Realmlist>,
@@ -873,6 +1151,13 @@ fn seed(
}
}
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 {
@@ -962,6 +1247,11 @@ fn flush(mgr: &mut GlueMgr, script: &mut UiScript, now_ms: u32) {
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();
}
}
}
}
@@ -4,6 +4,7 @@ use std::sync::Arc;
use super::*;
use crate::char_select::ClientState;
use crate::net::{LoginAbandon, LoginRequest, LoginSubmit};
use benilla_protocol::RealmInfo;
/// The manager with both agreements accepted, the way a returning player's CVars seed it.
fn accepted() -> GlueMgr {
@@ -168,13 +169,29 @@ fn a_logon_refusal_opens_its_result_and_ends_the_logon() {
}
}
/// One realm, `name`, as realmd lists it.
fn realm(name: &str) -> RealmInfo {
RealmInfo {
name: name.into(),
address: "127.0.0.1:8085".into(),
population: 1.0,
characters: 0,
realm_type: 0,
flags: 0,
category: 1,
id: 1,
}
}
/// 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.
/// five codes of `0x803740`, then the realm list over it (`CLOSE_STATUS_DIALOG` and the rebuild);
/// `AUTH_FAILED` instead goes 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.holder = vec![realm("Fake")];
m
};
let update = |key| Outgoing::Update {
@@ -192,7 +209,9 @@ fn a_world_refusal_opens_its_status_and_url() {
which: "OKAY_WITH_URL",
text: Some(Text::Key("AUTH_BANNED")),
data: Some("AUTH_BANNED_URL".into()),
}
},
Outgoing::Close,
Outgoing::Rebuild,
]
);
assert_eq!(m.state, ScreenState::Idle);
@@ -217,9 +236,31 @@ fn a_world_refusal_opens_its_status_and_url() {
m.outbox,
[
update("RESPONSE_FAILED_TO_CONNECT"),
open("OKAY", Some("RESPONSE_FAILED_TO_CONNECT"))
open("OKAY", Some("RESPONSE_FAILED_TO_CONNECT")),
Outgoing::Close,
Outgoing::Rebuild,
]
);
assert!(
m.realm_out.is_empty(),
"the held list is raised, not asked for"
);
// With no realm held the arm asks for the list (`0x46c06e` → `0x46b8d0` with 1).
let mut m = connected();
m.holder.clear();
m.on_failed(&failed(None, false));
m.tick();
assert_eq!(
m.outbox,
[
update("RESPONSE_FAILED_TO_CONNECT"),
open("OKAY", Some("RESPONSE_FAILED_TO_CONNECT")),
open("CANCEL", Some("REALM_LIST_IN_PROGRESS")),
]
);
assert_eq!(m.state, ScreenState::RealmList);
assert!(matches!(m.realm_out.as_slice(), [RealmRequest::Refresh]));
}
/// The world connect re-texts the dialog from its operation each frame: nothing while the connect
@@ -250,6 +291,7 @@ fn the_world_connect_shows_each_completion() {
m.outbox.is_empty(),
"the running authentication's text is empty"
);
m.characters = 2;
m.on_roster(None);
m.tick();
assert_eq!(
@@ -264,25 +306,70 @@ fn the_world_connect_shows_each_completion() {
);
}
/// The roster ends the world connect: the status text, state 0, the character screen.
/// The roster ends the world connect: the status text, state 0, the character screen, then the
/// roster's arm (`0x4724d0`): characters mark a realm chosen (`0x472556`); an empty roster with
/// no realm chosen asks for the list (`0x47253d` → `0x46b8d0`); one with a choice asks nothing.
#[test]
fn the_roster_puts_the_character_screen_up() {
let up = [
Outgoing::Update {
text: Text::Key("AUTH_OK"),
button: None,
},
Outgoing::Screen("charselect"),
];
let mut m = accepted();
m.state = ScreenState::Connect;
m.characters = 3;
m.on_roster(None);
m.tick();
assert_eq!(m.outbox, up);
assert_eq!(m.state, ScreenState::Idle);
assert!(m.connected);
assert_eq!(m.realm_cells.chosen, Some(0));
assert!(m.cells_moved && m.realm_out.is_empty());
let mut m = accepted();
m.state = ScreenState::Connect;
m.on_roster(None);
m.tick();
let mut want = up.to_vec();
want.push(open("CANCEL", Some("REALM_LIST_IN_PROGRESS")));
assert_eq!(
m.outbox,
[
Outgoing::Update {
text: Text::Key("AUTH_OK"),
button: None
},
Outgoing::Screen("charselect")
]
m.outbox, want,
"an empty roster with no choice asks for the list"
);
assert_eq!(m.state, ScreenState::Idle);
assert!(m.connected);
assert_eq!(m.state, ScreenState::RealmList);
assert!(matches!(m.realm_out.as_slice(), [RealmRequest::Refresh]));
let mut m = accepted();
m.state = ScreenState::Connect;
m.realm_cells.chosen = Some(5);
m.on_roster(None);
m.tick();
assert_eq!(m.outbox, up, "a realm was chosen: nothing more");
assert!(m.realm_out.is_empty());
}
/// Change Realm with the world up drops the session before the connect's query (`0x46b22b`).
#[test]
fn change_realm_with_the_world_up_leaves_it_first() {
let mut m = accepted();
m.holder = vec![realm("Fake")];
m.connected = true;
m.connect_to_realm("fake");
assert!(matches!(
m.realm_out.as_slice(),
[RealmRequest::Leave, RealmRequest::Refresh]
));
assert_eq!(m.state, ScreenState::Connect);
assert!(!m.connected);
assert_eq!(m.selected.as_ref().map(|r| r.name.as_str()), Some("Fake"));
let mut m = accepted();
m.holder = vec![realm("Fake")];
m.connect_to_realm("Fake");
assert!(matches!(m.realm_out.as_slice(), [RealmRequest::Refresh]));
}
/// The queue re-texts the dialog every frame while the world connect waits.
@@ -296,22 +383,51 @@ fn the_queue_retexts_every_frame() {
assert_eq!(m.outbox, [Outgoing::Queue, Outgoing::Queue]);
}
/// `StatusDialogClick` by state: the logon and the world connect are abandoned; idle does nothing.
/// `StatusDialogClick` by state (`0x46b7a8`): the logon is cancelled; the world connect completes
/// `RESPONSE_CANCELLED`, whose failure arm shows no dialog, drops the world attempt alone and
/// raises the realm list; a query is cancelled to state 0; 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]
));
m.state = ScreenState::Logon;
m.status_dialog_click(&mut asks);
assert_eq!(m.state, ScreenState::Idle);
assert!(matches!(asks.as_slice(), [GlueLogin::Abandon]));
let mut asks = Vec::new();
let mut m = accepted();
m.holder = vec![realm("Fake")];
m.state = ScreenState::Connect;
m.op = Operation::begin(OpKind::Auth, STATUS_AUTHENTICATING);
m.status_dialog_click(&mut asks);
assert!(asks.is_empty(), "the logon stays up");
assert_eq!(m.state, ScreenState::Connect, "the next tick takes it");
m.tick();
assert_eq!(
m.outbox,
[
Outgoing::Update {
text: Text::Key("RESPONSE_CANCELLED"),
button: None
},
Outgoing::Close,
Outgoing::Rebuild
],
"no failure dialog for a cancel"
);
assert!(matches!(m.login_out.as_slice(), [GlueLogin::CancelWorld]));
assert_eq!(m.state, ScreenState::Idle);
let mut m = accepted();
m.request_realm_list(false);
assert_eq!(m.state, ScreenState::RealmList);
m.status_dialog_click(&mut asks);
assert_eq!(m.state, ScreenState::Idle);
m.tick();
assert!(m.outbox.is_empty(), "a cancelled query reports nothing");
}
/// A session that is over fires `DISCONNECTED_FROM_SERVER`; a logout's relist does not; in the
@@ -525,3 +641,308 @@ fn the_realmlist_button_writes_realmlist() {
);
assert!(vm(&app).errors().is_empty(), "{:?}", vm(&app).errors());
}
// ── The stock screens over the IO thread and a fake realmd ──────────────────────────────────────
use crate::net::io::fake::{self, FakeRealm};
/// The IO thread's events, pumped into the app's messages as `net::session` does.
#[derive(Resource)]
struct IoEvents(crossbeam_channel::Receiver<benilla_protocol::SessionEvent>);
/// World dials the IO thread began (`LoginStage::Handshaking`).
#[derive(Resource, Default)]
struct WorldDials(usize);
fn pump(
events: Res<IoEvents>,
mut dials: ResMut<WorldDials>,
mut stages: MessageWriter<LoginStageMessage>,
mut lists: MessageWriter<RealmListMessage>,
mut failures: MessageWriter<LoginFailedMessage>,
mut rosters: MessageWriter<CharListMessage>,
mut queued: MessageWriter<LoginQueuedMessage>,
) {
use benilla_protocol::SessionEvent as E;
for ev in events.0.try_iter() {
match ev {
E::LoginStage { stage } => {
if stage == LoginStage::Handshaking {
dials.0 += 1;
}
stages.write(LoginStageMessage { stage });
}
E::RealmList { realms, failed } => {
lists.write(RealmListMessage { realms, failed });
}
E::LoginFailed {
refusal,
reason,
terminal,
dial,
} => {
failures.write(LoginFailedMessage {
refusal,
reason,
terminal,
dial,
});
}
E::CharacterList { characters, realm } => {
rosters.write(CharListMessage { characters, realm });
}
E::LoginQueued { position, realm } => {
queued.write(LoginQueuedMessage { position, realm });
}
_ => {}
}
}
}
/// The stock login screen, agreements accepted, over the IO thread's cycle and a fake realmd
/// listing `realms`; `realm_name` is the persisted `realmName`.
fn live_app(realms: impl FnOnce(&str) -> Vec<FakeRealm>, realm_name: &str) -> (App, fake::Realmd) {
let (realmd, _) = fake::servers(realms);
let cycles = fake::spawn_cycles();
let mut app = App::new();
app.init_resource::<Time>()
.init_resource::<GlueMgr>()
.init_resource::<crate::login::LoginIntent>()
.insert_resource(crate::realmlist::Realmlist::unpinned(&realmd.addr))
.insert_resource(LoginSubmit(cycles.login.clone()))
.insert_resource(LoginAbandon(cycles.abandon.clone()))
.insert_resource(crate::net::RealmChoice(cycles.realm.clone()))
.insert_resource(crate::net::CharPick(cycles.pick.clone()))
.insert_resource(crate::realm_select::Realms::us_client())
.insert_resource(IoEvents(cycles.events.clone()))
.init_resource::<WorldDials>()
.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,
(pump, drive_glue_mgr, crate::login::answer_glue).chain(),
);
let mut s = UiScript::new_glue().unwrap();
s.set_screen_size(1024.0, 768.0);
s.register_cvars(crate::cvars::registered_pairs());
s.set_cvar_host(crate::realm_select::CVAR_REALM_NAME, realm_name);
let _ = super::super::glue::load_glue(&mut s);
super::super::glue::set_glue_screen(&mut s, super::super::glue::GlueScreen::Login);
s.run("AcceptEULA() AcceptTOS() AccountLogin_ShowUserAgreements()")
.unwrap();
let _ = s.take_errors();
app.insert_non_send_resource(s);
app.update();
// The cycles' ends stay alive with the app's resources.
std::mem::forget(cycles);
(app, realmd)
}
/// Run frames, the VM's own tick between them, until `done` holds.
fn until(app: &mut App, what: &str, done: impl Fn(&App) -> bool) {
let deadline = std::time::Instant::now() + fake::WAIT;
while !done(app) {
if std::time::Instant::now() >= deadline {
let m = app.world().resource::<GlueMgr>();
panic!(
"timed out waiting for {what}: state {:?}, op {:?}, holder {}, screen {:?}, \
dialog {:?}, errors {:?}",
m.state,
m.op,
m.holder.len(),
m.screen,
dialog(app),
vm(app).errors()
);
}
app.update();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
/// Type the fake realmd's account into the stock boxes and press Login.
fn log_in(app: &mut App) {
vm(app)
.run(&format!(
"AccountLoginAccountEdit:SetText('{}') AccountLoginPasswordEdit:SetText('{}') \
AccountLogin_Login()",
fake::USER,
fake::PASS
))
.unwrap();
app.update();
}
fn shown(app: &App, frame: &str) -> bool {
vm(app)
.eval::<bool>(&format!("return {frame}:IsShown() == 1"))
.unwrap()
}
fn screen_up(app: &App) -> bool {
let m = app.world().resource::<GlueMgr>();
m.state == ScreenState::Idle && m.connected
}
/// A launch whose `realmName` names no realm, for an account the session has seen no roster of
/// and with no realm chosen: the connect fails `REALM_LIST_REALM_NOT_FOUND`, its `OKAY` comes
/// down, and `GET_PREFERRED_REALM_INFO` puts the realm wizard up (#834). Its location buttons are
/// the Region's categories holding a realm; Suggest with PvP asks `SetPreferredInfo`, whose
/// `SUGGEST_REALM` the character screen turns into the suggestion dialog, and Accept connects.
#[test]
fn a_fresh_account_is_asked_for_a_realm_and_suggested_one() {
let _ = benilla_formats::wow_data_or_skip!();
let (mut app, realmd) = live_app(
|world| {
let mut pvp = FakeRealm::new("Bravo", world);
pvp.realm_type = 1;
let mut oz = FakeRealm::new("Oz", world);
oz.category = 5;
vec![FakeRealm::new("Alpha", world), pvp, oz]
},
"",
);
log_in(&mut app);
until(&mut app, "the realm wizard", |app| {
shown(app, "RealmWizard")
});
assert_eq!(dialog(&app), None, "the not-found OKAY is taken back down");
assert_eq!(app.world().resource::<GlueMgr>().state, ScreenState::Idle);
assert_eq!(
vm(&app)
.eval::<(String, String, bool)>(
"return RealmWizardLocationButton1Text:GetText(), \
RealmWizardLocationButton2Text:GetText(), \
RealmWizardLocationButton3:IsShown() == 1"
)
.unwrap(),
("United States".into(), "Oceanic".into(), false),
"one location per category holding a realm"
);
vm(&app)
.run(
"RealmWizardLocationButton1:Click() RealmWizardGameTypeButton2:Click() \
RealmWizardSuggest:Click()",
)
.unwrap();
let (which, text) = dialog(&app).expect("the suggestion dialog");
assert_eq!(which, "SUGGEST_REALM");
assert!(
text.contains("Bravo"),
"the PvP realm is suggested: {text:?}"
);
vm(&app).run("GlueDialogButton1:Click()").unwrap();
until(&mut app, "the character screen", screen_up);
assert_eq!(
vm(&app)
.cvar(crate::realm_select::CVAR_REALM_NAME)
.as_deref(),
Some("Bravo"),
"ChangeRealm sets realmName"
);
assert_eq!(
realmd.logons(),
1,
"the realm was dialled on the logon's link"
);
}
/// A launch whose `realmName` names no realm, once a realm has been chosen this session, raises
/// the realm list over the login screen, its tabs the two categories. Cancel there closes the
/// realmd link and the next login is taken (#833); the close button leaves the link up, and the
/// next login is taken too; a row and Okay then reach the character screen.
#[test]
fn closing_the_realm_list_lets_the_next_login_through() {
let _ = benilla_formats::wow_data_or_skip!();
let (mut app, realmd) = live_app(
|world| {
let mut oz = FakeRealm::new("Oz", world);
oz.category = 5;
vec![FakeRealm::new("Alpha", world), oz]
},
"Gone",
);
app.world_mut().resource_mut::<GlueMgr>().realm_cells.chosen = Some(0);
// The fresh VM takes the cells on the next frame.
app.world_mut().resource_mut::<GlueMgr>().vm = None;
app.update();
log_in(&mut app);
until(&mut app, "the realm list", |app| shown(app, "RealmList"));
assert!(
shown(&app, "AccountLogin"),
"the list stands over the login screen"
);
assert_eq!(dialog(&app), None);
assert_eq!(
vm(&app)
.eval::<(String, String, bool)>(
"return RealmListTab1:GetText(), RealmListTab2:GetText(), \
RealmListTab2:IsShown() == 1"
)
.unwrap(),
("United States".into(), "Oceanic".into(), true)
);
vm(&app).run("RealmListCancelButton:Click()").unwrap();
until(&mut app, "the realmd link to close", |_| realmd.open() == 0);
assert!(!shown(&app, "RealmList") && shown(&app, "AccountLogin"));
log_in(&mut app);
until(&mut app, "the list after the second login", |app| {
shown(app, "RealmList")
});
assert_eq!(realmd.logons(), 2);
vm(&app).run("RealmListCloseButton:Click()").unwrap();
app.update();
assert_eq!(realmd.open(), 1, "the close button leaves the link up");
log_in(&mut app);
until(&mut app, "the list after the third login", |app| {
shown(app, "RealmList")
});
assert_eq!(realmd.logons(), 3);
vm(&app)
.run("RealmListRealmButton1:Click() RealmListOkButton:Click()")
.unwrap();
until(&mut app, "the character screen", screen_up);
assert_eq!(
vm(&app)
.cvar(crate::realm_select::CVAR_REALM_NAME)
.as_deref(),
Some("Alpha")
);
}
/// A server that lists no realm: the connect fails `REALM_LIST_REALM_NOT_FOUND`, the failure arm
/// finds the holder empty and asks for the list (`0x46bf86` → `0x46c06e`), and the empty reply's
/// rebuild puts the wizard up; nothing dials a fallback.
#[test]
fn an_empty_realm_list_asks_again_and_shows_it() {
let _ = benilla_formats::wow_data_or_skip!();
let (mut app, realmd) = live_app(|_| Vec::new(), "");
log_in(&mut app);
until(&mut app, "the realm wizard", |app| {
shown(app, "RealmWizard")
});
assert_eq!(realmd.lists(), 2, "the logon's list and the arm's query");
assert_eq!(
app.world().resource::<WorldDials>().0,
0,
"no fallback dial"
);
}
+7 -3
View File
@@ -117,9 +117,13 @@ impl LoginRefusal {
pub enum SessionEvent {
/// A login attempt reached `stage`; emitted by the IO thread, never wire-decoded.
LoginStage { stage: LoginStage },
/// The account's realms (`CMD_REALM_LIST`); the IO thread blocks for the app's pick. Re-sent on
/// each refresh (the reference re-requests every 5 s) and on a realm change.
RealmList { realms: Vec<crate::RealmInfo> },
/// The account's realms (`CMD_REALM_LIST`); emitted by the IO thread after the logon and for
/// each query (the reference re-requests every 5 s). `failed` is a query that got no answer
/// (`REALM_LIST_FAILED`); `realms` is then the list last held, empty with no logon.
RealmList {
realms: Vec<crate::RealmInfo>,
failed: bool,
},
/// Queued for a full realm (`AUTH_WAIT_QUEUE`), per queue packet: a wait, not an outcome.
LoginQueued {
position: Option<u32>,
+2 -1
View File
@@ -115,5 +115,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(model.cursor_pos)
})?,
)?;
super::glue_core::install(lua)
super::glue_core::install(lua)?;
super::glue_realm::install(lua)
}
+16 -2
View File
@@ -106,6 +106,18 @@ pub enum GlueRequest {
DisconnectFromServer,
/// `QuitGame()` (`0x46cea0` → `0x46b870` → `0x401560(0)`).
QuitGame,
/// `RequestRealmList([dialog])` (`0x46ecf0` → `0x46b8d0`): screen state 4, the status dialog
/// when `dialog`, and a realm list query (`0x5ab840`).
RequestRealmList { dialog: bool },
/// `CancelRealmListQuery()` (`0x46ed10` → `0x46b7e0`): in screen state 4 only, the query
/// completes cancelled and the state goes to 0; nothing is sent.
CancelRealmListQuery,
/// `RealmListDialogCancelled()` on the login screen (`0x46b810` → `CLoginMgr::Cancel`
/// `0x5b3320`): the realmd link closes, with no dialog and no screen change.
CancelLogon,
/// `ChangeRealm` reaching `ConnectToRealm` (`0x46b210`) with this realm, `realmName` already
/// set to it (`0x5ab7b0`).
ChangeRealm(String),
}
/// The realm `GetServerName` (`0x46d280`) answers: the `realmName` CVar's text, and the
@@ -130,7 +142,9 @@ pub(crate) struct GlueCore {
server: GlueServer,
/// The current glue screen's name (`[0xb41478]`), which `SetCurrentScreen` (`0x46ce60` →
/// `0x46b860`) copies into a 0x40-byte buffer.
screen: String,
pub(super) screen: String,
/// The realm list the realm verbs read ([`super::glue_realm`]).
pub(super) realms: super::glue_realm::GlueRealmList,
}
/// `SetCurrentScreen`'s buffer, 0x40 bytes with its terminator (`0x46b860`, `SStrCopy`).
@@ -180,7 +194,7 @@ impl super::UiScript {
}
}
fn queue(lua: &Lua, request: GlueRequest) {
pub(super) fn queue(lua: &Lua, request: GlueRequest) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.glue
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -66,8 +66,13 @@ mod font_block;
mod frame_enum;
mod glue;
mod glue_core;
mod glue_realm;
pub use glue::{VmKind, GLUE_EVENTS};
pub use glue_core::{GlueCredentials, GlueNotice, GlueRequest, GlueServer, NoticeState};
pub use glue_realm::{
realm_load_classify, realm_load_stats, GlueRealm, GlueRealmCategory, GlueRealmCells,
GlueRealmType, RealmSortKey,
};
mod gm_ticket;
mod gossip;
mod guild;
@@ -649,8 +654,8 @@ impl UiScript {
/// The glue screens' VM, as the glue builder (`0x46a7b0`) leaves its state before GlueXML
/// loads: the base set and the shared registrars, with the glue event table and the glue frame
/// types ([`glue`]), and none of the in-game surface. Of the glue registrar's own four tables
/// (`0x46ddf0`, `0x4736c0`, `0x471a20`, `0x46f480`) only the screen verbs are built
/// ([`glue::install`]).
/// (`0x46ddf0`, `0x4736c0`, `0x471a20`, `0x46f480`) the screen verbs and the realm list are
/// built ([`glue::install`]).
pub fn new_glue() -> mlua::Result<UiScript> {
let lua = install_framescript(VmKind::Glue)?;
glue::install(&lua)?;
@@ -222,21 +222,6 @@ const NOT_YET: &[(&str, &[&str])] = &[
"UpdateCustomizationScene",
],
),
(
"the realm list (0x46f480, table 0x837ea0)",
&[
"CancelRealmListQuery",
"ChangeRealm",
"GetNumRealms",
"GetRealmCategories",
"GetRealmInfo",
"GetSelectedCategory",
"RealmListDialogCancelled",
"RequestRealmList",
"SetPreferredInfo",
"SortRealms",
],
),
];
/// The glue VM's globals by Lua type name.