World-enter queries wait for the player's create, as 1.12.1 sends them (#801)

At login benilla sent `CMSG_SET_ACTIVE_MOVER`, `CMSG_QUERY_TIME`, `MSG_QUERY_NEXT_MAIL_TIME` and `CMSG_BATTLEFIELD_STATUS` right after `CMSG_PLAYER_LOGIN`, before the server had seated the player. cmangos drops them; after a worldport or `/reload` the time and mail queries never went again.

In 1.12.1 all four follow the local player's own create (`0x5dea50`): `SetActiveMover` (`0x6006e0`), then the world-enter cascade (`0x5deb60 call 0x4908c0`), whose inits send `0x1CE`, `0x284` and `0x2D3` in that order. A cross-map worldport re-creates the player and takes the same route; `/reload` re-enters the cascade alone (`0x490168`).

The four now ride the self create, in that order, and `/reload` re-sends the three. Tests cover login, the create, a worldport, the schedule order and the reload; a live trace shows the order at login, after `.tele` and on `ReloadUI()`.

Fixes #789
Fixes #100

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Elias Miettinen <eliaspmiettinen@gmail.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
elipp
2026-10-05 22:28:59 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Elias Miettinen Sam
parent bb84f1f1fb
commit ffdc206ac2
9 changed files with 267 additions and 32 deletions
+1 -1
View File
@@ -1120,7 +1120,7 @@ pub(crate) mod schedule_tests {
("ui_bank/mod.rs", "feed_bank", Because::PlayerRoundTrip,
"`BankErrors` answers a `BuyBankSlot` click; the OPENED edge rides `VmMemo`s"),
("ui_battlefield.rs", "feed_battlefield", Because::SelfHealing,
"`reset_on_world_enter` runs before it, clears the session and re-sends `BattlefieldStatusRequest`"),
"`reset_on_world_enter` runs before it at each world-enter cascade, clears the session and re-sends `BattlefieldStatusRequest`"),
("ui_binder.rs", "feed_binder", Because::PlayerRoundTrip,
"`SMSG_BINDER_CONFIRM` only answers the innkeeper's gossip line"),
("ui_char.rs", "feed_char", Because::MemoLatched,
+30 -12
View File
@@ -29,6 +29,7 @@ mod session;
mod world;
pub(crate) use apply::apply_net_updates;
pub(crate) use apply::enter_world_on_self_create;
use apply::tag_self_player;
pub(crate) use handlers::NetHandlerApp;
@@ -120,6 +121,7 @@ impl Plugin for NetPlugin {
.add_message::<CharActionResultMessage>()
.add_message::<CharacterLoginFailedMessage>()
.add_message::<EnteredWorldMessage>()
.add_message::<WorldEnterCascadeMessage>()
.add_message::<CinematicTriggeredMessage>()
.add_message::<ServerSaidMessage>()
.add_message::<LoggedOutMessage>()
@@ -132,6 +134,7 @@ impl Plugin for NetPlugin {
(
apply_net_updates,
tag_self_player,
enter_world_on_self_create,
sample_splines,
// Swim state from the water at the feet (the wire never carries it for
// creatures), before the clamp so a swimmer is exempt the same frame.
@@ -151,8 +154,14 @@ impl Plugin for NetPlugin {
// `apply_net_updates` and before `drive_display_facing`, so the `"npc"` token is
// current when a window's show handler reads it.
)
// Not part of the movement chain above: one send on the world-enter message.
.add_systems(Update, send_query_time.in_set(WorldStage::Net))
// Not part of the movement chain above: the cascade's time query, after the self create
// has claimed the mover.
.add_systems(
Update,
send_query_time
.in_set(WorldStage::Net)
.after(enter_world_on_self_create),
)
.add_systems(Update, population_pulse.in_set(WorldStage::Net));
}
}
@@ -553,19 +562,22 @@ impl ServerWallClock {
/// `[0xbb749c]`, armed `now + 0xe10` at `0x4def11`).
const RESYNC_AFTER: Duration = Duration::from_secs(3600);
/// Ask for the server's wall clock on entering the world (login, worldport, instance transfer)
/// and hourly after, which tracks a server re-clocked under us.
fn send_query_time(
mut entered: MessageReader<EnteredWorldMessage>,
/// Ask for the server's wall clock at each world-enter cascade (the quest-log init `0x4de430`,
/// called at `0x4909a1`, sends it at `0x4de45b`) and hourly after, which tracks a server
/// re-clocked under us.
pub(crate) fn send_query_time(
mut cascades: MessageReader<WorldEnterCascadeMessage>,
commands: Res<NetCommands>,
clock: Res<ServerWallClock>,
status: Res<NetStatus>,
mut asked_at: Local<Option<Instant>>,
mut seated: Local<bool>,
) {
let entering = entered.read().next().is_some();
// Only while connected; the world-enter send covers a reconnect.
let due =
status.connected && clock.stale() && asked_at.is_none_or(|t| t.elapsed() >= RESYNC_AFTER);
let entering = cascades.read().next().is_some();
// `connected` rises at `CMSG_PLAYER_LOGIN`, before the server seats us, so the resync waits
// for the first cascade; a logout or a disconnect clears `connected` and so the wait re-arms.
*seated = status.connected && (*seated || entering);
let due = *seated && clock.stale() && asked_at.is_none_or(|t| t.elapsed() >= RESYNC_AFTER);
if entering || due {
*asked_at = Some(Instant::now());
let _ = commands.0.send(ClientCommand::QueryTime);
@@ -812,8 +824,8 @@ pub(crate) enum ClientCommand {
guid: u64,
lag_ms: u32,
},
/// `CMSG_SET_ACTIVE_MOVER`: at login and on possession; the server drops `MSG_MOVE_*` for an
/// unconfirmed mover.
/// `CMSG_SET_ACTIVE_MOVER`: at our own player's create and on possession; the server drops
/// `MSG_MOVE_*` for an unconfirmed mover.
SetActiveMover {
guid: u64,
},
@@ -2004,6 +2016,12 @@ pub(crate) struct EnteredWorldMessage {
pub(crate) tutorial_flags: Option<Vec<u8>>,
}
/// The reference's world-enter cascade (`0x4908c0`) ran: our own player's create, at login and
/// every cross-map worldport ([`enter_world_on_self_create`]), or a `/reload`. Its sends wait for
/// this, not [`EnteredWorldMessage`], which fires before the server has seated the player.
#[derive(Message)]
pub(crate) struct WorldEnterCascadeMessage;
/// `SMSG_ADDON_INFO`: the addons the server hid from the Lua index space, or `None` if it did
/// not answer. A resource, since it must exist before the world-entry UI load runs any addon;
/// rewritten on every login.
+31 -3
View File
@@ -1,14 +1,19 @@
//! The per-frame wire→ECS bridge systems: [`apply_net_updates`] drains the inbound
//! [`SessionEvent`] channel through the handler table, and [`tag_self_player`] marks our own
//! streamed entity. Nothing is applied here: every packet's handler lives with its subsystem.
//! [`SessionEvent`] channel through the handler table, [`tag_self_player`] marks our own
//! streamed entity, and [`enter_world_on_self_create`] runs what its create starts. Nothing is
//! applied here: every packet's handler lives with its subsystem.
use benilla_protocol::SessionEvent;
use bevy::prelude::*;
use super::{Guid, NetEvents, SelfGuid, SelfPlayer};
use super::{
ClientCommand, Guid, NetCommands, NetEvents, SelfGuid, SelfPlayer, WorldEnterCascadeMessage,
};
#[cfg(test)]
mod seam_tests;
#[cfg(test)]
mod world_enter_tests;
// ── The per-frame bridge systems ─────────────────────────────────────────────────────────────────
@@ -38,3 +43,26 @@ pub(super) fn tag_self_player(
}
}
}
/// Our own player's create, the reference's `0x5dea50` (reached `0x465dbc` → `0x5debe0` →
/// `0x5dec7b`): `SetActiveMover` (`0x6006e0`, sending `CMSG_SET_ACTIVE_MOVER` at `0x6007ae`), then
/// the world-enter cascade (`0x5deb60 call 0x4908c0`). `SMSG_LOGIN_VERIFY_WORLD` and
/// `SMSG_NEW_WORLD` purge the streamed world (`session::worldport`), so a login, a reconnect and
/// every cross-map worldport create us afresh and tag us again; a same-map teleport creates
/// nothing. The server has seated the player by then, so nothing sent here is dropped.
pub(crate) fn enter_world_on_self_create(
created: Query<&Guid, Added<SelfPlayer>>,
net: Res<NetCommands>,
mut cascades: MessageWriter<WorldEnterCascadeMessage>,
) {
for guid in &created {
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"mvr",
&format!("SET_ACTIVE_MOVER guid={:#x} (self create)", guid.0),
);
}
let _ = net.0.send(ClientCommand::SetActiveMover { guid: guid.0 });
cascades.write(WorldEnterCascadeMessage);
}
}
@@ -0,0 +1,148 @@
//! The world-enter sends wait for our own player's create, as the reference's do (`0x5dea50`):
//! none goes out on the login edge alone, which fires before the server seats the player; the
//! create sends `CMSG_SET_ACTIVE_MOVER`, then the cascade's `CMSG_QUERY_TIME`,
//! `MSG_QUERY_NEXT_MAIL_TIME` and `CMSG_BATTLEFIELD_STATUS`; and a worldport's fresh create sends
//! all four again.
use bevy::prelude::*;
use crossbeam_channel::Receiver;
use crate::net::{
ClientCommand, EnteredWorldMessage, Guid, NetCommands, NetStatus, SelfGuid, ServerWallClock,
WorldEnterCascadeMessage,
};
const ME: u64 = 0x0000_0000_0000_0042;
/// The self-create edge and the cascade's three senders, in the order the client schedules them
/// (`the_client_schedules_the_sends_in_the_reference_order` pins that), logged in as [`ME`].
fn app() -> (App, Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(NetCommands(tx))
.insert_resource(SelfGuid(Some(ME)))
.insert_resource(NetStatus {
connected: true,
last_reason: None,
})
.init_resource::<ServerWallClock>()
.init_resource::<crate::ui_mail::MailPending>()
.init_resource::<crate::ui_battlefield::Battlefield>()
.add_message::<EnteredWorldMessage>()
.add_message::<WorldEnterCascadeMessage>()
.add_systems(
Update,
(
super::tag_self_player,
super::enter_world_on_self_create,
crate::net::send_query_time,
crate::ui_mail::send_query_next_mail_time_on_enter,
crate::ui_battlefield::reset_on_world_enter,
)
.chain(),
);
(app, rx)
}
/// The login edge `connected` writes at `CMSG_PLAYER_LOGIN`.
fn log_in(app: &mut App) {
app.world_mut().write_message(EnteredWorldMessage {
billing_time_rested: 0,
tutorial_flags: None,
});
}
/// What `object_create` spawns for our own create block.
fn self_create(app: &mut App) -> Entity {
app.world_mut().spawn(Guid(ME)).id()
}
fn sent(rx: &Receiver<ClientCommand>) -> Vec<String> {
rx.try_iter()
.map(|c| match c {
ClientCommand::SetActiveMover { guid } => format!("SetActiveMover {guid:#x}"),
other => format!("{other:?}"),
})
.collect()
}
fn the_four() -> Vec<String> {
vec![
format!("SetActiveMover {ME:#x}"),
"QueryTime".into(),
"QueryNextMailTime".into(),
"BattlefieldStatusRequest".into(),
]
}
/// cmangos drops each of the four as "the player has not logged in yet" when it beats the load,
/// so the login edge sends none, and the hourly clock resync (the clock never answered, so stale)
/// waits for the create too.
#[test]
fn the_login_edge_alone_sends_nothing() {
let (mut app, rx) = app();
log_in(&mut app);
for _ in 0..3 {
app.update();
}
assert_eq!(sent(&rx), Vec::<String>::new());
}
#[test]
fn the_self_create_sends_the_four_in_the_reference_order() {
let (mut app, rx) = app();
log_in(&mut app);
app.update();
self_create(&mut app);
app.update();
assert_eq!(sent(&rx), the_four());
// The same body on later frames is no new create.
app.update();
app.update();
assert_eq!(sent(&rx), Vec::<String>::new());
}
/// A cross-map worldport purges the streamed world and the server creates us afresh
/// (`0x401bc0` rebuilds the object manager; the create takes the login's route).
#[test]
fn a_worldport_create_sends_the_four_again() {
let (mut app, rx) = app();
log_in(&mut app);
let first = self_create(&mut app);
app.update();
assert_eq!(sent(&rx), the_four());
app.world_mut().despawn(first);
app.update();
assert_eq!(sent(&rx), Vec::<String>::new(), "nothing between the two");
self_create(&mut app);
app.update();
assert_eq!(sent(&rx), the_four());
}
/// The production schedule: the self-create edge, then the time, mail and battlefield sends.
#[test]
fn the_client_schedules_the_sends_in_the_reference_order() {
use crate::test_support::runs_before;
let mut app = crate::game_plugins::schedule_tests::headless_client();
assert!(runs_before(
&mut app,
super::tag_self_player,
super::enter_world_on_self_create
));
assert!(runs_before(
&mut app,
super::enter_world_on_self_create,
crate::net::send_query_time
));
assert!(runs_before(
&mut app,
crate::net::send_query_time,
crate::ui_mail::send_query_next_mail_time_on_enter
));
assert!(runs_before(
&mut app,
crate::ui_mail::send_query_next_mail_time_on_enter,
crate::ui_battlefield::reset_on_world_enter
));
}
+2 -1
View File
@@ -607,8 +607,9 @@ fn run(
.find(|c| c.guid == guid)
.map(|c| c.name.clone())
.unwrap_or_default();
// No `CMSG_SET_ACTIVE_MOVER` here: it waits for our own player's create, as the
// reference's does (`super::enter_world_on_self_create`); a server may drop it before then.
session.player_login(refuse_once(guid))?;
session.set_active_mover(guid)?;
let billing_time_rested = session.billing_time_rested();
let tutorial_flags = session.take_tutorial_flags();
+8 -6
View File
@@ -16,7 +16,7 @@ use benilla_protocol::messages::{BattlefieldList, BattlefieldStatus};
use benilla_ui::script::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot, UiScript};
use crate::names::NameCache;
use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands};
use crate::net::{ClientCommand, NetCommands, WorldEnterCascadeMessage};
use crate::player::Player;
use crate::ui_dialog_verbs::BattlefieldQueue;
use crate::ui_party::GroupState;
@@ -307,15 +307,16 @@ fn drain_battlefield(
}
}
/// World enter (`0x4a9db0`): the list, the selection and the anchor clear, the queue slots stay,
/// and the bodyless `CMSG_BATTLEFIELD_STATUS` goes out, answered slot by slot.
fn reset_on_world_enter(
mut entered: MessageReader<EnteredWorldMessage>,
/// The world-enter cascade's battlefield init (`0x4909fb` → `0x4a9db0`), after the mail query: the
/// list, the selection and the anchor clear, the queue slots stay, and the bodyless
/// `CMSG_BATTLEFIELD_STATUS` goes out, answered slot by slot.
pub(crate) fn reset_on_world_enter(
mut cascades: MessageReader<WorldEnterCascadeMessage>,
mut state: ResMut<Battlefield>,
script: Option<NonSendMut<UiScript>>,
commands: Res<NetCommands>,
) {
if entered.read().next().is_none() {
if cascades.read().next().is_none() {
return;
}
state.clear_session();
@@ -362,6 +363,7 @@ impl Plugin for BattlefieldPlugin {
(
reset_on_world_enter
.in_set(crate::ui_script::UiFeed)
.after(crate::ui_mail::send_query_next_mail_time_on_enter)
.before(feed_battlefield),
feed_battlefield
.before(crate::ui_battlefield_score::feed_battlefield_score)
+9 -8
View File
@@ -19,7 +19,7 @@ use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{
ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, Objects, SelfPlayer,
ClientCommand, NetCommands, ObjectStore, Objects, SelfPlayer, WorldEnterCascadeMessage,
};
use crate::query_cache::QueryCache;
use crate::ui_script::{UiFeed, UiInput};
@@ -175,21 +175,22 @@ impl Plugin for UiMailPlugin {
close_npc_session_out_of_range::<MailOpen>.before(feed_mail),
feed_mail.after(crate::ui_unit::UnitFeed).in_set(UiFeed),
drain_mail.after(UiInput),
send_query_next_mail_time_on_enter,
// The cascade's order: the time query, this, then the battlefield status.
send_query_next_mail_time_on_enter.after(crate::net::send_query_time),
),
);
}
}
/// `MSG_QUERY_NEXT_MAIL_TIME` at every world enter, from the mail module's init in the world-enter
/// cascade (`0x4908c0`). The init stamps "no mail" first, so the icon goes dark across a loading
/// screen until the reply.
fn send_query_next_mail_time_on_enter(
mut entered: MessageReader<EnteredWorldMessage>,
/// `MSG_QUERY_NEXT_MAIL_TIME` at each world-enter cascade, from the mail init (`0x4acb10`, called
/// at `0x4909f6`), after the time query and before the battlefield status. The init stamps "no
/// mail" first (`0x4acb87`).
pub(crate) fn send_query_next_mail_time_on_enter(
mut cascades: MessageReader<WorldEnterCascadeMessage>,
commands: Res<NetCommands>,
mut pending: ResMut<MailPending>,
) {
if entered.read().next().is_some() {
if cascades.read().next().is_some() {
pending.on_query_sent();
let _ = commands.0.send(ClientCommand::QueryNextMailTime);
}
@@ -631,6 +631,17 @@ pub(crate) fn run_pending_reload(world: &mut World) {
info!("ui_script: ReloadUI — ending the UI session and building a new one");
end_ui_session(world);
load_ingame_ui_on_world_entry(world);
// `UI_Init` ends by re-entering the world-enter cascade (`0x490168`), its sends included, but
// only with the active player set (`0x490166 je`): our own player's entity exists. Not
// `SetActiveMover`, which only the player's create runs.
let seated = world
.query_filtered::<(), With<crate::net::SelfPlayer>>()
.iter(world)
.next()
.is_some();
if seated {
world.write_message(crate::net::WorldEnterCascadeMessage);
}
}
/// `AppExit`, the quit roots, read as a message because a quit from in-world never leaves
@@ -842,6 +853,31 @@ mod tests {
assert_eq!(*world.resource::<VPlateMode>(), on, "a reload keeps them");
}
/// A `ReloadUI()` re-enters the world-enter cascade (`0x490168`), whose readers re-send the
/// time, mail and battlefield queries, only with the active player set (`0x490166 je`).
#[test]
fn a_reload_reenters_the_world_enter_cascade_once_the_player_exists() {
use crate::net::WorldEnterCascadeMessage;
let reload = |seated: bool| {
let mut world = World::new();
world.insert_resource(State::new(crate::char_select::ClientState::InWorld));
world.insert_resource(crate::run_mode::CaptureMode);
world.init_resource::<Messages<WorldEnterCascadeMessage>>();
if seated {
world.spawn(crate::net::SelfPlayer);
}
world.insert_resource(ReloadUiPending(true));
run_pending_reload(&mut world);
assert!(!world.resource::<ReloadUiPending>().0, "the reload ran");
world
.resource_mut::<Messages<WorldEnterCascadeMessage>>()
.drain()
.count()
};
assert_eq!(reload(true), 1, "in the world");
assert_eq!(reload(false), 0, "before our own player's create");
}
/// The reference re-makes its Lua state inside `UI_Init` (`0x48fe97`).
#[test]
fn the_world_entry_builds_its_own_vm_rather_than_adopting_the_glue_phases() {
+2 -1
View File
@@ -284,7 +284,8 @@ impl WorldSession {
self.send(opcode::CMSG_PLAYER_LOGIN, &messages::full_guid(guid))
}
/// Declare the unit we move, as the 1.12 client does at login; vmangos drops moves until then.
/// Declare the unit we move, as the 1.12 client does at its own create; vmangos drops moves
/// until then.
pub fn set_active_mover(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_SET_ACTIVE_MOVER, &messages::full_guid(guid))
}