From ffdc206ac2e87e3164e31caf26862c43a5180152 Mon Sep 17 00:00:00 2001 From: elipp <1861512+elipp@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:28:59 +0300 Subject: [PATCH] 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 Co-Authored-By: Elias Miettinen Co-Authored-By: Sam --- crates/benilla-app/src/game_plugins.rs | 2 +- crates/benilla-app/src/net.rs | 42 +++-- crates/benilla-app/src/net/apply.rs | 34 +++- .../src/net/apply/world_enter_tests.rs | 148 ++++++++++++++++++ crates/benilla-app/src/net/io.rs | 3 +- crates/benilla-app/src/ui_battlefield.rs | 14 +- crates/benilla-app/src/ui_mail/mod.rs | 17 +- crates/benilla-app/src/ui_script/lifecycle.rs | 36 +++++ crates/benilla-protocol/src/world/session.rs | 3 +- 9 files changed, 267 insertions(+), 32 deletions(-) create mode 100644 crates/benilla-app/src/net/apply/world_enter_tests.rs diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 53e216f0..0efd6118 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -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, diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index eaaca9f7..7f0a1c3b 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -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::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -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, +/// 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, commands: Res, clock: Res, status: Res, mut asked_at: Local>, + mut seated: Local, ) { - 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>, } +/// 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. diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index a78014d3..6d949719 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -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>, + net: Res, + mut cascades: MessageWriter, +) { + 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); + } +} diff --git a/crates/benilla-app/src/net/apply/world_enter_tests.rs b/crates/benilla-app/src/net/apply/world_enter_tests.rs new file mode 100644 index 00000000..b90bec40 --- /dev/null +++ b/crates/benilla-app/src/net/apply/world_enter_tests.rs @@ -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) { + 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::() + .init_resource::() + .init_resource::() + .add_message::() + .add_message::() + .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) -> Vec { + rx.try_iter() + .map(|c| match c { + ClientCommand::SetActiveMover { guid } => format!("SetActiveMover {guid:#x}"), + other => format!("{other:?}"), + }) + .collect() +} + +fn the_four() -> Vec { + 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::::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::::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::::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 + )); +} diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 869b97c4..e696b9e2 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -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(); diff --git a/crates/benilla-app/src/ui_battlefield.rs b/crates/benilla-app/src/ui_battlefield.rs index cc410bc6..75533b86 100644 --- a/crates/benilla-app/src/ui_battlefield.rs +++ b/crates/benilla-app/src/ui_battlefield.rs @@ -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, +/// 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, mut state: ResMut, script: Option>, commands: Res, ) { - 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) diff --git a/crates/benilla-app/src/ui_mail/mod.rs b/crates/benilla-app/src/ui_mail/mod.rs index e4e68a69..a52bca50 100644 --- a/crates/benilla-app/src/ui_mail/mod.rs +++ b/crates/benilla-app/src/ui_mail/mod.rs @@ -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::.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, +/// `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, commands: Res, mut pending: ResMut, ) { - if entered.read().next().is_some() { + if cascades.read().next().is_some() { pending.on_query_sent(); let _ = commands.0.send(ClientCommand::QueryNextMailTime); } diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 2a7bbedf..e9744050 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -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>() + .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::(), 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::>(); + if seated { + world.spawn(crate::net::SelfPlayer); + } + world.insert_resource(ReloadUiPending(true)); + run_pending_reload(&mut world); + assert!(!world.resource::().0, "the reload ran"); + world + .resource_mut::>() + .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() { diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs index 9a8e529d..692635f6 100644 --- a/crates/benilla-protocol/src/world/session.rs +++ b/crates/benilla-protocol/src/world/session.rs @@ -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)) }