The meeting-stone query runs at every world entry, as 1.12.1 sends it (#810)

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 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-06 09:29:18 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 85980939f5
commit 2e82d34c1b
7 changed files with 261 additions and 90 deletions
+1 -1
View File
@@ -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<bool>`), 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<FedDuel>` (another player's act: coincidence-only residual)"),
("ui_follow.rs", "feed_follow", Because::MemoLatched,
@@ -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<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
@@ -28,6 +28,7 @@ fn app() -> (App, Receiver<ClientCommand>) {
.init_resource::<ServerWallClock>()
.init_resource::<crate::ui_mail::MailPending>()
.init_resource::<crate::ui_battlefield::Battlefield>()
.init_resource::<crate::ui_dialog_verbs::MeetingStone>()
.add_message::<EnteredWorldMessage>()
.add_message::<WorldEnterCascadeMessage>()
.add_systems(
@@ -38,6 +39,7 @@ fn app() -> (App, Receiver<ClientCommand>) {
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<ClientCommand>) -> Vec<String> {
.collect()
}
fn the_four() -> Vec<String> {
fn the_five() -> Vec<String> {
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::<String>::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
));
}
+158 -56
View File
@@ -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<NonSendMut<UiScript>>,
/// 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<WorldEnterCascadeMessage>,
mut stone: ResMut<MeetingStone>,
commands: Res<NetCommands>,
mut asked: Local<crate::ui_script::VmMemo<bool>>,
) {
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<MeetingStone>) {
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<WorldLeaveSweepMessage>,
mut stone: ResMut<MeetingStone>,
) {
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::<BattlefieldQueue>()
.init_resource::<MeetingStone>()
.add_message::<MeetingStoneUse>()
.add_message::<WorldEnterCascadeMessage>()
.add_message::<WorldLeaveSweepMessage>()
.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<VmMemo<bool>>` 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<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<MeetingStone>()
.insert_resource(NetCommands(tx))
.add_systems(Update, meeting_stone_enter_world);
.init_resource::<crate::ui_script::LeavingWorldArmed>()
.add_message::<crate::net::WorldportMessage>()
.add_message::<WorldEnterCascadeMessage>()
.add_message::<WorldLeaveSweepMessage>()
.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::<crate::ui_script::LeavingWorldArmed>()
.arm();
(app, rx)
}
let queries = |rx: &crossbeam_channel::Receiver<ClientCommand>| {
rx.try_iter()
.filter(|c| matches!(c, ClientCommand::MeetingStoneStatusQuery))
.count()
};
fn stone_queries(rx: &crossbeam_channel::Receiver<ClientCommand>) -> 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::<MeetingStone>();
stone.area = area;
stone.text = StoneText::Built("Meeting Stone: Wailing Caverns".into());
stone.dirty = false;
}
fn stone_text(app: &App) -> StoneText {
app.world().resource::<MeetingStone>().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::<MeetingStone>();
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::<MeetingStone>().area = 1519;
app.world_mut().resource_mut::<MeetingStone>().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::<MeetingStone>();
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::<MeetingStone>();
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::<MeetingStone>().area, 1519);
}
#[test]
+66 -16
View File
@@ -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::<LeavingWorldArmed>()
.is_some_and(|mut l| l.spend());
if swept {
if let Some(mut sweeps) = world.get_resource_mut::<Messages<WorldLeaveSweepMessage>>() {
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::<AddOnIdentity>()
.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::<LeavingWorldArmed>()
.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::<UiScript>() {
shutdown_ui_state(&mut script, identity.as_ref(), leaving_world);
@@ -651,7 +688,7 @@ pub(crate) fn shutdown_on_exit(
script: Option<NonSendMut<UiScript>>,
id: Res<AddOnIdentity>,
pending_entry: Option<Res<PendingEntryUiLoad>>,
mut armed: ResMut<LeavingWorldArmed>,
mut sweep: LeaveWorldSweep,
mut exits: MessageReader<AppExit>,
) {
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::<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`).
/// 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::<Messages<WorldEnterCascadeMessage>>();
world.init_resource::<Messages<WorldLeaveSweepMessage>>();
world.init_resource::<LeavingWorldArmed>();
world.resource_mut::<LeavingWorldArmed>().arm();
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
let sweeps = world
.resource_mut::<Messages<WorldLeaveSweepMessage>>()
.drain()
.count();
let cascades = world
.resource_mut::<Messages<WorldEnterCascadeMessage>>()
.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`).
+3 -2
View File
@@ -295,6 +295,7 @@ impl Plugin for UiScriptPlugin {
)
// `init_` here and in `UiUnitPlugin`: either plugin may be built alone in a test.
.init_resource::<LeavingWorldArmed>()
.add_message::<WorldLeaveSweepMessage>()
.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)]
+5 -3
View File
@@ -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<NonSendMut<UiScript>>,
mut armed: ResMut<crate::ui_script::LeavingWorldArmed>,
mut sweep: crate::ui_script::LeaveWorldSweep,
mut ports: MessageReader<crate::net::WorldportMessage>,
) {
// `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::<crate::net::WorldportMessage>()
.add_message::<crate::net::FieldChanged>()
.init_resource::<crate::ui_script::LeavingWorldArmed>()
.add_message::<crate::ui_script::WorldLeaveSweepMessage>()
.add_systems(
Update,
(
@@ -3593,6 +3594,7 @@ mod tests {
let mut app = App::new();
app.add_message::<crate::net::WorldportMessage>()
.init_resource::<crate::ui_script::LeavingWorldArmed>()
.add_message::<crate::ui_script::WorldLeaveSweepMessage>()
.add_systems(Update, fire_leaving_world_on_worldport);
app.insert_non_send_resource(UiScript::new().expect("VM"));
app.world_mut()
@@ -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, &[])
}