From 24c7cab983fa17e7550f889afa5df6c012019dc8 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:59:54 +0700 Subject: [PATCH] Stock character select roster; the screen asks for the list (#1014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 4a of the stock GlueXML (`WOW_GLUE_STOCK=1`): character select's roster. - The select verbs run on the reference's roster (`0x4736c0`): `GetCharacterInfo` (race and class from `Name_Lang`), `SelectCharacter` with its clamp and event, the facing gate, and `GetCharacterListUpdate`. They are backed by the roster load and teardown (`0x4724d0`, `0x472580`) and screen state 3. - Under the stock screens the character list is fetched only when the screen asks, as `0x46b880` does. The world connect completes on the auth response (`0x5aab80`), not on a roster. - Every character-park request goes through one ordered channel. A roster reply completes only the current link's query. A late auth after a cancel is dropped, Warden stays terminal, and a failed list shows `CHAR_LIST_FAILED`. The native screens are unchanged. Fake-server tests cover the ordering, the cancel, the realm change and the native policy. Part of #856 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5.5 --- crates/benilla-app/src/capture/mod.rs | 38 + .../src/capture/probe_charcreate.rs | 3 + crates/benilla-app/src/capture/probe_rig.rs | 3 + crates/benilla-app/src/char_select/input.rs | 4 +- crates/benilla-app/src/char_select/mod.rs | 14 +- crates/benilla-app/src/cvars/mod.rs | 6 + crates/benilla-app/src/entities.rs | 15 + crates/benilla-app/src/login/mod.rs | 195 +++- crates/benilla-app/src/net.rs | 124 ++- crates/benilla-app/src/net/io.rs | 205 ++-- crates/benilla-app/src/net/io/cycle_tests.rs | 288 +++++- crates/benilla-app/src/net/io/fake.rs | 208 +++- crates/benilla-app/src/net/session.rs | 51 +- crates/benilla-app/src/realm_select/mod.rs | 2 +- crates/benilla-app/src/realm_select/screen.rs | 2 +- crates/benilla-app/src/ui_script/glue.rs | 6 +- crates/benilla-app/src/ui_script/glue_mgr.rs | 434 ++++++--- .../src/ui_script/glue_mgr_tests.rs | 889 +++++++++++++++++- crates/benilla-app/src/ui_script/lifecycle.rs | 53 +- .../src/characters/customization.rs | 63 +- crates/benilla-formats/src/chr_classes.rs | 45 +- crates/benilla-formats/src/dbc.rs | 10 + crates/benilla-protocol/src/events.rs | 33 + crates/benilla-protocol/src/events/decode.rs | 3 + crates/benilla-protocol/src/lib.rs | 4 +- crates/benilla-protocol/tests/char_enum.rs | 4 +- crates/benilla-ui/src/script/glue.rs | 3 +- crates/benilla-ui/src/script/glue_core.rs | 8 +- crates/benilla-ui/src/script/glue_realm.rs | 2 +- crates/benilla-ui/src/script/glue_select.rs | 771 +++++++++++++++ crates/benilla-ui/src/script/mod.rs | 2 + .../src/script/tests/glue_surface.rs | 16 +- docs/MAP.md | 2 +- 33 files changed, 3194 insertions(+), 312 deletions(-) create mode 100644 crates/benilla-ui/src/script/glue_select.rs diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 462fbdfd..ceb0166e 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -115,6 +115,31 @@ pub(crate) use probes::{ ProbeGlueLuaPlugin, ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, RevealAuditPlugin, SchedCensusPlugin, StallPlugin, TrailCensusPlugin, UnitVisualsPlugin, }; +/// A probe that reads the native screens' rosters (the park's own, and the one re-sent after a +/// create or delete) cannot run under `WOW_GLUE_STOCK=1`, where the screen asks for its own and the +/// probe would sit until its timeout. Refuse it where it is requested: an error at plugin build, +/// and the exit of a driverless run ([`crate::run_mode::fatal_when_driverless`]), at the first +/// frame. `true` means the probe must not install. +pub(crate) fn refuse_under_stock(app: &mut App, probe: &'static str) -> bool { + refuse_if_stock(app, probe, crate::ui_script::glue::GlueStock::from_env().0) +} + +fn refuse_if_stock(app: &mut App, probe: &'static str, stock: bool) -> bool { + if !stock { + return false; + } + error!( + "probe: {probe} reads the native screens' rosters and cannot run under WOW_GLUE_STOCK=1; \ + unset one of them" + ); + if crate::run_mode::fatal_when_driverless(&format!("{probe} under WOW_GLUE_STOCK=1")) { + app.add_systems(Startup, |mut exit: MessageWriter| { + exit.write(AppExit::error()); + }); + } + true +} + pub(crate) use scenarios::ui_opted_in; use scenarios::GlueScreen; use scenarios::{ @@ -1052,3 +1077,16 @@ fn drive_capture( } } } + +#[cfg(test)] +mod refuse_tests { + use super::*; + + /// A native-only probe installs under the native screens and is refused under the stock ones. + #[test] + fn a_native_only_probe_is_refused_under_the_stock_screens() { + let mut app = App::new(); + assert!(!refuse_if_stock(&mut app, "WOW_RIG", false)); + assert!(refuse_if_stock(&mut app, "WOW_RIG", true)); + } +} diff --git a/crates/benilla-app/src/capture/probe_charcreate.rs b/crates/benilla-app/src/capture/probe_charcreate.rs index 3709570d..090ad559 100644 --- a/crates/benilla-app/src/capture/probe_charcreate.rs +++ b/crates/benilla-app/src/capture/probe_charcreate.rs @@ -21,6 +21,9 @@ impl Plugin for ProbeCharCreatePlugin { else { return; // inert without a valid env spec }; + if super::refuse_under_stock(app, "WOW_PROBE_CHARCREATE") { + return; + } let keep = std::env::var("WOW_PROBE_CHARCREATE_KEEP").as_deref() == Ok("1"); app.insert_resource(CharCreateProbe { req, diff --git a/crates/benilla-app/src/capture/probe_rig.rs b/crates/benilla-app/src/capture/probe_rig.rs index c349aa61..119623b7 100644 --- a/crates/benilla-app/src/capture/probe_rig.rs +++ b/crates/benilla-app/src/capture/probe_rig.rs @@ -73,6 +73,9 @@ impl Plugin for ProbeRigPlugin { else { return; // inert without a parseable spec (parse() has already said why) }; + if super::refuse_under_stock(app, "WOW_RIG") { + return; + } info!("rig: {}", spec.describe()); // Publish the claim on the character pick, so the roster knows the pick is spoken for. if let Some(name) = rig_char_name(&spec) { diff --git a/crates/benilla-app/src/char_select/input.rs b/crates/benilla-app/src/char_select/input.rs index fc15b8f5..a09b3c0c 100644 --- a/crates/benilla-app/src/char_select/input.rs +++ b/crates/benilla-app/src/char_select/input.rs @@ -195,8 +195,8 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() - .insert_resource(CharPick(pick_tx)) - .insert_resource(RealmChoice(realm_tx)) + .insert_resource(CharPick(pick_tx.into())) + .insert_resource(RealmChoice(realm_tx.into())) .add_message::() .add_message::() .add_message::() diff --git a/crates/benilla-app/src/char_select/mod.rs b/crates/benilla-app/src/char_select/mod.rs index f10afe43..235e628a 100644 --- a/crates/benilla-app/src/char_select/mod.rs +++ b/crates/benilla-app/src/char_select/mod.rs @@ -921,7 +921,7 @@ pub(crate) fn test_character(guid: u64, name: &str) -> Character { pub(crate) fn add_test_systems(app: &mut App, pick: crossbeam_channel::Sender) { app.insert_state(ClientState::CharSelect) .init_resource::() - .insert_resource(CharPick(pick)) + .insert_resource(CharPick(pick.into())) .add_message::() .add_systems( Update, @@ -1197,7 +1197,7 @@ mod tests { let mut app = App::new(); app.add_plugins(MinimalPlugins) .init_resource::() - .insert_resource(CharPick(tx)) + .insert_resource(CharPick(tx.into())) .insert_resource(match stored { Some(v) => crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, v), None => crate::cvars::Cvars::default(), @@ -1215,6 +1215,9 @@ mod tests { .map(|(i, n)| character(i as u64 + 1, n)) .collect(), realm: None, + cause: benilla_protocol::RosterCause::Park, + epoch: 0, + generation: 0, }); app.update(); app.world().resource::().selected() @@ -1309,7 +1312,7 @@ mod tests { let mut app = App::new(); app.add_plugins(MinimalPlugins) .init_resource::() - .insert_resource(CharPick(tx)) + .insert_resource(CharPick(tx.into())) .insert_resource(crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, "0")) .add_message::() .add_message::() @@ -1325,6 +1328,9 @@ mod tests { character(3, "Zzbullone"), ], realm: None, + cause: benilla_protocol::RosterCause::Park, + epoch: 0, + generation: 0, }); app.update(); assert_eq!( @@ -1361,7 +1367,7 @@ mod tests { app.add_plugins(MinimalPlugins) .init_resource::() .init_resource::() - .insert_resource(CharPick(tx)) + .insert_resource(CharPick(tx.into())) .add_systems(Update, persist_last_character); { let mut roster = app.world_mut().resource_mut::(); diff --git a/crates/benilla-app/src/cvars/mod.rs b/crates/benilla-app/src/cvars/mod.rs index b0aa50b9..7dcf0a92 100644 --- a/crates/benilla-app/src/cvars/mod.rs +++ b/crates/benilla-app/src/cvars/mod.rs @@ -1829,6 +1829,9 @@ mod tests { app.world_mut().write_message(crate::net::CharListMessage { characters: roster(), realm: None, + cause: benilla_protocol::RosterCause::Park, + epoch: 0, + generation: 0, }); app.update(); assert_eq!( @@ -1856,6 +1859,9 @@ mod tests { app.world_mut().write_message(crate::net::CharListMessage { characters: roster(), realm: None, + cause: benilla_protocol::RosterCause::Park, + epoch: 0, + generation: 0, }); app.update(); diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 345ed244..522d31ab 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -237,6 +237,21 @@ pub(crate) struct Creatures { } impl Creatures { + /// Whether a display id has a model row behind it (`CreatureDisplayInfo` โ†’ `CreatureModelData`), + /// the chain `0x5ec480` walks after the race row's id. + pub(crate) fn resolves_model(&self, display_id: u32) -> bool { + self.catalog.model(display_id).is_some() + } + + /// A catalog with no cached models, for tests that only ask whether a display resolves. + #[cfg(test)] + pub(crate) fn from_catalog(catalog: CreatureCatalog) -> Self { + Creatures { + catalog, + models: HashMap::new(), + } + } + /// A display's foley material (`Material.dbc` id), the creature half of the footfall rustle. pub(crate) fn foley_material(&self, display_id: u32) -> Option { self.catalog.foley_material(display_id) diff --git a/crates/benilla-app/src/login/mod.rs b/crates/benilla-app/src/login/mod.rs index fd117e21..6191ca8f 100644 --- a/crates/benilla-app/src/login/mod.rs +++ b/crates/benilla-app/src/login/mod.rs @@ -32,7 +32,7 @@ use crate::glue::dialog::{DialogKind, GlueDialog}; use crate::glue_strings::GlueStrings; use crate::net::{ CharListMessage, DisconnectedMessage, LoginAbandon, LoginFailedMessage, LoginQueuedMessage, - LoginRequest, LoginStageMessage, LoginSubmit, + LoginRequest, LoginStageMessage, LoginSubmit, WorldAuthMessage, }; use crate::portrait::{GluePreview, GlueScene}; use crate::sound::GlueSound; @@ -638,16 +638,47 @@ fn drive_policy( } } -/// The roster is the login's success edge: go to CharSelect, but only from `Login`, since a -/// reconnect's roster lands while `InWorld`. +/// The login's success edge: go to CharSelect, but only from `Login`, since a reconnect's roster +/// lands while `InWorld`. The native screens take the roster for it, which the IO thread fetches on +/// reaching the park; the stock screens take the world server's authentication +/// (`SMSG_AUTH_RESPONSE`, `0x5ab2b8`), since the roster there is the screen's own ask and comes +/// only once the screen is up. fn to_select_on_roster( mut msgs: MessageReader, + mut auths: MessageReader, + stock: Option>, mut intent: ResMut, mut dialog: ResMut, state: Res>, mut next: ResMut>, + abandon: Option>, + pick: Option>, ) { - if msgs.read().next().is_none() { + // A message of an attempt a Cancel or a disconnect has passed since is not an edge: the + // reference drops the socket there (`0x46befd`, `0x46d360`), so the session that parked + // behind the screen's back is abandoned. + let now = LoginAbandon::now(abandon.as_deref()); + let (mut edge, mut stale) = (false, false); + let generations: Vec = if stock.is_some_and(|s| s.0) { + msgs.read().for_each(drop); + auths.read().map(|m| m.generation).collect() + } else { + auths.read().for_each(drop); + msgs.read().map(|m| m.generation).collect() + }; + for generation in generations { + if generation == now { + edge = true; + } else { + stale = true; + } + } + if stale && *state.get() == ClientState::Login { + if let Some(pick) = pick.as_deref() { + let _ = pick.0.send(crate::net::CharRequest::Abandon); + } + } + if !edge { return; } intent.in_flight = false; @@ -1131,7 +1162,7 @@ mod tests { .insert_resource(LoginAbandon(std::sync::Arc::new( std::sync::atomic::AtomicU64::new(0), ))) - .insert_resource(crate::net::RealmChoice(realm_tx)) + .insert_resource(crate::net::RealmChoice(realm_tx.into())) .add_message::() .add_message::() .add_message::() @@ -1194,7 +1225,7 @@ mod tests { crate::realmlist::DEFAULT_REALMLIST, )) .insert_resource(LoginAbandon(abandon.clone())) - .insert_resource(crate::net::RealmChoice(realm_tx)) + .insert_resource(crate::net::RealmChoice(realm_tx.into())) .add_message::() .add_systems(Update, answer_dialog); app.world_mut().resource_mut::().in_flight = true; @@ -1236,7 +1267,7 @@ mod tests { .insert_resource(LoginAbandon(std::sync::Arc::new( std::sync::atomic::AtomicU64::new(0), ))) - .insert_resource(crate::net::RealmChoice(realm_tx)) + .insert_resource(crate::net::RealmChoice(realm_tx.into())) .insert_resource(State::new(ClientState::Login)) .add_message::() .add_message::() @@ -1344,6 +1375,7 @@ mod tests { .init_resource::() .init_resource::() .add_message::() + .add_message::() .add_systems(Update, to_select_on_roster); // The refusal's dialog survives its own relist. @@ -1353,6 +1385,9 @@ mod tests { app.world_mut().write_message(CharListMessage { characters: Vec::new(), realm: None, + cause: benilla_protocol::RosterCause::Park, + epoch: 0, + generation: 0, }); app.update(); let dialog = app.world().resource::(); @@ -1366,11 +1401,157 @@ mod tests { app.world_mut().write_message(CharListMessage { characters: Vec::new(), realm: None, + cause: benilla_protocol::RosterCause::Park, + epoch: 0, + generation: 0, }); app.update(); assert!(app.world().resource::().kind.is_none()); } + /// The login's success edge is the authentication under the stock screens, whose roster comes + /// only after the screen is up and asks, and the roster under the native ones. Until the stock + /// edge the state stays `Login`, so the screen's Exit still abandons the attempt. + #[test] + fn the_stock_login_ends_at_the_authentication_and_the_native_at_the_roster() { + use crate::net::WorldAuthMessage; + let roster = || CharListMessage { + characters: Vec::new(), + realm: None, + cause: benilla_protocol::RosterCause::Asked, + epoch: 1, + generation: 0, + }; + let auth = || WorldAuthMessage { + realm: benilla_protocol::RealmInfo { + name: "Fake".into(), + address: "127.0.0.1:8085".into(), + realm_type: 0, + flags: 0, + population: 0.0, + characters: 0, + category: 1, + id: 1, + }, + epoch: 1, + generation: 0, + }; + for stock in [true, false] { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin)) + .insert_state(ClientState::Login) + .insert_resource(crate::ui_script::glue::GlueStock(stock)) + .init_resource::() + .init_resource::() + .add_message::() + .add_message::() + .add_systems(Update, to_select_on_roster); + let (first, second) = ( + |app: &mut App| { + if stock { + app.world_mut().write_message(roster()); + } else { + app.world_mut().write_message(auth()); + } + }, + |app: &mut App| { + if stock { + app.world_mut().write_message(auth()); + } else { + app.world_mut().write_message(roster()); + } + }, + ); + // The message of the other policy's edge leaves the screen where it is. + first(&mut app); + app.update(); + app.update(); + assert_eq!( + *app.world().resource::>().get(), + ClientState::Login, + "stock {stock}" + ); + second(&mut app); + app.update(); + app.update(); + assert_eq!( + *app.world().resource::>().get(), + ClientState::CharSelect, + "stock {stock}" + ); + } + } + + /// An attempt a Cancel or a disconnect has passed since is no edge: the screen stays on login + /// and the session that parked behind its back is abandoned, under either policy. + #[test] + fn a_login_edge_behind_the_abandon_generation_abandons_the_session() { + use crate::net::WorldAuthMessage; + for stock in [true, false] { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin)) + .insert_state(ClientState::Login) + .insert_resource(crate::ui_script::glue::GlueStock(stock)) + .insert_resource(LoginAbandon(std::sync::Arc::new( + std::sync::atomic::AtomicU64::new(1), + ))) + .insert_resource(crate::net::CharPick(tx.into())) + .init_resource::() + .init_resource::() + .add_message::() + .add_message::() + .add_systems(Update, to_select_on_roster); + let send = |app: &mut App, generation: u64| { + if stock { + app.world_mut().write_message(WorldAuthMessage { + realm: benilla_protocol::RealmInfo { + name: "Fake".into(), + address: "127.0.0.1:8085".into(), + realm_type: 0, + flags: 0, + population: 0.0, + characters: 0, + category: 1, + id: 1, + }, + epoch: 1, + generation, + }); + } else { + app.world_mut().write_message(CharListMessage { + characters: Vec::new(), + realm: None, + cause: benilla_protocol::RosterCause::Park, + epoch: 1, + generation, + }); + } + }; + send(&mut app, 0); + app.update(); + app.update(); + assert_eq!( + *app.world().resource::>().get(), + ClientState::Login, + "stock {stock}: a stale edge" + ); + assert!( + matches!(rx.try_recv(), Ok(crate::net::CharRequest::Abandon)), + "stock {stock}: the parked session is abandoned" + ); + send(&mut app, 1); + app.update(); + app.update(); + assert_eq!( + *app.world().resource::>().get(), + ClientState::CharSelect, + "stock {stock}: the current attempt's edge" + ); + assert!(rx.try_recv().is_err()); + } + } + /// An unattended run still reconnects on a lost session; the verdict rides the message. #[test] fn an_unattended_run_still_reconnects_on_its_own() { diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 49b66ac1..7698b2c4 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -84,6 +84,7 @@ impl Plugin for NetPlugin { .insert_resource(NetCommands(handles.commands)) .insert_resource(CharPick(handles.pick)) .insert_resource(RealmChoice(handles.realm)) + .insert_resource(RosterPolicy(handles.screen_asks_roster)) .insert_resource(LoginSubmit(handles.login)) .insert_resource(LoginAbandon(handles.login_abandon)) .insert_resource(PingShared(handles.ping)) @@ -118,6 +119,8 @@ impl Plugin for NetPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -379,9 +382,89 @@ pub(crate) struct NetEvents(Receiver); pub(crate) struct NetCommands(pub(crate) Sender); /// The character-select channel to the IO thread parked at select: create and delete are served -/// in place, re-emitting the roster, until an [`CharRequest::Enter`] moves into the world. +/// in place, re-emitting the roster (under the native policy), until an [`CharRequest::Enter`] +/// moves into the world. #[derive(Resource)] -pub(crate) struct CharPick(pub(crate) Sender); +pub(crate) struct CharPick(pub(crate) ParkSender); + +/// A request to the IO thread at a park. The character screen's and the realm list's requests +/// travel on one channel, in the order the app made them: the reference's verbs act at the call +/// (`GetCharacterListUpdate` sends its `CMSG_CHAR_ENUM`, `ChangeRealm` then closes the link), so +/// the IO thread must meet them in that order, which two channels cannot give. +pub(crate) enum ParkRequest { + Char(CharRequest), + Realm(RealmRequest), +} + +impl From for ParkRequest { + fn from(r: CharRequest) -> Self { + ParkRequest::Char(r) + } +} + +impl From for ParkRequest { + fn from(r: RealmRequest) -> Self { + ParkRequest::Realm(r) + } +} + +/// The IO thread is gone. +#[derive(Debug)] +pub(crate) struct ParkGone; + +/// The app's end of the park channel for one kind of request. +pub(crate) enum ParkSender { + /// The shared, ordered channel the IO thread reads. + Park(Sender, std::marker::PhantomData), + /// A channel of that kind alone, for a test that reads the requests itself. + #[cfg(test)] + Direct(Sender), +} + +impl Clone for ParkSender { + fn clone(&self) -> Self { + match self { + ParkSender::Park(tx, kind) => ParkSender::Park(tx.clone(), *kind), + #[cfg(test)] + ParkSender::Direct(tx) => ParkSender::Direct(tx.clone()), + } + } +} + +impl ParkSender { + /// The app's end of the IO thread's park channel. + pub(super) fn park(tx: Sender) -> Self { + ParkSender::Park(tx, std::marker::PhantomData) + } +} + +impl ParkSender { + /// Send a request of either kind, in the order it is made against the rest. + pub(crate) fn send_request(&self, request: ParkRequest) -> Result<(), ParkGone> { + match self { + ParkSender::Park(tx, _) => tx.send(request).map_err(|_| ParkGone), + #[cfg(test)] + ParkSender::Direct(_) => Err(ParkGone), + } + } +} + +impl> ParkSender { + pub(crate) fn send(&self, request: T) -> Result<(), ParkGone> { + match self { + ParkSender::Park(tx, _) => tx.send(request.into()).map_err(|_| ParkGone), + #[cfg(test)] + ParkSender::Direct(tx) => tx.send(request).map_err(|_| ParkGone), + } + } +} + +#[cfg(test)] +impl From> for ParkSender { + fn from(tx: Sender) -> Self { + ParkSender::Direct(tx) + } +} /// One request to the IO thread parked at character select. pub(crate) enum CharRequest { @@ -393,13 +476,24 @@ pub(crate) enum CharRequest { Delete(u64), /// Select's Back: drop the session and return to the pre-logon park. Abandon, + /// Fetch the roster again (`CMSG_CHAR_ENUM`) on the parked session, then stay parked with + /// the fresh roster: `GetCharacterListUpdate`, which the stock screen calls whenever it shows. + Enumerate, } +/// Whether the screen asks for its roster (the stock glue screens' `GetCharacterListUpdate`) +/// instead of the IO thread fetching it on reaching the character park (the native screens'). Set +/// by the UI session as it installs the VM that runs: true with the stock glue VM up, false +/// otherwise, so a run whose stock VM did not build still gets its roster. The IO thread reads it +/// as each park is entered. +#[derive(Resource, Clone)] +pub(crate) struct RosterPolicy(pub(crate) std::sync::Arc); + /// The realm channel: the answer to each [`RealmListMessage`]. Two parks listen, the login-side /// one and the character one, since the reference's realm list is a dialog over either screen /// (`RealmList.xml` is `frameStrata="DIALOG"`). #[derive(Resource)] -pub(crate) struct RealmChoice(pub(crate) Sender); +pub(crate) struct RealmChoice(pub(crate) ParkSender); /// One request to the IO thread about the realm list, served at the logon park and at character /// select; at the login park a query fails and the rest are dropped. @@ -2011,6 +2105,30 @@ pub(crate) struct RealmListMessage { pub(crate) struct CharListMessage { pub(crate) characters: Vec, pub(crate) realm: Option, + /// What the roster was fetched for. + pub(crate) cause: benilla_protocol::RosterCause, + /// The world session that sent it ([`WorldAuthMessage::epoch`]). + pub(crate) epoch: u64, + /// The abandon generation of the attempt that dialed that session. + pub(crate) generation: u64, +} + +/// The world server accepted the authentication: the world connect to `realm` is done and the IO +/// thread is at the character park (`SMSG_AUTH_RESPONSE` success, `0x5ab2b8`). +#[derive(Message)] +pub(crate) struct WorldAuthMessage { + pub(crate) realm: benilla_protocol::RealmInfo, + /// This world session's number, counted per process: what its roster replies carry. + pub(crate) epoch: u64, + /// The abandon generation of the attempt that dialed it: behind [`LoginAbandon`], the attempt + /// was cancelled or disconnected since, and the session is not the screen's. + pub(crate) generation: u64, +} + +/// A roster ask on the world session `epoch` failed on the stream. +#[derive(Message)] +pub(crate) struct RosterFailedMessage { + pub(crate) epoch: u64, } /// A character create or delete result; a refreshed [`CharListMessage`] precedes a success. diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 954730aa..7c332f53 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -14,7 +14,7 @@ //! One long-lived write thread drains [`ClientCommand`](super::ClientCommand)s; each connection //! hands it the fresh [`WorldWriter`] over a swap channel, so there is only ever one writer. -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -22,12 +22,12 @@ use std::time::{Duration, Instant}; use anyhow::{anyhow, Result}; use benilla_assets::LockRecover; use benilla_protocol::{ - messages, AuthReject, CharAction, LoginStage, Poll, SessionEnd, SessionEvent, WardenRequired, - WorldSession, WorldWriter, + messages, AuthReject, CharAction, LoginStage, Poll, RosterCause, SessionEnd, SessionEvent, + WardenRequired, WorldSession, WorldWriter, }; use crossbeam_channel::{Receiver, Sender}; -use super::{CharRequest, ClientCommand, RealmRequest}; +use super::{CharRequest, ClientCommand, ParkRequest, ParkSender, RealmRequest}; /// The inbound census: every packet off the world socket, and the unix ms of the latest, which /// tell a silent server from a dead socket for the runaway watch ([`crate::net::motion`]). @@ -69,13 +69,22 @@ pub(crate) struct LoginRequest { pub(crate) generation: u64, } -/// The park-answer receivers, in the order a cycle blocks on them: credentials, realm, character. +/// The park-answer receivers: credentials, and the realm and character requests on the one +/// ordered channel ([`ParkRequest`]). pub(super) struct Parks { pub(super) login_rx: Receiver, - pub(super) realm_rx: Receiver, - pub(super) pick_rx: Receiver, + pub(super) park_rx: Receiver, + /// The screen asks for the roster itself ([`CharRequest::Enumerate`], the stock glue's + /// `GetCharacterListUpdate`); false is the native screens' policy, the IO thread enumerating + /// on reaching the character park and after a create or delete. Read as each world park is + /// entered; the app sets it as it installs the VM that runs ([`super::RosterPolicy`]). + pub(super) screen_asks_roster: Arc, } +/// World sessions begun this process, each numbered from 1; the events a session sends carry its +/// number, so a reply is told from an older link's. +static WORLD_EPOCH: AtomicU64 = AtomicU64::new(0); + /// How long a realm-list query waits for realmd; the park serving it is deaf that long. const REALM_REFRESH_TIMEOUT: Duration = Duration::from_secs(5); @@ -153,6 +162,10 @@ enum Parked { Play(u64), /// A create or delete was serviced in place; its result byte still has to go out. Acted(CharAction, u8), + /// `CMSG_CHAR_ENUM` was serviced in place; the roster is re-emitted, with no result. + Listed, + /// The server refused the roster to a client without Warden: the login fails for good. + Refused(String), /// Stay parked: a realm-list refresh, or a Cancel over this screen. StayPut, /// Drop the parked world session and dial this realm under the abandon generation its ask @@ -188,8 +201,9 @@ enum Cycle { pub(super) struct NetHandles { pub(super) events: Receiver, pub(super) commands: Sender, - pub(super) pick: Sender, - pub(super) realm: Sender, + pub(super) pick: ParkSender, + pub(super) realm: ParkSender, + pub(super) screen_asks_roster: Arc, pub(super) login: Sender, pub(super) login_abandon: Arc, pub(super) ping: Arc>, @@ -203,8 +217,9 @@ pub(super) fn spawn_net(connect: bool) -> NetHandles { } let (events_tx, events_rx) = crossbeam_channel::unbounded(); let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); - let (pick_tx, pick_rx) = crossbeam_channel::unbounded::(); - let (realm_tx, realm_rx) = crossbeam_channel::unbounded::(); + let (park_tx, park_rx) = crossbeam_channel::unbounded::(); + let screen_asks_roster = Arc::new(AtomicBool::new(false)); + let policy = Arc::clone(&screen_asks_roster); let (login_tx, login_rx) = crossbeam_channel::unbounded::(); let login_abandon = Arc::new(AtomicU64::new(0)); let ping_clock = Arc::new(Mutex::new(PingClock::default())); @@ -233,8 +248,8 @@ pub(super) fn spawn_net(connect: bool) -> NetHandles { ); let parks = Parks { login_rx, - realm_rx, - pick_rx, + park_rx, + screen_asks_roster: policy, }; // Opcodes already reported for trailing bytes, once per process. let mut tails_announced = std::collections::HashSet::new(); @@ -292,8 +307,9 @@ pub(super) fn spawn_net(connect: bool) -> NetHandles { NetHandles { events: events_rx, commands: cmd_tx, - pick: pick_tx, - realm: realm_tx, + pick: ParkSender::park(park_tx.clone()), + realm: ParkSender::park(park_tx), + screen_asks_roster, login: login_tx, login_abandon, ping: ping_clock, @@ -379,16 +395,18 @@ fn query( fn login_park(parks: &Parks, events_tx: &Sender) -> Option { bevy::log::info!("net: parked at the login screen โ€” waiting for credentials"); let Parks { - login_rx, realm_rx, .. + login_rx, park_rx, .. } = parks; loop { // No `break` or `continue` inside the `select!` arms: the macro expands into a loop of its // own, which they would bind to. let answer = crossbeam_channel::select! { recv(login_rx) -> req => Some(req.ok()), - recv(realm_rx) -> msg => match msg { + recv(park_rx) -> msg => match msg { Err(_) => Some(None), - Ok(RealmRequest::Refresh) => (!publish(events_tx, &[], true)).then_some(None), + Ok(ParkRequest::Realm(RealmRequest::Refresh)) => { + (!publish(events_tx, &[], true)).then_some(None) + } Ok(_) => None, }, }; @@ -412,21 +430,21 @@ fn logon_park( logon.realms.len() ); let Parks { - login_rx, realm_rx, .. + login_rx, park_rx, .. } = parks; loop { let answer = crossbeam_channel::select! { - recv(realm_rx) -> msg => match msg { + recv(park_rx) -> msg => match msg { Err(_) => Some(LogonAnswer::Exit), - Ok(RealmRequest::Refresh) => { + Ok(ParkRequest::Realm(RealmRequest::Refresh)) => { (!query(logon, req, events_tx)).then_some(LogonAnswer::Exit) } - Ok(RealmRequest::Enter { realm, generation }) => { + Ok(ParkRequest::Realm(RealmRequest::Enter { realm, generation })) => { Some(LogonAnswer::Enter(realm, generation)) } - Ok(RealmRequest::Close) => Some(LogonAnswer::Close), - // No world session here to leave. - Ok(RealmRequest::Leave) => None, + Ok(ParkRequest::Realm(RealmRequest::Close)) => Some(LogonAnswer::Close), + // No world session here to leave, and no character screen to ask. + Ok(ParkRequest::Realm(RealmRequest::Leave) | ParkRequest::Char(_)) => None, }, recv(login_rx) -> msg => Some(msg.map_or(LogonAnswer::Exit, LogonAnswer::Login)), }; @@ -499,7 +517,7 @@ fn run( // frame over the current glue screen. Change Realm is served at the character park. // // An answer queued during a dead cycle must not answer this list. - while parks.realm_rx.try_recv().is_ok() {} + while parks.park_rx.try_recv().is_ok() {} if !publish(events_tx, &logon.realms, false) { return Ok(Cycle::Exit); } @@ -561,8 +579,8 @@ fn world( ) -> Result { let Parks { login_rx, - realm_rx, - pick_rx, + park_rx, + screen_asks_roster, } = parks; let stage = |s: LoginStage| { let _ = events_tx.send(SessionEvent::LoginStage { stage: s }); @@ -657,51 +675,81 @@ fn world( // The roster as the account holds it, an empty one included: the reference builds // `CMSG_CHAR_CREATE` in one place (`0x5aac50`), reached only from the create screen. // Failures here are still pre-roster. - let mut characters = match session.char_enum() { - Ok(c) => c, - Err(e) => { - if canceled() { - return Ok(WorldEnd::Failed); + // + // Only the native screens' policy enumerates here and after a create or delete. The stock + // screen asks for its roster when it shows and after each action it makes + // (`GetCharacterListUpdate`, [`CharRequest::Enumerate`], `0x46c1a7`), as the reference + // does: the world connect completes with the authentication response (`0x5ab2b8`), and + // the `CMSG_CHAR_ENUM` is the screen's. + let asks = screen_asks_roster.load(Ordering::SeqCst); + let mut characters = if asks { + Vec::new() + } else { + match session.char_enum() { + Ok(c) => c, + Err(e) => { + if canceled() { + return Ok(WorldEnd::Failed); + } + if let Some(w) = e.downcast_ref::() { + return fail_terminal(w.to_string()); + } + return fail(None, format!("character roster: {e:#}")); } - if let Some(w) = e.downcast_ref::() { - return fail_terminal(w.to_string()); - } - return fail(None, format!("character roster: {e:#}")); } }; if canceled() { return Ok(WorldEnd::Failed); } - // A pick or realm answer queued during a dead cycle must not answer this roster: a logout - // must land on the list, not bounce back in off a stale pick. The app re-sends its wants. - while pick_rx.try_recv().is_ok() {} - while realm_rx.try_recv().is_ok() {} + // A request or realm answer queued during a dead cycle must not answer this roster: a + // logout must land on the list, not bounce back in off a stale pick. The app re-sends + // its wants. + while park_rx.try_recv().is_ok() {} + let epoch = WORLD_EPOCH.fetch_add(1, Ordering::SeqCst) + 1; if events_tx - .send(SessionEvent::CharacterList { - characters: characters.clone(), - realm: Some(realm.clone()), + .send(SessionEvent::WorldAuthenticated { + realm: realm.clone(), + epoch, + generation, }) .is_err() { return Ok(WorldEnd::Cycle(Cycle::Exit)); } + if !asks + && events_tx + .send(SessionEvent::CharacterList { + characters: characters.clone(), + realm: Some(realm.clone()), + cause: RosterCause::Park, + epoch, + generation, + }) + .is_err() + { + return Ok(WorldEnd::Cycle(Cycle::Exit)); + } // Park at character select until the app answers. Create and delete are serviced in place. // If the server kicked the parked socket meanwhile, the login below fails, the cycle // restarts and the app re-sends its pick, so the park needs no keepalive. bevy::log::info!("net: parked at character select"); let guid = loop { - // Two channels, one park: `RealmList.xml` is a `frameStrata="DIALOG"` frame, so Change - // Realm raises it over character select and both must be served while it is open. + // The realm list's requests and the character screen's share one channel, in the + // order they were made: `RealmList.xml` is a `frameStrata="DIALOG"` frame, so Change + // Realm raises it over character select and both must be served while it is open, + // and a roster ask made before a realm change goes out on this link before the + // change drops it (the reference sends at the verb, `0x46b880`, and `ConnectToRealm` + // only then closes the link, `0x46b22b`). // // No `break` or `continue` inside the `select!` arms: the macro expands into a loop of // its own, which they would bind to. The arms answer with a [`Parked`]. let answer = crossbeam_channel::select! { - recv(realm_rx) -> msg => match msg { + recv(park_rx) -> msg => match msg { Err(_) => Parked::Exit, // `RequestRealmList`: a query on the realmd link this cycle holds, then the // list; also Change Realm's first list. - Ok(RealmRequest::Refresh) => { + Ok(ParkRequest::Realm(RealmRequest::Refresh)) => { if query(logon, req, events_tx) { Parked::StayPut } else { @@ -709,26 +757,41 @@ fn world( } } // `RealmListDialogCancelled` over character select is a no-op (`0x46b810`). - Ok(RealmRequest::Close) => Parked::StayPut, + Ok(ParkRequest::Realm(RealmRequest::Close)) => Parked::StayPut, // `ChangeRealm(category, index)`: dial the chosen realm with the session key // we hold. - Ok(RealmRequest::Enter { realm, generation }) => { + Ok(ParkRequest::Realm(RealmRequest::Enter { realm, generation })) => { Parked::Realm(realm, generation) } // `ConnectToRealm` with the world up drops it first (`0x46b22b`). - Ok(RealmRequest::Leave) => Parked::Leave, - }, - recv(pick_rx) -> pick => match pick { - Err(_) => Parked::Exit, - Ok(CharRequest::Enter(guid)) => Parked::Play(guid), + Ok(ParkRequest::Realm(RealmRequest::Leave)) => Parked::Leave, + Ok(ParkRequest::Char(CharRequest::Enter(guid))) => Parked::Play(guid), // Select's Back: drop the parked session, return to the login park. - Ok(CharRequest::Abandon) => Parked::Repark, - Ok(CharRequest::Create(create)) => { + Ok(ParkRequest::Char(CharRequest::Abandon)) => Parked::Repark, + Ok(ParkRequest::Char(CharRequest::Create(create))) => { Parked::Acted(CharAction::Create, session.create_character(&create)?) } - Ok(CharRequest::Delete(target)) => { + Ok(ParkRequest::Char(CharRequest::Delete(target))) => { Parked::Acted(CharAction::Delete, session.delete_character(target)?) } + // `GetCharacterListUpdate` (`0x4723d0` โ†’ `0x46b880` โ†’ `0x5aabb0` โ†’ `0x5b4690`): + // the reference asks the world server again whenever the glue screen wants + // the list. A Warden refusal is the server's answer, as at the park entry. + Ok(ParkRequest::Char(CharRequest::Enumerate)) => match session.char_enum() { + Ok(c) => { + characters = c; + Parked::Listed + } + Err(e) => match e.downcast_ref::() { + Some(w) => Parked::Refused(w.to_string()), + // The stream failed under the ask: the screen's query fails + // (`0x5ab300`), and the session ends as a dropped socket does. + None => { + let _ = events_tx.send(SessionEvent::RosterFailed { epoch }); + return Err(e); + } + }, + }, }, recv(login_rx) -> msg => msg.map_or(Parked::Exit, Parked::Login), }; @@ -743,20 +806,40 @@ fn world( continue 'realm; } Parked::Leave => return Ok(WorldEnd::Failed), + Parked::Refused(reason) => return fail_terminal(reason), Parked::Acted(action, code) => (action, code), + Parked::Listed => { + if events_tx + .send(SessionEvent::CharacterList { + characters: characters.clone(), + realm: Some(realm.clone()), + cause: RosterCause::Asked, + epoch, + generation, + }) + .is_err() + { + return Ok(WorldEnd::Cycle(Cycle::Exit)); + } + continue; + } }; - // A success changed the roster: re-emit it before the result, so the screen has the - // fresh list when it reacts. + // A success changed the roster: under the native policy re-emit it before the result, + // so the screen has the fresh list when it reacts. The stock screen asks again itself + // (`0x46c1a7`). let changed = match action { CharAction::Create => code == messages::CHAR_CREATE_SUCCESS, CharAction::Delete => code == messages::CHAR_DELETE_SUCCESS, }; - if changed { + if changed && !asks { characters = session.char_enum()?; if events_tx .send(SessionEvent::CharacterList { characters: characters.clone(), realm: Some(realm.clone()), + cause: RosterCause::Changed, + epoch, + generation, }) .is_err() { diff --git a/crates/benilla-app/src/net/io/cycle_tests.rs b/crates/benilla-app/src/net/io/cycle_tests.rs index 7f02067a..f893d86e 100644 --- a/crates/benilla-app/src/net/io/cycle_tests.rs +++ b/crates/benilla-app/src/net/io/cycle_tests.rs @@ -7,11 +7,11 @@ use std::sync::Mutex; use std::thread; use benilla_protocol::messages::opcode; -use benilla_protocol::{RealmInfo, SessionEvent, WorldWriter}; +use benilla_protocol::{RealmInfo, RosterCause, SessionEvent, WorldWriter}; use super::fake::{self, FakeRealm, WAIT}; use super::{run, Cycle, LoginRequest, Parks, PingClock}; -use crate::net::{CharRequest, RealmRequest}; +use crate::net::{CharRequest, ParkRequest, RealmRequest}; const REALM: &str = "Fake"; @@ -26,6 +26,17 @@ fn next_event(events: &crossbeam_channel::Receiver) -> SessionEven } } +/// The next event past the world authentication that opens every park: the roster the native +/// policy fetches. +fn next_roster(events: &crossbeam_channel::Receiver) -> SessionEvent { + loop { + match next_event(events) { + SessionEvent::WorldAuthenticated { .. } => continue, + ev => return ev, + } + } +} + /// A dial of `realm` asked under generation 0, the cycles' start. fn enter(realm: RealmInfo) -> RealmRequest { RealmRequest::Enter { @@ -52,13 +63,12 @@ fn an_empty_account_parks_at_select_without_creating_a_character() { let (events_tx, events) = crossbeam_channel::unbounded(); let (writer_tx, _writer_rx) = crossbeam_channel::unbounded::(); let (login_tx, login_rx) = crossbeam_channel::unbounded(); - let (realm_tx, realm_rx) = crossbeam_channel::unbounded(); - let (pick_tx, pick_rx) = crossbeam_channel::unbounded(); + let (park_tx, park_rx) = crossbeam_channel::unbounded(); let cycle = thread::spawn(move || { let parks = Parks { login_rx, - realm_rx, - pick_rx, + park_rx, + screen_asks_roster: Default::default(), }; let ended = run( &events_tx, @@ -82,22 +92,77 @@ fn an_empty_account_parks_at_select_without_creating_a_character() { let (realms, failed) = next_list(&events); assert!(!failed); assert_eq!(realms[0].name, REALM); - realm_tx.send(enter(realms[0].clone())).unwrap(); + park_tx + .send(ParkRequest::Realm(enter(realms[0].clone()))) + .unwrap(); + assert!( + matches!(next_event(&events), SessionEvent::WorldAuthenticated { .. }), + "the world connect completes with the authentication" + ); match next_event(&events) { - SessionEvent::CharacterList { characters, .. } => assert!( - characters.is_empty(), - "the roster at select must be the account's, empty: {characters:?}" - ), + SessionEvent::CharacterList { + characters, cause, .. + } => { + assert!( + characters.is_empty(), + "the roster at select must be the account's, empty: {characters:?}" + ); + assert_eq!( + cause, + RosterCause::Park, + "the native screens' own enumeration" + ); + } other => panic!("expected the roster at select, got {other:?}"), } // Select's Back ends the cycle, so the socket is done and its log complete. - pick_tx.send(CharRequest::Abandon).unwrap(); + park_tx + .send(ParkRequest::Char(CharRequest::Abandon)) + .unwrap(); assert!(cycle.join().unwrap(), "Back returns to the login park"); let sent = world.sent.lock().unwrap().clone(); assert!( !sent.contains(&opcode::CMSG_CHAR_CREATE), "the login sent CMSG_CHAR_CREATE on its own: {sent:#06x?}" ); + assert_eq!( + sent.iter() + .filter(|&&op| op == opcode::CMSG_CHAR_ENUM) + .count(), + 1, + "the native policy enumerates once on reaching the park: {sent:#06x?}" + ); +} + +/// Under the stock screen's policy the park fetches no roster: the world connect ends with the +/// authentication, and the one `CMSG_CHAR_ENUM` goes out when the screen asks, its reply tagged as +/// asked for (`0x5aabb0`). +#[test] +fn the_stock_policy_enumerates_only_when_asked() { + let (realmd, world) = fake::servers(|world| vec![FakeRealm::new(REALM, world)]); + let cycles = fake::spawn_cycles_asking(); + fake::login(&cycles, &realmd.addr); + let (realms, _) = next_list(&cycles.events); + cycles.realm.send(enter(realms[0].clone())).unwrap(); + assert!(matches!( + next_event(&cycles.events), + SessionEvent::WorldAuthenticated { .. } + )); + assert!( + cycles + .events + .recv_timeout(std::time::Duration::from_millis(400)) + .is_err(), + "the park sent a roster nobody asked for" + ); + assert_eq!(world.enumerations(), 0); + cycles.pick.send(CharRequest::Enumerate).unwrap(); + match next_event(&cycles.events) { + SessionEvent::CharacterList { cause, .. } => assert_eq!(cause, RosterCause::Asked), + other => panic!("expected the asked-for roster, got {other:?}"), + } + assert_eq!(world.enumerations(), 1); + cycles.pick.send(CharRequest::Abandon).unwrap(); } /// The logon park takes the next login (#833): closing the list ends the logon and the thread @@ -172,7 +237,7 @@ fn a_failed_world_dial_keeps_the_logon_for_the_next_realm() { assert_eq!(realmd.lists(), 2); cycles.realm.send(enter(up)).unwrap(); assert!(matches!( - next_event(&cycles.events), + next_roster(&cycles.events), SessionEvent::CharacterList { .. } )); assert_eq!(realmd.logons(), 1, "one logon throughout"); @@ -249,7 +314,7 @@ fn leave_drops_the_world_session_and_keeps_the_logon() { let (realms, _) = next_list(&cycles.events); cycles.realm.send(enter(realms[0].clone())).unwrap(); assert!(matches!( - next_event(&cycles.events), + next_roster(&cycles.events), SessionEvent::CharacterList { .. } )); assert_eq!(world.open(), 1); @@ -266,8 +331,201 @@ fn leave_drops_the_world_session_and_keeps_the_logon() { assert!(!next_list(&cycles.events).1); cycles.realm.send(enter(realms[0].clone())).unwrap(); assert!(matches!( - next_event(&cycles.events), + next_roster(&cycles.events), SessionEvent::CharacterList { .. } )); assert_eq!(realmd.logons(), 1, "one logon throughout"); } + +/// The character park's requests and the realm list's are met in the order the app made them, on +/// one channel. Two roster asks and a `Leave` queue while the IO thread is held in an earlier +/// ask's slow reply: both asks go out on the old link before it leaves, none is dropped, and the +/// leave is honored after them. The reference sends `CMSG_CHAR_ENUM` at the verb (`0x46b880`) and +/// `ConnectToRealm` only then closes the link (`0x46b22b`). +#[test] +fn the_park_serves_its_requests_in_the_order_they_were_made() { + let (realmd, world) = fake::servers(|world| vec![FakeRealm::new(REALM, world)]); + world.delay_rosters(std::time::Duration::from_millis(15)); + let cycles = fake::spawn_cycles_asking(); + fake::login(&cycles, &realmd.addr); + let (realms, _) = next_list(&cycles.events); + cycles.realm.send(enter(realms[0].clone())).unwrap(); + assert!(matches!( + next_event(&cycles.events), + SessionEvent::WorldAuthenticated { .. } + )); + // The first ask holds the thread; the second and the leave queue behind it together. + cycles.pick.send(CharRequest::Enumerate).unwrap(); + cycles.pick.send(CharRequest::Enumerate).unwrap(); + cycles.realm.send(RealmRequest::Leave).unwrap(); + for ask in 1..=2 { + match next_event(&cycles.events) { + SessionEvent::CharacterList { cause, .. } => { + assert_eq!(cause, RosterCause::Asked, "ask {ask}") + } + other => panic!("expected the asked-for roster, got {other:?}"), + } + } + assert_eq!(world.enumerations_in(0), 2); + wait_until(|| world.open() == 0, "the leave closes the world session"); +} + +/// Wait for `done`, up to the tests' bound. +fn wait_until(done: impl Fn() -> bool, what: &str) { + let deadline = std::time::Instant::now() + WAIT; + while !done() { + assert!(std::time::Instant::now() < deadline, "timed out: {what}"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +/// A session's authentication and its roster carry the same epoch and the attempt's generation; a +/// realm change dials a new session, which has a new epoch. +#[test] +fn a_world_session_has_one_epoch_and_a_realm_change_a_new_one() { + let (realmd, _world) = fake::servers(|world| vec![FakeRealm::new(REALM, world)]); + let cycles = fake::spawn_cycles(); + fake::login(&cycles, &realmd.addr); + let (realms, _) = next_list(&cycles.events); + let mut seen = Vec::new(); + cycles.realm.send(enter(realms[0].clone())).unwrap(); + for round in 0..2 { + let (auth, listed) = match (next_event(&cycles.events), next_event(&cycles.events)) { + ( + SessionEvent::WorldAuthenticated { + epoch, generation, .. + }, + SessionEvent::CharacterList { + epoch: listed, + generation: listed_generation, + .. + }, + ) => ((epoch, generation), (listed, listed_generation)), + other => panic!("round {round}: expected the auth and the roster, got {other:?}"), + }; + assert_eq!(auth, listed, "round {round}: one session, one epoch"); + assert_eq!(auth.1, 0, "the attempt's generation"); + seen.push(auth.0); + if round == 0 { + // Change Realm at the park: a new dial, a new session. + cycles.realm.send(enter(realms[0].clone())).unwrap(); + } + } + assert_ne!(seen[0], seen[1], "a new session has a new epoch"); +} + +/// A server that wants Warden answers the stock screen's first ask with `SMSG_WARDEN_DATA`: the +/// login fails for good with the server's words, as it does at the park under the native policy, +/// not as a lost stream. +#[test] +fn a_warden_demand_at_the_asked_roster_is_a_terminal_login_failure() { + let (realmd, world) = fake::servers(|world| vec![FakeRealm::new(REALM, world)]); + world.demand_warden(); + let cycles = fake::spawn_cycles_asking(); + fake::login(&cycles, &realmd.addr); + let (realms, _) = next_list(&cycles.events); + cycles.realm.send(enter(realms[0].clone())).unwrap(); + assert!(matches!( + next_event(&cycles.events), + SessionEvent::WorldAuthenticated { .. } + )); + cycles.pick.send(CharRequest::Enumerate).unwrap(); + loop { + match cycles.events.recv_timeout(WAIT).expect("went quiet") { + SessionEvent::LoginFailed { terminal, .. } => { + assert!(terminal); + break; + } + SessionEvent::LoginStage { .. } => {} + other => panic!("expected the terminal login failure, got {other:?}"), + } + } + // The world socket is closed and the realmd link kept, as after any failed world dial. + wait_until(|| world.open() == 0, "the world socket closes"); + assert_eq!(realmd.open(), 1, "the realmd link is kept"); +} + +/// A roster ask that fails on the stream (the server cut the connection) tells the screen its +/// query failed (`0x5ab300`'s failure leg) before the session ends as a dropped socket does. +#[test] +fn a_roster_ask_cut_on_the_stream_reports_its_failure_then_the_loss() { + let (realmd, world) = fake::servers(|world| vec![FakeRealm::new(REALM, world)]); + world.cut_on_roster_ask(); + let cycles = fake::spawn_cycles_asking(); + fake::login(&cycles, &realmd.addr); + let (realms, _) = next_list(&cycles.events); + cycles.realm.send(enter(realms[0].clone())).unwrap(); + let epoch = match next_event(&cycles.events) { + SessionEvent::WorldAuthenticated { epoch, .. } => epoch, + other => panic!("expected the authentication, got {other:?}"), + }; + cycles.pick.send(CharRequest::Enumerate).unwrap(); + match next_event(&cycles.events) { + SessionEvent::RosterFailed { epoch: failed } => assert_eq!(failed, epoch), + other => panic!("expected the failed roster ask, got {other:?}"), + } + assert!(matches!( + next_event(&cycles.events), + SessionEvent::Disconnected { .. } + )); +} + +/// After a create the native policy re-emits the roster before the result; the stock screen asks +/// again itself (`0x46c1a7`), so the IO thread sends it nothing and enumerates nothing. +#[test] +fn only_the_native_policy_re_enumerates_after_a_create() { + let req = benilla_protocol::CharCreateReq { + name: "Newone".into(), + race: 1, + class: 1, + gender: 0, + skin: 0, + face: 0, + hair_style: 0, + hair_color: 0, + facial_hair: 0, + }; + // Native: the park's roster, then the create's, tagged as changed, then the result. + let (realmd, world) = fake::servers(|world| vec![FakeRealm::new(REALM, world)]); + let cycles = fake::spawn_cycles(); + fake::login(&cycles, &realmd.addr); + let (realms, _) = next_list(&cycles.events); + cycles.realm.send(enter(realms[0].clone())).unwrap(); + assert!(matches!( + next_roster(&cycles.events), + SessionEvent::CharacterList { + cause: RosterCause::Park, + .. + } + )); + cycles.pick.send(CharRequest::Create(req.clone())).unwrap(); + assert!(matches!( + next_event(&cycles.events), + SessionEvent::CharacterList { + cause: RosterCause::Changed, + .. + } + )); + assert!(matches!( + next_event(&cycles.events), + SessionEvent::CharActionResult { .. } + )); + assert_eq!(world.enumerations(), 2); + + // Stock: the result alone. + let (realmd, world) = fake::servers(|world| vec![FakeRealm::new(REALM, world)]); + let cycles = fake::spawn_cycles_asking(); + fake::login(&cycles, &realmd.addr); + let (realms, _) = next_list(&cycles.events); + cycles.realm.send(enter(realms[0].clone())).unwrap(); + assert!(matches!( + next_event(&cycles.events), + SessionEvent::WorldAuthenticated { .. } + )); + cycles.pick.send(CharRequest::Create(req)).unwrap(); + assert!(matches!( + next_event(&cycles.events), + SessionEvent::CharActionResult { .. } + )); + assert_eq!(world.enumerations(), 0); +} diff --git a/crates/benilla-app/src/net/io/fake.rs b/crates/benilla-app/src/net/io/fake.rs index 9a8815d5..b861643e 100644 --- a/crates/benilla-app/src/net/io/fake.rs +++ b/crates/benilla-app/src/net/io/fake.rs @@ -4,7 +4,7 @@ use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; @@ -21,7 +21,7 @@ use num_bigint::BigUint; use sha1::{Digest, Sha1}; use super::{run, Cycle, LoginRequest, Parks, PingClock}; -use crate::net::{CharRequest, RealmRequest}; +use crate::net::{CharRequest, ParkRequest, ParkSender, RealmRequest}; pub(crate) const USER: &str = "empty"; pub(crate) const PASS: &str = "secret"; @@ -257,25 +257,136 @@ fn send(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>, op: u16, body: &[u let _ = s.write_all(body); } -/// A client packet's opcode, reading past its body: `u16` BE size counting the 4-byte opcode. -fn recv(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>) -> Option { +/// A client packet as it left the client, header decrypted: `u16` BE size counting the 4-byte +/// opcode, `u32` LE opcode, the body. +fn recv_packet(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>) -> Option> { let mut header = [0u8; 6]; s.read_exact(&mut header).ok()?; if let Some(c) = crypto { c.decrypter().decrypt(&mut header); } let size = usize::from(u16::from_be_bytes([header[0], header[1]])); - s.read_exact(&mut vec![0u8; size.saturating_sub(4)]).ok()?; - Some(u16::from_le_bytes([header[2], header[3]])) + let mut packet = header.to_vec(); + packet.resize(2 + size, 0); + s.read_exact(&mut packet[6..]).ok()?; + Some(packet) +} + +/// A client packet's opcode, reading past its body. +fn recv(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>) -> Option { + let packet = recv_packet(s, crypto)?; + Some(u16::from_le_bytes([packet[2], packet[3]])) +} + +/// An `SMSG_CHAR_ENUM` body for `characters`, in the order the client parses it +/// (`Character::read`, the reference's `0x5b42a0`). +pub(crate) fn char_enum_body(characters: &[benilla_protocol::Character]) -> Vec { + let mut b = vec![characters.len() as u8]; + for c in characters { + b.extend_from_slice(&c.guid.to_le_bytes()); + b.extend_from_slice(c.name.as_bytes()); + b.push(0); + b.extend_from_slice(&[ + c.race, + c.class, + c.gender, + c.skin, + c.face, + c.hair_style, + c.hair_color, + c.facial_hair, + c.level, + ]); + b.extend_from_slice(&c.zone.to_le_bytes()); + b.extend_from_slice(&c.map.to_le_bytes()); + for v in [c.position.x, c.position.y, c.position.z] { + b.extend_from_slice(&v.to_le_bytes()); + } + b.extend_from_slice(&0u32.to_le_bytes()); // guild + b.extend_from_slice(&c.flags.to_le_bytes()); + b.push(0); // first login + for v in [c.pet_display_id, c.pet_level, c.pet_family] { + b.extend_from_slice(&v.to_le_bytes()); + } + for item in &c.equipment { + b.extend_from_slice(&item.display_id.to_le_bytes()); + b.push(item.inventory_type); + } + b.extend_from_slice(&0u32.to_le_bytes()); // first bag + b.push(0); + } + b } /// The fake world server's record: every opcode the client sent, and the sessions still open. pub(crate) struct World { pub(crate) sent: Arc>>, + /// Every packet the client sent, header decrypted, as [`recv_packet`] reads it. + pub(crate) packets: Arc>>>, + /// Every session's packets in the order it sent them, by the order the sessions began. + pub(crate) session_packets: Arc>>>>, + /// The `SMSG_CHAR_ENUM` body each session answers its `CMSG_CHAR_ENUM` with, by the order the + /// sessions began (the last repeats): no characters until set. + rosters: Arc>>>, + /// How long a `CMSG_CHAR_ENUM` waits before its answer. + enum_delay: Arc>, + /// Answer `CMSG_CHAR_ENUM` with `SMSG_WARDEN_DATA`, as a server does that wants Warden. + warden: Arc, + /// Close the socket on `CMSG_CHAR_ENUM` instead of answering. + cut: Arc, open: Arc, } impl World { + /// The characters the account holds from now on. + pub(crate) fn set_roster(&self, characters: &[benilla_protocol::Character]) { + *self.rosters.lock().unwrap() = vec![char_enum_body(characters)]; + } + + /// The characters each session sees, by the order the sessions begin. + pub(crate) fn set_rosters(&self, per_session: &[&[benilla_protocol::Character]]) { + *self.rosters.lock().unwrap() = per_session.iter().map(|c| char_enum_body(c)).collect(); + } + + /// Have the server demand Warden in place of the roster from now on. + pub(crate) fn demand_warden(&self) { + self.warden.store(true, Ordering::SeqCst); + } + + /// Have the server cut the connection on the next roster ask. + pub(crate) fn cut_on_roster_ask(&self) { + self.cut.store(true, Ordering::SeqCst); + } + + /// Hold every roster answer back this long. + pub(crate) fn delay_rosters(&self, by: Duration) { + *self.enum_delay.lock().unwrap() = by; + } + + /// How many `CMSG_CHAR_ENUM` session `index` has sent. + pub(crate) fn enumerations_in(&self, index: usize) -> usize { + self.session_packets + .lock() + .unwrap() + .get(index) + .map_or(0, |packets| { + packets + .iter() + .filter(|p| p[2..4] == opcode::CMSG_CHAR_ENUM.to_le_bytes()) + .count() + }) + } + + /// How many `CMSG_CHAR_ENUM` the client has sent. + pub(crate) fn enumerations(&self) -> usize { + self.sent + .lock() + .unwrap() + .iter() + .filter(|&&op| op == opcode::CMSG_CHAR_ENUM) + .count() + } + /// Sessions the client still holds. pub(crate) fn open(&self) -> usize { self.open.load(Ordering::SeqCst) @@ -283,11 +394,23 @@ impl World { } /// A world server whose account has no characters: it admits each session under the latest key -/// realmd made, answers every `CMSG_CHAR_ENUM` with an empty roster and a `CMSG_CHAR_CREATE` with -/// success, and logs every opcode the client sends. +/// realmd made, answers every `CMSG_CHAR_ENUM` with its roster (empty until [`World::set_roster`]) +/// and a `CMSG_CHAR_CREATE` with success, and logs every opcode the client sends. pub(crate) fn world(listener: TcpListener, keys: Receiver) -> World { let sent = Arc::new(Mutex::new(Vec::new())); let log = Arc::clone(&sent); + let packets = Arc::new(Mutex::new(Vec::new())); + let packet_log = Arc::clone(&packets); + let rosters = Arc::new(Mutex::new(vec![vec![0u8]])); + let roster_bodies = Arc::clone(&rosters); + let session_packets = Arc::new(Mutex::new(Vec::>>::new())); + let session_log = Arc::clone(&session_packets); + let enum_delay = Arc::new(Mutex::new(Duration::ZERO)); + let enum_wait = Arc::clone(&enum_delay); + let warden = Arc::new(AtomicBool::new(false)); + let warden_flag = Arc::clone(&warden); + let cut = Arc::new(AtomicBool::new(false)); + let cut_flag = Arc::clone(&cut); let open = Arc::new(AtomicUsize::new(0)); let sessions = Arc::clone(&open); thread::spawn(move || { @@ -322,14 +445,41 @@ pub(crate) fn world(listener: TcpListener, keys: Receiver) -> World { &[messages::AUTH_OK], ); let log = Arc::clone(&log); + let packet_log = Arc::clone(&packet_log); + let roster_bodies = Arc::clone(&roster_bodies); + let session_log = Arc::clone(&session_log); + let enum_wait = Arc::clone(&enum_wait); + let warden_flag = Arc::clone(&warden_flag); + let cut_flag = Arc::clone(&cut_flag); + let index = { + let mut all = session_log.lock().unwrap(); + all.push(Vec::new()); + all.len() - 1 + }; let sessions = Arc::clone(&sessions); sessions.fetch_add(1, Ordering::SeqCst); thread::spawn(move || { - while let Some(op) = recv(&mut s, Some(&mut crypto)) { + while let Some(packet) = recv_packet(&mut s, Some(&mut crypto)) { + let op = u16::from_le_bytes([packet[2], packet[3]]); log.lock().unwrap().push(op); + session_log.lock().unwrap()[index].push(packet.clone()); + packet_log.lock().unwrap().push(packet); match op { opcode::CMSG_CHAR_ENUM => { - send(&mut s, Some(&mut crypto), opcode::SMSG_CHAR_ENUM, &[0]); + let body = { + let all = roster_bodies.lock().unwrap(); + all[index.min(all.len() - 1)].clone() + }; + let wait = *enum_wait.lock().unwrap(); + thread::sleep(wait); + if cut_flag.load(Ordering::SeqCst) { + break; + } + if warden_flag.load(Ordering::SeqCst) { + send(&mut s, Some(&mut crypto), opcode::SMSG_WARDEN_DATA, &[]); + continue; + } + send(&mut s, Some(&mut crypto), opcode::SMSG_CHAR_ENUM, &body); } opcode::CMSG_CHAR_CREATE => send( &mut s, @@ -344,7 +494,16 @@ pub(crate) fn world(listener: TcpListener, keys: Receiver) -> World { }); } }); - World { sent, open } + World { + sent, + packets, + session_packets, + rosters, + enum_delay, + warden, + cut, + open, + } } /// A fake realmd listing `realms` and the world server they point at. `realms` takes the world's @@ -367,25 +526,34 @@ pub(crate) fn dead_address() -> String { pub(crate) struct Cycles { pub(crate) events: Receiver, pub(crate) login: Sender, - pub(crate) realm: Sender, - pub(crate) pick: Sender, + pub(crate) realm: ParkSender, + pub(crate) pick: ParkSender, pub(crate) abandon: Arc, } -/// The read thread's cycle loop, as [`super::spawn_net`] runs it, with the world writer dropped. +/// The read thread's cycle loop, as [`super::spawn_net`] runs it, with the world writer dropped: +/// the native screens' policy, the IO thread enumerating on reaching the character park. pub(crate) fn spawn_cycles() -> Cycles { + spawn_cycles_for(false) +} + +/// [`spawn_cycles`] under the stock screen's policy: the roster comes only when it is asked for. +pub(crate) fn spawn_cycles_asking() -> Cycles { + spawn_cycles_for(true) +} + +fn spawn_cycles_for(screen_asks_roster: bool) -> Cycles { let (events_tx, events) = crossbeam_channel::unbounded(); let (login, login_rx) = crossbeam_channel::unbounded(); - let (realm, realm_rx) = crossbeam_channel::unbounded(); - let (pick, pick_rx) = crossbeam_channel::unbounded(); + let (park_tx, park_rx) = crossbeam_channel::unbounded::(); let abandon = Arc::new(AtomicU64::new(0)); let thread_abandon = Arc::clone(&abandon); thread::spawn(move || { let (writer_tx, _writer_rx) = crossbeam_channel::unbounded::(); let parks = Parks { login_rx, - realm_rx, - pick_rx, + park_rx, + screen_asks_roster: Arc::new(std::sync::atomic::AtomicBool::new(screen_asks_roster)), }; let clock = Mutex::new(PingClock::default()); let mut tails = Default::default(); @@ -423,8 +591,8 @@ pub(crate) fn spawn_cycles() -> Cycles { Cycles { events, login, - realm, - pick, + realm: ParkSender::park(park_tx.clone()), + pick: ParkSender::park(park_tx), abandon, } } diff --git a/crates/benilla-app/src/net/session.rs b/crates/benilla-app/src/net/session.rs index d5e8c5a3..f2588e38 100644 --- a/crates/benilla-app/src/net/session.rs +++ b/crates/benilla-app/src/net/session.rs @@ -19,13 +19,16 @@ use super::{ EnteredWorldMessage, GameTime, GuidIndex, HomeBind, KnockBackMessage, LoggedOutMessage, LoginFailedMessage, LoginQueuedMessage, LoginStageMessage, NetCommands, NetHandlerApp, NetStatus, ObjectStore, PendingTransfer, Proficiencies, RealmListMessage, Reputations, - SelfGuid, ServerTime, ServerWallClock, TeleportMessage, WorldportMessage, + RosterFailedMessage, SelfGuid, ServerTime, ServerWallClock, TeleportMessage, WorldAuthMessage, + WorldportMessage, }; /// Registers the session handlers. pub(super) fn register(app: &mut App) { use SessionEventKind as K; app.net_handler(K::LoginStage, on_login_edge) + .net_handler(K::WorldAuthenticated, on_login_edge) + .net_handler(K::RosterFailed, on_login_edge) .net_handler(K::LoginQueued, on_login_edge) .net_handler(K::LoginFailed, on_login_edge) .net_handler(K::RealmList, on_login_edge) @@ -68,6 +71,8 @@ pub(crate) struct Edges<'w> { knockbacks: MessageWriter<'w, KnockBackMessage>, login_stages: MessageWriter<'w, LoginStageMessage>, login_queued: MessageWriter<'w, LoginQueuedMessage>, + world_auth: MessageWriter<'w, WorldAuthMessage>, + roster_failed: MessageWriter<'w, RosterFailedMessage>, login_failures: MessageWriter<'w, LoginFailedMessage>, disconnects: MessageWriter<'w, DisconnectedMessage>, client_control: MessageWriter<'w, ClientControlMessage>, @@ -99,6 +104,20 @@ pub(crate) struct Bridge<'w, 's> { fn on_login_edge(In(ev): In, mut e: Edges) { match ev { SessionEvent::LoginStage { stage } => login_stage(stage, &mut e.login_stages), + SessionEvent::WorldAuthenticated { + realm, + epoch, + generation, + } => { + e.world_auth.write(WorldAuthMessage { + realm, + epoch, + generation, + }); + } + SessionEvent::RosterFailed { epoch } => { + e.roster_failed.write(RosterFailedMessage { epoch }); + } SessionEvent::LoginQueued { position, realm } => { e.login_queued.write(LoginQueuedMessage { position, realm }); } @@ -125,8 +144,23 @@ fn on_login_edge(In(ev): In, mut e: Edges) { } fn on_character_list(In(ev): In, mut e: Edges, mut b: Bridge) { - if let SessionEvent::CharacterList { characters, realm } = ev { - character_list(characters, realm, &mut b.status, &mut e.char_lists); + if let SessionEvent::CharacterList { + characters, + realm, + cause, + epoch, + generation, + } = ev + { + character_list( + characters, + realm, + cause, + epoch, + generation, + &mut b.status, + &mut e.char_lists, + ); } } @@ -420,6 +454,9 @@ fn char_action_result( fn character_list( characters: Vec, realm: Option, + cause: benilla_protocol::RosterCause, + epoch: u64, + generation: u64, status: &mut NetStatus, char_lists: &mut MessageWriter, ) { @@ -430,7 +467,13 @@ fn character_list( // A roster means a live link: clear the failure the logout's synthesized `Disconnected` // left, or the select screen keeps its "Server down" note until the next world entry. status.last_reason = None; - char_lists.write(CharListMessage { characters, realm }); + char_lists.write(CharListMessage { + characters, + realm, + cause, + epoch, + generation, + }); } /// `SMSG_CHARACTER_LOGIN_FAILED`: the announced entry is void; `crate::char_select` says why. diff --git a/crates/benilla-app/src/realm_select/mod.rs b/crates/benilla-app/src/realm_select/mod.rs index bc3fd388..90feeaab 100644 --- a/crates/benilla-app/src/realm_select/mod.rs +++ b/crates/benilla-app/src/realm_select/mod.rs @@ -764,7 +764,7 @@ mod tests { let mut app = App::new(); app.init_resource::() .init_resource::() - .insert_resource(RealmChoice(tx)) + .insert_resource(RealmChoice(tx.into())) .add_message::() .add_systems(Update, apply_realm_policy); app.world_mut().write_message(RealmListMessage { diff --git a/crates/benilla-app/src/realm_select/screen.rs b/crates/benilla-app/src/realm_select/screen.rs index cbf070d7..0a2d859e 100644 --- a/crates/benilla-app/src/realm_select/screen.rs +++ b/crates/benilla-app/src/realm_select/screen.rs @@ -850,7 +850,7 @@ mod tests { let (tx, _rx) = crossbeam_channel::unbounded(); app.init_resource::