From e175209f282d3f45f4e937d1224cbe16adcdb310 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:50:44 +0700 Subject: [PATCH] Stock EnterWorld: its guards, the refused login and the glue reseat (#1067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/benilla-app/src/char_select/mod.rs | 72 ++- crates/benilla-app/src/cvars/mod.rs | 11 + crates/benilla-app/src/net/io.rs | 11 +- crates/benilla-app/src/net/io/fake.rs | 38 +- crates/benilla-app/src/ui_script/glue_mgr.rs | 279 ++++++++- .../src/ui_script/glue_mgr_tests.rs | 549 +++++++++++++++++- crates/benilla-app/src/ui_script/lifecycle.rs | 2 + crates/benilla-ui/src/script/glue_core.rs | 4 + crates/benilla-ui/src/script/glue_select.rs | 61 +- .../src/script/tests/glue_surface.rs | 1 - 10 files changed, 973 insertions(+), 55 deletions(-) diff --git a/crates/benilla-app/src/char_select/mod.rs b/crates/benilla-app/src/char_select/mod.rs index b7dbb46cb..032dae2d6 100644 --- a/crates/benilla-app/src/char_select/mod.rs +++ b/crates/benilla-app/src/char_select/mod.rs @@ -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 { +/// 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 { ( 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) -> 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, 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, mut roster: ResMut, mut next: ResMut>, mut dialog: ResMut, strings: Option>, + stock: Option>, ) { 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::() - .init_resource::() - .init_resource::() - .add_message::() - .add_message::() + .insert_resource(Roster::with_pending_pick(vec![character(7, "Seven")], 7)) + .insert_resource(CharPick(tx.into())) + .init_resource::() .init_resource::() - .add_systems(Update, (enter_on_connected, back_on_login_refused).chain()); - app.world_mut().resource_mut::().pending_pick = Some(7); + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + // The production chain, in its own order. + .add_systems(Update, session_edges()); + app.world_mut().resource_mut::().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::() + .add_message::() + .add_systems(Update, back_on_login_refused); + app.world_mut() + .write_message(CharacterLoginFailedMessage { result: 0x01 }); + app.update(); + app.update(); + assert_eq!( + *app.world().resource::>().get(), + ClientState::CharSelect + ); + assert_eq!(app.world().resource::().pending_pick, None); + let dialog = app.world().resource::(); + 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. diff --git a/crates/benilla-app/src/cvars/mod.rs b/crates/benilla-app/src/cvars/mod.rs index 7dcf0a929..7f837f219 100644 --- a/crates/benilla-app/src/cvars/mod.rs +++ b/crates/benilla-app/src/cvars/mod.rs @@ -825,6 +825,17 @@ fn save_config_when_quiet(mut cvars: ResMut) { } } +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) { if cvars.dirty { diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 92cb9e8ee..2e1a15bb8 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -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 diff --git a/crates/benilla-app/src/net/io/fake.rs b/crates/benilla-app/src/net/io/fake.rs index 34e07708e..4b6d95ed7 100644 --- a/crates/benilla-app/src/net/io/fake.rs +++ b/crates/benilla-app/src/net/io/fake.rs @@ -324,6 +324,9 @@ type Rosters = Arc>>>; /// Packets, by opcode and body, the fake world sends behind `SMSG_LOGOUT_COMPLETE`. type Trail = Arc)>>>; +/// Whether refusals are held, and the signal that releases them. +type RefusalGate = Arc<(Mutex, std::sync::Condvar)>; + /// The `SMSG_ADDON_INFO` body a fake world sends, and whether it trails the authentication answer. type AddonReply = Arc, bool)>>>; @@ -349,6 +352,8 @@ pub(crate) struct World { cut: Arc, /// Answer `CMSG_PLAYER_LOGIN` with `SMSG_CHARACTER_LOGIN_FAILED`. refuse: Arc, + /// 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) -> 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) -> 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) -> 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) -> World { warden, cut, refuse, + refusal_gate, trail, addon_info, open, diff --git a/crates/benilla-app/src/ui_script/glue_mgr.rs b/crates/benilla-app/src/ui_script/glue_mgr.rs index 83bf6c48b..3cbda4cfb 100644 --- a/crates/benilla-app/src/ui_script/glue_mgr.rs +++ b/crates/benilla-app/src/ui_script/glue_mgr.rs @@ -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, + /// A refused character login's status, waiting for the glue rebuild that sets state 9. + refused: Option, + /// The index `EnterWorld` writes to `lastCharacterIndex`, for the host's CVar registry. + last_character: Option, } 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>, 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>, } @@ -1173,8 +1304,12 @@ pub(crate) struct GlueAsks<'w> { launch_addon: MessageWriter<'w, super::server_alert::LaunchAddonUrl>, realm: Option>, abandon: Option>, - /// 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>, + pick: Option>, + /// The engine-side CVar table `EnterWorld` writes and saves. + cvars: Option>, } /// 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::() { + return; + } + world.resource_scope(|world, mut mgr: Mut| { + seat_glue( + &mut mgr, + script, + world + .get_resource::() + .and_then(|r| r.0.as_deref()), + world.get_resource::(), + world.get_resource::(), + world.get_resource::(), + ); + }); +} + /// 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, 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, 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); } diff --git a/crates/benilla-app/src/ui_script/glue_mgr_tests.rs b/crates/benilla-app/src/ui_script/glue_mgr_tests.rs index 4421b49e0..c84b8e886 100644 --- a/crates/benilla-app/src/ui_script/glue_mgr_tests.rs +++ b/crates/benilla-app/src/ui_script/glue_mgr_tests.rs @@ -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::() .add_message::() .add_message::() + .add_message::() .init_resource::() .add_message::() .add_message::() @@ -1158,11 +1341,14 @@ struct IoEvents(crossbeam_channel::Receiver); #[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::() .add_message::() .add_message::() + .add_message::() + .add_message::() + .add_message::() .init_resource::() .add_message::() .add_message::() @@ -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::::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::>(); + w.remove_resource::>(); + 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::(); + } + 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::>().get() +} + +/// The glue VM standing in the world, if one does. +fn glue_vm(app: &App) -> Option<&UiScript> { + app.world() + .get_non_send_resource::() + .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::().state, + glue_vm(app).map(UiScript::errors) + ); + app.update(); + if let Some(mut s) = app.world_mut().get_non_send_resource_mut::() { + 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 { + 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::().state, + ScreenState::EnterWorld + ); + assert_eq!( + app.world() + .resource::() + .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::() + .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::().is_none(), + "no VM of the glue's left in the world" + ); + app.update(); + assert_eq!(app.world().resource::().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::().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::().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::::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::("return CharacterRenameText1:GetText()") + .unwrap(), + vm(&app).global_text("CHAR_RENAME_DESCRIPTION") + ); + for _ in 0..5 { + app.update(); + } + assert_eq!(app.world().resource::().state, ScreenState::Idle); + assert!(world + .bodies(benilla_protocol::messages::opcode::CMSG_PLAYER_LOGIN) + .is_empty()); + assert_eq!(vm(&app).errors(), Vec::::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::("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::() + .0 + .send(crate::net::ClientCommand::Logout) + .unwrap(); + until_entry(&mut app, "the select's list", |app| { + glue_vm(app).is_some() + && app.world().resource::().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::::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::().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::().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::().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::() + .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::::new()); +} diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index a9b53cb0c..ddfbad702 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -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))", diff --git a/crates/benilla-ui/src/script/glue_core.rs b/crates/benilla-ui/src/script/glue_core.rs index 15c923015..038ae02fe 100644 --- a/crates/benilla-ui/src/script/glue_core.rs +++ b/crates/benilla-ui/src/script/glue_core.rs @@ -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 diff --git a/crates/benilla-ui/src/script/glue_select.rs b/crates/benilla-ui/src/script/glue_select.rs index 2af80592c..cb7d67c26 100644 --- a/crates/benilla-ui/src/script/glue_select.rs +++ b/crates/benilla-ui/src/script/glue_select.rs @@ -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::(&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::(&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] diff --git a/crates/benilla-ui/src/script/tests/glue_surface.rs b/crates/benilla-ui/src/script/tests/glue_surface.rs index 9d2da146c..6dfcbecc3 100644 --- a/crates/benilla-ui/src/script/tests/glue_surface.rs +++ b/crates/benilla-ui/src/script/tests/glue_surface.rs @@ -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",