After thirty idle minutes the logout warning prints once per logout: the idle leg, line and request, is skipped while a logout is pending (0x482ef5) (#474)

After thirty idle minutes the "You have been inactive for some time" line now prints once per logout, not on every frame of the twenty-second countdown. As in 1.12.1, the idle handler skips its whole thirty-minute leg, the line and the logout request together, while the logout-pending byte is set (`0x482eee call 0x5ab0d0`, `0x482ef5 jne 0x482fbf`); the request sets the byte (`0x5ab05b`), and a cancel or a refusal clears it, so the leg arms again.

- The handler reads the pending latch `ui_logout` already keeps; the logout drain now runs after the handler, so the byte is set in the frame the request is made.
- The test runs sixty frames past thirty minutes through one schedule, in both system orders, and expects one line and one request, then one more of each after a cancel.

Fixes #188

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-27 20:07:30 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 03b1072220
commit bd074c3f47
2 changed files with 89 additions and 13 deletions
+75 -7
View File
@@ -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<crate::net::NetCommands>,
mut stand: MessageWriter<crate::player::StandStateRequest>,
follow: Res<crate::player::FollowState>,
logout: Res<crate::ui_logout::LogoutState>,
) {
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::<ChatLog>()
.init_resource::<LastInput>()
.init_resource::<crate::player::FollowState>()
.init_resource::<crate::ui_logout::LogoutState>()
.add_message::<crate::player::StandStateRequest>()
.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::<crate::ui_script::ReloadUiPending>()
.init_resource::<crate::cinematic::Cinematic>()
.add_message::<AppExit>()
.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<ClientCommand>| {
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::<UiScript>()
.queue_session_request(SessionRequest::CancelLogout);
app.world_mut()
.run_system_once(crate::ui_logout::drain_logout)
.unwrap();
assert!(!app
.world()
.resource::<crate::ui_logout::LogoutState>()
.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() {
+14 -6
View File
@@ -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<LogoutSignal>,
}
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<NonSendMut<UiScript>>,
mut logout: ResMut<LogoutState>,
self_guid: Res<SelfGuid>,
@@ -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::<LogoutState>().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);
}