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

@ -11,7 +11,12 @@ public readonly record struct RuntimeWorldTransitOwnershipSnapshot(
int ActiveRevealCount,
int PendingDestinationReadinessCount,
int HostProjectionCount,
int PendingHostAcknowledgementCount)
int PendingHostAcknowledgementCount,
/// <summary>Logout round (2026-08-17): 1 while a character-logoff
/// lifecycle (request/hold/presentation/confirmation) is in flight.
/// Defaulted so pre-existing positional constructions read
/// identically.</summary>
int ActiveLogoutCount = 0)
{
public bool IsSessionIdle =>
BufferedTeleportDestinationCount == 0
@ -21,7 +26,45 @@ public readonly record struct RuntimeWorldTransitOwnershipSnapshot(
&& ActiveRevealCount == 0
&& PendingDestinationReadinessCount == 0
&& HostProjectionCount == 0
&& PendingHostAcknowledgementCount == 0;
&& PendingHostAcknowledgementCount == 0
&& ActiveLogoutCount == 0;
}
/// <summary>
/// Logout round (2026-08-17): the canonical stages of retail's ONE
/// character-logoff flow, owned Runtime-side exactly like the login/portal
/// reveal lifecycles (the App host projects presentation only).
/// </summary>
/// <remarks>
/// Retail:
/// <list type="bullet">
/// <item><description><see cref="Requested"/> —
/// <c>CPlayerSystem::RequestLogOff @ 0x00562DD0</c>: the 0xF653 request is
/// on the wire, <c>logOffRequested = 1</c>, <c>logOffRequestTime =
/// now + 3.0</c> (+20.0 more when <c>IsPlayerKiller()</c> — 0x00562E3E /
/// 0x00562E67), and the command interpreter is disabled
/// (<c>CommandInterpreter::HandleLogOff @ 0x006B3330</c> → Disable). The
/// server-broadcast LogOut motion plays during this hold.</description></item>
/// <item><description><see cref="PresentationActive"/> —
/// <c>gmSmartBoxUI::UseTime @ 0x004D6E7D</c>: the hold elapsed;
/// <c>BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @ 0x004D6E83</c> (which
/// plays the enter cue @ 0x004D638E), <c>SetTeleportInProgress(1)</c>,
/// <c>SetLogOffStarted</c>. The wormhole then runs WorldFadeOut →
/// TunnelFadeIn → Tunnel and HOLDS (the char-select swap preempts retail's
/// TunnelContinue/FadeOut tail, so no exit cue plays on logout).</description></item>
/// <item><description><see cref="Confirmed"/> — the server's opcode-only
/// 0xF653 echo (ACE <c>SendFinalLogOffMessages</c>, Session.cs:249):
/// retail's inbound dispatch case 3 (@ 0x0055C963) runs
/// <c>CPlayerSystem::ExecuteLogOff @ 0x0055D780</c>. The fresh
/// CharacterList in the same batch drives the character-select return.</description></item>
/// </list>
/// </remarks>
public enum RuntimeLogoutStage
{
None,
Requested,
PresentationActive,
Confirmed,
}
/// <summary>
@ -66,6 +109,22 @@ public sealed class RuntimeWorldTransitState
public static readonly TimeSpan RetailWaitCueDelay =
TimeSpan.FromSeconds(5);
/// <summary>
/// Retail's logoff presentation hold: <c>logOffRequestTime =
/// Timer::cur_time + 3.0</c> (<c>CPlayerSystem::RequestLogOff
/// @ 0x00562E3E</c>). The server-broadcast LogOut motion plays on the
/// player during this window; the wormhole begins when it elapses.
/// </summary>
public const double RetailLogoutHoldSeconds = 3.0;
/// <summary>
/// The additional player-killer hold: <c>+ 20.0</c> when the local
/// player's weenie reports <c>IsPlayerKiller()</c>
/// (<c>CPlayerSystem::RequestLogOff @ 0x00562E4E-0x00562E67</c>),
/// mirroring ACE's own server-side <c>pk_timer</c> logoff queue.
/// </summary>
public const double RetailPlayerKillerAdditionalHoldSeconds = 20.0;
private readonly Action<string> _log;
private readonly Dictionary<ushort, RuntimeTeleportDestination>
_bufferedDestinations = [];
@ -82,6 +141,9 @@ public sealed class RuntimeWorldTransitState
private bool _destinationAccepted;
private bool _hasAcceptedDestination;
private RuntimeTeleportDestination _acceptedDestination;
private RuntimeLogoutStage _logoutStage;
private double _logoutHoldElapsedSeconds;
private double _logoutHoldRequiredSeconds;
public RuntimeWorldTransitState(Action<string>? log = null)
{
@ -124,7 +186,124 @@ public sealed class RuntimeWorldTransitState
revealActive ? 1 : 0,
revealActive && !_snapshot.IsReady ? 1 : 0,
_hostProjections.Count,
pendingHostAcknowledgements);
pendingHostAcknowledgements,
_logoutStage != RuntimeLogoutStage.None ? 1 : 0);
}
// ── Logout lifecycle (2026-08-17) — see RuntimeLogoutStage's remarks
// for the retail derivation. ─────────────────────────────────────────
public RuntimeLogoutStage LogoutStage => _logoutStage;
public bool IsLogoutActive => _logoutStage != RuntimeLogoutStage.None;
/// <summary>
/// The Yes-click edge: latches retail's <c>logOffRequested</c> +
/// <c>logOffRequestTime</c> pair (<c>CPlayerSystem::RequestLogOff
/// @ 0x00562DD0</c>). Refuses while a teleport or logout lifecycle is
/// already in flight — the upstream grounded gate
/// (<c>gmGamePlayUI::UseTime @ 0x004EA445</c>) already refuses mid-air,
/// which covers portal transit for the player-driven path; this guard
/// keeps the invariant structural.
/// </summary>
public bool TryBeginLogoutRequest(bool isPlayerKiller)
{
if (_logoutStage != RuntimeLogoutStage.None
|| _teleportActive
|| _hasPendingTeleportStart)
{
LogRejected(
"logout-request-refused",
$"stage={_logoutStage} teleportActive={_teleportActive} "
+ $"pendingStart={_hasPendingTeleportStart}");
return false;
}
_logoutStage = RuntimeLogoutStage.Requested;
_logoutHoldElapsedSeconds = 0d;
_logoutHoldRequiredSeconds = RetailLogoutHoldSeconds
+ (isPlayerKiller ? RetailPlayerKillerAdditionalHoldSeconds : 0d);
SafeLog(
$"[world-reveal] event=logout-requested "
+ $"holdSeconds={_logoutHoldRequiredSeconds:F1} "
+ $"pk={(isPlayerKiller ? 1 : 0)}");
return true;
}
/// <summary>
/// Rolls back a request whose 0xF653 wire send refused — nothing is on
/// the wire, so no logout lifecycle may remain armed. Only legal from
/// <see cref="RuntimeLogoutStage.Requested"/>.
/// </summary>
public bool CancelLogoutRequest()
{
if (_logoutStage != RuntimeLogoutStage.Requested)
return false;
_logoutStage = RuntimeLogoutStage.None;
_logoutHoldElapsedSeconds = 0d;
_logoutHoldRequiredSeconds = 0d;
SafeLog("[world-reveal] event=logout-request-cancelled");
return true;
}
/// <summary>
/// Advances the request hold (retail's <c>logOffRequestTime</c> compare
/// at <c>gmSmartBoxUI::UseTime @ 0x004D6E6E</c>). Returns true exactly
/// once — on the tick the hold elapses — moving the lifecycle to
/// <see cref="RuntimeLogoutStage.PresentationActive"/>; the host begins
/// the wormhole (WorldFadeOut entry, enter cue) on that edge.
/// </summary>
public bool AdvanceLogoutHold(double deltaSeconds)
{
if (_logoutStage != RuntimeLogoutStage.Requested || deltaSeconds < 0d)
return false;
_logoutHoldElapsedSeconds += deltaSeconds;
if (_logoutHoldElapsedSeconds < _logoutHoldRequiredSeconds)
return false;
_logoutStage = RuntimeLogoutStage.PresentationActive;
SafeLog("[world-reveal] event=logout-presentation-begin");
return true;
}
/// <summary>
/// The server's opcode-only 0xF653 echo landed — retail's
/// <c>ExecuteLogOff</c> edge. Legal from either pre-confirmation stage:
/// ACE's confirmation timing (≥6 s after the request, and only once the
/// player left the landblock) normally lands mid-tunnel, but nothing
/// forbids it landing during the hold.
/// </summary>
public bool AcknowledgeLogoutConfirmed()
{
if (_logoutStage is not (
RuntimeLogoutStage.Requested
or RuntimeLogoutStage.PresentationActive))
{
return false;
}
_logoutStage = RuntimeLogoutStage.Confirmed;
SafeLog("[world-reveal] event=logout-confirmed");
return true;
}
/// <summary>
/// The character-select handoff: the host retired the presentation and
/// is about to run the return-to-selection session transaction. Clears
/// the lifecycle so the world generation reset inside that transaction
/// sees a converged transit owner.
/// </summary>
public bool CompleteLogout()
{
if (_logoutStage != RuntimeLogoutStage.Confirmed)
return false;
_logoutStage = RuntimeLogoutStage.None;
_logoutHoldElapsedSeconds = 0d;
_logoutHoldRequiredSeconds = 0d;
SafeLog("[world-reveal] event=logout-complete");
return true;
}
/// <summary>
@ -836,6 +1015,9 @@ public sealed class RuntimeWorldTransitState
_hasLastTeleportStart = false;
_lastTeleportStartSequence = 0;
_bufferedDestinations.Clear();
_logoutStage = RuntimeLogoutStage.None;
_logoutHoldElapsedSeconds = 0d;
_logoutHoldRequiredSeconds = 0d;
}
private bool TryGetHostRecord(