Stock character select roster; the screen asks for the list (#1014)

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 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 15:59:54 +07:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent 0f505a2ebf
commit 24c7cab983
33 changed files with 3194 additions and 312 deletions
+38
View File
@@ -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<AppExit>| {
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));
}
}
@@ -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,
@@ -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) {
+2 -2
View File
@@ -195,8 +195,8 @@ mod tests {
.init_resource::<super::super::addons::AddonsPanel>()
.init_resource::<crate::login::LoginIntent>()
.init_resource::<crate::glue::dialog::GlueDialog>()
.insert_resource(CharPick(pick_tx))
.insert_resource(RealmChoice(realm_tx))
.insert_resource(CharPick(pick_tx.into()))
.insert_resource(RealmChoice(realm_tx.into()))
.add_message::<GlueSound>()
.add_message::<crate::login::GlueLogin>()
.add_message::<bevy::input::mouse::MouseWheel>()
+10 -4
View File
@@ -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<CharRequest>) {
app.insert_state(ClientState::CharSelect)
.init_resource::<Roster>()
.insert_resource(CharPick(pick))
.insert_resource(CharPick(pick.into()))
.add_message::<CharListMessage>()
.add_systems(
Update,
@@ -1197,7 +1197,7 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.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::<Roster>().selected()
@@ -1309,7 +1312,7 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.insert_resource(CharPick(tx))
.insert_resource(CharPick(tx.into()))
.insert_resource(crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, "0"))
.add_message::<CharListMessage>()
.add_message::<AppExit>()
@@ -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::<Roster>()
.init_resource::<crate::cvars::Cvars>()
.insert_resource(CharPick(tx))
.insert_resource(CharPick(tx.into()))
.add_systems(Update, persist_last_character);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
+6
View File
@@ -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();
+15
View File
@@ -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<u32> {
self.catalog.foley_material(display_id)
+188 -7
View File
@@ -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<CharListMessage>,
mut auths: MessageReader<WorldAuthMessage>,
stock: Option<Res<crate::ui_script::glue::GlueStock>>,
mut intent: ResMut<LoginIntent>,
mut dialog: ResMut<GlueDialog>,
state: Res<State<ClientState>>,
mut next: ResMut<NextState<ClientState>>,
abandon: Option<Res<LoginAbandon>>,
pick: Option<Res<crate::net::CharPick>>,
) {
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<u64> = 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::<KeyboardInput>()
.add_message::<GlueSound>()
.add_message::<GlueLogin>()
@@ -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::<crate::glue::dialog::GlueDialogAnswer>()
.add_systems(Update, answer_dialog);
app.world_mut().resource_mut::<LoginIntent>().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::<KeyboardInput>()
.add_message::<GlueSound>()
@@ -1344,6 +1375,7 @@ mod tests {
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.add_message::<CharListMessage>()
.add_message::<WorldAuthMessage>()
.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::<GlueDialog>();
@@ -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::<GlueDialog>().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::<LoginIntent>()
.init_resource::<GlueDialog>()
.add_message::<CharListMessage>()
.add_message::<WorldAuthMessage>()
.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::<State<ClientState>>().get(),
ClientState::Login,
"stock {stock}"
);
second(&mut app);
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().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::<LoginIntent>()
.init_resource::<GlueDialog>()
.add_message::<CharListMessage>()
.add_message::<WorldAuthMessage>()
.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::<State<ClientState>>().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::<State<ClientState>>().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() {
+121 -3
View File
@@ -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::<WorldportMessage>()
.add_message::<RealmListMessage>()
.add_message::<CharListMessage>()
.add_message::<WorldAuthMessage>()
.add_message::<RosterFailedMessage>()
.add_message::<CharActionResultMessage>()
.add_message::<CharacterLoginFailedMessage>()
.add_message::<EnteredWorldMessage>()
@@ -379,9 +382,89 @@ pub(crate) struct NetEvents(Receiver<SessionEvent>);
pub(crate) struct NetCommands(pub(crate) Sender<ClientCommand>);
/// 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<CharRequest>);
pub(crate) struct CharPick(pub(crate) ParkSender<CharRequest>);
/// 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<CharRequest> for ParkRequest {
fn from(r: CharRequest) -> Self {
ParkRequest::Char(r)
}
}
impl From<RealmRequest> 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<T> {
/// The shared, ordered channel the IO thread reads.
Park(Sender<ParkRequest>, std::marker::PhantomData<fn(T)>),
/// A channel of that kind alone, for a test that reads the requests itself.
#[cfg(test)]
Direct(Sender<T>),
}
impl<T> Clone for ParkSender<T> {
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<T> ParkSender<T> {
/// The app's end of the IO thread's park channel.
pub(super) fn park(tx: Sender<ParkRequest>) -> Self {
ParkSender::Park(tx, std::marker::PhantomData)
}
}
impl<T> ParkSender<T> {
/// 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<T: Into<ParkRequest>> ParkSender<T> {
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<T> From<Sender<T>> for ParkSender<T> {
fn from(tx: Sender<T>) -> 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<std::sync::atomic::AtomicBool>);
/// 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<RealmRequest>);
pub(crate) struct RealmChoice(pub(crate) ParkSender<RealmRequest>);
/// 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<benilla_protocol::Character>,
pub(crate) realm: Option<benilla_protocol::RealmInfo>,
/// 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.
+144 -61
View File
@@ -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<LoginRequest>,
pub(super) realm_rx: Receiver<RealmRequest>,
pub(super) pick_rx: Receiver<CharRequest>,
pub(super) park_rx: Receiver<ParkRequest>,
/// 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<AtomicBool>,
}
/// 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<SessionEvent>,
pub(super) commands: Sender<ClientCommand>,
pub(super) pick: Sender<CharRequest>,
pub(super) realm: Sender<RealmRequest>,
pub(super) pick: ParkSender<CharRequest>,
pub(super) realm: ParkSender<RealmRequest>,
pub(super) screen_asks_roster: Arc<AtomicBool>,
pub(super) login: Sender<LoginRequest>,
pub(super) login_abandon: Arc<AtomicU64>,
pub(super) ping: Arc<Mutex<PingClock>>,
@@ -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::<CharRequest>();
let (realm_tx, realm_rx) = crossbeam_channel::unbounded::<RealmRequest>();
let (park_tx, park_rx) = crossbeam_channel::unbounded::<ParkRequest>();
let screen_asks_roster = Arc::new(AtomicBool::new(false));
let policy = Arc::clone(&screen_asks_roster);
let (login_tx, login_rx) = crossbeam_channel::unbounded::<LoginRequest>();
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<SessionEvent>) -> Option<LoginRequest> {
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<WorldEnd> {
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::<WardenRequired>() {
return fail_terminal(w.to_string());
}
return fail(None, format!("character roster: {e:#}"));
}
if let Some(w) = e.downcast_ref::<WardenRequired>() {
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::<WardenRequired>() {
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()
{
+273 -15
View File
@@ -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<SessionEvent>) -> 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>) -> 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::<WorldWriter>();
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);
}
+188 -20
View File
@@ -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<u16> {
/// 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<Vec<u8>> {
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<u16> {
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<u8> {
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<Mutex<Vec<u16>>>,
/// Every packet the client sent, header decrypted, as [`recv_packet`] reads it.
pub(crate) packets: Arc<Mutex<Vec<Vec<u8>>>>,
/// Every session's packets in the order it sent them, by the order the sessions began.
pub(crate) session_packets: Arc<Mutex<Vec<Vec<Vec<u8>>>>>,
/// 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<Mutex<Vec<Vec<u8>>>>,
/// How long a `CMSG_CHAR_ENUM` waits before its answer.
enum_delay: Arc<Mutex<Duration>>,
/// Answer `CMSG_CHAR_ENUM` with `SMSG_WARDEN_DATA`, as a server does that wants Warden.
warden: Arc<AtomicBool>,
/// Close the socket on `CMSG_CHAR_ENUM` instead of answering.
cut: Arc<AtomicBool>,
open: Arc<AtomicUsize>,
}
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<Key>) -> 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::<Vec<Vec<u8>>>::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<Key>) -> 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<Key>) -> 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<SessionEvent>,
pub(crate) login: Sender<LoginRequest>,
pub(crate) realm: Sender<RealmRequest>,
pub(crate) pick: Sender<CharRequest>,
pub(crate) realm: ParkSender<RealmRequest>,
pub(crate) pick: ParkSender<CharRequest>,
pub(crate) abandon: Arc<AtomicU64>,
}
/// 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::<ParkRequest>();
let abandon = Arc::new(AtomicU64::new(0));
let thread_abandon = Arc::clone(&abandon);
thread::spawn(move || {
let (writer_tx, _writer_rx) = crossbeam_channel::unbounded::<WorldWriter>();
let parks = Parks {
login_rx,
realm_rx,
pick_rx,
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,
}
}
+47 -4
View File
@@ -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<SessionEvent>, 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<SessionEvent>, mut e: Edges) {
}
fn on_character_list(In(ev): In<SessionEvent>, 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<Character>,
realm: Option<benilla_protocol::RealmInfo>,
cause: benilla_protocol::RosterCause,
epoch: u64,
generation: u64,
status: &mut NetStatus,
char_lists: &mut MessageWriter<CharListMessage>,
) {
@@ -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.
+1 -1
View File
@@ -764,7 +764,7 @@ mod tests {
let mut app = App::new();
app.init_resource::<Realms>()
.init_resource::<crate::cvars::Cvars>()
.insert_resource(RealmChoice(tx))
.insert_resource(RealmChoice(tx.into()))
.add_message::<RealmListMessage>()
.add_systems(Update, apply_realm_policy);
app.world_mut().write_message(RealmListMessage {
@@ -850,7 +850,7 @@ mod tests {
let (tx, _rx) = crossbeam_channel::unbounded();
app.init_resource::<Time>()
.init_resource::<GlueClicks>()
.insert_resource(crate::net::RealmChoice(tx))
.insert_resource(crate::net::RealmChoice(tx.into()))
.add_message::<crate::sound::GlueSound>()
.add_message::<crate::login::GlueLogin>()
.insert_resource(Realms {
+1 -5
View File
@@ -175,11 +175,7 @@ mod tests {
/// The glue functions the stock GlueXML calls that benilla has not built yet: each one's call
/// raises "attempt to call global `X' (a nil value)", at the load or on the login screen. The
/// list only shrinks: a name built, or no longer reached, comes off it.
const UNBUILT_CALLED: &[&str] = &[
"GetNumCharacters",
"SetCharCustomizeFrame",
"SetCharSelectModelFrame",
];
const UNBUILT_CALLED: &[&str] = &["SetCharCustomizeFrame"];
/// What the player's own files make the reference report too: their `tos.html` uses `<H4>`,
/// which 1.12's SimpleHTML does not know (`"Frame %s: Unknown element type: %s"`,
+329 -105
View File
@@ -60,30 +60,52 @@
//! event table (`0xb41ebc`, `0xb41ec0`) but no glue code fires them: no `SignalEvent`
//! (`0x703e50`, `0x703f50`) site in `[0x46a400, 0x476020)` passes 19 or 20.
//!
//! The character list, the other half of the character screen's show:
//!
//! 1. The connect's success tears the roster down (`0x46c014`: `UPDATE_SELECTED_CHARACTER` with 1,
//! `CHARACTER_LIST_UPDATE`) and puts `charselect` up, whose `OnShow` calls
//! `GetCharacterListUpdate` (`0x4723d0`). The glue VM clears its records in the call; the tail
//! (`0x46b880`) is state 3, the `CHAR_LIST_RETRIEVING` dialog and the query (`0x5aabb0`), which
//! is a `CMSG_CHAR_ENUM` on the parked world session.
//! 2. The world connect completes with the authentication response (`0x5ab2b8` → `0x5aab80`),
//! not with a roster; the IO thread fetches none of its own under the stock screen, so every
//! call sends, in order with the realm requests on the one park channel. A reply from the
//! current link (the world session's epoch) to that request completes the query, as
//! `SetStatus` does (`0x5aa920`), so one that lands before the tick that takes a Cancel turns
//! it into a load. A roster of another cause or link, or one with no query pending, is dropped
//! ([`GlueMgr::on_roster`]).
//! 3. State 3's frame (`0x46c03a`) re-texts the dialog and acts on the completed query: success
//! closes the dialog and loads the roster (`0x4724d0`: the records, `lastCharacterIndex`
//! selected, `CHARACTER_LIST_UPDATE`); an empty roster with no realm chosen asks for the realm
//! list instead and fires no update. A cancel (the dialog's button, `0x46b754`) tears the
//! roster down and raises the realm list; another failure opens `OKAY`. The roster is torn
//! down on the world connect's failure arm too (`0x46bf1f`).
//!
//! 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`]).
use bevy::prelude::*;
use benilla_protocol::{LoginRefusal, LoginStage, RealmInfo};
use benilla_protocol::{Character, LoginRefusal, LoginStage, RealmInfo, RosterCause};
use benilla_ui::script::{
GlueNotice, GlueRealm, GlueRealmCells, GlueRequest, GlueServer, NoticeState, ScriptValue,
UiScript, VmKind,
GlueCharacter, GlueNotice, GlueRealm, GlueRealmCells, GlueRequest, GlueSelectCells, GlueServer,
NoticeState, ScriptValue, UiScript, VmKind,
};
use crate::login::queue::QueueEstimate;
use crate::login::GlueLogin;
use crate::net::{
CharListMessage, DisconnectedMessage, LoginFailedMessage, LoginQueuedMessage,
LoginStageMessage, RealmListMessage, RealmRequest,
CharListMessage, CharRequest, 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 and the realm list reach. The reference's others are the character list (3,
/// `0x46b893`), create, delete and rename (5-7, `0x46b29e`, `0x46b2f8`, `0x46b3a4`), `EnterWorld`
/// (8, `0x46b711`), a refused character login (9, `0x402075`) and the patch and survey downloads
/// (10 and 11, `0x473c40`, `0x473da8`), each set by a verb these screens do not have yet.
/// states the login, the realm list and the character list reach. The reference's others are
/// create, delete and rename (5-7, `0x46b29e`, `0x46b2f8`, `0x46b3a4`), `EnterWorld` (8,
/// `0x46b711`), a refused character login (9, `0x402075`) and the patch and survey downloads (10
/// and 11, `0x473c40`, `0x473da8`), each set by a verb these screens do not have yet.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum ScreenState {
/// No operation (`0x46a7ce`, and every terminal arm).
@@ -93,6 +115,8 @@ pub(crate) enum ScreenState {
Logon = 1,
/// The world connect: realm list, dial and authentication (`0x46b1f9`, `0x46b250`).
Connect = 2,
/// The character list query (`0x46b880`, from `GetCharacterListUpdate`).
CharList = 3,
/// A realm list query (`RequestRealmList`, `0x46b8d2`).
RealmList = 4,
}
@@ -262,6 +286,9 @@ const STATUS_REALM_LIST_IN_PROGRESS: u8 = 0x22;
const STATUS_REALM_LIST_SUCCESS: u8 = 0x23;
const STATUS_REALM_LIST_FAILED: u8 = 0x24;
const STATUS_REALM_LIST_REALM_NOT_FOUND: u8 = 0x26;
const STATUS_CHAR_LIST_RETRIEVING: u8 = 0x2a;
const STATUS_CHAR_LIST_RETRIEVED: u8 = 0x2b;
const STATUS_CHAR_LIST_FAILED: u8 = 0x2c;
/// What a realmd result byte becomes, `(login state, result)`: `Logon::OnAuthResult`'s
/// dispatch (`0x5b2c90`, byte index `0x5b2ea4`, jumps `0x5b2e78`) after the handlers' clamp of a
@@ -303,6 +330,8 @@ enum OpKind {
Auth,
/// `COP_GET_REALMS` (9, `0x5ab840`).
GetRealms,
/// The character list (6, `0x5aabb0`).
CharList,
}
/// The operation record (`BeginOperation` `0x5aa400`, `SetStatus` `0x5aa920`): its status code
@@ -377,6 +406,11 @@ enum Outgoing {
/// The realm list rebuild (`0x46e510`) over the holder, which fires `OPEN_REALM_LIST` or
/// `GET_PREFERRED_REALM_INFO`.
Rebuild,
/// The roster teardown (`0x472580`): `UPDATE_SELECTED_CHARACTER` with 1, then
/// `CHARACTER_LIST_UPDATE`.
Teardown,
/// The roster load (`0x4724d0`) over the roster the last reply left.
Roster,
}
/// The logon's notification cells: the latest `(state, result)` (`0x836b28`, `0x836b24`) and the
@@ -431,12 +465,21 @@ pub(crate) struct GlueMgr {
selected: Option<RealmInfo>,
/// `[svc+0x1ae4]`: the last roster's character count.
characters: u16,
/// The roster the last character list reply left in `ClientServices` (`[svc+0x1ae8]`), which
/// the roster load (`0x4724d0`) walks.
roster: Vec<Character>,
/// The number of the world session the link is (`WorldAuthMessage::epoch`): the roster
/// replies of any other are not this link's.
link: u64,
/// The select verbs' cells, carried from one glue VM to the next.
select_cells: GlueSelectCells,
/// The realm list's sort and selection cells, carried from one glue VM to the next.
realm_cells: GlueRealmCells,
/// The manager moved [`Self::realm_cells`], for the next seed.
cells_moved: bool,
/// Realm requests for the IO thread, sent at the end of the frame.
realm_out: Vec<RealmRequest>,
/// Realm and character requests for the IO thread in the order they were made, sent at the end
/// of the frame on its one ordered channel.
park_out: Vec<ParkRequest>,
/// Asks of the login engine the per-frame arms made, sent with the verbs' own.
login_out: Vec<GlueLogin>,
}
@@ -493,11 +536,14 @@ impl GlueMgr {
asks.push(GlueLogin::Abandon);
self.state = ScreenState::Idle;
}
// `0x46b754`: the operation completes `RESPONSE_CANCELLED`, which the next tick's
// failure arm takes without a dialog (`0x46bef2`), dropping the world socket and
// raising the realm list. Its `0x472580` tears the glue character list down, which
// these screens do not have yet.
ScreenState::Connect => self.op.complete(false, STATUS_CANCELLED),
// `0x46b754` (states 2 and 3): the operation completes `RESPONSE_CANCELLED` and the
// roster is torn down (`0x472580`). A world connect's next tick takes the failure arm
// without a dialog (`0x46bef2`), dropping the world socket and raising the realm
// list; a character list's asks for the list again (`0x46c069`).
ScreenState::Connect | ScreenState::CharList => {
self.op.complete(false, STATUS_CANCELLED);
self.outbox.push(Outgoing::Teardown);
}
// `0x46b776`: the query cancelled, state 0, nothing sent.
ScreenState::RealmList => {
self.op.complete(false, STATUS_CANCELLED);
@@ -507,13 +553,29 @@ impl GlueMgr {
}
/// The asks the per-frame arms made of the login engine and of the IO thread.
fn take_asks(&mut self) -> (Vec<GlueLogin>, Vec<RealmRequest>) {
fn take_asks(&mut self) -> (Vec<GlueLogin>, Vec<ParkRequest>) {
(
std::mem::take(&mut self.login_out),
std::mem::take(&mut self.realm_out),
std::mem::take(&mut self.park_out),
)
}
/// `0x46b880`, the tail of `GetCharacterListUpdate`: state 3, the `CHAR_LIST_RETRIEVING`
/// 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.
fn get_character_list(&mut self) {
self.state = ScreenState::CharList;
self.open("CANCEL", Some(Text::Key("CHAR_LIST_RETRIEVING")));
self.op = Operation::begin(OpKind::CharList, STATUS_CHAR_LIST_RETRIEVING);
if !self.connected {
self.op.complete(false, STATUS_FAILED_TO_CONNECT);
} else {
self.park_out.push(CharRequest::Enumerate.into());
}
}
/// `0x46b8d0`, `RequestRealmList`'s body: state 4, the dialog when asked, and the query
/// (`0x5ab840`, `COP_GET_REALMS` at `REALM_LIST_IN_PROGRESS`).
fn request_realm_list(&mut self, dialog: bool) {
@@ -522,7 +584,7 @@ impl GlueMgr {
self.open("CANCEL", Some(Text::Key("REALM_LIST_IN_PROGRESS")));
}
self.op = Operation::begin(OpKind::GetRealms, STATUS_REALM_LIST_IN_PROGRESS);
self.realm_out.push(RealmRequest::Refresh);
self.park_out.push(RealmRequest::Refresh.into());
}
/// `CancelRealmListQuery` (`0x46b7e0`): in state 4 only, the query completes cancelled and the
@@ -545,7 +607,7 @@ impl GlueMgr {
}
if self.connected {
// `0x46b22b`: the world connection drops before anything is asked of realmd.
self.realm_out.push(RealmRequest::Leave);
self.park_out.push(RealmRequest::Leave.into());
}
self.connected = false;
self.state = ScreenState::Connect;
@@ -553,7 +615,7 @@ impl GlueMgr {
self.op = Operation::begin(OpKind::Connect, STATUS_CONNECTING);
self.queue = QueueEstimate::default();
// The connect's own query (`0x5aab20` → `0x5b2220`).
self.realm_out.push(RealmRequest::Refresh);
self.park_out.push(RealmRequest::Refresh.into());
}
/// A realm list reply: the holder filled (`0x5b2230`), then the pending operation's arm of
@@ -583,10 +645,13 @@ impl GlueMgr {
Some(r) => {
info!("glue: the connect dials {:?}", r.name);
// Stamped with the abandon generation as it is sent.
self.realm_out.push(RealmRequest::Enter {
realm: r.clone(),
generation: 0,
});
self.park_out.push(
RealmRequest::Enter {
realm: r.clone(),
generation: 0,
}
.into(),
);
}
None => {
info!("glue: realmName {realm_name:?} names no listed realm");
@@ -594,7 +659,7 @@ impl GlueMgr {
}
}
}
OpKind::Auth => {}
OpKind::Auth | OpKind::CharList => {}
}
}
@@ -656,7 +721,14 @@ impl GlueMgr {
self.cells.result = result;
}
}
ScreenState::Connect => {
// The character list query's reply never came: the server refused the roster to a
// client without Warden (the park's `char_enum`), the one failure it reports. The
// query completes failed with the words, and the link is gone.
ScreenState::CharList if self.op.kind != OpKind::CharList || self.op.done => {}
ScreenState::Connect | ScreenState::CharList => {
if self.state == ScreenState::CharList {
self.connected = false;
}
let status = match msg.refusal {
Some(LoginRefusal::World(code)) => code,
Some(LoginRefusal::Logon(_) | LoginRefusal::LogonProof(_)) => {
@@ -678,9 +750,9 @@ impl GlueMgr {
/// State 2's failure arm (`0x46bea7`-`0x46bfc8`): the dialog unless cancelled, state 0, a
/// cancel's world socket dropped (`0x46befd`); `AUTH_FAILED` from the character screen goes
/// back to login (`0x46bfa7`); every other status raises the realm list (`0x46bf1f`-`0x46bf96`),
/// or with no realm held asks for one (`0x46c06e`). The arm's `0x472580` tears the glue
/// character list down, which these screens do not have yet.
/// back to login (`0x46bfa7`); every other status tears the roster down (`0x46bf1f`) and
/// raises the realm list (`0x46bf24`-`0x46bf96`), or with no realm held asks for one
/// (`0x46c06e`).
fn world_failed(&mut self) {
let status = self.op.status;
if status != STATUS_CANCELLED {
@@ -708,6 +780,7 @@ impl GlueMgr {
}
return;
}
self.outbox.push(Outgoing::Teardown);
if self.holder.is_empty() {
self.request_realm_list(true);
} else {
@@ -729,13 +802,49 @@ impl GlueMgr {
}
}
/// The roster: in state 2, the authentication completes `AUTH_OK` (`0x5aab80`), which the tick
/// shows and takes down the success arm.
fn on_roster(&mut self, realm: Option<benilla_protocol::RealmInfo>) {
self.connected = true;
if realm.is_some() {
self.realm = realm;
/// A roster reply from the current link stores the roster and completes the operation
/// `CHAR_LIST_RETRIEVED` (`0x5ab300` → `SetStatus`, `0x5aa920`), which overwrites the status
/// whatever it was: a reply that lands between a Cancel and the tick that takes it turns the
/// cancel into a load, as the reference's does, and a second reply replaces the first.
///
/// Deviation: the reference's handler completes whichever operation is current; here only the
/// character list's does, in state 3, and a reply of another cause (a roster nothing of the
/// screen asked for) or of another link (before a realm change) completes nothing. Those
/// replies cannot be told from the operation they would be applied to in the reference, which
/// has one link and one request in flight; here the IO thread's park serves several.
fn on_roster(&mut self, roster: Vec<Character>, cause: RosterCause, epoch: u64) {
if cause != RosterCause::Asked
|| epoch != self.link
|| self.state != ScreenState::CharList
|| self.op.kind != OpKind::CharList
{
return;
}
self.characters = roster.len().min(usize::from(u16::MAX)) as u16;
self.roster = roster;
self.op.complete(true, STATUS_CHAR_LIST_RETRIEVED);
}
/// The roster ask failed on the stream: the query completes `CHAR_LIST_FAILED` (`0x5ab300`'s
/// failure leg, `SetStatus(0, 0x2c)`), which the tick opens as `OKAY`. The session ends behind
/// it as a dropped socket does, so the disconnect handler follows (`0x46c540`).
fn on_roster_failed(&mut self, epoch: u64) {
if epoch != self.link
|| self.state != ScreenState::CharList
|| self.op.kind != OpKind::CharList
{
return;
}
self.op.complete(false, STATUS_CHAR_LIST_FAILED);
}
/// The world server accepted the authentication: the link is up, and in state 2 the
/// authentication completes `AUTH_OK` (`0x5aab80`), which the tick shows and takes down the
/// success arm.
fn on_world_authenticated(&mut self, realm: benilla_protocol::RealmInfo, epoch: u64) {
self.connected = true;
self.link = epoch;
self.realm = Some(realm);
if self.state == ScreenState::Logon {
// A logon whose world half finished inside one frame.
self.on_logon_done();
@@ -798,6 +907,7 @@ impl GlueMgr {
}
}
ScreenState::Connect => self.tick_connect(),
ScreenState::CharList => self.tick_char_list(),
ScreenState::RealmList => self.tick_realm_list(),
ScreenState::Idle => {}
}
@@ -840,26 +950,42 @@ impl GlueMgr {
// `0x46bfdd`: the connect done, the authentication begins (`0x5aaba0`).
self.op = Operation::begin(OpKind::Auth, STATUS_AUTHENTICATING);
} else {
// `0x46c00e`-`0x46c023`: state 0, `charselect`, the queue ring cleared.
// `0x46c00e`-`0x46c023`: state 0, the roster torn down, `charselect`, the queue ring
// cleared. The screen's own `GetCharacterListUpdate` loads the roster.
self.state = ScreenState::Idle;
self.op = Operation::default();
self.queue = QueueEstimate::default();
self.outbox.push(Outgoing::Teardown);
self.outbox.push(Outgoing::Screen("charselect"));
self.roster_arm();
}
}
/// The roster's arm (`0x4724d0`), which the reference reaches from the character list query
/// that `charselect` asks for (`0x46c094`); benilla's roster comes with the authentication.
/// Characters mark a realm chosen (`0x472556`); an empty roster with no realm chosen asks for
/// the list with its dialog (`0x47253d` → `0x46b8d0`), skipping `CHARACTER_LIST_UPDATE`.
fn roster_arm(&mut self) {
if self.characters != 0 {
self.realm_cells.chosen = Some(0);
self.cells_moved = true;
} else if self.realm_cells.chosen.is_none() {
self.request_realm_list(true);
/// State 3's frame (`0x46c03a`-`0x46c0a4`): the dialog re-texted, then a completed query's
/// arm. A cancel tears the roster down (`0x46c069`) and raises the realm list
/// (`RequestRealmList(1)`, `0x46c073`); any other failure opens `OKAY` with its text and goes
/// to state 0 (`0x46c173`); a success goes to state 0, closes the dialog and loads the roster
/// (`0x46c084`-`0x46c094`).
fn tick_char_list(&mut self) {
if let Some(text) = self.op.text() {
self.outbox.push(Outgoing::Update { text, button: None });
}
if !self.op.done {
return;
}
if !self.op.ok {
if self.op.status == STATUS_CANCELLED {
self.outbox.push(Outgoing::Teardown);
self.request_realm_list(true);
} else {
let text = self.op.text();
self.open("OKAY", text);
self.state = ScreenState::Idle;
}
return;
}
self.state = ScreenState::Idle;
self.outbox.push(Outgoing::Close);
self.outbox.push(Outgoing::Roster);
}
/// `0x46b140`: a dialog for the logon's `(state, result)` when it differs from the pair last
@@ -940,6 +1066,8 @@ pub(crate) struct LoginNews<'w, 's> {
failures: MessageReader<'w, 's, LoginFailedMessage>,
queued: MessageReader<'w, 's, LoginQueuedMessage>,
rosters: MessageReader<'w, 's, CharListMessage>,
world_auths: MessageReader<'w, 's, WorldAuthMessage>,
roster_failures: MessageReader<'w, 's, RosterFailedMessage>,
realm_lists: MessageReader<'w, 's, RealmListMessage>,
disconnects: MessageReader<'w, 's, DisconnectedMessage>,
}
@@ -973,6 +1101,10 @@ pub(crate) fn drive_glue_mgr(
mut asks: GlueAsks,
realms: Option<Res<crate::realm_select::Realms>>,
mut realmlist: Option<ResMut<crate::realmlist::Realmlist>>,
areas: Option<Res<crate::area::AreaTableRes>>,
create: Option<Res<crate::entities::CharCreate>>,
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
creatures: Option<Res<crate::entities::Creatures>>,
state: Option<Res<State<crate::char_select::ClientState>>>,
time: Res<Time>,
) {
@@ -1010,6 +1142,7 @@ pub(crate) fn drive_glue_mgr(
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);
mgr.holder_moved = true;
fresh_vm = true;
}
@@ -1055,6 +1188,12 @@ pub(crate) fn drive_glue_mgr(
asks.music.write(crate::sound::GlueMusicRequest::Stop);
}
GlueRequest::DisconnectFromServer => {
// The disconnect handler zeroes the state (`0x46c55d`), the operation in
// flight with it, so no reply completes on the login screen.
if mgr.connected || mgr.state == ScreenState::Connect {
mgr.state = ScreenState::Idle;
mgr.op = Operation::default();
}
mgr.connected = false;
logins.push(GlueLogin::Disconnect);
}
@@ -1064,6 +1203,8 @@ pub(crate) fn drive_glue_mgr(
// `CLoginMgr::Cancel` (`0x5b3320`): nothing else moves, so no dialog shows.
GlueRequest::CancelLogon => logins.push(GlueLogin::Abandon),
GlueRequest::ChangeRealm(name) => mgr.connect_to_realm(&name),
// `0x46b880`; the VM cleared the roster in the verb.
GlueRequest::GetCharacterList => mgr.get_character_list(),
}
}
mgr.realm_cells = s.glue_realm_cells();
@@ -1095,27 +1236,24 @@ pub(crate) fn drive_glue_mgr(
for msg in news.failures.read() {
mgr.on_failed(msg);
}
// A session of an attempt a Cancel or a disconnect has passed is not the screen's.
let generation = crate::net::LoginAbandon::now(asks.abandon.as_deref());
for msg in news.world_auths.read() {
if msg.generation == generation {
mgr.on_world_authenticated(msg.realm.clone(), msg.epoch);
}
}
for msg in news.roster_failures.read() {
mgr.on_roster_failed(msg.epoch);
}
for msg in news.rosters.read() {
mgr.characters = msg.characters.len().min(usize::from(u16::MAX)) as u16;
mgr.on_roster(msg.realm.clone());
mgr.on_roster(msg.characters.clone(), msg.cause, msg.epoch);
}
for msg in news.disconnects.read() {
mgr.on_disconnected(msg.session_over);
}
mgr.tick();
let (arms, realm_asks) = mgr.take_asks();
for ask in logins.into_iter().chain(arms) {
asks.login.write(ask);
}
let now = crate::net::LoginAbandon::now(asks.abandon.as_deref());
if let Some(choice) = asks.realm.as_deref() {
for mut ask in realm_asks {
if let RealmRequest::Enter { generation, .. } = &mut ask {
*generation = now;
}
let _ = choice.0.send(ask);
}
}
send_asks(&mut mgr, logins, &mut asks);
let Some(mut script) = script else {
return;
@@ -1134,7 +1272,67 @@ pub(crate) fn drive_glue_mgr(
realms.as_deref(),
realmlist.as_deref(),
);
flush(&mut mgr, &mut script, now_ms);
let names = RosterNames {
areas: areas.as_deref().map(|a| &a.0),
create: create.as_deref().map(|c| &c.0),
classes: classes.as_deref().map(|c| &c.0),
creatures: creatures.as_deref(),
};
flush(&mut mgr, &mut script, now_ms, &names);
mgr.select_cells = script.glue_select_cells();
// What the outbox asked in turn (the roster load's realm list).
send_asks(&mut mgr, Vec::new(), &mut asks);
}
/// 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.
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) {
asks.login.write(ask);
}
let now = crate::net::LoginAbandon::now(asks.abandon.as_deref());
if let Some(choice) = asks.realm.as_deref() {
for mut ask in park_asks {
if let ParkRequest::Realm(RealmRequest::Enter { generation, .. }) = &mut ask {
*generation = now;
}
let _ = choice.0.send_request(ask);
}
}
}
/// The DBC catalogs the roster's strings come from.
struct RosterNames<'a> {
areas: Option<&'a benilla_formats::AreaTableCatalog>,
create: Option<&'a benilla_formats::CharCreateCatalog>,
classes: Option<&'a benilla_formats::ChrClasses>,
creatures: Option<&'a crate::entities::Creatures>,
}
/// A roster entry as the glue VM's records take it (`0x4732a0`). The race and class names are
/// the `Name_Lang` of the race and class rows (`ChrRaces` `+0x44`, `ChrClasses` `+0x14`) at the
/// enUS slot (the one locale benilla reads, #429), the file string the race row's (`ChrRaces` column 15), and the model flag
/// whether the race row's body for the gender resolves to a model. A race, class or zone with no row reads
/// empty, empty, nil as the reference's does, and so does one whose catalog did not load.
fn glue_character(c: &Character, names: &RosterNames) -> GlueCharacter {
let text = |s: Option<&str>| s.unwrap_or_default().to_string();
GlueCharacter {
guid: c.guid,
name: c.name.clone(),
race: text(names.create.and_then(|r| r.race_name(c.race))),
class: text(names.classes.and_then(|k| k.name(u32::from(c.class)))),
level: c.level,
zone: names.areas.and_then(|a| a.name(c.zone)).map(str::to_string),
file_string: text(names.create.and_then(|r| r.race_file(c.race))),
gender: c.gender,
flags: c.flags,
// `0x5ec480`: the race row's display for the gender, then its model rows.
model: names
.create
.and_then(|r| r.body_display(c.race, c.gender))
.is_some_and(|display| names.creatures.is_some_and(|k| k.resolves_model(display))),
}
}
/// The glue VM's slots, from the manager's state.
@@ -1203,55 +1401,81 @@ fn seed_realmlist(script: &UiScript, realmlist: &crate::realmlist::Realmlist) {
}
/// Fire the outbox into the glue VM, the texts resolved through its own strings (`0x703bf0`).
fn flush(mgr: &mut GlueMgr, script: &mut UiScript, now_ms: u32) {
fn flush(mgr: &mut GlueMgr, script: &mut UiScript, now_ms: u32, names: &RosterNames) {
// The roster load can queue more (the realm list's dialog), which goes out in turn.
while !mgr.outbox.is_empty() {
for out in std::mem::take(&mut mgr.outbox) {
flush_one(mgr, script, now_ms, names, out);
}
}
}
fn flush_one(
mgr: &mut GlueMgr,
script: &mut UiScript,
now_ms: u32,
names: &RosterNames,
out: Outgoing,
) {
let text = |script: &UiScript, t: &Text| match t {
Text::Key(k) => script.global_text(k),
Text::Raw(s) => s.clone(),
};
for out in std::mem::take(&mut mgr.outbox) {
match out {
Outgoing::Open {
which,
text: t,
data,
} => {
let mut args = vec![ScriptValue::Str(which.to_string())];
if t.is_some() || data.is_some() {
args.push(t.map_or(ScriptValue::Nil, |t| ScriptValue::Str(text(script, &t))));
}
if let Some(d) = data {
args.push(ScriptValue::Str(d));
}
script.fire_event("OPEN_STATUS_DIALOG", args);
match out {
Outgoing::Open {
which,
text: t,
data,
} => {
let mut args = vec![ScriptValue::Str(which.to_string())];
if t.is_some() || data.is_some() {
args.push(t.map_or(ScriptValue::Nil, |t| ScriptValue::Str(text(script, &t))));
}
Outgoing::Update { text: t, button } => {
let mut args = vec![ScriptValue::Str(text(script, &t))];
if let Some(b) = button {
args.push(ScriptValue::Str(text(script, &b)));
}
script.fire_event("UPDATE_STATUS_DIALOG", args);
if let Some(d) = data {
args.push(ScriptValue::Str(d));
}
Outgoing::Queue => {
let body = mgr
.queue
.text_by(now_ms, mgr.queue_realm.as_deref(), |key| {
script.global_text(key)
});
let button = script.global_text("CHANGE_REALM");
script.fire_event(
"UPDATE_STATUS_DIALOG",
vec![ScriptValue::Str(body), ScriptValue::Str(button)],
);
script.fire_event("OPEN_STATUS_DIALOG", args);
}
Outgoing::Update { text: t, button } => {
let mut args = vec![ScriptValue::Str(text(script, &t))];
if let Some(b) = button {
args.push(ScriptValue::Str(text(script, &b)));
}
Outgoing::Screen(name) => {
script.fire_event("SET_GLUE_SCREEN", vec![ScriptValue::Str(name.to_string())]);
}
Outgoing::Disconnected => script.fire_event("DISCONNECTED_FROM_SERVER", Vec::new()),
Outgoing::Close => script.fire_event("CLOSE_STATUS_DIALOG", Vec::new()),
Outgoing::Rebuild => {
script.rebuild_glue_realms();
mgr.realm_cells = script.glue_realm_cells();
script.fire_event("UPDATE_STATUS_DIALOG", args);
}
Outgoing::Queue => {
let body = mgr
.queue
.text_by(now_ms, mgr.queue_realm.as_deref(), |key| {
script.global_text(key)
});
let button = script.global_text("CHANGE_REALM");
script.fire_event(
"UPDATE_STATUS_DIALOG",
vec![ScriptValue::Str(body), ScriptValue::Str(button)],
);
}
Outgoing::Screen(name) => {
script.fire_event("SET_GLUE_SCREEN", vec![ScriptValue::Str(name.to_string())]);
}
Outgoing::Disconnected => script.fire_event("DISCONNECTED_FROM_SERVER", Vec::new()),
Outgoing::Close => script.fire_event("CLOSE_STATUS_DIALOG", Vec::new()),
Outgoing::Rebuild => {
script.rebuild_glue_realms();
mgr.realm_cells = script.glue_realm_cells();
}
Outgoing::Teardown => script.teardown_glue_roster(),
Outgoing::Roster => {
let roster = mgr
.roster
.iter()
.map(|c| glue_character(c, names))
.collect();
// `RequestRealmList(1)` at `0x47254b`, when no realm has been chosen.
if script.load_glue_roster(roster) {
mgr.request_realm_list(true);
}
mgr.realm_cells = script.glue_realm_cells();
}
}
}
File diff suppressed because it is too large Load Diff
+47 -6
View File
@@ -19,14 +19,28 @@ pub(crate) fn setup_script(world: &mut World) {
/// Install the VM the world holds outside the world: the stock glue screens' under
/// [`super::glue::GlueStock`], put up on `screen`, else a boot VM.
///
/// The IO thread's roster policy follows the VM that is up: the screen asks for its roster only
/// when the stock glue VM was built and its character select screen loaded, so a run whose stock VM failed to build (logged as an error
/// by [`new_vm`]) falls back to the park's own enumeration instead of waiting on a screen that
/// never asks.
fn install_glue_side_vm(world: &mut World, screen: super::glue::GlueScreen) {
if world
let stock_asked = world
.get_resource::<super::glue::GlueStock>()
.is_some_and(|s| s.0)
{
install_glue_vm(world, screen);
.is_some_and(|s| s.0);
let stock_up = if stock_asked {
install_glue_vm(world, screen)
} else {
install_boot_vm(world);
false
};
if stock_asked && !stock_up {
error!("ui_script: WOW_GLUE_STOCK=1 but the stock glue VM did not come up with its character select screen; the roster is fetched at the park");
}
if let Some(policy) = world.get_resource::<crate::net::RosterPolicy>() {
policy
.0
.store(stock_up, std::sync::atomic::Ordering::SeqCst);
}
}
@@ -54,9 +68,9 @@ pub(crate) fn strings_vm(world: &mut World) -> Option<UiScript> {
/// builder `0x46a7b0` then `SET_GLUE_SCREEN` (`0x402b65` at startup, `0x402090` back from the
/// world). It holds what the glue state holds and nothing of the boot VM's: no `GlobalStrings.lua`,
/// no emote tokens.
fn install_glue_vm(world: &mut World, screen: super::glue::GlueScreen) {
fn install_glue_vm(world: &mut World, screen: super::glue::GlueScreen) -> bool {
let Some(mut script) = new_vm(world, UiScript::new_glue) else {
return;
return false;
};
seat_raster_seam_for_load(world, &mut script);
let start = std::time::Instant::now();
@@ -69,7 +83,15 @@ fn install_glue_vm(world: &mut World, screen: super::glue::GlueScreen) {
screen.key(),
failures.len()
);
// Up means the screen that asks for the roster exists: the stock files loaded far enough to
// define the character select frame and its `OnShow`, not merely that a VM started.
let charselect = script
.eval::<bool>(
"return type(CharacterSelect) == 'table' and type(CharacterSelect_OnShow) == 'function'",
)
.unwrap_or(false);
world.insert_non_send_resource(script);
charselect
}
/// A fresh VM from `make` on the process clock with the addon asset probes wired, or `None` (the
@@ -1048,6 +1070,25 @@ mod tests {
);
}
/// The IO thread's roster policy follows the VM that came up: the screen asks only when the
/// stock glue VM stands, so a boot VM leaves the park to fetch the roster.
#[test]
fn the_roster_policy_follows_the_vm_that_stands() {
use std::sync::atomic::Ordering;
for stock in [true, false] {
let mut world = World::new();
let policy = crate::net::RosterPolicy(Default::default());
policy.0.store(!stock, Ordering::SeqCst);
world.insert_resource(policy.clone());
world.insert_resource(super::super::glue::GlueStock(stock));
world.insert_resource(State::new(crate::char_select::ClientState::Login));
setup_script(&mut world);
// The stock policy needs the stock files to have loaded: no install, no screen.
let up = stock && benilla_formats::wow_data().is_some();
assert_eq!(policy.0.load(Ordering::SeqCst), up, "stock {stock}");
}
}
/// Under `WOW_GLUE_STOCK=1` the world holds the glue VM where it holds a boot VM, at startup
/// on the login screen (`0x402b65`) and back from the world on the screen the exit enters
/// (`0x402090`); a `ReloadUI()` never builds the glue.
@@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet};
use anyhow::{bail, Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
use crate::dbc::{name_lang_at, parse, str_at, u32_at};
use crate::Chain;
const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc";
@@ -102,6 +102,8 @@ pub struct CharCreateCatalog {
displays: HashMap<u8, (u32, u32)>,
/// race → ChrRaces fileString (col 15), the GlueStrings key stem (`RACE_INFO_<FILE>`).
files: HashMap<u8, String>,
/// race → ChrRaces `Name_Lang` (cols 17-24, `+0x44`), the race name `GetCharacterInfo` returns.
names: HashMap<u8, String>,
/// race → ([male, female] facial-hair token, cols 26/27; hair token, col 28).
custom_tokens: HashMap<u8, ([String; 2], String)>,
combos: HashSet<(u8, u8)>,
@@ -112,10 +114,16 @@ pub struct CharCreateCatalog {
impl CharCreateCatalog {
/// The body displayId for a (race, sex), resolved through CreatureDisplayInfo like any unit.
/// The reference reads the race row's male id for gender 0 and the female for 1 and nothing
/// for any other gender byte (`0x5ec4c0`), and a zero id finds no model (`0x5ec485`).
pub fn body_display(&self, race: u8, sex: u8) -> Option<u32> {
self.displays
.get(&race)
.map(|&(m, f)| if sex == 0 { m } else { f })
let &(male, female) = self.displays.get(&race)?;
let id = match sex {
0 => male,
1 => female,
_ => return None,
};
(id != 0).then_some(id)
}
/// Whether a race may be created as a class (CharBaseInfo less `UNUSED_COMBOS`).
@@ -128,6 +136,12 @@ impl CharCreateCatalog {
self.files.get(&race).map(String::as_str)
}
/// The ChrRaces `Name_Lang` name for a race (`"Night Elf"`, `"Undead"`) at the enUS slot:
/// `GetCharacterInfo`'s second return (`0x47332e`).
pub fn race_name(&self, race: u8) -> Option<&str> {
self.names.get(&race).map(String::as_str)
}
/// A race's hair glue token (`"NORMAL"`, `"HORNS"`), keying `HAIR_<tok>_STYLE`/`_COLOR`.
pub fn hair_customization(&self, race: u8) -> Option<&str> {
self.custom_tokens.get(&race).map(|(_, h)| h.as_str())
@@ -169,7 +183,7 @@ impl CharCreateCatalog {
/// Load the catalog from the patch chain. The misparse guards compare the raw combos, so they
/// run before `UNUSED_COMBOS` are stripped.
pub fn load(chain: &mut Chain) -> Result<Self> {
let (displays, files, custom_tokens) = load_races(chain)?;
let (displays, files, names, custom_tokens) = load_races(chain)?;
let combos = load_combos(chain)?;
let start_outfits = load_start_outfits(chain)?;
let avail = load_available_sections(chain)?;
@@ -189,6 +203,7 @@ impl CharCreateCatalog {
let mut catalog = Self {
displays,
files,
names,
custom_tokens,
combos,
ranges,
@@ -267,6 +282,7 @@ fn load_races(
) -> Result<(
HashMap<u8, (u32, u32)>,
HashMap<u8, String>,
HashMap<u8, String>,
HashMap<u8, ([String; 2], String)>,
)> {
let bytes = chain
@@ -275,7 +291,7 @@ fn load_races(
let mut schema = Schema::new("ChrRaces");
for i in 0..29 {
let ty = match i {
15 | 26 | 27 | 28 => FieldType::String,
15 | 17..=24 | 26 | 27 | 28 => FieldType::String,
_ => FieldType::UInt32,
};
schema.add_field(SchemaField::new(format!("f{i}"), ty));
@@ -283,6 +299,7 @@ fn load_races(
let rs = parse(&bytes, schema, "ChrRaces")?;
let mut displays = HashMap::new();
let mut files = HashMap::new();
let mut names = HashMap::new();
let mut tokens = HashMap::new();
for r in rs.records() {
let Some(race) = u32_at(r, 0) else { continue };
@@ -296,13 +313,16 @@ fn load_races(
if let Some(file) = str_at(&rs, r, 15) {
files.insert(race, file);
}
if let Some(name) = name_lang_at(&rs, r, 17) {
names.insert(race, name);
}
if let (Some(fm), Some(ff), Some(hair)) =
(str_at(&rs, r, 26), str_at(&rs, r, 27), str_at(&rs, r, 28))
{
tokens.insert(race, ([fm, ff], hair));
}
}
Ok((displays, files, tokens))
Ok((displays, files, names, tokens))
}
/// CharBaseInfo → (race, class) pairs: two one-byte fields, parsed by hand off the record block.
@@ -544,6 +564,35 @@ mod tests {
assert!(shipped_combos_present(&combos).is_err());
}
/// `GetCharacterInfo`'s race is ChrRaces `Name_Lang` (cols 17-24), not the file string beside
/// it: Night Elf and Undead differ from "NightElf" and "Scourge" in the one column each, so a
/// slip of a column reads the wrong one.
#[test]
fn race_names_are_the_name_lang_column() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = CharCreateCatalog::load(&mut chain).expect("load char-create catalog");
for (race, name, file) in [
(1, "Human", "Human"),
(2, "Orc", "Orc"),
(3, "Dwarf", "Dwarf"),
(4, "Night Elf", "NightElf"),
(5, "Undead", "Scourge"),
(6, "Tauren", "Tauren"),
(7, "Gnome", "Gnome"),
(8, "Troll", "Troll"),
] {
assert_eq!(cat.race_name(race), Some(name), "race {race}");
assert_eq!(cat.race_file(race), Some(file), "race {race}");
}
assert_eq!(cat.race_name(0), None);
// The gender byte picks the race row's male (0) or female (1) display and nothing else.
for race in PLAYABLE_RACES {
assert!(cat.body_display(race, 0).is_some() && cat.body_display(race, 1).is_some());
assert_eq!(cat.body_display(race, 2), None, "race {race}");
}
}
#[test]
fn char_create_catalog_matches_the_5875_dbcs() {
let data = crate::wow_data_or_skip!();
+40 -5
View File
@@ -1,4 +1,4 @@
//! `ChrClasses.dbc`, the per-class table, narrowed to the three columns the reference reads off
//! `ChrClasses.dbc`, the per-class table, narrowed to the four columns the reference reads off
//! its class-indexed record table (`0xc0def4`, max id `0xc0def8`, loader `0x542360`).
//!
//! Field 2, the damage bonus stat: `GetDamageBonusStat` (`0x48b520`) reads it at `rec + 8` for the
@@ -9,6 +9,9 @@
//! row but the Warlock's `"DEMON"`. FrameXML resolves it with `getglobal("PET_TYPE_"..token)`
//! (`SpellBookFrame.lua:173`), so the token is a key, never display text, and is not localized.
//!
//! Field 5, the class name: the `Name_Lang` block (`+0x14`, eight locale columns), which
//! `GetCharacterInfo` (`0x4732a0`) returns as the class (`0x473368`).
//!
//! Field 15, the class spell family: a talent modifier applies to a spell only when its
//! `SpellFamilyName` (`Spell.dbc` column 160) equals the local player's (`GetSpellModifiers`,
//! `0x6e6b30`), cached in `[0xcecaac]` by its one non-zeroing writer, `0x6e6ca0` (called from
@@ -25,7 +28,7 @@ use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::chain::Chain;
use crate::dbc::{parse, str_at, u32_at};
use crate::dbc::{name_lang_at, parse, str_at, u32_at};
const CHR_CLASSES: &str = "DBFilesClient\\ChrClasses.dbc";
@@ -37,6 +40,9 @@ const CHR_CLASSES_FIELDS: usize = 17;
/// Field 2, read at `rec + 8` by `GetDamageBonusStat` (`0x48b569`).
const DAMAGE_BONUS_STAT_FIELD: usize = 0x8 / 4;
/// Field 5, the first of the eight `Name_Lang` columns (`rec + 0x14`, `0x473368`).
const NAME_FIELD: usize = 0x14 / 4;
/// Field 4, read at `rec + 0x10` by `HasPetSpells` (`0x4b447c`).
const PET_NAME_TOKEN_FIELD: usize = 0x10 / 4;
@@ -54,6 +60,7 @@ pub const PET_NAME_TOKEN_FALLBACK: &str = "PET";
struct ChrClass {
damage_bonus_stat: u32,
pet_name_token: Option<String>,
name: Option<String>,
has_relic_slot: bool,
spell_family: u32,
}
@@ -72,6 +79,12 @@ impl ChrClasses {
.unwrap_or(PET_NAME_TOKEN_FALLBACK)
}
/// The class's `Name_Lang` name, `GetCharacterInfo`'s third return; `None` for a class with no
/// row or no name, which that binding answers with the empty string (`0x47336e`).
pub fn name(&self, class: u32) -> Option<&str> {
self.0.get(&class)?.name.as_deref()
}
/// The class's damage bonus stat, 0-based (Strength 0, Agility 1); `None` for a class with
/// no row, where `GetDamageBonusStat` answers 0 (`0x48b58a`).
pub fn damage_bonus_stat(&self, class: u32) -> Option<u32> {
@@ -100,9 +113,8 @@ impl ChrClasses {
fn schema() -> Schema {
let mut s = Schema::new("ChrClasses");
for i in 0..CHR_CLASSES_FIELDS {
// Only the token is a string; the rest, the class name block from field 5 included, stay
// opaque dwords.
let ty = if i == PET_NAME_TOKEN_FIELD {
// The token and the eight name columns are strings; the rest stay opaque dwords.
let ty = if i == PET_NAME_TOKEN_FIELD || (NAME_FIELD..NAME_FIELD + 8).contains(&i) {
FieldType::String
} else {
FieldType::UInt32
@@ -126,6 +138,7 @@ pub fn load_chr_classes(chain: &mut Chain) -> Result<ChrClasses> {
ChrClass {
damage_bonus_stat: u32_at(r, DAMAGE_BONUS_STAT_FIELD).unwrap_or(0),
pet_name_token: str_at(&rs, r, PET_NAME_TOKEN_FIELD),
name: name_lang_at(&rs, r, NAME_FIELD),
has_relic_slot: u32_at(r, RELIC_SLOT_FIELD).is_some_and(|v| v != 0),
spell_family: u32_at(r, SPELL_FAMILY_FIELD).unwrap_or(0),
},
@@ -202,6 +215,28 @@ mod tests {
assert_eq!(t.damage_bonus_stat(6), None);
}
/// `GetCharacterInfo`'s class is the `Name_Lang` block at field 5, one column per locale; a
/// class with no row has no name (6 and 10 in 1.12).
#[test]
fn class_names_are_the_name_lang_column() {
let Some(mut chain) = chain() else { return };
let t = load_chr_classes(&mut chain).expect("load ChrClasses.dbc");
for (class, name) in [
(1, "Warrior"),
(2, "Paladin"),
(3, "Hunter"),
(4, "Rogue"),
(5, "Priest"),
(7, "Shaman"),
(8, "Mage"),
(9, "Warlock"),
(11, "Druid"),
] {
assert_eq!(t.name(class), Some(name), "class {class}");
}
assert_eq!((t.name(6), t.name(10), t.name(0)), (None, None, None));
}
/// The whole column pins field 15: nine distinct values matching vmangos `SpellFamilyNames`
/// are a shape no neighbour has (field 14 is the class's file name, field 16 the relic flag).
#[test]
+10
View File
@@ -57,6 +57,16 @@ pub(crate) fn str_at(rs: &RecordSet, r: &Record, i: usize) -> Option<String> {
}
}
/// The `Name_Lang` column benilla reads, the enUS one (the reference reads its client's locale
/// slot, `[0xc0e080]`). Other locales are unsupported for now (issue #429).
pub(crate) const LOCALE_SLOT: usize = 0;
/// The name a `Name_Lang` block holds, the eight locale columns from `first`: the column at
/// [`LOCALE_SLOT`], `None` when it is empty.
pub(crate) fn name_lang_at(rs: &RecordSet, r: &Record, first: usize) -> Option<String> {
str_at(rs, r, first + LOCALE_SLOT)
}
/// `SpellIcon.dbc`: id → extensionless texture path (`Interface\Icons\…`).
pub(crate) fn load_spell_icon_map(chain: &mut Chain) -> Result<HashMap<u32, String>> {
let bytes = chain
+33
View File
@@ -75,6 +75,19 @@ pub enum LoginStage {
WorldConnected,
}
/// What a roster was fetched for. The screen takes a reply as its own only when it asked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RosterCause {
/// The IO thread's own enumeration on reaching the character park, which the native screens
/// wait for; the stock screens ask for theirs instead.
Park,
/// The reply to a roster request from the screen (`CMSG_CHAR_ENUM` for
/// `GetCharacterListUpdate`, `0x5aabb0`).
Asked,
/// A create or delete changed the roster; re-sent before the action's result.
Changed,
}
/// How a world session ended. On a loss the reference fires `DISCONNECTED_FROM_SERVER` and
/// `GlueParent.lua` returns to the login screen; a logout returns to character select.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -124,6 +137,21 @@ pub enum SessionEvent {
realms: Vec<crate::RealmInfo>,
failed: bool,
},
/// The world server accepted the authentication (`SMSG_AUTH_RESPONSE` `AUTH_OK`, `0x5ab2b8`
/// → `0x5aab80`): the world connect is done and the IO thread is at the character park, with
/// no roster fetched unless the native policy's enumeration follows. Emitted by the IO thread,
/// never wire-decoded. `epoch` numbers the world session, per process; `generation` is the
/// abandon generation of the attempt that dialed it, which a Cancel or a disconnect since has
/// moved past (the reference drops the socket there, `0x46befd`, `0x46d360`).
WorldAuthenticated {
realm: crate::RealmInfo,
epoch: u64,
generation: u64,
},
/// A roster ask on the parked session failed on the stream (`0x5ab300`'s failure leg,
/// `SetStatus(0, 0x2c)`, `CHAR_LIST_FAILED`); the session ends after it. Emitted by the IO
/// thread, never wire-decoded.
RosterFailed { epoch: u64 },
/// Queued for a full realm (`AUTH_WAIT_QUEUE`), per queue packet: a wait, not an outcome.
LoginQueued {
position: Option<u32>,
@@ -143,6 +171,11 @@ pub enum SessionEvent {
CharacterList {
characters: Vec<Character>,
realm: Option<crate::RealmInfo>,
cause: RosterCause,
/// The world session's number ([`Self::WorldAuthenticated`]'s), 0 on the raw decode path.
epoch: u64,
/// The abandon generation of the attempt that dialed that session.
generation: u64,
},
/// `SMSG_CHAR_CREATE`/`_DELETE`'s raw `WorldResult`; on success a fresh roster comes first.
CharActionResult { action: CharAction, code: u8 },
@@ -31,6 +31,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
ServerPacket::CharEnum { characters } => vec![SessionEvent::CharacterList {
characters,
realm: None,
cause: super::RosterCause::Park,
epoch: 0,
generation: 0,
}],
ServerPacket::CharacterLoginFailed { result } => {
vec![SessionEvent::CharacterLoginFailed { result }]
+2 -2
View File
@@ -9,8 +9,8 @@ pub mod wire;
pub mod world;
pub use auth::{AuthReject, LogonReply};
pub use events::{
decode, CharAction, EntityKind, LoginRefusal, LoginStage, MoveSpeeds, Poll, SessionEnd,
SessionEvent, SessionEventKind,
decode, CharAction, EntityKind, LoginRefusal, LoginStage, MoveSpeeds, Poll, RosterCause,
SessionEnd, SessionEvent, SessionEventKind,
};
pub use messages::field;
pub use messages::{
+3 -1
View File
@@ -112,7 +112,9 @@ fn char_enum_roster_parses_and_decodes() {
// The raw decode has no realm; the IO thread's own emit carries the auth realm entry.
match &decode(p)[..] {
[SessionEvent::CharacterList { characters, realm }] => {
[SessionEvent::CharacterList {
characters, realm, ..
}] => {
assert_eq!(characters.len(), 2);
assert!(realm.is_none());
}
+2 -1
View File
@@ -140,5 +140,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
super::glue_core::install(lua)?;
super::glue_realm::install(lua)
super::glue_realm::install(lua)?;
super::glue_select::install(lua)
}
+7 -1
View File
@@ -55,7 +55,7 @@ impl NoticeState {
}
/// `atoi`: an optional sign and the leading digits, 0 when there are none.
fn leading_int(s: &str) -> i64 {
pub(super) fn leading_int(s: &str) -> i64 {
let s = s.trim_start();
let (sign, digits) = match s.as_bytes().first() {
Some(b'-') => (-1, &s[1..]),
@@ -118,6 +118,10 @@ pub enum GlueRequest {
/// `ChangeRealm` reaching `ConnectToRealm` (`0x46b210`) with this realm, `realmName` already
/// set to it (`0x5ab7b0`).
ChangeRealm(String),
/// `GetCharacterListUpdate()` (`0x473270` → `0x4723d0` → `0x46b880`): the roster is already
/// cleared in the VM; the host takes screen state 3, the `CHAR_LIST_RETRIEVING` dialog and
/// the `CMSG_CHAR_ENUM`.
GetCharacterList,
}
/// The realm `GetServerName` (`0x46d280`) answers: the `realmName` CVar's text, and the
@@ -145,6 +149,8 @@ pub(crate) struct GlueCore {
pub(super) screen: String,
/// The realm list the realm verbs read ([`super::glue_realm`]).
pub(super) realms: super::glue_realm::GlueRealmList,
/// The character roster and the select verbs' cells ([`super::glue_select`]).
pub(super) roster: super::glue_select::GlueRoster,
}
/// `SetCurrentScreen`'s buffer, 0x40 bytes with its terminator (`0x46b860`, `SStrCopy`).
+1 -1
View File
@@ -127,7 +127,7 @@ pub(crate) struct GlueRealmList {
/// `[0xb41fb0]` and `[0xb41fb4]`, the load band's mean and scaled deviation.
mean: f32,
stddev: f32,
cells: GlueRealmCells,
pub(super) cells: GlueRealmCells,
/// `[svc+0x1ae4]`, the last `SMSG_CHAR_ENUM`'s character count.
characters: u16,
}
+771
View File
@@ -0,0 +1,771 @@
//! The character select's glue verbs (`SelectCharacter.cpp`, registrar `0x4736c0`, table
//! `0x838578`) and the roster they read: the record array (`[0xb42144]`, stride `0x120`) and its
//! count (`[0xb42140]`), the selection (`[0x83856c]`), the model frame (`[0xb42174]`), the facing
//! (`[0xb4217c]`) and the build counter (`[0xb42178]`).
//!
//! The host seeds the roster where the reference's list reply is walked (`0x4724d0` through
//! `0x5aabe0` and the appender `0x472300`) and tears it down where `0x472580` does; the verbs that
//! only read it answer here, and `SelectCharacter` fires `UPDATE_SELECTED_CHARACTER` as it returns,
//! as `0x472740` does. Indices are 1-based in Lua and 0-based inside, the boundary at `0x473470`.
//!
//! The 3-D side of the reference (`0x472950`'s model build into the scene, the attachment detaches
//! `0x4722c0`/`0x4722e0`, the camera reset `0x76cec0`) is the host's scene drive and lives outside
//! this module; what a build leaves behind for Lua to see is kept here: the facing reset and the
//! build counter that gates `SetCharacterSelectFacing`.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::{flag, number_arg, string_arg};
use super::glue_core::{leading_int, queue, GlueRequest};
use super::{Model, ScriptValue};
/// One character of the roster as `GetCharacterInfo` (`0x4732a0`) reads the record. The host
/// resolves the DBC-backed strings; an absent row is the empty string where the reference pushes
/// one (`0x882748`) and `None` where it pushes nil.
#[derive(Clone, Debug, PartialEq)]
pub struct GlueCharacter {
/// `+0x00`.
pub guid: u64,
/// `+0x08`.
pub name: String,
/// The `ChrRaces` row's `Name_Lang` for the race byte at `+0x100`; empty without a row.
pub race: String,
/// The `ChrClasses` row's `Name_Lang` for the class byte at `+0x101`; empty without a row.
pub class: String,
/// `+0x108`.
pub level: u8,
/// The `AreaTable` row's name for the id at `+0x3c`; `None` without a row.
pub zone: Option<String>,
/// The race row's file string (`ChrRaces+0x3c`); empty without a row.
pub file_string: String,
/// `+0x102`.
pub gender: u8,
/// `+0xfc`: bit `0x2000` is a ghost.
pub flags: u32,
/// Whether the race and gender have a character model (`0x5ec480` finds a row with a model
/// path): the build `0x472950` makes for a record only goes ahead with one.
pub model: bool,
}
/// The selection cells that outlive a glue VM: the reference keeps them in C globals, so a glue
/// rebuild finds them as they were. The host carries them from one glue VM to the next.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GlueSelectCells {
/// `[0x83856c]`, 0-based; -1 at load (the image's `.data`), written only by `0x472740`.
pub selected: i32,
/// `[0xb4217c]`, radians.
pub facing: f32,
/// `[0xb42178]` is nonzero: a select model has been built this process (`0x472fb2` is its one
/// writer), which opens `SetCharacterSelectFacing`'s gate and nothing closes.
pub built: bool,
}
impl Default for GlueSelectCells {
fn default() -> Self {
GlueSelectCells {
selected: -1,
facing: 0.0,
built: false,
}
}
}
/// A record of the array: the character and whether `+0x110`, the built composite, is set.
#[derive(Clone, Debug)]
struct Record {
character: GlueCharacter,
built: bool,
}
/// The roster and the select screen's cells the verbs read.
#[derive(Default)]
pub(crate) struct GlueRoster {
records: Vec<Record>,
cells: GlueSelectCells,
/// `[0xb42174]`: the name of the model frame `SetCharSelectModelFrame` found.
model_frame: Option<String>,
/// The file `SetCharSelectBackground` last gave the model frame (`0x472110`).
background: Option<String>,
}
/// `rad = deg · 0.017453292` (`0x7ffaac`, a float).
const DEG_TO_RAD: f32 = f32::from_bits(0x3c8e_fa35);
/// `deg = rad · 57.29578` (`0x803a78`, a float).
const RAD_TO_DEG: f32 = f32::from_bits(0x4265_2ee1);
impl GlueRoster {
/// `0x473700` on every record and the count to 0 (`0x4723ff`, `0x4725af`).
fn clear(&mut self) {
self.records.clear();
}
/// `0x472740`: a negative index reads 0, so does one past the end, then the cell is stored and
/// `0x472950` runs. Returns the event's argument, the stored index plus one, re-read from the
/// cell (`0x47275e`), not the caller's.
///
/// The build, for what Lua sees (`0x472950`): an index off the roster exits at `0x472971`
/// before touching anything; otherwise the facing is zeroed (`0x472a61`, ahead of the built
/// test and not gated), and a record with no composite yet that has a model gets one, which is
/// the one place the build counter rises (`0x472fb2`).
fn select(&mut self, index: i32) -> i32 {
let clamped = if index < 0 { 0 } else { index };
let stored = if (clamped as usize) < self.records.len() {
clamped
} else {
0
};
self.cells.selected = stored;
if let Some(record) = self.records.get_mut(stored as usize) {
self.cells.facing = 0.0;
if !record.built && record.character.model {
record.built = true;
self.cells.built = true;
}
}
stored + 1
}
/// `SetCharacterSelectFacing`'s body (`0x4730e0`): the store is inside the build counter's
/// gate (`0x4730e6`).
fn set_facing(&mut self, radians: f32) {
if self.cells.built {
self.cells.facing = radians;
}
}
}
impl super::UiScript {
/// The selection cells as the VM holds them.
pub fn glue_select_cells(&self) -> GlueSelectCells {
self.model_ref().glue.roster.cells
}
/// Carry the selection cells over from the last glue VM.
pub fn set_glue_select_cells(&mut self, cells: GlueSelectCells) {
self.model_mut().glue.roster.cells = cells;
}
/// The model frame's background file, as `SetCharSelectBackground` last recorded it: what the
/// scene drive loads into the select scene.
pub fn glue_select_background(&self) -> Option<String> {
self.model_ref().glue.roster.background.clone()
}
/// The selected character's guid, none off the roster.
pub fn glue_selected_guid(&self) -> Option<u64> {
let model = self.model_ref();
let roster = &model.glue.roster;
usize::try_from(roster.cells.selected)
.ok()
.and_then(|i| roster.records.get(i))
.map(|r| r.character.guid)
}
/// The roster's guids in order.
pub fn glue_roster_guids(&self) -> Vec<u64> {
self.model_ref()
.glue
.roster
.records
.iter()
.map(|r| r.character.guid)
.collect()
}
/// The roster teardown (`0x472580`): the records freed, the count 0, the selection reset
/// through `0x472740(-1)`, which clamps to 0 and fires `UPDATE_SELECTED_CHARACTER` with 1 on
/// the empty roster whatever it held, then `CHARACTER_LIST_UPDATE`. The reset of the model
/// frame's camera (`0x76cec0`) is the scene drive's.
pub fn teardown_glue_roster(&mut self) {
self.model_mut().glue.roster.clear();
self.select_glue_character(-1);
self.fire_event("CHARACTER_LIST_UPDATE", Vec::new());
}
/// The roster load (`0x4724d0`), run when the character list reply completes: the old records
/// freed, `roster` appended in order (`0x472300`), then an empty roster selects -1 and, with
/// no realm chosen this session (`[0x837e9c]` = -1), skips `CHARACTER_LIST_UPDATE` and asks
/// the host to raise the realm list (the return value, `RequestRealmList(1)` at `0x47254b`);
/// otherwise the cell is set to 0 (`0x472556`), the `lastCharacterIndex` CVar's integer is
/// selected (`0x472550`-`0x472563`) and `CHARACTER_LIST_UPDATE` fires.
pub fn load_glue_roster(&mut self, roster: Vec<GlueCharacter>) -> bool {
let last = self
.cvar("lastCharacterIndex")
.map_or(0, |v| i32::try_from(leading_int(&v)).unwrap_or(0));
let count = roster.len();
{
let mut model = self.model_mut();
model.glue.roster.records = roster
.into_iter()
.map(|character| Record {
character,
built: false,
})
.collect();
}
if count == 0 {
self.select_glue_character(-1);
if self.model_ref().glue.realms.cells.chosen.is_none() {
return true;
}
} else {
self.model_mut().glue.realms.cells.chosen = Some(0);
self.select_glue_character(last);
}
self.fire_event("CHARACTER_LIST_UPDATE", Vec::new());
false
}
/// `0x472740` from the host: the selection stored and `UPDATE_SELECTED_CHARACTER` fired.
fn select_glue_character(&mut self, index: i32) {
let arg = self.model_mut().glue.roster.select(index);
self.fire_event(
"UPDATE_SELECTED_CHARACTER",
vec![ScriptValue::Number(f64::from(arg))],
);
}
}
fn model(lua: &Lua) -> mlua::AppDataRefMut<'_, Model> {
lua.app_data_mut::<Model>().expect("model app_data")
}
fn arg(args: &MultiValue, i: usize) -> Value {
args.get(i).cloned().unwrap_or(Value::Nil)
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// `0x4731c0`: a string or number (`0x6f3510`) or the usage error (`0x838720`); the global of
// that name castable to the model type (`0x76c760`) becomes the frame (`0x472100`), else the
// call changes nothing.
g.set(
"SetCharSelectModelFrame",
lua.create_function(|lua, args: MultiValue| {
let name = string_arg(
lua,
arg(&args, 0),
"Usage: SetCharSelectModelFrame(\"frameName\")",
)?;
let mut m = model(lua);
let is_model = m
.arena
.lookup(&name)
.and_then(|h| m.arena.frame(h))
.is_some_and(|f| f.kind == crate::widget::FrameKind::Model);
if is_model {
m.glue.roster.model_frame = Some(name);
}
Ok(())
})?,
)?;
// `0x473230`: the file (`0x6f3510`, else `0x83874c`) into `0x472110`, which needs the frame
// set and a non-empty name and hands it to the frame's SetModel (vtable `+0x94`).
//
// Only the path is recorded: the scene drive loads it into the select scene, and the frame's
// own model state, which benilla's widget renders nowhere on the glue, stays as it was.
g.set(
"SetCharSelectBackground",
lua.create_function(|lua, args: MultiValue| {
let file = string_arg(
lua,
arg(&args, 0),
"Usage: SetCharSelectBackground(\"filename\")",
)?;
let mut m = model(lua);
if m.glue.roster.model_frame.is_some() && !file.is_empty() {
m.glue.roster.background = Some(file);
}
Ok(())
})?,
)?;
// `0x473270` → `0x4723d0`: every record freed and the count 0 (`0x4723ff`) in the call, so a
// `GetNumCharacters` straight after reads 0; then `0x46b880`, the host's: state 3, the
// dialog and the list request.
g.set(
"GetCharacterListUpdate",
lua.create_function(|lua, _: MultiValue| {
model(lua).glue.roster.clear();
queue(lua, GlueRequest::GetCharacterList);
Ok(())
})?,
)?;
// `0x473280`: the count, a number.
g.set(
"GetNumCharacters",
lua.create_function(|lua, _: MultiValue| Ok(model(lua).glue.roster.records.len() as f64))?,
)?;
// `0x4732a0`: one number (`0x6f34d0`) or `Usage: GetCharacterInfo(index)` (`0x838778`), then
// eight values on every returning path; off the roster the tuple is nil, nil, nil, 0, nil,
// nil, 0, nil (`0x47341e`-`0x473459`).
g.set(
"GetCharacterInfo",
lua.create_function(|lua, args: MultiValue| {
let index =
number_arg(lua, arg(&args, 0), "Usage: GetCharacterInfo(index)")?.wrapping_sub(1);
let m = model(lua);
let Some(record) = usize::try_from(index)
.ok()
.and_then(|i| m.glue.roster.records.get(i))
else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Nil,
Value::Number(0.0),
Value::Nil,
Value::Nil,
Value::Number(0.0),
Value::Nil,
]));
};
let c = &record.character;
let text = |s: &str| lua.create_string(s).map(Value::String);
Ok(MultiValue::from_vec(vec![
text(&c.name)?,
text(&c.race)?,
text(&c.class)?,
Value::Number(f64::from(c.level)),
match &c.zone {
Some(zone) => text(zone)?,
None => Value::Nil,
},
text(&c.file_string)?,
Value::Number(f64::from(c.gender)),
flag(c.flags & 0x2000 != 0),
]))
})?,
)?;
// `0x473470`: one number or `Usage: SelectCharacter(index)` (`0x838798`), decremented and
// clamped to -1 outside `[0, count)` (`0x4734a4`-`0x4734b2`), into `0x472740`, which stores
// and fires `UPDATE_SELECTED_CHARACTER` whatever was selected before: the gate against a
// repeat is the Lua caller's. The binding's clamp and `0x472740`'s own land on the same 0, so
// only the latter is kept.
g.set(
"SelectCharacter",
lua.create_function(|lua, args: MultiValue| {
let index =
number_arg(lua, arg(&args, 0), "Usage: SelectCharacter(index)")?.wrapping_sub(1);
let selected = model(lua).glue.roster.select(index);
super::tick::fire_event_into(
lua,
"UPDATE_SELECTED_CHARACTER",
vec![ScriptValue::Number(f64::from(selected))],
);
Ok(())
})?,
)?;
// `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
// there is nothing for the call to do on the Lua side.
g.set(
"UpdateSelectionCustomizationScene",
lua.create_function(|_, _: MultiValue| Ok(()))?,
)?;
// `0x473650`: the cell (a float) times 57.29578, pushed as a double.
g.set(
"GetCharacterSelectFacing",
lua.create_function(|lua, _: MultiValue| {
let radians = model(lua).glue.roster.cells.facing;
Ok(f64::from(radians) * f64::from(RAD_TO_DEG))
})?,
)?;
// `0x473670`: one number or `Usage: SetCharacterSelectFacing(degrees)` (`0x838814`), times
// 0.017453292 and stored as a float, with no wrap or clamp, once a model has been built. It
// returns one value (`0x4736ae`, `mov eax,1`) with nothing pushed since the arguments, so the
// value is the top of the stack: the last argument, the degrees as the caller wrote them.
g.set(
"SetCharacterSelectFacing",
lua.create_function(|lua, args: MultiValue| {
let degrees = match lua.coerce_number(arg(&args, 0))? {
Some(n) => n,
None => {
return Err(mlua::Error::RuntimeError(
"Usage: SetCharacterSelectFacing(degrees)".into(),
))
}
};
let radians = (degrees * f64::from(DEG_TO_RAD)) as f32;
model(lua).glue.roster.set_facing(radians);
Ok(args.back().cloned().unwrap_or(Value::Nil))
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
fn character(guid: u64, name: &str) -> GlueCharacter {
GlueCharacter {
guid,
name: name.into(),
race: "Night Elf".into(),
class: "Druid".into(),
level: 12,
zone: Some("Teldrassil".into()),
file_string: "NightElf".into(),
gender: 1,
flags: 0,
model: true,
}
}
/// A VM with the three characters loaded and `FIRED` recording the select events.
fn vm(roster: Vec<GlueCharacter>) -> UiScript {
let mut s = UiScript::new_glue().unwrap();
s.register_cvars([("lastCharacterIndex", "0")]);
s.run(
"FIRED = {} local f = CreateFrame('Frame', 'Listener') \
f:SetScript('OnEvent', function() \
table.insert(FIRED, event .. ' ' .. tostring(arg1)) end) \
f:RegisterEvent('UPDATE_SELECTED_CHARACTER') \
f:RegisterEvent('CHARACTER_LIST_UPDATE')",
)
.unwrap();
let _ = s.load_glue_roster(roster);
s.run("FIRED = {}").unwrap();
s
}
fn three() -> Vec<GlueCharacter> {
vec![
character(1, "Alpha"),
character(2, "Bravo"),
character(3, "Charlie"),
]
}
/// A chunk returning how many values `call` returns.
fn count(call: &str) -> String {
format!("local function n(...) return arg.n end return n({call})")
}
fn fired(s: &UiScript) -> Vec<String> {
s.eval::<Vec<String>>("return FIRED").unwrap()
}
#[test]
fn the_count_and_the_eight_values_of_a_character() {
let mut roster = three();
roster[1].zone = None;
roster[1].flags = 0x2000;
roster[1].gender = 0;
let s = vm(roster);
assert_eq!(s.eval::<f64>("return GetNumCharacters()").unwrap(), 3.0);
assert_eq!(
s.eval::<(
String,
String,
String,
f64,
String,
String,
f64,
Option<i64>
)>("return GetCharacterInfo(1)")
.unwrap(),
(
"Alpha".into(),
"Night Elf".into(),
"Druid".into(),
12.0,
"Teldrassil".into(),
"NightElf".into(),
1.0,
None
)
);
// No zone row is nil in the fifth place; a ghost is 1 in the last.
assert_eq!(
s.eval::<(String, Option<String>, f64, Option<i64>)>(
"local n, _, _, _, zone, _, gender, ghost = GetCharacterInfo(2) \
return n, zone, gender, ghost"
)
.unwrap(),
("Bravo".into(), None, 0.0, Some(1))
);
assert_eq!(s.eval::<f64>(&count("GetCharacterInfo(3)")).unwrap(), 8.0);
}
/// Off the roster the tuple is nil, nil, nil, 0, nil, nil, 0, nil, and still eight values
/// (`0x47341e`).
#[test]
fn an_index_off_the_roster_answers_the_eight_tuple() {
let s = vm(three());
for call in [
"GetCharacterInfo(0)",
"GetCharacterInfo(4)",
"GetCharacterInfo(-7)",
] {
assert_eq!(
s.eval::<(
Option<String>,
Option<String>,
Option<String>,
f64,
Option<String>,
Option<String>,
f64,
Option<i64>
)>(&format!("return {call}"))
.unwrap(),
(None, None, None, 0.0, None, None, 0.0, None),
"{call}"
);
assert_eq!(s.eval::<f64>(&count(call)).unwrap(), 8.0);
}
// A numeric string passes `is-number`; 2.9 truncates to 2.
assert_eq!(
s.eval::<String>("return (GetCharacterInfo('2'))").unwrap(),
"Bravo"
);
assert_eq!(
s.eval::<String>("return (GetCharacterInfo(2.9))").unwrap(),
"Bravo"
);
for call in ["GetCharacterInfo()", "GetCharacterInfo('x')"] {
let err = s.run(call).unwrap_err().to_string();
assert!(err.contains("Usage: GetCharacterInfo(index)"), "{err}");
}
}
/// `0x472740` clamps a negative or a past-the-end index to 0 and fires with the STORED index
/// plus one, whatever the caller passed or the cell held: a bad index fires 1 (`0x47275e`).
#[test]
fn select_character_clamps_and_fires_the_stored_index() {
let s = vm(three());
for (call, want) in [
("SelectCharacter(2)", "UPDATE_SELECTED_CHARACTER 2"),
("SelectCharacter(3)", "UPDATE_SELECTED_CHARACTER 3"),
("SelectCharacter(0)", "UPDATE_SELECTED_CHARACTER 1"),
("SelectCharacter(-5)", "UPDATE_SELECTED_CHARACTER 1"),
("SelectCharacter(4)", "UPDATE_SELECTED_CHARACTER 1"),
("SelectCharacter(1.9)", "UPDATE_SELECTED_CHARACTER 1"),
] {
s.run("FIRED = {}").unwrap();
s.run(call).unwrap();
assert_eq!(fired(&s), [want], "{call}");
}
// No "already selected" test: the same index fires again.
s.run("FIRED = {} SelectCharacter(2) SelectCharacter(2)")
.unwrap();
assert_eq!(fired(&s), ["UPDATE_SELECTED_CHARACTER 2"; 2]);
assert_eq!(s.glue_selected_guid(), Some(2));
let err = s.run("SelectCharacter()").unwrap_err().to_string();
assert!(err.contains("Usage: SelectCharacter(index)"), "{err}");
assert!(fired(&s).len() == 2, "a usage error fires nothing");
}
/// An empty roster still fires, with 1, and stores 0.
#[test]
fn select_character_on_an_empty_roster_fires_one() {
let s = vm(Vec::new());
s.run("SelectCharacter(1)").unwrap();
assert_eq!(fired(&s), ["UPDATE_SELECTED_CHARACTER 1"]);
assert_eq!(s.glue_select_cells().selected, 0);
assert_eq!(s.glue_selected_guid(), None);
}
/// The facing store is inside the build counter's gate (`0x4730e6`): nothing before a model
/// has been built, and then degrees in, degrees out through the float cell, unwrapped.
#[test]
fn the_facing_stores_only_once_a_model_has_been_built() {
let facing = |s: &UiScript| s.eval::<f64>("return GetCharacterSelectFacing()").unwrap();
// A fresh process: nothing built, the gate shut.
let s = UiScript::new_glue().unwrap();
s.run("SetCharacterSelectFacing(90)").unwrap();
assert_eq!(facing(&s), 0.0);
// No record builds without a model, so selecting one leaves the gate shut.
let mut roster = three();
for c in &mut roster {
c.model = false;
}
let s = vm(roster);
s.run("SelectCharacter(1) SetCharacterSelectFacing(90)")
.unwrap();
assert_eq!(facing(&s), 0.0);
assert!(!s.glue_select_cells().built);
// The roster load selects the last character, which builds its model.
let s = vm(three());
assert!(s.glue_select_cells().built);
s.run("SetCharacterSelectFacing(90)").unwrap();
let got = facing(&s);
assert!((got - 90.0).abs() < 1e-4, "{got}");
// One value back, the argument itself, as the caller wrote it (`0x4736ae`).
assert_eq!(
s.eval::<(f64, String, f64)>(
"local n = function(...) return arg.n end \
return n(SetCharacterSelectFacing(5)), SetCharacterSelectFacing('45'), \
SetCharacterSelectFacing(1, 7)"
)
.unwrap(),
(1.0, "45".to_string(), 7.0)
);
// No wrap or clamp.
s.run("SetCharacterSelectFacing(-725.5)").unwrap();
let got = facing(&s);
assert!((got + 725.5).abs() < 1e-3, "{got}");
// Every select zeroes it first (`0x472a61`), already-built records included.
s.run("SelectCharacter(1)").unwrap();
assert_eq!(facing(&s), 0.0);
// The gate stays open: a teardown does not close it.
let mut s = s;
s.teardown_glue_roster();
s.run("SetCharacterSelectFacing(10)").unwrap();
assert!((facing(&s) - 10.0).abs() < 1e-4);
let err = s
.run("SetCharacterSelectFacing('x')")
.unwrap_err()
.to_string();
assert!(
err.contains("Usage: SetCharacterSelectFacing(degrees)"),
"{err}"
);
}
/// An index off the roster exits before the facing reset (`0x472971`), so an empty roster
/// keeps the facing the player left.
#[test]
fn an_empty_select_keeps_the_facing() {
let mut s = UiScript::new_glue().unwrap();
s.set_glue_select_cells(GlueSelectCells {
selected: 0,
facing: 1.5,
built: true,
});
s.run("SelectCharacter(1)").unwrap();
assert_eq!(s.glue_select_cells().facing, 1.5);
}
#[test]
fn the_model_frame_and_the_background() {
let s = vm(three());
s.run("CreateFrame('Frame', 'Plain') CreateFrame('ModelFFX', 'Scene')")
.unwrap();
// No frame set yet: the call records nothing.
s.run("SetCharSelectBackground('a.mdx')").unwrap();
assert_eq!(s.glue_select_background(), None);
// A frame that is not a model is not taken, a missing name is not either.
s.run("SetCharSelectModelFrame('Plain') SetCharSelectModelFrame('Nope')")
.unwrap();
s.run("SetCharSelectBackground('a.mdx')").unwrap();
assert_eq!(s.glue_select_background(), None);
s.run("SetCharSelectModelFrame('Scene') SetCharSelectBackground('a.mdx')")
.unwrap();
assert_eq!(s.glue_select_background().as_deref(), Some("a.mdx"));
// An empty name is ignored (`0x472123`).
s.run("SetCharSelectBackground('')").unwrap();
assert_eq!(s.glue_select_background().as_deref(), Some("a.mdx"));
for (call, usage) in [
(
"SetCharSelectModelFrame()",
"Usage: SetCharSelectModelFrame(\"frameName\")",
),
(
"SetCharSelectBackground({})",
"Usage: SetCharSelectBackground(\"filename\")",
),
] {
let err = s.run(call).unwrap_err().to_string();
assert!(err.contains(usage), "{err}");
}
s.run("UpdateSelectionCustomizationScene()").unwrap();
}
/// `0x4723d0` clears at the call: the count reads 0 straight after, and the host is asked for
/// the list.
#[test]
fn get_character_list_update_clears_and_asks_the_host() {
let mut s = vm(three());
s.take_glue_requests();
s.run("GetCharacterListUpdate()").unwrap();
assert_eq!(s.eval::<f64>("return GetNumCharacters()").unwrap(), 0.0);
assert!(matches!(
s.take_glue_requests().as_slice(),
[GlueRequest::GetCharacterList]
));
assert!(fired(&s).is_empty(), "the clear fires nothing");
}
/// `0x472580`: whatever the roster held, an empty one is selected through the clamp, which
/// fires with 1, then `CHARACTER_LIST_UPDATE`.
#[test]
fn the_teardown_fires_select_one_then_the_list_update() {
let mut s = vm(three());
s.run("SelectCharacter(3)").unwrap();
s.run("FIRED = {}").unwrap();
s.teardown_glue_roster();
assert_eq!(
fired(&s),
["UPDATE_SELECTED_CHARACTER 1", "CHARACTER_LIST_UPDATE nil"]
);
assert_eq!(s.eval::<f64>("return GetNumCharacters()").unwrap(), 0.0);
assert_eq!(s.glue_select_cells().selected, 0);
}
/// `0x4724d0` with characters: the realm marked chosen, `lastCharacterIndex` selected (0-based
/// into the roster, one more in the event), then the list update; out of range reads 0.
#[test]
fn the_load_selects_the_last_character_then_updates_the_list() {
let mut s = UiScript::new_glue().unwrap();
s.register_cvars([("lastCharacterIndex", "0")]);
s.run(
"FIRED = {} local f = CreateFrame('Frame', 'Listener') \
f:SetScript('OnEvent', function() table.insert(FIRED, event .. ' ' .. tostring(arg1)) end) \
f:RegisterEvent('UPDATE_SELECTED_CHARACTER') f:RegisterEvent('CHARACTER_LIST_UPDATE')",
)
.unwrap();
s.set_cvar_engine("lastCharacterIndex", "2");
assert!(!s.load_glue_roster(three()));
assert_eq!(
fired(&s),
["UPDATE_SELECTED_CHARACTER 3", "CHARACTER_LIST_UPDATE nil"]
);
assert_eq!(s.glue_realm_cells().chosen, Some(0));
assert_eq!(s.glue_selected_guid(), Some(3));
s.run("FIRED = {}").unwrap();
s.set_cvar_engine("lastCharacterIndex", "9");
assert!(!s.load_glue_roster(three()));
assert_eq!(fired(&s)[0], "UPDATE_SELECTED_CHARACTER 1");
s.run("FIRED = {}").unwrap();
s.set_cvar_engine("lastCharacterIndex", "-3");
assert!(!s.load_glue_roster(three()));
assert_eq!(fired(&s)[0], "UPDATE_SELECTED_CHARACTER 1");
}
/// `0x4724d0` with none: -1 is selected (the event carries 1), then with no realm chosen the
/// load asks for the realm list and fires no list update; with one it fires the update.
#[test]
fn an_empty_load_asks_for_the_realm_list_unless_one_is_chosen() {
let mut s = vm(Vec::new());
assert!(s.load_glue_roster(Vec::new()));
assert_eq!(fired(&s), ["UPDATE_SELECTED_CHARACTER 1"]);
s.run("FIRED = {}").unwrap();
let mut cells = s.glue_realm_cells();
cells.chosen = Some(4);
s.set_glue_realm_cells(cells);
assert!(!s.load_glue_roster(Vec::new()));
assert_eq!(
fired(&s),
["UPDATE_SELECTED_CHARACTER 1", "CHARACTER_LIST_UPDATE nil"]
);
assert_eq!(s.glue_realm_cells().chosen, Some(4), "a choice stands");
}
}
+2
View File
@@ -67,12 +67,14 @@ mod frame_enum;
mod glue;
mod glue_core;
mod glue_realm;
mod glue_select;
pub use glue::{VmKind, GLUE_EVENTS};
pub use glue_core::{GlueCredentials, GlueNotice, GlueRequest, GlueServer, NoticeState};
pub use glue_realm::{
realm_load_classify, realm_load_stats, GlueRealm, GlueRealmCategory, GlueRealmCells,
GlueRealmType, RealmSortKey,
};
pub use glue_select::{GlueCharacter, GlueSelectCells};
mod gm_ticket;
mod gossip;
mod guild;
@@ -178,20 +178,8 @@ const NOT_YET: &[(&str, &[&str])] = &[
],
),
(
"character select (0x4736c0, table 0x838578)",
&[
"DeleteCharacter",
"GetCharacterInfo",
"GetCharacterListUpdate",
"GetCharacterSelectFacing",
"GetNumCharacters",
"RenameCharacter",
"SelectCharacter",
"SetCharSelectBackground",
"SetCharSelectModelFrame",
"SetCharacterSelectFacing",
"UpdateSelectionCustomizationScene",
],
"character select (0x4736c0, table 0x838578): the delete and the rename",
&["DeleteCharacter", "RenameCharacter"],
),
(
"character create (0x471a20, table 0x8380f8)",
+1 -1
View File
@@ -454,7 +454,7 @@
- `WOW_RES_CENSUS` — benilla-app/src/perf/census.rs
- `WOW_RESIZE` — benilla-app/src/capture/mod.rs
- `WOW_REVEAL` — benilla-app/src/capture/probes/reveal.rs, benilla-app/src/dev.rs
- `WOW_RIG` — benilla-app/src/capture/probe_rig.rs, benilla-app/src/dev.rs, benilla-app/src/local_state.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/probe_shield.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs, benilla-world/src/bgwin.rs
- `WOW_RIG` — benilla-app/src/capture/mod.rs, benilla-app/src/capture/probe_rig.rs, benilla-app/src/dev.rs, benilla-app/src/local_state.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/probe_shield.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs, benilla-world/src/bgwin.rs
- `WOW_RIG_CENSUS` — benilla-world/src/rig_palette.rs
- `WOW_RIG_COST` — benilla-world/src/rig_palette.rs
- `WOW_RIG_SKIN` — benilla-assets/src/materials.rs