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:
co-authored by
Claude Opus 5.5
parent
85980939f5
commit
2e82d34c1b
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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, &[])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user