From 2e82d34c1b561e3bb033f7cd910036ba63ce1760 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:29:18 +0700 Subject: [PATCH] The meeting-stone query runs at every world entry, as 1.12.1 sends it (#810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The meeting-stone status query (`CMSG 0x296`) and its text reset ran once per UI VM, and the text drop only on leaving the world. So after a cross-map worldport benilla kept the old text and never asked again. In 1.12.1 the world-enter cascade's meeting-stone init (`0x490a14` → `0x4c9f40`) resets the text to `UNKNOWN` and sends `0x296`, right after the battlefield init, on every pass: login, every cross-map worldport and every `/reload`. The leave-world sweep `0x490a80` drops the text (`0x490b2f` → `0x4c9f80`) on a worldport, a `/reload` and leaving the world. The enter leg now runs on the world-enter cascade after the battlefield status. The sweep is one edge, written whenever its latch is spent, and the drop reads it. Tests cover the cascade, a worldport, a reload and the schedule order; a live trace shows `0x296` after `0x2D3` and its reply at login, on `ReloadUI()` and after `.tele`. Fixes #99 Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/game_plugins.rs | 2 +- .../src/net/apply/world_enter_tests.rs | 37 ++- crates/benilla-app/src/ui_dialog_verbs.rs | 214 +++++++++++++----- crates/benilla-app/src/ui_script/lifecycle.rs | 82 +++++-- crates/benilla-app/src/ui_script/mod.rs | 5 +- crates/benilla-app/src/ui_unit.rs | 8 +- .../src/world/writer/meeting_stone.rs | 3 +- 7 files changed, 261 insertions(+), 90 deletions(-) diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 0efd6118..81e0ca2f 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -1128,7 +1128,7 @@ pub(crate) mod schedule_tests { ("ui_chat/recruitment.rs", "guild_recruitment_cascade", Because::SelfHealing, "`pending` is held until the zone mask and zone id are settled, which happens on the entry VM"), ("ui_dialog_verbs.rs", "feed_meeting_stone", Because::SelfHealing, - "the query is sent once per VM (`asked: VmMemo`), so the entry VM re-asks and the reply lands after the UI is up"), + "the query waits for the world-enter cascade (`meeting_stone_enter_world`), our own create a server round trip past the login edge, and every `/reload` re-asks onto its new VM"), ("ui_duel.rs", "feed_duel", Because::PlayerRoundTrip, "a duel exists only after someone's Duel cast, and the server ends any duel at logout; FINISHED/bounds ride `VmMemo` (another player's act: coincidence-only residual)"), ("ui_follow.rs", "feed_follow", Because::MemoLatched, diff --git a/crates/benilla-app/src/net/apply/world_enter_tests.rs b/crates/benilla-app/src/net/apply/world_enter_tests.rs index b90bec40..0ea7d84b 100644 --- a/crates/benilla-app/src/net/apply/world_enter_tests.rs +++ b/crates/benilla-app/src/net/apply/world_enter_tests.rs @@ -1,8 +1,8 @@ //! 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. +//! `MSG_QUERY_NEXT_MAIL_TIME`, `CMSG_BATTLEFIELD_STATUS` and `CMSG 0x296`; a worldport's fresh +//! create sends all five again. use bevy::prelude::*; use crossbeam_channel::Receiver; @@ -14,7 +14,7 @@ use crate::net::{ 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 self-create edge and the cascade's four 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(); @@ -28,6 +28,7 @@ fn app() -> (App, Receiver) { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_message::() .add_message::() .add_systems( @@ -38,6 +39,7 @@ fn app() -> (App, Receiver) { crate::net::send_query_time, crate::ui_mail::send_query_next_mail_time_on_enter, crate::ui_battlefield::reset_on_world_enter, + crate::ui_dialog_verbs::meeting_stone_enter_world, ) .chain(), ); @@ -66,16 +68,17 @@ fn sent(rx: &Receiver) -> Vec { .collect() } -fn the_four() -> Vec { +fn the_five() -> Vec { vec![ format!("SetActiveMover {ME:#x}"), "QueryTime".into(), "QueryNextMailTime".into(), "BattlefieldStatusRequest".into(), + "MeetingStoneStatusQuery".into(), ] } -/// cmangos drops each of the four as "the player has not logged in yet" when it beats the load, +/// cmangos drops each of the five 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] @@ -89,13 +92,13 @@ fn the_login_edge_alone_sends_nothing() { } #[test] -fn the_self_create_sends_the_four_in_the_reference_order() { +fn the_self_create_sends_the_five_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()); + assert_eq!(sent(&rx), the_five()); // The same body on later frames is no new create. app.update(); app.update(); @@ -105,22 +108,24 @@ fn the_self_create_sends_the_four_in_the_reference_order() { /// 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() { +fn a_worldport_create_sends_the_five_again() { let (mut app, rx) = app(); log_in(&mut app); let first = self_create(&mut app); app.update(); - assert_eq!(sent(&rx), the_four()); + assert_eq!(sent(&rx), the_five()); 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()); + assert_eq!(sent(&rx), the_five()); } -/// The production schedule: the self-create edge, then the time, mail and battlefield sends. +/// The production schedule: the self-create edge, then the time, mail, battlefield and meeting +/// stone sends (`0x4909a1`, `0x4909f6`, `0x4909fb`, `0x490a14`); and a `/reload` frame's sweep +/// drops the stone's text before its cascade resets it (`0x490c20`, then `0x490168`). #[test] fn the_client_schedules_the_sends_in_the_reference_order() { use crate::test_support::runs_before; @@ -145,4 +150,14 @@ fn the_client_schedules_the_sends_in_the_reference_order() { crate::ui_mail::send_query_next_mail_time_on_enter, crate::ui_battlefield::reset_on_world_enter )); + assert!(runs_before( + &mut app, + crate::ui_battlefield::reset_on_world_enter, + crate::ui_dialog_verbs::meeting_stone_enter_world + )); + assert!(runs_before( + &mut app, + crate::ui_dialog_verbs::meeting_stone_leave_world, + crate::ui_dialog_verbs::meeting_stone_enter_world + )); } diff --git a/crates/benilla-app/src/ui_dialog_verbs.rs b/crates/benilla-app/src/ui_dialog_verbs.rs index 036bf8db..6f76bb07 100644 --- a/crates/benilla-app/src/ui_dialog_verbs.rs +++ b/crates/benilla-app/src/ui_dialog_verbs.rs @@ -15,9 +15,11 @@ use crate::area::AreaTableRes; use crate::creature_anim::spell_visual::{meeting_stone_join_fx, SpellKitFx, SpellVisuals}; use crate::names::NameCache; -use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer}; +use crate::net::{ + ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer, WorldEnterCascadeMessage, +}; use crate::ui_party::GroupState; -use crate::ui_script::{UiFeed, UiInput}; +use crate::ui_script::{UiFeed, UiInput, WorldLeaveSweepMessage}; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; /// The pet trainer's pending question: the latch (`0xc4d7b0/b4`) and the cost (`0xc4d7b8`). @@ -506,21 +508,17 @@ fn feed_meeting_stone( } } -/// The enter-world bring-up's meeting-stone leg: the text reset, then the empty `CMSG 0x296`. -/// Once per VM, a [`crate::ui_script::VmMemo`] claim: the UI teardown clears the run-once byte -/// `[0xb4b424]` (`0x490a8d`) and `UI_Init` (`0x48fbf0`) re-runs the bring-up, so a `/reload` -/// re-asks the server; until the reply fires `MEETINGSTONE_CHANGED` (`0x4ca38f`) the minimap icon -/// is hidden, as in the reference. The queued area survives the reload. -fn meeting_stone_enter_world( - script: Option>, +/// The world-enter cascade's meeting-stone leg (`0x490a14` → `0x4c9f40`), after the battlefield +/// status: the text reset, then the empty `CMSG 0x296` (`0x4ca1c0`). The cascade runs at login, +/// after every cross-map worldport and on every `/reload`; until the reply fires +/// `MEETINGSTONE_CHANGED` (`0x4ca38f`) the minimap icon is hidden, as in the reference. The +/// queued area survives. +pub(crate) fn meeting_stone_enter_world( + mut cascades: MessageReader, mut stone: ResMut, commands: Res, - mut asked: Local>, ) { - let Some(script) = script else { - return; - }; - if !asked.claim(&script) { + if cascades.read().next().is_none() { return; } stone.enter_world(); @@ -636,9 +634,15 @@ fn drain_meeting_stone_joins( } } -/// The leave-world sweep's leg: the text dropped, the area kept. -fn meeting_stone_leave_world(mut stone: ResMut) { - stone.leave_world(); +/// The leave-world sweep's meeting-stone leg (`0x490b2f` → `0x4c9f80`): the text dropped, the +/// area kept. +pub(crate) fn meeting_stone_leave_world( + mut sweeps: MessageReader, + mut stone: ResMut, +) { + if sweeps.read().next().is_some() { + stone.leave_world(); + } } /// Drop the cached spirit guide when the world does, so no healer, wave or event reaches the @@ -969,6 +973,8 @@ impl Plugin for UiDialogVerbsPlugin { .init_resource::() .init_resource::() .add_message::() + .add_message::() + .add_message::() .add_systems( Update, ( @@ -986,11 +992,15 @@ impl Plugin for UiDialogVerbsPlugin { .after(crate::target::TargetUpdate) .run_if(crate::ui_script::ingame_ui_up) .in_set(crate::char_select::InWorldGated), - // In world only: the query is a world packet, and the glue screen has a VM. + // The sweep's drop, then the cascade's reset: a `/reload` runs both in one + // frame, in this order (`0x490c20`, then `0x490168`). + meeting_stone_leave_world.before(meeting_stone_enter_world), + // The cascade's order: the battlefield status, then this (`0x4909fb`, + // `0x490a14`), so `CMSG 0x296` follows `CMSG 0x2D3` on the wire. meeting_stone_enter_world - .in_set(crate::ui_script::UiFeed) - .before(feed_meeting_stone) - .in_set(crate::char_select::InWorldGated), + .in_set(UiFeed) + .after(crate::ui_battlefield::reset_on_world_enter) + .before(feed_meeting_stone), feed_meeting_stone.in_set(UiFeed), // Before the target chain, where the poll and the click can change the // healer: the reference resolves an Accept press in the UI dispatch, before @@ -1008,7 +1018,7 @@ impl Plugin for UiDialogVerbsPlugin { ) .add_systems( OnExit(crate::char_select::ClientState::InWorld), - (meeting_stone_leave_world, area_spirit_healer_leave_world), + area_spirit_healer_leave_world, ); } } @@ -1464,55 +1474,147 @@ mod tests { ); } - /// A registered schedule: `run_system_once` would build a fresh `Local>` per call, - /// an unclaimed memo every frame. - #[test] - fn a_rebuilt_vm_re_asks_the_server_for_the_meeting_stone_queue() { + /// The two meeting-stone legs behind the worldport's sweep producer, the latch armed as our + /// own create arms it; the cascade and a `/reload`'s sweep are written by hand. + fn stone_app() -> (App, crossbeam_channel::Receiver) { let (tx, rx) = crossbeam_channel::unbounded(); let mut app = App::new(); app.init_resource::() .insert_resource(NetCommands(tx)) - .add_systems(Update, meeting_stone_enter_world); + .init_resource::() + .add_message::() + .add_message::() + .add_message::() + .add_systems( + Update, + ( + crate::ui_unit::fire_leaving_world_on_worldport, + meeting_stone_leave_world, + meeting_stone_enter_world, + ) + .chain(), + ); + app.world_mut() + .resource_mut::() + .arm(); + (app, rx) + } - let queries = |rx: &crossbeam_channel::Receiver| { - rx.try_iter() - .filter(|c| matches!(c, ClientCommand::MeetingStoneStatusQuery)) - .count() - }; + fn stone_queries(rx: &crossbeam_channel::Receiver) -> usize { + rx.try_iter() + .filter(|c| matches!(c, ClientCommand::MeetingStoneStatusQuery)) + .count() + } + /// The server's answer has landed: queued at `area`, the line built. + fn answered(app: &mut App, area: u32) { + let mut stone = app.world_mut().resource_mut::(); + stone.area = area; + stone.text = StoneText::Built("Meeting Stone: Wailing Caverns".into()); + stone.dirty = false; + } + + fn stone_text(app: &App) -> StoneText { + app.world().resource::().text.clone() + } + + fn stone_port(needs_ack: bool) -> crate::net::WorldportMessage { + crate::net::WorldportMessage { + map_id: 1, + position: [0.0; 3], + orientation: 0.0, + needs_ack, + transport_entry: None, + } + } + + /// Each world-enter cascade (`0x490a14` → `0x4c9f40`) resets the text to the bare `UNKNOWN` + /// and sends `CMSG 0x296`, a VM or not; frames without one send nothing. + #[test] + fn every_world_enter_cascade_resets_the_text_and_asks_again() { + let (mut app, rx) = stone_app(); app.update(); - assert_eq!(queries(&rx), 0, "no VM, no bring-up"); + assert_eq!(stone_queries(&rx), 0, "no cascade, no query"); - app.insert_non_send_resource(UiScript::new().expect("VM")); - app.update(); - assert_eq!(queries(&rx), 1, "the first VM asks the server"); - app.update(); - app.update(); - assert_eq!(queries(&rx), 0, "…and does not ask again on later frames"); + for entry in [ + "the login create", + "a worldport's create", + "a second worldport's", + ] { + answered(&mut app, 1519); + app.world_mut().write_message(WorldEnterCascadeMessage); + app.update(); + assert_eq!(stone_queries(&rx), 1, "{entry} asks the server"); + assert_eq!( + stone_text(&app), + StoneText::Unknown, + "{entry} resets the text" + ); + let stone = app.world().resource::(); + assert!(stone.dirty, "{entry} owes the VM the reset"); + assert_eq!( + stone.area, 1519, + "{entry} keeps the queued area (`[0xb72038]`)" + ); + app.update(); + assert_eq!(stone_queries(&rx), 0, "{entry} asks once, not every frame"); + } + } - // The server answers: the player is queued. - app.world_mut().resource_mut::().area = 1519; - app.world_mut().resource_mut::().dirty = false; - - // `ReloadUI()`: a fresh VM, with no world leave. - app.insert_non_send_resource(UiScript::new().expect("VM")); + /// A cross-map worldport runs the sweep from the local player's destructor (`0x5dd543`), + /// which drops the text (`0x490b2f` → `0x4c9f80`); the login map's verify-world destroys + /// nothing, so the text stays. + #[test] + fn a_cross_map_worldport_drops_the_text_and_the_login_map_does_not() { + let (mut app, rx) = stone_app(); + app.world_mut().write_message(WorldEnterCascadeMessage); app.update(); - assert_eq!( - queries(&rx), - 1, - "a rebuilt VM re-asks — the reference's UI teardown clears the run-once byte, so \ - `UI_Init` re-sends `CMSG 0x296` (0x490a8d / 0x490168)" - ); + answered(&mut app, 1519); - let stone = app.world().resource::(); + app.world_mut().write_message(stone_port(false)); + app.update(); assert!( - stone.dirty, - "the bring-up re-armed the push, so the fresh VM is told the queued area again" + matches!(stone_text(&app), StoneText::Built(_)), + "an arrival keeps it" ); + + app.world_mut().write_message(stone_port(true)); + app.update(); assert_eq!( - stone.area, 1519, - "the queued area itself is untouched, matching `[0xb72038]` surviving the reload" + stone_text(&app), + StoneText::None, + "the worldport's sweep drops it" ); + let stone = app.world().resource::(); + assert!(stone.dirty, "the drop reaches the VM"); + assert_eq!(stone.area, 1519, "the queued area survives the sweep"); + assert_eq!( + stone_queries(&rx), + 1, + "the sweep sends nothing; the new create's cascade will" + ); + } + + /// `/reload` sweeps (`0x490c20`) and then re-enters the cascade (`0x490168`) in one frame: + /// the drop, then the reset and the query, in that order. + #[test] + fn a_reload_drops_the_text_then_resets_it_and_asks_again() { + let (mut app, rx) = stone_app(); + app.world_mut().write_message(WorldEnterCascadeMessage); + app.update(); + assert_eq!(stone_queries(&rx), 1); + answered(&mut app, 1519); + + app.world_mut().write_message(WorldLeaveSweepMessage); + app.world_mut().write_message(WorldEnterCascadeMessage); + app.update(); + assert_eq!(stone_queries(&rx), 1, "the reload re-asks the server"); + assert_eq!( + stone_text(&app), + StoneText::Unknown, + "the reset is the last word" + ); + assert_eq!(app.world().resource::().area, 1519); } #[test] diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index e9744050..eafdcd06 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -486,14 +486,53 @@ pub(crate) fn seat_from_roster( pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>); /// The world latch, the reference's `[0xb4b424]`: set at `0x4908ce` in the world-enter cascade -/// `0x4908c0` and cleared at `0x490a8d` as `PLAYER_LEAVING_WORLD` fires (`0x490b4d`, its one -/// site), so the first departure in a world fires and the rest are no-ops until the next entry. -/// Ours has two producers, [`shutdown_ui_state`]'s tail and `ui_unit`'s worldport fire. A +/// `0x4908c0` and cleared at `0x490a8d` by the leave-world sweep `0x490a80`, which then fires +/// `PLAYER_LEAVING_WORLD` (`0x490b4d`, its one site), so the first departure in a world sweeps +/// and the rest are no-ops until the next entry. Ours is spent through [`LeaveWorldSweep`] or +/// [`run_leave_world_sweep`], by [`shutdown_ui_state`]'s roots and `ui_unit`'s worldport fire. A /// cross-map worldport spends it without leaving `InWorld`, so a quit on its loading screen /// fires nothing; a `/reload` fires, and its rebuild re-arms. #[derive(Resource, Default)] pub(crate) struct LeavingWorldArmed(bool); +/// The leave-world sweep `0x490a80` ran past its latch: a cross-map worldport (the local +/// player's destructor, `0x5dd543`), a `/reload` (`0x490c20`) or leaving the world. Its legs +/// past the clear read this, as the meeting stone's text drop (`0x490b2f` → `0x4c9f80`) does; a +/// same-map teleport destroys nothing and never sweeps. +#[derive(Message)] +pub(crate) struct WorldLeaveSweepMessage; + +/// The latch and the sweep it gates, for a system: spending one writes the other. +#[derive(bevy::ecs::system::SystemParam)] +pub(crate) struct LeaveWorldSweep<'w> { + armed: ResMut<'w, LeavingWorldArmed>, + sweeps: MessageWriter<'w, WorldLeaveSweepMessage>, +} + +impl LeaveWorldSweep<'_> { + /// Run the sweep: `true`, and [`WorldLeaveSweepMessage`] written, at most once per world. + pub(crate) fn run(&mut self) -> bool { + let swept = self.armed.spend(); + if swept { + self.sweeps.write(WorldLeaveSweepMessage); + } + swept + } +} + +/// [`LeaveWorldSweep::run`] for an exclusive edge; no latch is no world to leave. +pub(crate) fn run_leave_world_sweep(world: &mut World) -> bool { + let swept = world + .get_resource_mut::() + .is_some_and(|mut l| l.spend()); + if swept { + if let Some(mut sweeps) = world.get_resource_mut::>() { + sweeps.write(WorldLeaveSweepMessage); + } + } + swept +} + impl LeavingWorldArmed { /// A world began. Idempotent, like the reference's `mov byte [0xb4b424],1`. pub(crate) fn arm(&mut self) { @@ -501,7 +540,7 @@ impl LeavingWorldArmed { } /// Take the one-shot: `true` at most once per world, `0x490a8d`'s clear folded in. - pub(crate) fn spend(&mut self) -> bool { + fn spend(&mut self) -> bool { std::mem::take(&mut self.0) } @@ -566,9 +605,7 @@ pub(crate) fn end_ui_session(world: &mut World) { .get_resource::() .and_then(|id| id.0.clone()); // Spent at the root: of this edge and the worldport's, the first to a departure owns it. - let leaving_world = world - .get_resource_mut::() - .is_some_and(|mut l| l.spend()); + let leaving_world = run_leave_world_sweep(world); if !ui_never_loaded { if let Some(mut script) = world.get_non_send_resource_mut::() { shutdown_ui_state(&mut script, identity.as_ref(), leaving_world); @@ -651,7 +688,7 @@ pub(crate) fn shutdown_on_exit( script: Option>, id: Res, pending_entry: Option>, - mut armed: ResMut, + mut sweep: LeaveWorldSweep, mut exits: MessageReader, ) { if exits.read().next().is_none() { @@ -661,7 +698,7 @@ pub(crate) fn shutdown_on_exit( if pending_entry.is_some() { return; } - let leaving_world = armed.spend(); + let leaving_world = sweep.run(); if let Some(mut script) = script { shutdown_ui_state(&mut script, id.0.as_ref(), leaving_world); } @@ -853,29 +890,42 @@ 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`). + /// A `ReloadUI()` runs the leave-world sweep (`0x490c20`, past the armed latch) and then + /// re-enters the world-enter cascade (`0x490168`), whose readers re-send the time, mail, + /// battlefield and meeting-stone queries, only with the active player set (`0x490166 je`). #[test] - fn a_reload_reenters_the_world_enter_cascade_once_the_player_exists() { + fn a_reload_sweeps_and_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::>(); + world.init_resource::>(); + world.init_resource::(); + world.resource_mut::().arm(); 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 + let sweeps = world + .resource_mut::>() + .drain() + .count(); + let cascades = world .resource_mut::>() .drain() - .count() + .count(); + (sweeps, cascades) }; - assert_eq!(reload(true), 1, "in the world"); - assert_eq!(reload(false), 0, "before our own player's create"); + assert_eq!(reload(true), (1, 1), "in the world"); + assert_eq!( + reload(false).1, + 0, + "no cascade before our own player's create" + ); } /// The reference re-makes its Lua state inside `UI_Init` (`0x48fe97`). diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index a443ecf2..47df8f77 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -295,6 +295,7 @@ impl Plugin for UiScriptPlugin { ) // `init_` here and in `UiUnitPlugin`: either plugin may be built alone in a test. .init_resource::() + .add_message::() .add_systems(Update, lifecycle::arm_leaving_world_on_self_create) // A queued `ReloadUI()` runs in `PreUpdate`, a frame after its drain (the reference's // deferral, `0x495590`) and before every `Update` seed or feed. @@ -381,8 +382,8 @@ fn arbitrate_pointer_over_ui( /// The session lifecycle: the VM's birth, identity, death and reload. mod lifecycle; pub(crate) use lifecycle::{ - end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity, - LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending, + end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity, LeaveWorldSweep, + LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending, WorldLeaveSweepMessage, }; // Test-only: other modules' tests consume these, and a plain re-export would warn unused. #[cfg(test)] diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 8102fe82..41a89027 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -64,9 +64,9 @@ pub(crate) struct CombatTextEvent { /// /// Deviation: fires with our descriptor present, so `UnitExists("player")` answers true where the /// reference's (`0x515970`) misses, because matching it breaks `UnitExists` for no stock reader. -fn fire_leaving_world_on_worldport( +pub(crate) fn fire_leaving_world_on_worldport( script: Option>, - mut armed: ResMut, + mut sweep: crate::ui_script::LeaveWorldSweep, mut ports: MessageReader, ) { // `needs_ack` false is the initial-login map, an arrival; the whole iterator is read so no @@ -77,7 +77,7 @@ fn fire_leaving_world_on_worldport( } // The world latch keeps this producer and the shutdown tail from both claiming one departure; // spent even with no VM, as the reference clears it at `0x490a8d`, ahead of the fire. - if !armed.spend() { + if !sweep.run() { return; } let Some(mut script) = script else { @@ -145,6 +145,7 @@ impl Plugin for UiUnitPlugin { .add_message::() .add_message::() .init_resource::() + .add_message::() .add_systems( Update, ( @@ -3593,6 +3594,7 @@ mod tests { let mut app = App::new(); app.add_message::() .init_resource::() + .add_message::() .add_systems(Update, fire_leaving_world_on_worldport); app.insert_non_send_resource(UiScript::new().expect("VM")); app.world_mut() diff --git a/crates/benilla-protocol/src/world/writer/meeting_stone.rs b/crates/benilla-protocol/src/world/writer/meeting_stone.rs index 62afd247..0bf79c3d 100644 --- a/crates/benilla-protocol/src/world/writer/meeting_stone.rs +++ b/crates/benilla-protocol/src/world/writer/meeting_stone.rs @@ -25,7 +25,8 @@ impl WorldWriter { ) } - /// `CMSG_MEETINGSTONE_STATUS_QUERY`, empty: the reference sends it once per world session. + /// `CMSG_MEETINGSTONE_STATUS_QUERY`, empty: the reference sends it at each world-enter + /// cascade, so at login, after every cross-map worldport and on every `/reload`. pub fn meeting_stone_status_query(&mut self) -> Result<()> { self.send(opcode::CMSG_MEETINGSTONE_STATUS_QUERY, &[]) }