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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user