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

@ -235,6 +235,17 @@ public interface ILiveSessionOperations
session.SendCharacterCreation(accountName, request, skillAdvancementClasses);
void Tick(WorldSession session);
void DisposeSession(WorldSession session);
/// <summary>Logout round (2026-08-17): the in-world 0xF653 request —
/// <see cref="WorldSession.RequestCharacterLogOff"/>.</summary>
void RequestCharacterLogOff(WorldSession session) =>
session.RequestCharacterLogOff();
/// <summary>Logout round (2026-08-17): the live-connection return to
/// character select — <see cref="WorldSession.ReturnToCharacterSelect"/>.
/// </summary>
void ReturnToCharacterSelect(WorldSession session) =>
session.ReturnToCharacterSelect();
}
internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
@ -1155,6 +1166,198 @@ public sealed class LiveSessionController
}
}
/// <summary>
/// Logout round (2026-08-17): retail's
/// <c>CPlayerSystem::LogOffCharacter(force=0) @ 0x00563520</c> —
/// <c>CPlayerModule::SaveToServer</c> FIRST (the pre-logoff flush hook,
/// @ 0x00563528), then <c>RequestLogOff @ 0x00562DD0</c>'s 0xF653 wire
/// send. No teardown happens here: the ~3 s hold, the wormhole
/// presentation, and the confirmation ride
/// <c>RuntimeWorldTransitState</c>'s logout lifecycle App-side; the
/// world teardown is <see cref="CompleteCharacterLogOff"/>.
/// </summary>
public RuntimeCommandResult BeginCharacterLogOff(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeGenerationToken current = new(_generation);
if (expectedGeneration != current)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.StaleGeneration,
current);
}
if (_disposed
|| _disposeRequested
|| _scope is null
|| !_inWorld
|| _operationDepth != 0)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.Inactive,
current);
}
SessionScope scope = _scope;
InvokePreLogoffFlush(scope.Session);
try
{
_operations.RequestCharacterLogOff(scope.Session);
}
catch (Exception error)
{
Console.Error.WriteLine(
$"live: character-logoff request failed: {error.Message}");
return new RuntimeCommandResult(
RuntimeCommandStatus.Rejected,
current);
}
return new RuntimeCommandResult(
RuntimeCommandStatus.Accepted,
current);
}
}
/// <summary>
/// Logout round (2026-08-17): the return-to-character-select transaction
/// — retail's <c>ExecuteLogOff @ 0x0055D780</c> (world teardown, logon
/// connection kept, event counter reset via
/// <c>Proto_UI::SetEventCounter(0) @ 0x00541E79</c>) composed with the
/// character-select re-show its fresh CharacterList drives
/// (<c>gmGamePlayUI::Update @ 0x004E9CD0</c> →
/// <c>QueueUIMode(0x1000000a)</c>). Invoked by the graphical host AFTER
/// the logout presentation retired and the server's 0xF653 echo landed.
/// Structurally it is <c>ReconnectCore</c> minus the transport swap: the
/// retiring world generation's routes are disposed, the host resets that
/// generation, the SAME live <see cref="WorldSession"/> flips back to
/// character select, and a fresh generation re-binds routes and re-applies
/// the roster ACE pushed alongside the logoff echo
/// (<c>Session.SendFinalLogOffMessages</c> — 0xF653 + CharacterList +
/// ServerName; the session stays <c>AuthConnected</c>). Any failure
/// degrades to the full <c>StopCore</c> teardown rather than leaving a
/// half-reset session.
/// </summary>
public RuntimeCommandResult CompleteCharacterLogOff(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeGenerationToken current = new(_generation);
if (expectedGeneration != current)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.StaleGeneration,
current);
}
if (_disposed
|| _disposeRequested
|| _scope is null
|| _retiredScope is not null
|| !_inWorld)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.Inactive,
current);
}
if (_operationDepth != 0)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.Rejected,
current);
}
return RunTopLevel(CompleteCharacterLogOffCore);
}
}
private RuntimeCommandResult CompleteCharacterLogOffCore()
{
SessionScope scope = _scope!;
ILiveSessionLifecycleHost host = scope.Host;
WorldSession session = scope.Session;
RuntimeGenerationToken retiring = scope.Generation;
try
{
// 1. Retire the world generation's routes: outbound commands
// become inert before inbound subscriptions detach (the same
// ordering LiveSessionBinding's own teardown guarantees). The
// transport is deliberately untouched — retail keeps the
// logon connection at character select.
scope.Binding?.Dispose();
scope.CharacterSelectionBinding?.Dispose();
// 2. Detach, then reset the retiring world generation (the
// DrainTeardown stage 2→3 ordering, without stage 1's
// transport disposal).
if (scope.HostAttached)
{
host.DetachSession(session);
scope.HostAttached = false;
}
host.ResetSessionState(retiring);
// 3. Core.Net: InWorld → InCharacterSelect + retail's outbound
// event-counter reset.
_operations.ReturnToCharacterSelect(session);
// 4. Fresh generation for character select and the next world.
ulong generation = ++_generation;
var activeGeneration = new RuntimeGenerationToken(generation);
_inWorld = false;
_activeSelection = null;
CharacterSelectionState.Reset(activeGeneration);
CharacterCreationState.Reset(activeGeneration);
_createsSinceCharacterList = 0;
CharacterSelectionState.Begin(activeGeneration);
CharacterCreationState.Begin(activeGeneration);
// 5. New scope over the SAME session and host; commands stay
// inert until the next EnterWorld activates them, exactly
// like the awaiting-selection connect flow.
var newScope = new SessionScope(session, host, activeGeneration);
_scope = newScope;
LiveSessionBinding binding = host.BindSession(session);
newScope.Binding = binding;
newScope.HostAttached = true;
newScope.CharacterSelectionBinding = BindCharacterSelection(
newScope,
generation);
// 6. Roster + world name from the session's post-logoff caches
// (ACE pushed both in the SAME batch as the 0xF653 echo).
CharacterList.Parsed? characters =
_operations.GetCharacters(session);
if (characters is not null)
{
_createsSinceCharacterList = 0;
LiveSessionRosterReport roster = BuildRosterReport(characters);
CharacterSelectionState.ApplyRoster(roster);
host.ReportRoster(roster);
}
if (_operations.GetServerInfo(session) is { } serverInfo)
CharacterSelectionState.ApplyWorldName(serverInfo.WorldName);
Console.WriteLine(
"live: character logoff complete — returned to character "
+ "select (session connected)");
return new RuntimeCommandResult(
RuntimeCommandStatus.Accepted,
activeGeneration);
}
catch (Exception error)
{
Console.Error.WriteLine(
"live: return-to-character-select failed; stopping session: "
+ error.Message);
_ = StopAfterFailure(error);
return new RuntimeCommandResult(
RuntimeCommandStatus.Rejected,
new RuntimeGenerationToken(_generation));
}
}
private RuntimeCommandResult EnterSelectedCore() =>
EnterHighlightedCore(static (operations, session, character, _) =>
operations.EnterWorld(session, character.ActiveIndex));