diff --git a/crates/benilla-app/src/ui_chat/idle.rs b/crates/benilla-app/src/ui_chat/idle.rs index d34ac27d..51b4d4f5 100644 --- a/crates/benilla-app/src/ui_chat/idle.rs +++ b/crates/benilla-app/src/ui_chat/idle.rs @@ -4,10 +4,10 @@ //! //! The logout is a camp, not a kick: `0x5ab000` sends the game menu's `CMSG_LOGOUT_REQUEST`, which //! the server refuses in combat, grants at once when resting, or counts down for 20 seconds -//! (vmangos `MiscHandler.cpp:284-339`). The reference skips the whole 30-minute leg, line -//! included, while the logout-pending byte `[session+0x1b1d]` is set (`0x482eee`); here -//! [`crate::ui_logout`] holds that byte and drops only the repeated request, so `IDLE_MESSAGE` -//! prints again every frame. +//! (vmangos `MiscHandler.cpp:284-339`). The 30-minute leg, line and request together, is skipped +//! while the logout-pending byte `[session+0x1b1d]` is set (`0x482eee`, `0x482ef5`), the byte the +//! request itself sets (`0x5ab05b`) and a cancel or a refusal clears: one line and one request per +//! logout, and the leg arms again once the byte clears. [`crate::ui_logout`] holds that byte. use std::time::Duration; @@ -75,7 +75,8 @@ pub(crate) struct IdleAction { pub(crate) sit: bool, /// `0x482fba call 0x5eb740(NULL)`. pub(crate) afk: bool, - /// `0x482f1a` and `0x5ab000`: `IDLE_MESSAGE` and the logout request. + /// `0x482f1a` and `0x5ab000`: `IDLE_MESSAGE` and the logout request. The band only: the leg's + /// own gate, the logout-pending byte (`0x482ef5`), is the handler's to read. pub(crate) camp: bool, } @@ -99,7 +100,8 @@ pub(crate) struct IdleGates { } /// Resolve the timer against the gates. The bands are exclusive (`0x482ee5 jl 0x482f39`): the -/// thirty-minute leg neither sits nor marks AFK. +/// thirty-minute leg neither sits nor marks AFK. Its one gate, the logout-pending byte, is not a +/// descriptor field, so [`idle_handler`] applies it to `camp`. pub(crate) fn idle_action(idle: Duration, gates: IdleGates) -> IdleAction { if idle < AUTO_AFK_AFTER { // `0x482ed8 jl 0x482fbf`: all three legs sit below this. @@ -129,6 +131,7 @@ pub(crate) fn idle_handler( commands: Res, mut stand: MessageWriter, follow: Res, + logout: Res, ) { let idle = time.elapsed().saturating_sub(last.0); if idle < AUTO_AFK_AFTER { @@ -181,7 +184,9 @@ pub(crate) fn idle_handler( text: out.body, }); } - if action.camp { + // `0x482eee call 0x5ab0d0; 0x482ef5 jne`: while a logout is pending the whole leg is skipped, + // the line with the request, so it runs once per logout and again after a cancel. + if action.camp && !logout.pending() { if let Some(line) = strings("IDLE_MESSAGE") { push_system(&mut chat, line); } @@ -495,6 +500,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_message::() .insert_resource(NetCommands(tx)); // `LastInput` stays at zero and the clock walks forward, so "idle" is just the elapsed. @@ -595,6 +601,68 @@ mod tests { .is_none()); } + /// `0x482ef5`: the leg, line and request, is skipped while the logout it asked for is pending, + /// and arms again once a cancel clears the byte (`0x5aaf80`). The pending byte is the real + /// drain's, run after the handler as the app orders it and before it as well, so the gate + /// holds whichever frame the byte is set in. + #[test] + fn thirty_minutes_idle_prints_and_asks_once_per_logout() { + use benilla_ui::script::{SessionRequest, UiScript}; + + for idle_first in [true, false] { + let (mut app, rx) = world(1_800_000, &[]); + app.init_resource::() + .init_resource::() + .add_message::() + .insert_resource(crate::net::SelfGuid(Some(0x4000_0000_0000_0009))); + // One schedule for every frame, so any state a system keeps between frames persists. + let mut schedule = Schedule::default(); + if idle_first { + schedule.add_systems((idle_handler, crate::ui_logout::drain_logout).chain()); + } else { + schedule.add_systems((crate::ui_logout::drain_logout, idle_handler).chain()); + } + let mut frame = |app: &mut App| schedule.run(app.world_mut()); + let requests = |rx: &crossbeam_channel::Receiver| { + rx.try_iter() + .filter(|c| matches!(c, ClientCommand::Logout)) + .count() + }; + + for _ in 0..60 { + frame(&mut app); + } + assert_eq!( + lines(&app).len(), + 1, + "one IDLE_MESSAGE for sixty frames while the request is pending (idle first: \ + {idle_first})" + ); + assert_eq!(requests(&rx), 1, "and one CMSG_LOGOUT_REQUEST"); + + // The CAMP dialog's cancel: `CancelLogout` clears the byte, and idle continues. + app.world_mut() + .non_send_resource_mut::() + .queue_session_request(SessionRequest::CancelLogout); + app.world_mut() + .run_system_once(crate::ui_logout::drain_logout) + .unwrap(); + assert!(!app + .world() + .resource::() + .pending()); + for _ in 0..60 { + frame(&mut app); + } + assert_eq!( + lines(&app).len(), + 2, + "the leg arms again after the cancel, and again only once" + ); + assert_eq!(requests(&rx), 1, "one more request"); + } + } + /// Through the real system, so the descriptor decode is under test too. #[test] fn the_descriptor_bits_reach_the_gates() { diff --git a/crates/benilla-app/src/ui_logout.rs b/crates/benilla-app/src/ui_logout.rs index 86ebb3a3..0c6cc6e9 100644 --- a/crates/benilla-app/src/ui_logout.rs +++ b/crates/benilla-app/src/ui_logout.rs @@ -43,13 +43,18 @@ enum LogoutSignal { pub(crate) struct LogoutState { quitting: bool, /// The reference's `[session+0x1b1d]`: a request is out and unanswered. `0x5ab000` drops a - /// second request silently while it is set, which the idle logout, asking every frame, relies - /// on; `ForceLogout` (`0x5aaff0`) bypasses it and does not set it. + /// second request silently while it is set, and the idle handler skips its thirty-minute leg + /// on it (`0x482ef5`); `ForceLogout` (`0x5aaff0`) bypasses it and does not set it. pending: bool, signals: Vec, } impl LogoutState { + /// The pending byte, as `0x5ab0d0` returns it. + pub(crate) fn pending(&self) -> bool { + self.pending + } + /// `SMSG_LOGOUT_RESPONSE`, the module doc's table; an instant logout signals nothing. pub(crate) fn apply_response(&mut self, reason: u32, instant: bool) { // Logged: the server's inputs (combat, resting, account level) are invisible from here. @@ -105,7 +110,7 @@ fn feed_logout( } /// Turn the Lua intents into packets; a forced quit, or a quit with no world session, exits. -fn drain_logout( +pub(crate) fn drain_logout( script: Option>, mut logout: ResMut, self_guid: Res, @@ -203,8 +208,11 @@ impl Plugin for UiLogoutPlugin { Update, ( feed_logout.in_set(UiFeed), - drain_logout.after(UiInput), - exit_on_logout_complete.after(UiInput), + // After the idle handler, which reads the pending byte: its request is drained + // and the byte set in the frame it was asked, as `0x5ab05b` sets it in the call. + (drain_logout, exit_on_logout_complete) + .after(UiInput) + .after(crate::ui_chat::idle::idle_handler), ), ); } @@ -302,7 +310,7 @@ mod tests { assert_eq!(sent(&rx), 1, "the first ask goes out"); assert!(app.world().resource::().pending); - // The idle logout's shape: asking every frame while unanswered. + // A second ask while the first is unanswered, as a double-clicked Logout makes. for _ in 0..60 { ask(&mut app, SessionRequest::Logout); }