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:
co-authored by
Claude Sonnet 5.5
parent
0f505a2ebf
commit
24c7cab983
@@ -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) {
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`,
|
||||
|
||||
@@ -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
@@ -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!();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user