Stock EnterWorld: its guards, the refused login and the glue reseat (#1067)

The stock character select had no EnterWorld.

EnterWorld follows 0x46b500:
- it returns silently with no realm, no record or no world link;
- flag 0x4 opens OKAY CHAR_LOGIN_LOCKED_FOR_TRANSFER and flag 0x4000 fires FORCE_RENAME_CHARACTER, neither sending;
- otherwise it writes and saves lastCharacterIndex, enters state 8 and logs in, the last call of a frame winning.
A refused login (0x5aad70) sets state 9, whose OKAY button re-enumerates on the same link.

The rebuilt glue is now seated before SET_GLUE_SCREEN, so a logout's select asks for its list. A refusal queued in the same drain as the entry now wins, natively too. The login-send doc is a Deviation line stating the reference's load-then-send.

Tests cover each guard and arm, and the entry, refusal and logout flows over the fake world.

Part of #856
Part of #977
Part of #79
Fixes #979

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-13 01:50:44 +07:00
committed by GitHub
parent 71d65a4883
commit e175209f28
10 changed files with 973 additions and 55 deletions
+60 -12
View File
@@ -145,14 +145,17 @@ impl Plugin for CharSelectPlugin {
/// The session's edges, in every state: the reconnect auto-answer and the logout edge arrive
/// `InWorld`. A logout or a refused login clears the pick before the roster policy reads it: the
/// roster comes right behind them on the kept link, often in the same drain, and must be shown,
/// not answered with the pick just left. `back_on_disconnect` runs after the entry: a session that
/// died during entry queues both edges into one frame, and the dead one must win.
fn session_edges() -> bevy::ecs::schedule::ScheduleConfigs<bevy::ecs::system::ScheduleSystem> {
/// not answered with the pick just left. The refusal and `back_on_disconnect` run after the entry:
/// the entry is announced with `CMSG_PLAYER_LOGIN`, so a refusal or a session that died during
/// entry queues both edges into one frame, and the later word must win (`0x5aad70` leaves the
/// player on the select screen).
pub(crate) fn session_edges(
) -> bevy::ecs::schedule::ScheduleConfigs<bevy::ecs::system::ScheduleSystem> {
(
back_on_logout,
enter_on_connected,
back_on_login_refused,
apply_roster_policy,
enter_on_connected,
back_on_disconnect,
// Last and ungated: it mirrors the pick the frame ended with, and world entry is reached
// from the create screen too.
@@ -200,6 +203,15 @@ impl Roster {
}
}
/// A roster the screen holds with no pick, for tests outside this module.
#[cfg(test)]
pub(crate) fn with_chars(chars: Vec<Character>) -> Self {
Self {
chars,
..Self::default()
}
}
/// A roster with a pick in flight, for tests outside this module.
#[cfg(test)]
pub(crate) fn with_pending_pick(chars: Vec<Character>, guid: u64) -> Self {
@@ -476,19 +488,24 @@ fn back_on_disconnect(
/// `SMSG_CHARACTER_LOGIN_FAILED`: back to select with the refusal in the glue dialog. The IO
/// thread announces the entry with the pick, so this undoes a half-built one. The pick must be
/// cleared, or the roster behind the refusal is auto-answered and the refused login loops.
/// cleared, or the roster behind the refusal is auto-answered and the refused login loops. Under
/// the stock glue the refusal's dialog is its glue manager's (state 9), not this one.
fn back_on_login_refused(
mut msgs: MessageReader<CharacterLoginFailedMessage>,
mut roster: ResMut<Roster>,
mut next: ResMut<NextState<ClientState>>,
mut dialog: ResMut<crate::glue::dialog::GlueDialog>,
strings: Option<Res<GlueStrings>>,
stock: Option<Res<crate::ui_script::glue::GlueStock>>,
) {
let Some(msg) = msgs.read().last().copied() else {
return;
};
roster.pending_pick = None;
next.set(ClientState::CharSelect);
if stock.is_some_and(|s| s.0) {
return;
}
let empty = GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
dialog.open_error(char_login_refusal_text(strings, msg.result));
@@ -1074,17 +1091,22 @@ mod tests {
/// database, so the refusal lands in the same drain and must be the last word.
#[test]
fn a_refused_login_beats_the_entry_it_revokes() {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::CharSelect)
.init_resource::<Roster>()
.init_resource::<crate::ui_script::PlayerUiHover>()
.init_resource::<crate::ui_script::UiKeyboardCapture>()
.add_message::<EnteredWorldMessage>()
.add_message::<CharacterLoginFailedMessage>()
.insert_resource(Roster::with_pending_pick(vec![character(7, "Seven")], 7))
.insert_resource(CharPick(tx.into()))
.init_resource::<crate::cvars::Cvars>()
.init_resource::<crate::glue::dialog::GlueDialog>()
.add_systems(Update, (enter_on_connected, back_on_login_refused).chain());
app.world_mut().resource_mut::<Roster>().pending_pick = Some(7);
.add_message::<CharListMessage>()
.add_message::<EnteredWorldMessage>()
.add_message::<LoggedOutMessage>()
.add_message::<CharacterLoginFailedMessage>()
.add_message::<crate::net::DisconnectedMessage>()
// The production chain, in its own order.
.add_systems(Update, session_edges());
app.world_mut().resource_mut::<Roster>().env_read = true;
app.world_mut().write_message(EnteredWorldMessage {
billing_time_rested: 0,
@@ -1107,6 +1129,7 @@ mod tests {
"and the pick goes with it: the relist behind the refusal is auto-answered with \
`pending_pick`, so keeping it would re-enter the character just refused, forever",
);
assert!(rx.try_recv().is_err(), "nothing picked again");
// No GlueStrings here, so the fallback literal; the shipped one is checked below.
assert_eq!(
app.world()
@@ -1117,6 +1140,31 @@ mod tests {
);
}
/// Under the stock glue a refusal still leaves for select and drops the pick, but opens no
/// native dialog: the stock screen's own (its glue manager's state 9) says why.
#[test]
fn a_refusal_under_the_stock_glue_opens_no_native_dialog() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::InWorld)
.insert_resource(Roster::with_pending_pick(vec![character(7, "Seven")], 7))
.insert_resource(crate::ui_script::glue::GlueStock(true))
.init_resource::<crate::glue::dialog::GlueDialog>()
.add_message::<CharacterLoginFailedMessage>()
.add_systems(Update, back_on_login_refused);
app.world_mut()
.write_message(CharacterLoginFailedMessage { result: 0x01 });
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::CharSelect
);
assert_eq!(app.world().resource::<Roster>().pending_pick, None);
let dialog = app.world().resource::<crate::glue::dialog::GlueDialog>();
assert!(dialog.kind.is_none() && dialog.text.is_empty());
}
/// A logout, or a refused login, and the roster the kept link sends right behind it can land
/// in one drain: the pick the world was entered with goes first, so the roster is shown and
/// not answered with that pick again.
+11
View File
@@ -825,6 +825,17 @@ fn save_config_when_quiet(mut cvars: ResMut<Cvars>) {
}
}
impl Cvars {
/// `CVar::SaveConfig` (`0x63d980`) on demand, as the reference's callers that save at once do
/// (`EnterWorld`, `0x46b6f6`): the file written now if anything moved, the quiet second
/// skipped.
pub(crate) fn save(&mut self) {
if self.dirty {
write_config(self);
}
}
}
/// The exit frame: save whatever is still dirty, quiet second or not.
fn save_config_on_exit(mut cvars: ResMut<Cvars>) {
if cvars.dirty {
+8 -3
View File
@@ -5,9 +5,14 @@
//! 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 closes the realmd link (`0x46b70c`), 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 logout or a later
//! `SMSG_CHARACTER_LOGIN_FAILED` ends the world session, announced as a
//! without waiting for the verdict.
//!
//! Deviation: the reference loads the world first and logs in after: the entry's first frame
//! starts the load at the record's map and position (`0x401570`, called at `0x46c236`), and a
//! later one, once its file reader is idle (`0x46c247`), sends the login (`0x46c272`). Here the two
//! overlap, so entry is a round trip faster, and a refusal ends the cycle as a logout does.
//!
//! A logout or a later `SMSG_CHARACTER_LOGIN_FAILED` ends the world session, announced as a
//! [`SessionEvent::Disconnected`] with [`SessionEnd::LoggedOut`], and parks at character select
//! again on the same link. A stream failure emits it with [`SessionEnd::Lost`] and re-parks for
//! credentials. All policy lives in [`crate::login`] and [`crate::char_select`]; this thread only
+32 -6
View File
@@ -324,6 +324,9 @@ type Rosters = Arc<Mutex<Vec<Vec<benilla_protocol::Character>>>>;
/// Packets, by opcode and body, the fake world sends behind `SMSG_LOGOUT_COMPLETE`.
type Trail = Arc<Mutex<Vec<(u16, Vec<u8>)>>>;
/// Whether refusals are held, and the signal that releases them.
type RefusalGate = Arc<(Mutex<bool>, std::sync::Condvar)>;
/// The `SMSG_ADDON_INFO` body a fake world sends, and whether it trails the authentication answer.
type AddonReply = Arc<Mutex<Option<(Vec<u8>, bool)>>>;
@@ -349,6 +352,8 @@ pub(crate) struct World {
cut: Arc<AtomicBool>,
/// Answer `CMSG_PLAYER_LOGIN` with `SMSG_CHARACTER_LOGIN_FAILED`.
refuse: Arc<AtomicBool>,
/// Refusals wait while this holds, until [`World::release_refusals`].
refusal_gate: RefusalGate,
/// Packets sent right behind `SMSG_LOGOUT_COMPLETE`, as the world session leaves them in flight.
trail: Trail,
/// The `SMSG_ADDON_INFO` body every session is sent, and whether it follows the authentication
@@ -406,6 +411,17 @@ impl World {
self.refuse.store(true, Ordering::SeqCst);
}
/// Hold every refusal back until [`Self::release_refusals`].
pub(crate) fn hold_refusals(&self) {
*self.refusal_gate.0.lock().unwrap() = true;
}
/// Send the refusals [`Self::hold_refusals`] held, and every later one at once.
pub(crate) fn release_refusals(&self) {
*self.refusal_gate.0.lock().unwrap() = false;
self.refusal_gate.1.notify_all();
}
/// Send `SMSG_ADDON_INFO` with one record per status (`2` hides the addon) to every session
/// from now on: ahead of the authentication answer, or behind it with `after`.
pub(crate) fn set_addon_info(&self, statuses: &[u8], after: bool) {
@@ -517,6 +533,8 @@ pub(crate) fn world(listener: TcpListener, keys: Receiver<Key>) -> World {
let cut_flag = Arc::clone(&cut);
let refuse = Arc::new(AtomicBool::new(false));
let refuse_flag = Arc::clone(&refuse);
let refusal_gate: RefusalGate = Arc::default();
let refuse_wait = Arc::clone(&refusal_gate);
let trail: Trail = Arc::default();
let trailing = Arc::clone(&trail);
let addon_info: AddonReply = Arc::default();
@@ -570,6 +588,7 @@ pub(crate) fn world(listener: TcpListener, keys: Receiver<Key>) -> World {
let warden_flag = Arc::clone(&warden_flag);
let cut_flag = Arc::clone(&cut_flag);
let refuse_flag = Arc::clone(&refuse_flag);
let refuse_wait = Arc::clone(&refuse_wait);
let trailing = Arc::clone(&trailing);
let index = {
let mut all = session_log.lock().unwrap();
@@ -643,12 +662,18 @@ pub(crate) fn world(listener: TcpListener, keys: Receiver<Key>) -> World {
}
send(&mut s, Some(&mut crypto), opcode::SMSG_CHAR_RENAME, &body);
}
opcode::CMSG_PLAYER_LOGIN if refuse_flag.load(Ordering::SeqCst) => send(
&mut s,
Some(&mut crypto),
opcode::SMSG_CHARACTER_LOGIN_FAILED,
&[1],
),
opcode::CMSG_PLAYER_LOGIN if refuse_flag.load(Ordering::SeqCst) => {
let (held, released) = &*refuse_wait;
let _ = released
.wait_timeout_while(held.lock().unwrap(), WAIT, |h| *h)
.unwrap();
send(
&mut s,
Some(&mut crypto),
opcode::SMSG_CHARACTER_LOGIN_FAILED,
&[1],
);
}
opcode::CMSG_LOGOUT_REQUEST => {
send(&mut s, Some(&mut crypto), opcode::SMSG_LOGOUT_COMPLETE, &[]);
for (op, body) in trailing.lock().unwrap().clone() {
@@ -672,6 +697,7 @@ pub(crate) fn world(listener: TcpListener, keys: Receiver<Key>) -> World {
warden,
cut,
refuse,
refusal_gate,
trail,
addon_info,
open,
+248 -31
View File
@@ -100,6 +100,33 @@
//! The dialog's button in state 6 cancels the operation and goes to state 0 (`0x46b776`), in state
//! 7 it goes to state 0 alone (`0x46b79d`); neither sends anything.
//!
//! The world entry and its refusal:
//!
//! 1. `EnterWorld` reaches `CGlueMgr::EnterWorld` (`0x46b500`) with the selected record. Three
//! guards return having done nothing: no realm selected this process (`0x5ab9a0`), the
//! selection off the roster (the VM's, `0x46b53e`), no world link (`[svc+0x1b00]`, `0x46b56c`).
//! A record flagged `0x4` (`+0xfc`, locked for a transfer, `0x46b57e`) opens `OKAY` with status
//! `0x44`'s text, `CHAR_LOGIN_LOCKED_FOR_TRANSFER` (`0x46b589`-`0x46b5a5`); one flagged `0x4000`
//! fires `FORCE_RENAME_CHARACTER` with `CHAR_RENAME_DESCRIPTION` (`0x46b5c2`), which the stock
//! screen answers with its rename dialog. Either returns there. Otherwise `lastCharacterIndex`
//! takes the selection's index (`0x46b5fa`), the CVars are saved (`0x46b6f6`) and the state
//! goes to 8 (`0x46b711`). The record byte the call sets on the way (`+0x118`, `0x46b54a`) is
//! read nowhere.
//! 2. The rest is the world entry the native screens make too ([`crate::char_select::send_pick`]):
//! the loading screen and its tip off the picked record (`0x406640`, `0x46b614`-`0x46b6e3`),
//! the realmd link closed (`0x46b70c`), the glue torn down (`0x46ac90`, the VM leaving with the
//! character screen) and `CMSG_PLAYER_LOGIN` (`0x46c272`), sent here as the call returns rather
//! than once the world has loaded (the `Deviation:` in [`crate::net::io`]). State 8 has no frame
//! arm here and ends with the glue, as the entry's second phase ends it (`0x46c252`); its dialog
//! button cancels to state 0 (`0x46b776`).
//! 3. `SMSG_CHARACTER_LOGIN_FAILED` (`0x5aad70`) tears the world down and rebuilds the glue
//! (`0x401ee0`), setting state 9 before the `charselect` screen goes up (`0x402075`), so the
//! screen's `GetCharacterListUpdate` asks nothing (`0x46b880`'s first test); the login operation
//! then completes failed with the refusal's status (table `0x5aae08`, `0x5aaa50`). State 9's
//! frame (`0x46c283`) opens `OKAY` with its text; the button (`0x46b767`), which the stock
//! `OKAY` type's `OnShow` presses at once, goes to state 0 and asks for the list again on the
//! same link (`0x46b880`).
//!
//! The native login reports a logon as one blocking step, so of the logon's states only
//! `CONNECTING` and `AUTHENTICATED` are seen; its refusals map through the reference's own
//! byte-to-result table ([`realmd_result`]).
@@ -116,15 +143,16 @@ use crate::login::queue::QueueEstimate;
use crate::login::GlueLogin;
use crate::net::{
AddonInfoMessage, AddonInfoReply, CharActionResultMessage, CharListMessage, CharRenameMessage,
CharRequest, DisconnectedMessage, LoginFailedMessage, LoginQueuedMessage, LoginStageMessage,
ParkRequest, RealmListMessage, RealmRequest, RosterFailedMessage, WorldAuthMessage,
CharRequest, CharacterLoginFailedMessage, DisconnectedMessage, LoginFailedMessage,
LoginQueuedMessage, LoginStageMessage, ParkRequest, RealmListMessage, RealmRequest,
RosterFailedMessage, WorldAuthMessage,
};
/// `CGlueMgr`'s `screenState` (`0xb41da0`), named by the functions that set each value: the
/// states the login, the realm list, the character list, the delete and the rename reach. The
/// reference's others are create (5, `0x46b29e`), `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, the realm list, the character list, the delete, the rename and the world
/// entry reach. The reference's others are create (5, `0x46b29e`) 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).
@@ -142,6 +170,11 @@ pub(crate) enum ScreenState {
Delete = 6,
/// A character rename (`0x46b3a4`).
Rename = 7,
/// The world entry (`CGlueMgr::EnterWorld`, `0x46b711`), until the world is up.
EnterWorld = 8,
/// A refused character login (`0x402075`, inside `0x401ee0` from `0x5aad70`), until the
/// status dialog's button (`0x46b767`).
LoginRefused = 9,
}
/// The logon's `LOGIN_STATE_*` keys by login state (`0x836b40`, 13 entries).
@@ -226,6 +259,25 @@ const STATUS_CHAR_LIST_RETRIEVED: u8 = 0x2b;
const STATUS_CHAR_LIST_FAILED: u8 = 0x2c;
const STATUS_CHAR_DELETE_IN_PROGRESS: u8 = 0x38;
const STATUS_CHAR_DELETE_SUCCESS: u8 = 0x39;
const STATUS_CHAR_LOGIN_IN_PROGRESS: u8 = 0x3c;
const STATUS_CHAR_LOGIN_FAILED: u8 = 0x41;
const STATUS_CHAR_LOGIN_LOCKED_FOR_TRANSFER: u8 = 0x44;
/// The record flags `EnterWorld` tests (`+0xfc`): locked for a paid transfer (`0x46b57e`) and
/// flagged for a rename (`0x46b5b1`).
const FLAG_LOCKED_FOR_TRANSFER: u32 = 0x4;
const FLAG_RENAME: u32 = benilla_protocol::CHARACTER_FLAG_RENAME;
/// `SMSG_CHARACTER_LOGIN_FAILED`'s byte as a status (`0x5aad70`, jump table `0x5aae08`): a 1-based
/// index onto `0x3e 0x3f 0x40 0x42 0x43 0x44`, every other byte the default arm's
/// `CHAR_LOGIN_FAILED` (`0x5aadf7`).
fn char_login_status(result: u8) -> u8 {
match result {
1..=3 => 0x3d + result,
4..=6 => 0x3e + result,
_ => STATUS_CHAR_LOGIN_FAILED,
}
}
/// `SMSG_CHAR_RENAME`'s one result the handler tells apart (`0x46b498 cmp al,0x31`).
const RENAME_NAME_IN_USE: u8 = 0x31;
@@ -274,6 +326,8 @@ enum OpKind {
CharList,
/// A character delete (7, `0x5ab100`).
Delete,
/// The character login (8, `COP_LOGIN_CHARACTER`, begun at `0x5aae20`).
Login,
}
/// The operation record (`BeginOperation` `0x5aa400`, `SetStatus` `0x5aa920`): its status code
@@ -438,6 +492,10 @@ pub(crate) struct GlueMgr {
/// The text a finished fetch left for the next frame (`alertReady`, `[0xb41e2c]`, and the
/// buffer `0xb414f8` past its prefix).
alert_ready: Option<String>,
/// A refused character login's status, waiting for the glue rebuild that sets state 9.
refused: Option<u8>,
/// The index `EnterWorld` writes to `lastCharacterIndex`, for the host's CVar registry.
last_character: Option<i32>,
}
impl GlueMgr {
@@ -542,13 +600,18 @@ impl GlueMgr {
self.op.complete(false, STATUS_CANCELLED);
self.outbox.push(Outgoing::Teardown);
}
// `0x46b776` (states 4 and 6): the operation cancelled, state 0, nothing sent.
ScreenState::RealmList | ScreenState::Delete => {
// `0x46b776` (states 4, 6 and 8): the operation cancelled, state 0, nothing sent.
ScreenState::RealmList | ScreenState::Delete | ScreenState::EnterWorld => {
self.op.complete(false, STATUS_CANCELLED);
self.state = ScreenState::Idle;
}
// `0x46b79d`: state 0 alone; the rename's reply still lands (`0x46b440`).
ScreenState::Rename => self.state = ScreenState::Idle,
// `0x46b767`: state 0, then the list asked for again on the same link (`0x46b880`).
ScreenState::LoginRefused => {
self.state = ScreenState::Idle;
self.get_character_list();
}
}
}
@@ -564,8 +627,11 @@ impl GlueMgr {
/// dialog and the query (`0x5aabb0`: operation 6 at status `0x2a`, then `CMSG_CHAR_ENUM` on
/// the world link). With no link the operation fails `RESPONSE_FAILED_TO_CONNECT` at once
/// (`0x5aabca`). State 9, a refused character login (`0x46b880`'s first test), returns before
/// any of it; that state is not reached yet.
/// any of it.
fn get_character_list(&mut self) {
if self.state == ScreenState::LoginRefused {
return;
}
self.state = ScreenState::CharList;
self.open("CANCEL", Some(Text::Key("CHAR_LIST_RETRIEVING")));
self.op = Operation::begin(OpKind::CharList, STATUS_CHAR_LIST_RETRIEVING);
@@ -602,6 +668,63 @@ impl GlueMgr {
}
}
/// `CGlueMgr::EnterWorld` (`0x46b500`) for the record the VM's selection named (its guard
/// `0x46b53e`): the realm and link guards (`0x46b50d`, `0x46b56c`), the two flag arms
/// (`0x46b57e`, `0x46b5b1`), then `lastCharacterIndex` (`0x46b5fa`, saved at `0x46b6f6`), state 8
/// (`0x46b711`) and the pick, in order with the park's other requests. Every call repoints the
/// selected record (`[0xb41df4]`, `0x46b539`), and the first frame of state 8 stashes the guid
/// the login sends from it (`0x46c1d6`-`0x46c1e6`, sent once at `0x46c272`): here the pick
/// leaves at the end of the frame, so a later call in the same frame replaces its guid, and
/// one after it sends nothing more.
fn enter_world(&mut self, index: i32, guid: u64, flags: u32) {
if self.selected.is_none() || !self.connected {
return;
}
if flags & FLAG_LOCKED_FOR_TRANSFER != 0 {
self.open(
"OKAY",
Some(Text::Key(benilla_ui::script::glue_status_key(i32::from(
STATUS_CHAR_LOGIN_LOCKED_FOR_TRANSFER,
)))),
);
return;
}
if flags & FLAG_RENAME != 0 {
self.outbox
.push(Outgoing::ForceRename("CHAR_RENAME_DESCRIPTION"));
return;
}
self.last_character = Some(index);
let queued = self.park_out.iter_mut().find_map(|r| match r {
ParkRequest::Char(CharRequest::Enter(g)) => Some(g),
_ => None,
});
match queued {
Some(g) => *g = guid,
None if self.state != ScreenState::EnterWorld => {
self.park_out.push(CharRequest::Enter(guid).into());
}
None => {}
}
self.state = ScreenState::EnterWorld;
}
/// `SMSG_CHARACTER_LOGIN_FAILED` (`0x5aad70`): the status its byte maps to, for the glue
/// rebuild that leaves the world (`0x401ee0`) to put into state 9 ([`Self::take_refusal`]).
fn on_char_login_failed(&mut self, result: u8) {
self.refused = Some(char_login_status(result));
}
/// The rebuild's half of a refused login (`0x402075`, then `0x5aaa50`): state 9 and the login
/// operation completed failed with the refusal's status.
fn take_refusal(&mut self) {
if let Some(status) = self.refused.take() {
self.state = ScreenState::LoginRefused;
self.op = Operation::begin(OpKind::Login, STATUS_CHAR_LOGIN_IN_PROGRESS);
self.op.complete(false, status);
}
}
/// `SMSG_CHAR_DELETE` (`0x5b45c0` → `0x5ab0e0`): the result byte is the delete's status,
/// success on `0x39`, whatever the screen state (`SetStatus`, `0x5aa920`).
fn on_char_deleted(&mut self, code: u8) {
@@ -717,7 +840,7 @@ impl GlueMgr {
}
}
}
OpKind::Auth | OpKind::CharList | OpKind::Delete => {}
OpKind::Auth | OpKind::CharList | OpKind::Delete | OpKind::Login => {}
}
}
@@ -935,6 +1058,7 @@ impl GlueMgr {
if !session_over {
return;
}
self.refused = None;
let was_connect = self.state == ScreenState::Connect;
self.state = ScreenState::Idle;
let op = std::mem::take(&mut self.op);
@@ -973,8 +1097,14 @@ impl GlueMgr {
ScreenState::CharList => self.tick_char_list(),
ScreenState::RealmList => self.tick_realm_list(),
ScreenState::Delete => self.tick_delete(),
// No arm for state 7: the compares in `[0x46b930, 0x46c2ac)` never take it.
ScreenState::Rename | ScreenState::Idle => {}
// `0x46c283`: `OKAY` with the operation's text, every frame the state stands.
ScreenState::LoginRefused => {
let text = self.op.text();
self.open("OKAY", text);
}
// No arm for state 7: the compares in `[0x46b930, 0x46c2ac)` never take it. State 8's
// two phases are the world entry's here (`0x46c1b8`).
ScreenState::Rename | ScreenState::EnterWorld | ScreenState::Idle => {}
}
}
@@ -1160,6 +1290,7 @@ pub(crate) struct LoginNews<'w, 's> {
addon_reply: Option<Res<'w, AddonInfoReply>>,
char_actions: MessageReader<'w, 's, CharActionResultMessage>,
renames: MessageReader<'w, 's, CharRenameMessage>,
login_refusals: MessageReader<'w, 's, CharacterLoginFailedMessage>,
/// The name check's lists, which the glue VM is seated with (`0x6c91a0`).
names: Option<Res<'w, crate::text_filter::NameFilter>>,
}
@@ -1173,8 +1304,12 @@ pub(crate) struct GlueAsks<'w> {
launch_addon: MessageWriter<'w, super::server_alert::LaunchAddonUrl>,
realm: Option<Res<'w, crate::net::RealmChoice>>,
abandon: Option<Res<'w, crate::net::LoginAbandon>>,
/// The select screen's roster, which names the in-world files: a rename renames it too.
/// The select screen's roster, which names the in-world files: a rename renames it too, and
/// the world entry's pick is its pending one.
roster: Option<ResMut<'w, crate::char_select::Roster>>,
pick: Option<Res<'w, crate::net::CharPick>>,
/// The engine-side CVar table `EnterWorld` writes and saves.
cvars: Option<ResMut<'w, crate::cvars::Cvars>>,
}
/// The CVars each agreement is read from and written to.
@@ -1240,28 +1375,26 @@ pub(crate) fn drive_glue_mgr(
}
}
// 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.
// A refused character login, ahead of the glue build it comes back to (`0x5aad70`).
for msg in news.login_refusals.read() {
mgr.on_char_login_failed(msg.result);
}
// A glue VM the glue build did not seat ([`seat_glue_build`]) is seated here.
let mut fresh_vm = false;
if let Some(s) = script.as_deref_mut() {
if mgr.vm != Some(s.session()) {
mgr.vm = Some(s.session());
mgr.state = ScreenState::Idle;
mgr.cells = LoginCells::default();
mgr.auth_final = false;
s.set_glue_saved_account(&crate::login::load_saved_account());
s.set_glue_realm_cells(mgr.realm_cells);
s.set_glue_select_cells(mgr.select_cells);
// The AddOn registry and the reply the session has read; the reference's C globals
// outlive the glue's Lua state.
super::addons::seat_glue_addons(
seat_glue(
&mut mgr,
s,
news.addon_reply.as_deref().and_then(|r| r.0.as_deref()),
news.names.as_deref(),
realms.as_deref(),
realmlist.as_deref(),
);
mgr.holder_moved = true;
fresh_vm = true;
}
if let Some(names) = news.names.as_ref().filter(|n| fresh_vm || n.is_changed()) {
if let Some(names) = news.names.as_ref().filter(|n| !fresh_vm && n.is_changed()) {
s.set_glue_name_lists(std::rc::Rc::new((**names).clone()));
}
} else if in_world {
@@ -1269,8 +1402,15 @@ pub(crate) fn drive_glue_mgr(
// (`0x46c252`).
mgr.state = ScreenState::Idle;
}
// A refused login's state 9, on the glue the refusal comes back to, before its screen's list
// ask is served (`0x402075`). One that finds the glue still standing beat the world it
// revokes, which here is entered before the verdict (the pick's `Deviation:` in
// [`crate::net::io`]), and ends the same way.
if glue_up {
mgr.take_refusal();
}
if let (Some(s), Some(r)) = (script.as_deref_mut(), realms.as_ref()) {
if fresh_vm || r.is_changed() {
if !fresh_vm && r.is_changed() {
let (categories, types) = r.glue_setup();
s.set_glue_realm_setup(categories, types);
}
@@ -1339,10 +1479,21 @@ pub(crate) fn drive_glue_mgr(
GlueRequest::GetCharacterList => mgr.get_character_list(),
GlueRequest::DeleteCharacter(guid) => mgr.delete_character(guid),
GlueRequest::RenameCharacter { guid, name } => mgr.rename_character(guid, name),
GlueRequest::EnterWorld { index, guid, flags } => {
mgr.enter_world(index, guid, flags);
}
}
}
mgr.realm_cells = s.glue_realm_cells();
}
// `EnterWorld`'s `lastCharacterIndex` (`0x46b5fa`, `"%d"` of the selection) into the engine's
// table, then `SaveConfig` (`0x46b6f6`).
if let Some(index) = mgr.last_character.take() {
if let Some(cvars) = asks.cvars.as_deref_mut() {
cvars.set(crate::char_select::CVAR_LAST_CHARACTER, &index.to_string());
cvars.save();
}
}
// `SMSG_ADDON_INFO` read (`0x5b4260`): the index rebuilt from the reply (`0x51da70`), then
// the listener's `ADDON_LIST_UPDATE` (`0x5b4290`).
@@ -1443,8 +1594,65 @@ pub(crate) fn drive_glue_mgr(
send_asks(&mut mgr, Vec::new(), &mut asks);
}
/// The glue build (`0x46a7b0`) on a new glue VM: state 0, then everything the manager holds that
/// the reference keeps in C globals, which outlive the Lua state: the saved account, the realm list
/// and select cells, the AddOn registry and the reply the session has read, the name check's
/// lists, the realm categories, a refused login's state 9 (`0x402075`, set ahead of the screen)
/// and the slots the verbs read live, the world link (`IsConnectedToServer`, `0x46d380` reads
/// `[svc+0x1b00]`) and the server among them.
fn seat_glue(
mgr: &mut GlueMgr,
s: &mut UiScript,
addon_reply: Option<&[benilla_protocol::messages::AddonRecord]>,
names: Option<&crate::text_filter::NameFilter>,
realms: Option<&crate::realm_select::Realms>,
realmlist: Option<&crate::realmlist::Realmlist>,
) {
mgr.vm = Some(s.session());
mgr.state = ScreenState::Idle;
mgr.cells = LoginCells::default();
mgr.auth_final = false;
s.set_glue_saved_account(&crate::login::load_saved_account());
s.set_glue_realm_cells(mgr.realm_cells);
s.set_glue_select_cells(mgr.select_cells);
super::addons::seat_glue_addons(s, addon_reply);
mgr.holder_moved = true;
if let Some(names) = names {
s.set_glue_name_lists(std::rc::Rc::new(names.clone()));
}
if let Some(r) = realms {
let (categories, types) = r.glue_setup();
s.set_glue_realm_setup(categories, types);
}
mgr.take_refusal();
seed(mgr, s, realms, realmlist);
}
/// The glue build's seating ([`seat_glue`]) inside the build, before its first `SET_GLUE_SCREEN`:
/// the screen's `OnShow` runs in that event and reads what the build carried over (a logout's
/// `CharacterSelect_OnShow` asks for its list only on a link `IsConnectedToServer` reports, and
/// names the realm `GetServerName` does).
pub(crate) fn seat_glue_build(world: &mut World, script: &mut UiScript) {
if !world.contains_resource::<GlueMgr>() {
return;
}
world.resource_scope(|world, mut mgr: Mut<GlueMgr>| {
seat_glue(
&mut mgr,
script,
world
.get_resource::<AddonInfoReply>()
.and_then(|r| r.0.as_deref()),
world.get_resource::<crate::text_filter::NameFilter>(),
world.get_resource::<crate::realm_select::Realms>(),
world.get_resource::<crate::realmlist::Realmlist>(),
);
});
}
/// Send the asks of the frame: the login engine's, and the park requests in the order they were
/// made, the realm requests stamped with the abandon generation as they go.
/// made, the realm requests stamped with the abandon generation as they go and the world entry's
/// pick through the native screens' own ([`crate::char_select::send_pick`]).
fn send_asks(mgr: &mut GlueMgr, logins: Vec<GlueLogin>, asks: &mut GlueAsks) {
let (arms, park_asks) = mgr.take_asks();
for ask in logins.into_iter().chain(arms) {
@@ -1453,8 +1661,17 @@ fn send_asks(mgr: &mut GlueMgr, logins: Vec<GlueLogin>, asks: &mut GlueAsks) {
let now = crate::net::LoginAbandon::now(asks.abandon.as_deref());
if let Some(choice) = asks.realm.as_deref() {
for mut ask in park_asks {
if let ParkRequest::Realm(RealmRequest::Enter { generation, .. }) = &mut ask {
*generation = now;
match &mut ask {
ParkRequest::Realm(RealmRequest::Enter { generation, .. }) => *generation = now,
ParkRequest::Char(CharRequest::Enter(guid)) => {
if let (Some(roster), Some(pick)) =
(asks.roster.as_deref_mut(), asks.pick.as_deref())
{
crate::char_select::send_pick(roster, pick, *guid);
}
continue;
}
_ => {}
}
let _ = choice.0.send_request(ask);
}
@@ -887,6 +887,188 @@ fn a_logout_keeps_the_link_for_the_screens_roster_ask() {
assert!(!m.op.done, "and waits for its reply");
}
/// The manager on the character screen: a realm selected this process and the world link up.
fn on_select() -> GlueMgr {
let mut m = accepted();
m.selected = Some(realm("Alpha"));
m.connected = true;
m
}
/// Nothing `EnterWorld` does: no state, no dialog, no event, no ask, no `lastCharacterIndex`.
fn nothing_done(m: &GlueMgr) -> bool {
m.state == ScreenState::Idle
&& m.outbox.is_empty()
&& m.park_out.is_empty()
&& m.last_character.is_none()
}
/// `EnterWorld` returns having done nothing with no realm selected this process (`0x5ab9a0` at
/// `0x46b506`) or no world link (`[svc+0x1b00]`, `0x46b56c`), whatever the record's flags.
#[test]
fn enter_world_without_a_realm_or_a_link_does_nothing() {
for (realm, link) in [(false, true), (true, false), (false, false)] {
for flags in [0, 0x4, 0x4000] {
let mut m = on_select();
if !realm {
m.selected = None;
}
m.connected = link;
m.enter_world(1, 0x12, flags);
assert!(
nothing_done(&m),
"realm {realm}, link {link}, flags {flags:#x}"
);
}
}
}
/// A record locked for a transfer (`+0xfc` bit `0x4`, `0x46b57e`) opens `OKAY` with status
/// `0x44`'s text and goes no further: nothing sent, no `lastCharacterIndex`, the state as it was.
/// The test comes before the rename flag's.
#[test]
fn enter_world_on_a_character_locked_for_transfer_opens_okay() {
for flags in [0x4, 0x4004] {
let mut m = on_select();
m.enter_world(1, 0x12, flags);
assert_eq!(
m.outbox,
[open("OKAY", Some("CHAR_LOGIN_LOCKED_FOR_TRANSFER"))],
"{flags:#x}"
);
assert_eq!(m.state, ScreenState::Idle);
assert!(m.park_out.is_empty() && m.last_character.is_none());
}
}
/// A record flagged for a rename (`0x4000`, `0x46b5b1`) fires `FORCE_RENAME_CHARACTER` with
/// `CHAR_RENAME_DESCRIPTION` (`0x46b5c2`) and goes no further.
#[test]
fn enter_world_on_a_character_flagged_for_rename_asks_for_a_name() {
let mut m = on_select();
m.enter_world(1, 0x12, 0x4000 | 0x2000);
assert_eq!(m.outbox, [Outgoing::ForceRename("CHAR_RENAME_DESCRIPTION")]);
assert_eq!(m.state, ScreenState::Idle);
assert!(m.park_out.is_empty() && m.last_character.is_none());
}
/// Past the guards: `lastCharacterIndex` takes the index (`0x46b5fa`), the state goes to 8
/// (`0x46b711`) and the pick goes out behind the asks made before it. Every call repoints the
/// record the first frame of state 8 stashes the login's guid from (`0x46c1d6`-`0x46c1e6`), so the
/// last call of the frame wins both the index and the pick; once the pick has left, a call writes
/// the index and picks nothing (`0x46c272` sends once). The dialog's button in state 8 cancels
/// to state 0 (`0x46b776`).
#[test]
fn enter_world_writes_the_index_and_picks_in_state_8() {
let mut m = on_select();
m.get_character_list();
m.outbox.clear();
m.state = ScreenState::Idle;
m.enter_world(2, 0x13, 0x2000);
assert_eq!(m.state, ScreenState::EnterWorld);
assert_eq!(m.last_character, Some(2));
assert!(m.outbox.is_empty(), "no dialog of its own");
assert!(matches!(
m.park_out.as_slice(),
[
ParkRequest::Char(CharRequest::Enumerate),
ParkRequest::Char(CharRequest::Enter(0x13))
]
));
// A second call in the same frame: the last record wins both.
m.enter_world(1, 0x12, 0);
assert_eq!(m.last_character, Some(1));
assert!(matches!(
m.park_out.as_slice(),
[
ParkRequest::Char(CharRequest::Enumerate),
ParkRequest::Char(CharRequest::Enter(0x12))
]
));
// The frame's asks leave.
m.park_out.clear();
m.last_character = None;
m.enter_world(0, 0x11, 0);
assert_eq!(m.last_character, Some(0));
assert!(m.park_out.is_empty(), "one login per entry");
m.tick();
assert!(m.outbox.is_empty(), "no frame arm for state 8 here");
m.status_dialog_click(&mut Vec::new());
assert_eq!(m.state, ScreenState::Idle);
assert!(m.park_out.is_empty());
}
/// `SMSG_CHARACTER_LOGIN_FAILED`'s byte through `0x5aad70`'s table (`0x5aae08`): 1 to 6 onto
/// `0x3e 0x3f 0x40 0x42 0x43 0x44`, everything else `CHAR_LOGIN_FAILED`.
#[test]
fn a_refused_login_maps_its_byte_through_the_references_table() {
let got: Vec<&str> = [0u8, 1, 2, 3, 4, 5, 6, 7, 0x41, 0xff]
.into_iter()
.map(|b| GLUE_STATUS_KEYS[usize::from(char_login_status(b))])
.collect();
assert_eq!(
got,
[
"CHAR_LOGIN_FAILED",
"CHAR_LOGIN_NO_WORLD",
"CHAR_LOGIN_DUPLICATE_CHARACTER",
"CHAR_LOGIN_NO_INSTANCES",
"CHAR_LOGIN_DISABLED",
"CHAR_LOGIN_NO_CHARACTER",
"CHAR_LOGIN_LOCKED_FOR_TRANSFER",
"CHAR_LOGIN_FAILED",
"CHAR_LOGIN_FAILED",
"CHAR_LOGIN_FAILED",
]
);
}
/// The rebuild a refusal comes back to is in state 9 (`0x402075`), where the screen's list ask
/// returns before anything (`0x46b880`); every frame of state 9 opens `OKAY` with the refusal's
/// text (`0x46c283`); the button goes to state 0 and asks for the list on the link (`0x46b767`).
#[test]
fn a_refused_login_takes_state_9_until_its_dialogs_button() {
let mut m = on_select();
m.enter_world(1, 0x12, 0);
m.park_out.clear();
m.on_char_login_failed(1);
assert_eq!(m.state, ScreenState::EnterWorld, "the rebuild sets it");
m.state = ScreenState::Idle;
m.take_refusal();
assert_eq!(m.state, ScreenState::LoginRefused);
m.get_character_list();
assert!(
m.outbox.is_empty() && m.park_out.is_empty(),
"no ask in state 9"
);
m.tick();
m.tick();
assert_eq!(
m.outbox,
[
open("OKAY", Some("CHAR_LOGIN_NO_WORLD")),
open("OKAY", Some("CHAR_LOGIN_NO_WORLD"))
]
);
m.outbox.clear();
m.status_dialog_click(&mut Vec::new());
assert_eq!(m.state, ScreenState::CharList);
assert_eq!(m.outbox, [open("CANCEL", Some("CHAR_LIST_RETRIEVING"))]);
assert!(matches!(
m.park_out.as_slice(),
[ParkRequest::Char(CharRequest::Enumerate)]
));
m.take_refusal();
assert_eq!(m.state, ScreenState::CharList, "a refusal is taken once");
// A session lost before the rebuild leaves no refusal for the next glue.
let mut m = on_select();
m.on_char_login_failed(6);
m.on_disconnected(SessionEnd::Lost, true);
m.take_refusal();
assert_eq!(m.state, ScreenState::Idle);
}
// ── The stock screens over the native login, end to end ─────────────────────────────────────────
/// An app with the manager, the native login's glue asks and the stock glue VM on the login
@@ -918,6 +1100,7 @@ fn stock_app() -> (
.add_message::<AddonInfoMessage>()
.add_message::<crate::net::CharActionResultMessage>()
.add_message::<crate::net::CharRenameMessage>()
.add_message::<crate::net::CharacterLoginFailedMessage>()
.init_resource::<AddonInfoReply>()
.add_message::<RealmListMessage>()
.add_message::<DisconnectedMessage>()
@@ -1158,11 +1341,14 @@ struct IoEvents(crossbeam_channel::Receiver<benilla_protocol::SessionEvent>);
#[derive(Resource, Default)]
struct WorldDials(usize);
/// The character verbs' replies, as `net::session` publishes them.
/// The character verbs' replies and the world session's edges, as `net::session` publishes them.
#[derive(bevy::ecs::system::SystemParam)]
struct CharWrites<'w> {
actions: MessageWriter<'w, crate::net::CharActionResultMessage>,
renames: MessageWriter<'w, crate::net::CharRenameMessage>,
entered: MessageWriter<'w, crate::net::EnteredWorldMessage>,
refused: MessageWriter<'w, crate::net::CharacterLoginFailedMessage>,
logouts: MessageWriter<'w, crate::net::LoggedOutMessage>,
}
fn pump(
@@ -1250,6 +1436,24 @@ fn pump(
.renames
.write(crate::net::CharRenameMessage { result, renamed });
}
E::Connected {
billing_time_rested,
tutorial_flags,
..
} => {
char_writes.entered.write(crate::net::EnteredWorldMessage {
billing_time_rested,
tutorial_flags,
});
}
E::CharacterLoginFailed { result } => {
char_writes
.refused
.write(crate::net::CharacterLoginFailedMessage { result });
}
E::LoggedOut => {
char_writes.logouts.write(crate::net::LoggedOutMessage);
}
_ => {}
}
}
@@ -1283,6 +1487,7 @@ fn live_app_with(
.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::net::NetCommands(cycles.commands.clone()))
.insert_resource(crate::realm_select::Realms::us_client())
.insert_resource(crate::entities::CharCreate(
benilla_formats::CharCreateCatalog::load(&mut chain).expect("ChrRaces"),
@@ -1312,6 +1517,9 @@ fn live_app_with(
.add_message::<AddonInfoMessage>()
.add_message::<crate::net::CharActionResultMessage>()
.add_message::<crate::net::CharRenameMessage>()
.add_message::<crate::net::EnteredWorldMessage>()
.add_message::<crate::net::CharacterLoginFailedMessage>()
.add_message::<crate::net::LoggedOutMessage>()
.init_resource::<AddonInfoReply>()
.add_message::<RealmListMessage>()
.add_message::<DisconnectedMessage>()
@@ -2864,3 +3072,342 @@ fn a_refused_stock_rename_asks_again() {
assert_eq!(row_names(&app), ["Alpha", "Bravo", "Charlie"]);
assert_eq!(vm(&app).errors(), Vec::<String>::new());
}
// ── The world entry under the stock select ──
/// [`select_screen`] over `roster3`'s account with the session's state machine running: the
/// select screen's edges ([`crate::char_select::session_edges`]), the world entry's VM edges
/// (`OnEnter(InWorld)` takes the glue VM out of the world, `OnExit(InWorld)` builds the stock glue
/// again on the screen the exit enters), and the CVar registry with its sync, saved under the
/// current `BENILLA_HOME`. With `refuse` the server refuses every character login.
fn entry_app(refuse: bool) -> (App, fake::World) {
let (mut app, _realmd, world) = select_screen(&roster3(), None);
if refuse {
world.refuse_logins();
}
// The registry as the player left it: the realm, and the agreements the login screen took.
let mut cvars = crate::cvars::Cvars::default();
cvars.set(crate::realm_select::CVAR_REALM_NAME, "Alpha");
cvars.set("readTOS", "1");
cvars.set("readEULA", "1");
{
let w = app.world_mut();
w.remove_resource::<State<ClientState>>();
w.remove_resource::<NextState<ClientState>>();
w.insert_resource(cvars);
w.insert_resource(super::super::glue::GlueStock(true));
// The roster the IO thread already published, as the select screen's edges took it.
w.insert_resource(crate::char_select::Roster::with_chars(roster3()));
w.init_resource::<crate::glue::dialog::GlueDialog>();
}
app.add_plugins(bevy::state::app::StatesPlugin)
.insert_state(ClientState::CharSelect)
.add_systems(
OnEnter(ClientState::InWorld),
super::super::lifecycle::arm_entry_ui_load,
)
.add_systems(
OnExit(ClientState::InWorld),
crate::ui_script::end_ui_session,
)
.add_systems(
Update,
(
crate::char_select::session_edges()
.after(pump)
.before(drive_glue_mgr),
crate::cvars::sync_cvars.after(drive_glue_mgr),
),
);
app.update();
(app, world)
}
fn client_state(app: &App) -> ClientState {
*app.world().resource::<State<ClientState>>().get()
}
/// The glue VM standing in the world, if one does.
fn glue_vm(app: &App) -> Option<&UiScript> {
app.world()
.get_non_send_resource::<UiScript>()
.filter(|s| s.vm_kind() == VmKind::Glue)
}
/// Run frames, the glue VM's own tick between them while one stands, until `done` holds.
fn until_entry(app: &mut App, what: &str, done: impl Fn(&App) -> bool) {
let deadline = std::time::Instant::now() + fake::WAIT;
while !done(app) {
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for {what}: client {:?}, glue {:?}, errors {:?}",
client_state(app),
app.world().resource::<GlueMgr>().state,
glue_vm(app).map(UiScript::errors)
);
app.update();
if let Some(mut s) = app.world_mut().get_non_send_resource_mut::<UiScript>() {
s.tick(0.01);
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
/// The `[cvars]` line `config.toml` holds for `name`, if any.
fn saved_cvar(home: &std::path::Path, name: &str) -> Option<String> {
let text = std::fs::read_to_string(home.join("config.toml")).ok()?;
let table: toml::Table = text.parse().ok()?;
table.get("cvars")?.get(name)?.as_str().map(str::to_string)
}
/// The stock Enter World end to end: the button's `EnterWorld` writes `lastCharacterIndex` and
/// saves it (`0x46b5fa`, `0x46b6f6`), takes state 8 and sends `CMSG_PLAYER_LOGIN` with the
/// selected guid; the entry takes the client into the world and the glue VM out of it.
#[test]
fn the_stock_enter_world_logs_in_and_the_glue_leaves_with_the_screen() {
let _ = benilla_formats::wow_data_or_skip!();
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (home, _guard) = addon_home("enter-world");
let (mut app, world) = entry_app(false);
vm(&app)
.run("CharacterSelect_SelectCharacter(2) CharSelectEnterWorldButton:Click()")
.unwrap();
app.update();
assert_eq!(
app.world().resource::<GlueMgr>().state,
ScreenState::EnterWorld
);
assert_eq!(
app.world()
.resource::<crate::cvars::Cvars>()
.get(crate::char_select::CVAR_LAST_CHARACTER),
Some("1")
);
assert_eq!(
saved_cvar(&home, crate::char_select::CVAR_LAST_CHARACTER).as_deref(),
Some("1"),
"saved in the call, not a quiet second later"
);
until_entry(&mut app, "the world", |app| {
client_state(app) == ClientState::InWorld && glue_vm(app).is_none()
});
assert_eq!(
world.bodies(benilla_protocol::messages::opcode::CMSG_PLAYER_LOGIN),
[0x12u64.to_le_bytes().to_vec()]
);
assert_eq!(
app.world()
.resource::<crate::char_select::Roster>()
.pending_row()
.map(|c| c.name.as_str()),
Some("Bravo"),
"the loading screen and the world are the picked record's"
);
assert!(
app.world().get_non_send_resource::<UiScript>().is_none(),
"no VM of the glue's left in the world"
);
app.update();
assert_eq!(app.world().resource::<GlueMgr>().state, ScreenState::Idle);
}
/// A refused login (vmangos's result 1) that lands once the client is in the world comes back to
/// the stock select in state 9, on a glue rebuilt with the link and the server carried over: the
/// screen's own list ask sends nothing (`0x46b880`), state 9's frame opens `OKAY` with
/// `CHAR_LOGIN_NO_WORLD`'s text (`0x46c283`), and the button the stock `OnShow` presses
/// (`0x46b767`) asks for the list again on the same session, which comes back with the character
/// entered selected (`lastCharacterIndex`).
#[test]
fn a_refused_stock_login_comes_back_to_the_select_and_lists_again() {
let _ = benilla_formats::wow_data_or_skip!();
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (_home, _guard) = addon_home("refused-login");
let (mut app, world) = entry_app(true);
// Held until the client is in the world, then let go.
world.hold_refusals();
vm(&app)
.run("CharacterSelect_SelectCharacter(2) CharSelectEnterWorldButton:Click()")
.unwrap();
until_entry(&mut app, "the world", |app| {
client_state(app) == ClientState::InWorld
});
world.release_refusals();
until_entry(&mut app, "state 9 on a new glue", |app| {
glue_vm(app).is_some()
&& app.world().resource::<GlueMgr>().state == ScreenState::LoginRefused
});
assert_eq!(client_state(&app), ClientState::CharSelect);
assert!(shown(&app, "CharacterSelect"));
assert_eq!(
realm_label(&app),
"Alpha ",
"the build carried the link and the server into the screen's OnShow"
);
assert_eq!(
dialog(&app),
Some(("OKAY".into(), "World server is down".into()))
);
assert_eq!(
world.enumerations(),
1,
"the OnShow's ask, made on the link, sent nothing in state 9"
);
until_entry(&mut app, "the list again", |app| {
app.world().resource::<GlueMgr>().state == ScreenState::Idle
&& vm(app).glue_roster_guids().len() == 3
});
assert_eq!(
(world.authentications(), world.enumerations_in(0)),
(1, 2),
"on the same session"
);
assert_eq!(vm(&app).glue_selected_guid(), Some(0x12));
assert_eq!(dialog(&app), None);
assert_eq!(row_names(&app), ["Alpha", "Bravo", "Charlie"]);
assert_eq!(vm(&app).errors(), Vec::<String>::new());
}
/// The two flag arms under the stock select, nothing sent by either: a character locked for a
/// transfer gets `OKAY` with `CHAR_LOGIN_LOCKED_FOR_TRANSFER`'s text, and one flagged for a
/// rename gets the stock rename dialog with `CHAR_RENAME_DESCRIPTION`'s.
#[test]
fn the_stock_enter_world_stops_at_a_locked_or_renamed_character() {
let _ = benilla_formats::wow_data_or_skip!();
let mut roster = roster3();
roster[0].flags |= 0x4;
roster[1].flags |= benilla_protocol::CHARACTER_FLAG_RENAME;
let (mut app, _realmd, world) = select_screen(&roster, None);
vm(&app)
.run("CharacterSelect_SelectCharacter(1) CharSelectEnterWorldButton:Click()")
.unwrap();
app.update();
assert_eq!(
dialog(&app),
Some((
"OKAY".into(),
vm(&app).global_text("CHAR_LOGIN_LOCKED_FOR_TRANSFER")
))
);
vm(&app).run("GlueDialog:Hide()").unwrap();
vm(&app)
.run("CharacterSelect_SelectCharacter(2) CharSelectEnterWorldButton:Click()")
.unwrap();
app.update();
assert!(shown(&app, "CharacterRenameDialog"));
assert_eq!(
vm(&app)
.eval::<String>("return CharacterRenameText1:GetText()")
.unwrap(),
vm(&app).global_text("CHAR_RENAME_DESCRIPTION")
);
for _ in 0..5 {
app.update();
}
assert_eq!(app.world().resource::<GlueMgr>().state, ScreenState::Idle);
assert!(world
.bodies(benilla_protocol::messages::opcode::CMSG_PLAYER_LOGIN)
.is_empty());
assert_eq!(vm(&app).errors(), Vec::<String>::new());
}
/// The realm line `CharacterSelect_OnShow` wrote: the server's name and type, with
/// `(Server down)` under it when `IsConnectedToServer` said no.
fn realm_label(app: &App) -> String {
vm(app)
.eval::<String>("return CharSelectRealmName:GetText() or ''")
.unwrap()
}
/// A logout from the world comes back to the stock select on the glue the build rebuilds with the
/// link and the server carried over (`0x46a7b0`): its `CharacterSelect_OnShow` finds
/// `IsConnectedToServer` (`0x46d380`) and names the realm, and its `GetCharacterListUpdate` asks
/// for the list on the same session (`0x5aaeb0` closes nothing).
#[test]
fn a_stock_logout_comes_back_to_the_select_and_asks_for_its_list() {
let _ = benilla_formats::wow_data_or_skip!();
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (_home, _guard) = addon_home("logout");
let (mut app, world) = entry_app(false);
vm(&app)
.run("CharacterSelect_SelectCharacter(3) CharSelectEnterWorldButton:Click()")
.unwrap();
until_entry(&mut app, "the world", |app| {
client_state(app) == ClientState::InWorld
});
app.world()
.resource::<crate::net::NetCommands>()
.0
.send(crate::net::ClientCommand::Logout)
.unwrap();
until_entry(&mut app, "the select's list", |app| {
glue_vm(app).is_some()
&& app.world().resource::<GlueMgr>().state == ScreenState::Idle
&& vm(app).glue_roster_guids().len() == 3
});
assert_eq!(client_state(&app), ClientState::CharSelect);
assert_eq!(realm_label(&app), "Alpha ");
assert_eq!(
(world.authentications(), world.enumerations_in(0)),
(1, 2),
"the screen's own ask, on the same session"
);
assert_eq!(vm(&app).glue_selected_guid(), Some(0x13));
assert_eq!(dialog(&app), None);
assert_eq!(vm(&app).errors(), Vec::<String>::new());
}
/// A refusal in the same drain as the entry it revokes (vmangos refuses before touching the
/// database) is the last word: the client never leaves the stock select, whose glue VM stands, and
/// the refusal runs its state 9 there, to the list again on the same session.
#[test]
fn a_refusal_in_the_entrys_drain_keeps_the_stock_select() {
let _ = benilla_formats::wow_data_or_skip!();
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (_home, _guard) = addon_home("refused-drain");
let (mut app, world) = entry_app(true);
let session = vm(&app).session();
vm(&app)
.run("CharacterSelect_SelectCharacter(2) CharSelectEnterWorldButton:Click()")
.unwrap();
app.update();
// The entry, its refusal and the session's end, all queued before the next drain.
let deadline = std::time::Instant::now() + fake::WAIT;
while app.world().resource::<IoEvents>().0.len() < 3 {
assert!(std::time::Instant::now() < deadline, "no refusal");
std::thread::sleep(std::time::Duration::from_millis(2));
}
until_entry(&mut app, "state 9", |app| {
app.world().resource::<GlueMgr>().state == ScreenState::LoginRefused
});
assert_eq!(
dialog(&app),
Some(("OKAY".into(), "World server is down".into())),
"state 9's dialog, on the glue that stood"
);
until_entry(&mut app, "the list again", |app| {
glue_vm(app).is_some()
&& app.world().resource::<GlueMgr>().state == ScreenState::Idle
&& vm(app).glue_roster_guids().len() == 3
});
assert_eq!(client_state(&app), ClientState::CharSelect);
assert_eq!(vm(&app).session(), session, "the glue never left");
assert!(
app.world()
.resource::<crate::char_select::Roster>()
.pending_row()
.is_none(),
"the pick went with the refusal"
);
assert_eq!((world.authentications(), world.enumerations_in(0)), (1, 2));
assert_eq!(vm(&app).glue_selected_guid(), Some(0x12));
assert_eq!(dialog(&app), None);
assert_eq!(vm(&app).errors(), Vec::<String>::new());
}
@@ -78,6 +78,8 @@ fn install_glue_vm(world: &mut World, screen: super::glue::GlueScreen) -> bool {
let failures = super::glue::load_glue(&mut script);
super::glue::finish_glue_load_log(&mut script);
start_server_alert(world, &script);
// What the glue carries across its rebuild, in place before the screen's `OnShow` reads it.
super::glue_mgr::seat_glue_build(world, &mut script);
super::glue::set_glue_screen(&mut script, screen);
info!(
"ui_script: stock glue up in {:.0} ms on \"{}\" ({} load failure(s))",
@@ -135,6 +135,10 @@ pub enum GlueRequest {
/// `0x46b39b`): the host takes screen state 7, the `CHAR_RENAME_IN_PROGRESS` dialog and the
/// `CMSG_CHAR_RENAME`.
RenameCharacter { guid: u64, name: String },
/// `EnterWorld()` (`0x46d3c0` → `CGlueMgr::EnterWorld` `0x46b500`) with the selection on the
/// roster (`0x46b513`-`0x46b53e`): its 0-based index (`[0x83856c]`), guid (`+0x00`) and flags
/// (`+0xfc`). The realm and link guards, the flag dialogs and the entry are the host's.
EnterWorld { index: i32, guid: u64, flags: u32 },
}
/// The `ClientServices` status keys (`0x85cae8`, 83 entries), the `ResponseCodes` the world
+60 -1
View File
@@ -9,7 +9,8 @@
//! as `0x472740` does. Indices are 1-based in Lua and 0-based inside, the boundary at `0x473470`.
//!
//! `DeleteCharacter` and `RenameCharacter` check here and hand the host what the reference sends
//! (`0x46b2e0`, `0x46b340`), whose screen states are the host's; the rename reply's applier
//! (`0x46b2e0`, `0x46b340`), whose screen states are the host's; `EnterWorld` hands it the selected
//! record (`0x46b500`); the rename reply's applier
//! (`0x4725f0`) changes the record here once the host has moved the character's files.
//!
//! The 3-D side of the reference (`0x472950`'s model build into the scene) is the host's scene
@@ -607,6 +608,32 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `0x46d3c0`, a glue core binding (table `0x8373b8`) that reads this roster: `CGlueMgr::
// EnterWorld` (`0x46b500`), then no values (`0x46d3c5 xor eax,eax`), arguments ignored. The
// selection cell indexes the record (`0x46b513`-`0x46b533`); off the roster the call returns
// having done nothing (`0x46b53e`). The record goes to the host, whose guards come next.
g.set(
"EnterWorld",
lua.create_function(|lua, _: MultiValue| {
let entry = {
let m = model(lua);
let roster = &m.glue.roster;
usize::try_from(roster.cells.selected)
.ok()
.and_then(|i| roster.records.get(i))
.map(|r| GlueRequest::EnterWorld {
index: roster.cells.selected,
guid: r.character.guid,
flags: r.character.flags,
})
};
if let Some(entry) = entry {
queue(lua, entry);
}
Ok(MultiValue::new())
})?,
)?;
// `0x473640` → `0x473190`: the selected record's composite (`+0x110`) is handed to the
// character bake (`0x477860`), once per `OnUpdateModel` (`CharacterSelect.lua:208`). The
// composite is the scene drive's, which bakes when the selection or the look changes, so
@@ -1286,6 +1313,38 @@ mod tests {
);
}
/// `0x46d3c0`: the selected record's index, guid and flags go to the host and the call returns
/// no values; with the selection off the roster nothing goes (`0x46b53e`), and the verb fires
/// nothing itself.
#[test]
fn enter_world_hands_the_selected_record_to_the_host() {
let mut roster = three();
roster[1].flags = 0x4004;
let mut s = vm(roster);
s.take_glue_requests();
s.run("SelectCharacter(2) FIRED = {}").unwrap();
assert_eq!(s.eval::<f64>(&count("EnterWorld('x', 9)")).unwrap(), 0.0);
let queued = s.take_glue_requests();
assert!(
matches!(
queued.as_slice(),
[GlueRequest::EnterWorld {
index: 1,
guid: 2,
flags: 0x4004
}]
),
"{queued:?}"
);
assert!(fired(&s).is_empty());
for mut s in [vm(Vec::new()), UiScript::new_glue().unwrap()] {
s.take_glue_requests();
assert_eq!(s.eval::<f64>(&count("EnterWorld()")).unwrap(), 0.0);
assert!(s.take_glue_requests().is_empty());
}
}
/// `0x4725f0` on the VM's side: the record found by guid takes the name, loses the rename flag
/// and keeps the rest, and `CHARACTER_LIST_UPDATE` fires; another guid changes nothing.
#[test]
@@ -143,7 +143,6 @@ const NOT_YET: &[(&str, &[&str])] = &[
patch and survey, the billing and the credits music; each a binding of its own, the ones \
with an in-game namesake included",
&[
"EnterWorld",
"GetBillingPlan",
"GetBillingTimeRemaining",
"GetBillingTimeRested",