feat(session): the in-world logoff — LogOut animation, reverse wormhole, live return to character select

Retires AD-74 (Exit to Character Selection 'behaves as Exit Game') and
files AD-110 (the composed handoff edge) — register rows in this commit.

Retail derivation (named decomp):
- gmGamePlayUI::UseTime @0x004EA3A0: confirmed Yes drains into
  CPlayerSystem::LogOffCharacter(0) when grounded (transient_state &
  CONTACT); the grounded three-way branch now also covers the
  indicator-bar end-session control (it was Options-only).
- CPlayerSystem::LogOffCharacter @0x00563520: SaveToServer FIRST (the
  existing pre-logoff flush hook), then RequestLogOff @0x00562DD0:
  'Logging off...' chat (type 0), 0xF653 via Proto_UI::LogOffCharacter
  @0x00546A20, logOffRequestTime = now + 3.0 (+20.0 when
  IsPlayerKiller @0x0058C910 — PWD bits 0x20|0x2000000), and
  CommandInterpreter::HandleLogOff @0x006B3330 -> Disable.
- The log-off ANIMATION is server-driven: ACE broadcasts
  MotionCommand.LogOut (0x1000011E, Player.cs:596 SendMotionAsCommands)
  and it plays on the local player through the existing inbound
  unpack_movement funnel during the 3 s hold — retail plays nothing
  locally; Disable() is the whole client-side effect.
- gmSmartBoxUI::UseTime @0x004D6E64: hold elapsed ->
  BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @0x004D6E83 (enter cue
  @0x004D638E, unconditional) -> TunnelFadeIn -> Tunnel. The tunnel
  plays the SAME forward 40 fps animation; nothing renders backwards,
  and NO exit cue ever fires on logout (the char-select swap preempts
  the TunnelContinue/FadeOut tail).
- Inbound 0xF653 echo (dispatch case 3 @0x0055C963) ->
  ExecuteLogOff @0x0055D780: world teardown with the LOGON CONNECTION
  KEPT (ExitWorldDisconnect @0x00541E00 removes every connection
  except logonRecID_ — one connection against ACE) and
  Proto_UI::SetEventCounter(0) @0x00541E79; the fresh CharacterList in
  the same batch re-shows character management (gmGamePlayUI::Update
  @0x004E9CD0 -> QueueUIMode(0x1000000a)). ACE mirrors it:
  SendFinalLogOffMessages (Session.cs:249) sends 0xF653 + CharacterList
  + ServerName >=6 s after the request and leaves the session
  AuthConnected — a second EnterWorld needs no re-handshake.

Implementation:
- RuntimeWorldTransitState: the canonical logout lifecycle
  (Requested/PresentationActive/Confirmed, retail 3 s/+20 s holds,
  cancel/reset/ownership convergence).
- WorldSession: RequestCharacterLogOff (non-blocking 0xF653),
  IsCharacterLogOffConfirmed, ReturnToCharacterSelect (InWorld ->
  InCharacterSelect + game-action sequence reset; transport untouched).
- LiveSessionController: BeginCharacterLogOff (flush-first request) and
  CompleteCharacterLogOff — the return-to-selection transaction
  (ReconnectCore minus the transport swap: retire the world
  generation's routes, host reset, state flip, fresh generation
  re-bind, roster re-applied from the pushed CharacterList; failures
  degrade to the full StopCore teardown).
- RuntimeLocalPlayerMovementState.DisableCommandInterpreter +
  DispatcherMovementInputSource gate: retail's Disable() — held keys
  produce no movement while the server LogOut motion plays; cleared by
  the generation reset.
- LocalPlayerTeleportController: the logout pump as the third arm of
  the one wormhole machine (request/hold/wormhole/confirmed handoff;
  teleport starts refused during logout; the handoff runs the session
  transaction whose world reset retires the tunnel as the fresh
  selection state re-shows the character screen).
- UI: both end-session surfaces share the retail three-way grounded
  gate and now run the REAL flow; Options' Exit Game keeps the app
  exit (window close -> the existing graceful-shutdown logoff).

Tests: +5 transit lifecycle, +4 session transaction, +7 logout pump.
Runtime 1756/0 (baseline 1747), App live-DAT 5523/3 (baseline 5512/3
+ 11 this round), Core.Net 1004/0, full solution green (0 failures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 14:02:40 +02:00
parent 2bc81480d4
commit d233f81dce
17 changed files with 1399 additions and 37 deletions

View file

@ -56,6 +56,11 @@ internal sealed record InteractionRetainedUiDependencies(
HostQuiescenceGate HostQuiescence,
RetainedUiInputCaptureSlot RetainedInputCapture,
InputDispatcher? InputDispatcher,
/// <summary>Logout round (2026-08-17): the construction-order teleport
/// bridge — the retained UI is composed before the teleport controller,
/// so the end-character-session binding reaches the wormhole owner
/// through this deferred sink.</summary>
AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink TeleportSink,
// Campaign OP slice OP8: the portable keybinds.json path
// (ApplicationPathSet.KeyBindingsFile) — the Configure Keyboard screen's
// Save button writes here, same file GameWindow's startup load reads.
@ -751,7 +756,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
() => late.Session.LinkStatus,
d.ClientTime,
() => late.Session.CurrentSession?.RequestLinkStatusPing(),
d.Window.Close),
// Logout round (2026-08-17): the in-world logoff flow
// (retail EndCharacterSession — animation + reverse
// wormhole + return to character select), forwarded
// through the construction-order teleport-sink bridge.
EndCharacterSession: d.TeleportSink.RequestLogout,
// Exit Game keeps the app-exit: window close runs the
// graceful-shutdown logoff in WorldSession.Dispose.
ExitGame: d.Window.Close),
Toolbar: new ToolbarRuntimeBindings(
d.Inventory.Objects,
d.Inventory.Shortcuts,

View file

@ -1025,7 +1025,16 @@ internal sealed class SessionPlayerCompositionPhase
// armed pre-reveal tunnel projects the Runtime
// character-selection lifecycle to disarm on a rejected
// EnterWorld (see the controller's field doc).
new RuntimeLoginLifecycleSource(d.Runtime));
new RuntimeLoginLifecycleSource(d.Runtime),
// Logout round (2026-08-17): the logout arm's Runtime seams
// (wire begin, confirmation, return-to-selection
// transaction, PK hold fact, interpreter disable).
new RuntimeLocalPlayerLogoutOperations(
d.Runtime,
d.PlayerController,
liveSessionSource,
d.Inventory.Objects,
d.PlayerIdentity));
LocalPlayerTeleportController CreateLocalTeleportWithTunnel(
PortalTunnelPresentation portalTunnel)

View file

@ -47,6 +47,15 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
public MovementInput Capture()
{
// Logout round (2026-08-17): retail disables the command interpreter
// the moment the logoff request goes on the wire
// (CPlayerSystem::RequestLogOff @ 0x00562E6D ->
// CommandInterpreter::HandleLogOff @ 0x006B3330 -> Disable) — held
// keys stop producing movement while the server-broadcast LogOut
// motion plays.
if (_movement.CommandInterpreterDisabled)
return default;
// Devtools owns the whole gameplay keyboard while active, including
// a latched autorun. Retained chat owns physical key state only;
// retail's autorun latch continues until an explicit cancel action.

View file

@ -1397,6 +1397,7 @@ public sealed class GameWindow :
_hostQuiescence,
_retainedInputCapture,
hostInputCamera.InputDispatcher,
_localPlayerTeleportSink,
_applicationPaths.KeyBindingsFile,
_runtimeSettings,
_runtime,

View file

@ -58,6 +58,18 @@ internal interface ILocalPlayerTeleportNetworkSink
/// </summary>
void ArmLoginTunnel();
/// <summary>
/// Logout round (2026-08-17): the confirmed exit-to-character-select
/// click — retail's <c>gmGamePlayUI::UseTime @ 0x004EA454</c> →
/// <c>CPlayerSystem::LogOffCharacter(0)</c> drain, forwarded through
/// this construction-order bridge so the retained UI (built before the
/// controller) can reach the one wormhole owner. Refusals are logged by
/// the controller; the grounded gate runs upstream in
/// <c>RetailUiRuntime</c> (the <c>transient_state &amp; CONTACT</c>
/// branch @ 0x004EA445).
/// </summary>
void RequestLogout();
void ResetSession();
void ResetGenerationPresentation();
@ -103,6 +115,8 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
public void ArmLoginTunnel() => Required().ArmLoginTunnel();
public void RequestLogout() => Required().RequestLogout();
public void ResetSession() => Required().ResetSession();
public void ResetGenerationPresentation() =>
@ -185,6 +199,105 @@ internal sealed class RuntimeLoginLifecycleSource
_runtime.CharacterSelection.Snapshot.Lifecycle;
}
/// <summary>
/// Logout round (2026-08-17): the logout pump's typed seams — the wire/session
/// transaction pair on the canonical Runtime session owner, the local-player
/// PK fact, and the retail side effects of the request itself (chat line +
/// command-interpreter disable). See <c>CPlayerSystem::RequestLogOff
/// @ 0x00562DD0</c> for the retail body these mirror.
/// </summary>
internal interface ILocalPlayerLogoutOperations
{
/// <summary>
/// Retail <c>ACCWeenieObject::IsPlayerKiller @ 0x0058C910</c>: PWD
/// bitfield <c>0x20</c> (PK) or <c>0x2000000</c> (PKLite). Drives the
/// +20 s logoff hold (<c>RequestLogOff @ 0x00562E4E-0x00562E67</c>).
/// </summary>
bool IsLocalPlayerKiller { get; }
/// <summary>
/// Retail <c>CPlayerSystem::LogOffCharacter(0) @ 0x00563520</c> +
/// <c>RequestLogOff @ 0x00562DD0</c>: options flush, "Logging off..."
/// chat line (type 0, <c>AddTextToScroll @ 0x00562DF2</c>), the 0xF653
/// wire send, and the command-interpreter disable
/// (<c>HandleLogOff @ 0x006B3330</c>).
/// </summary>
bool BeginCharacterLogOff();
/// <summary>The server's opcode-only 0xF653 echo has landed.</summary>
bool IsCharacterLogOffConfirmed { get; }
/// <summary>
/// The return-to-character-select session transaction
/// (<see cref="AcDream.Runtime.Session.LiveSessionController.CompleteCharacterLogOff"/>).
/// </summary>
bool CompleteCharacterLogOff();
}
/// <summary>Production adapter over the canonical Runtime owners.</summary>
internal sealed class RuntimeLocalPlayerLogoutOperations
: ILocalPlayerLogoutOperations
{
private readonly GameRuntime _runtime;
private readonly RuntimeLocalPlayerMovementState _movement;
private readonly ILiveWorldSessionSource _session;
private readonly AcDream.Core.Items.ClientObjectTable _objects;
private readonly ILocalPlayerIdentitySource _identity;
public RuntimeLocalPlayerLogoutOperations(
GameRuntime runtime,
RuntimeLocalPlayerMovementState movement,
ILiveWorldSessionSource session,
AcDream.Core.Items.ClientObjectTable objects,
ILocalPlayerIdentitySource identity)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
_session = session ?? throw new ArgumentNullException(nameof(session));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
}
public bool IsLocalPlayerKiller
{
get
{
uint bitfield = _objects.Get(_identity.ServerGuid)
?.PublicWeenieBitfield ?? 0u;
// IsPlayerKiller @ 0x0058C910: (bitfield & 0x20) | (bitfield &
// 0x2000000) — the PK and PKLite PWD bits.
return (bitfield & 0x20u) != 0u || (bitfield & 0x2000000u) != 0u;
}
}
public bool BeginCharacterLogOff()
{
// The Runtime command runs retail's SaveToServer-first ordering
// (the pre-logoff flush) then sends 0xF653.
if (!_runtime.Session.BeginCharacterLogOff(_runtime.Generation)
.Accepted)
{
return false;
}
// Retail RequestLogOff's own side effects, in its order: the chat
// line (@ 0x00562DF2, AddTextToScroll(str, 0, 1, 0)) and the
// command-interpreter disable (@ 0x00562E6D).
_runtime.CommunicationOwner.AddText(
"Logging off...",
AcDream.Core.Chat.RetailLogTextType.Default);
_movement.DisableCommandInterpreter();
return true;
}
public bool IsCharacterLogOffConfirmed =>
_session.CurrentSession?.IsCharacterLogOffConfirmed == true;
public bool CompleteCharacterLogOff() =>
_runtime.Session.CompleteCharacterLogOff(_runtime.Generation)
.Accepted;
}
internal sealed class LiveLocalPlayerTeleportAuthority
: ILocalPlayerTeleportAuthority
{
@ -381,6 +494,21 @@ internal interface ILocalPlayerTeleportPresentation : IDisposable
bool IsPortalViewportVisible { get; }
int CurrentTunnelFrame { get; }
void Begin(Matrix4x4 projection);
/// <summary>
/// Logout round (2026-08-17): the wormhole run in REVERSE ORDER —
/// retail's <c>BeginTeleportAnimation(TAS_WORLD_FADE_OUT)
/// @ 0x004D6E83</c>. The sequencer enters at WorldFadeOut (the world
/// stays drawn while the view plane pulls in), then TunnelFadeIn →
/// Tunnel; the tunnel scene itself plays the SAME forward 40 fps
/// animation (<c>set_sequence_animation @ 0x004D6F70</c> runs
/// identically for every tunnel-family entry — nothing renders
/// backwards), and the enter cue plays at this begin
/// (<c>Sound_UI_EnterPortal @ 0x004D638E</c>, unconditional). No exit
/// cue ever plays on logout: the character-select swap preempts
/// retail's TunnelContinue/FadeOut tail.
/// </summary>
void BeginLogout(Matrix4x4 projection);
(TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
Tick(float deltaSeconds, bool worldReady);
void TickTunnel(float deltaSeconds);
@ -414,6 +542,12 @@ internal sealed class LocalPlayerTeleportPresentation
_animation.Begin(TeleportEntryKind.Portal);
}
public void BeginLogout(Matrix4x4 projection)
{
_viewPlane.Begin(projection);
_animation.Begin(TeleportEntryKind.Logout);
}
public (TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
Tick(float deltaSeconds, bool worldReady)
{
@ -579,6 +713,15 @@ internal sealed class LocalPlayerTeleportController
/// </summary>
private readonly ILocalPlayerLoginLifecycleSource _loginLifecycle;
/// <summary>
/// Logout round (2026-08-17): the logout pump's Runtime seams — see
/// <see cref="ILocalPlayerLogoutOperations"/>. The pump itself is the
/// third arm of retail's ONE wormhole machine
/// (<c>gmSmartBoxUI::UseTime @ 0x004D6E30</c> drives login, teleport,
/// and logout from the same function).
/// </summary>
private readonly ILocalPlayerLogoutOperations _logout;
public LocalPlayerTeleportController(
ILocalPlayerTeleportAuthority authority,
ILocalPlayerTeleportInputLifetime input,
@ -590,7 +733,8 @@ internal sealed class LocalPlayerTeleportController
ILocalPlayerTeleportSession session,
ILocalPlayerTeleportPresentation presentation,
RuntimeAcceptedPositionDriveController acceptedPositionDrive,
ILocalPlayerLoginLifecycleSource loginLifecycle)
ILocalPlayerLoginLifecycleSource loginLifecycle,
ILocalPlayerLogoutOperations logout)
{
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
_input = input ?? throw new ArgumentNullException(nameof(input));
@ -605,6 +749,7 @@ internal sealed class LocalPlayerTeleportController
?? throw new ArgumentNullException(nameof(acceptedPositionDrive));
_loginLifecycle = loginLifecycle
?? throw new ArgumentNullException(nameof(loginLifecycle));
_logout = logout ?? throw new ArgumentNullException(nameof(logout));
}
public bool IsActive => _transit.IsTeleportActive;
@ -642,6 +787,16 @@ internal sealed class LocalPlayerTeleportController
public void OnTeleportStarted(uint sequence)
{
ThrowIfDisposed();
// Logout round (2026-08-17): a logoff in flight owns the wormhole;
// the character is leaving the world and no F751 may supersede the
// logout presentation (ACE does not teleport a logging-off player —
// Player.LogOut sets IsBusy/IsLoggingOut before any motion runs).
if (_transit.IsLogoutActive)
{
Console.WriteLine(
$"live: teleport start ignored during logout (seq={sequence})");
return;
}
ushort teleportSequence = (ushort)sequence;
if (!_authority.IsFreshStart(teleportSequence)
|| !_transit.CanQueueTeleportStart(teleportSequence))
@ -768,9 +923,199 @@ internal sealed class LocalPlayerTeleportController
return true;
}
// ── Logout round (2026-08-17): the logout arm of retail's ONE wormhole
// machine. Retail derivation:
// gmGamePlayUI::UseTime @ 0x004EA3A0 — confirmed Yes drains into
// CPlayerSystem::LogOffCharacter(0) when the player is grounded
// (transient_state & CONTACT — the upstream RetailUiRuntime gate).
// CPlayerSystem::LogOffCharacter @ 0x00563520 — SaveToServer first.
// CPlayerSystem::RequestLogOff @ 0x00562DD0 — "Logging off..." chat,
// 0xF653 send, logOffRequestTime = now + 3.0 (+20.0 PK),
// CommandInterpreter::HandleLogOff @ 0x006B3330 → Disable. The
// SERVER then broadcasts the LogOut motion (ACE Player.cs:596 →
// SendMotionAsCommands), which plays on the local player through
// the ordinary inbound movement funnel during this hold.
// gmSmartBoxUI::UseTime @ 0x004D6E64 — hold elapsed →
// BeginTeleportAnimation(TAS_WORLD_FADE_OUT) (enter cue) →
// TunnelFadeIn → Tunnel.
// Inbound 0xF653 echo (dispatch case @ 0x0055C963) →
// CPlayerSystem::ExecuteLogOff @ 0x0055D780 — world teardown with
// the logon connection kept; the fresh CharacterList in the same
// server batch re-shows character management
// (gmGamePlayUI::Update @ 0x004E9CD0 → QueueUIMode(0x1000000a)).
// No exit cue: the swap preempts the TunnelContinue/FadeOut tail.
/// <summary>The sink-forwarded UI entry — see
/// <see cref="ILocalPlayerTeleportNetworkSink.RequestLogout"/>.</summary>
public void RequestLogout()
{
if (!TryRequestLogout())
Console.WriteLine("live: character logoff request refused");
}
/// <summary>
/// The confirmed exit-to-character-select click. Returns false when a
/// logout, teleport, or login presentation already owns the machine or
/// the wire request refused.
/// </summary>
public bool TryRequestLogout()
{
ThrowIfDisposed();
if (_transit.IsLogoutActive
|| _transit.IsTeleportActive
|| _transit.HasPendingTeleportStart
|| _loginPresentationActive
|| _loginTunnelArmed)
{
return false;
}
if (!_transit.TryBeginLogoutRequest(_logout.IsLocalPlayerKiller))
return false;
long generation = _lifetimeGeneration;
if (!_logout.BeginCharacterLogOff())
{
// Nothing went on the wire — roll the request back rather than
// running a wormhole for a logoff the server never heard.
if (_lifetimeGeneration == generation)
_transit.CancelLogoutRequest();
return false;
}
if (_lifetimeGeneration != generation)
return true;
// Retail's Disable() also ends any mouse-driven turning; the same
// input-lifetime call every teleport start already makes.
_input.EndMouseLook();
Console.WriteLine("live: character logoff requested");
return true;
}
/// <summary>
/// Per-frame logout pump — the third arm of the wormhole machine (see
/// the derivation block above). Confirmation is polled every tick in
/// every pre-confirmed stage; on the Confirmed edge the handoff runs
/// IMMEDIATELY, exactly like retail's ExecuteLogOff-on-echo — the
/// character-select swap tears down whatever presentation state exists
/// (normally the held tunnel; on a fast confirmation, less).
/// </summary>
private void TickLogout(float deltaSeconds)
{
long generation = _lifetimeGeneration;
if (_transit.LogoutStage is RuntimeLogoutStage.Requested
or RuntimeLogoutStage.PresentationActive
&& _logout.IsCharacterLogOffConfirmed)
{
_transit.AcknowledgeLogoutConfirmed();
}
switch (_transit.LogoutStage)
{
case RuntimeLogoutStage.Requested:
// The 3 s (23 s PK) hold: the server-broadcast LogOut
// motion is playing on the player in-world.
if (_transit.AdvanceLogoutHold(deltaSeconds))
{
_presentation.BeginLogout(_mode.Projection);
if (_lifetimeGeneration != generation)
return;
PumpLogoutPresentation(0f, generation);
}
return;
case RuntimeLogoutStage.PresentationActive:
PumpLogoutPresentation(deltaSeconds, generation);
return;
case RuntimeLogoutStage.Confirmed:
CompleteLogoutHandoff(generation);
return;
default:
return;
}
}
private void PumpLogoutPresentation(float deltaSeconds, long generation)
{
var (_, events) = _presentation.Tick(deltaSeconds, worldReady: false);
if (_lifetimeGeneration != generation)
return;
foreach (TeleportAnimEvent teleportEvent in events)
{
switch (teleportEvent)
{
case TeleportAnimEvent.PlayEnterSound:
// Sound_UI_EnterPortal @ 0x004D638E — unconditional at
// BeginTeleportAnimation, INCLUDING the logout's
// TAS_WORLD_FADE_OUT entry.
Console.WriteLine(
"live: logout portal-space enter cue "
+ "(Sound_UI_EnterPortal)");
_presentation.PlayEnterCue();
if (_lifetimeGeneration != generation)
return;
break;
case TeleportAnimEvent.EnterTunnel:
_presentation.EnterTunnel();
if (_lifetimeGeneration != generation)
return;
break;
default:
// worldReady is pinned false: Place / PlayExitSound /
// FireLoginComplete cannot fire (the sequencer holds in
// Tunnel), matching retail's preempted logout tail.
break;
}
}
_presentation.TickTunnel(deltaSeconds);
}
private void CompleteLogoutHandoff(long generation)
{
if (!_transit.CompleteLogout() || _lifetimeGeneration != generation)
return;
Console.WriteLine(
"live: logout confirmed — returning to character select");
if (_logout.CompleteCharacterLogOff())
{
// The transaction's world reset already ran this controller's
// ResetGenerationPresentation (retiring the tunnel) and the
// fresh selection state re-shows the character-management
// screen — retail's QueueUIMode(0x1000000a) analogue.
return;
}
// The transaction refused or degraded to a full stop. If a reset
// reached this controller the lifetime moved and everything is
// already clean; otherwise retire the presentation here so a
// refused transaction can never leave a stranded tunnel over a
// still-running world.
if (_lifetimeGeneration == generation)
{
Console.Error.WriteLine(
"live: return-to-character-select refused — retiring the "
+ "logout presentation");
_presentation.Reset();
}
}
public void Tick(float deltaSeconds)
{
ThrowIfDisposed();
// Logout round (2026-08-17): an active logout owns the whole
// wormhole machine, exactly as retail's one teleportInProgress flag
// does (SetTeleportInProgress(1) at the logout begin,
// gmSmartBoxUI::UseTime @ 0x004D6E8C). Teleport starts are refused
// while it runs (OnTeleportStarted's own guard).
if (_transit.IsLogoutActive)
{
TickLogout(deltaSeconds);
return;
}
TryActivatePendingPresentation();
TryAimAcceptedDestination();
if (!_transit.IsTeleportActive)

View file

@ -117,7 +117,15 @@ public sealed record IndicatorRuntimeBindings(
Func<LinkStatusSnapshot> LinkStatus,
Func<double> CurrentTime,
Action RequestLinkStatusPing,
Action EndCharacterSession);
/// <summary>Logout round (2026-08-17): the IN-WORLD character logoff —
/// retail's end-character-session flow (log-off animation, reverse
/// wormhole, return to character select on the live connection). Was
/// the window-close action before the logout flow existed.</summary>
Action EndCharacterSession,
/// <summary>The app-exit action (window close → the graceful-shutdown
/// logoff in WorldSession.Dispose) — the Options panel's Exit Game
/// button, retail's m_shouldQuitOnLogout arm.</summary>
Action ExitGame);
public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects,
@ -2461,18 +2469,52 @@ public sealed class RetailUiRuntime : IDisposable
private void RequestEndCharacterSession()
{
ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted =>
// Logout round (2026-08-17): retail funnels BOTH end-session
// surfaces (the indicator bar's control and the Options panel's
// Exit to Character Selection) into gmGamePlayUI's ONE
// m_doEndSession drain, so the grounded three-way branch applies
// here too — the original OP3 port carried it only on the Options
// button.
ShowConfirmation(
ResolveEndCharacterSessionConfirmMessage(),
accepted =>
{
if (accepted)
EndCharacterSessionWithRetailGates();
});
}
/// <summary>
/// The shared confirmed-Yes drain — <c>gmGamePlayUI::UseTime
/// @ 0x004EA3A0</c>'s exact three-way branch (see
/// <see cref="RequestExitToCharacterSelection"/>'s doc for the
/// pseudocode): grounded → <c>CPlayerSystem::LogOffCharacter(0)</c>;
/// airborne → the mid-air refusal; no player → silent no-op.
/// </summary>
private void EndCharacterSessionWithRetailGates()
{
switch (_bindings.Options.IsGrounded())
{
if (accepted)
case true:
_bindings.Indicators.EndCharacterSession();
});
break;
case false:
_bindings.Options.DisplaySystemMessage(
ClientTextRefusals.CantLogOffMidAir);
break;
case null:
break;
}
}
/// <summary>
/// Campaign OP slice OP3: the Options panel's Exit to Character
/// Selection button (element <c>0x10000203</c>) — D6's "behaves as Exit
/// Game" adaptation (register row, same commit), PLUS retail's
/// confirmation dialog and mid-air refusal, which DO port exactly.
/// Selection button (element <c>0x10000203</c>), with retail's
/// confirmation dialog and mid-air refusal. Logout round (2026-08-17):
/// the D6 "behaves as Exit Game" adaptation (AD-76) is RETIRED — the
/// grounded confirmed exit now runs the real in-world logoff flow
/// (log-off animation, reverse wormhole, return to character select on
/// the live connection).
/// <c>gmGamePlayUI::UseTime @0x004EA3A0</c>'s drain, its EXACT three-way
/// branch (review-fix round, 2026-08-11 — the original port collapsed
/// this to a two-way `if/else` that fired the refusal outside player
@ -2490,26 +2532,18 @@ public sealed class RetailUiRuntime : IDisposable
/// </summary>
private void RequestExitToCharacterSelection()
{
ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted =>
{
if (!accepted) return;
switch (_bindings.Options.IsGrounded())
// Logout round (2026-08-17): the D6 "behaves as Exit Game"
// adaptation (AD-76, retired with this change) is gone — the
// confirmed, grounded exit now runs the REAL retail flow through
// EndCharacterSessionWithRetailGates: log-off animation, reverse
// wormhole, return to character select on the live connection.
ShowConfirmation(
ResolveEndCharacterSessionConfirmMessage(),
accepted =>
{
case true:
_bindings.Indicators.EndCharacterSession();
break;
case false:
_bindings.Options.DisplaySystemMessage(ClientTextRefusals.CantLogOffMidAir);
break;
case null:
// Retail's `else if (smartbox->player)` gate: outside
// player mode (or with no live controller) there is no
// player object for UseTime to test at all, so neither
// the airborne refusal nor the logoff itself ever runs.
break;
}
});
if (accepted)
EndCharacterSessionWithRetailGates();
});
}
/// <summary>
@ -2575,7 +2609,10 @@ public sealed class RetailUiRuntime : IDisposable
var callbacks = new Layout.OptionsPanelController.Callbacks(
Toggle: () => ToggleWindow(WindowNames.Options),
RequestExitToCharacterSelection: RequestExitToCharacterSelection,
ExitGame: _bindings.Indicators.EndCharacterSession,
// Logout round (2026-08-17): Exit Game keeps the app-exit
// (window close → graceful-shutdown logoff); the in-world
// return-to-charselect flow lives on EndCharacterSession.
ExitGame: _bindings.Indicators.ExitGame,
UseMouseTurningSettings: ApplyMouseTurningSettingsMacro,
DisplaySystemMessage: _bindings.Options.DisplaySystemMessage,
AfterApply: () => _bindings.Options.CommandBus().Publish(