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:
co-authored by
Claude Opus 5.5
parent
0c58d85cf9
commit
b2926edf53
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user