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

@ -935,6 +935,13 @@ public sealed class LocalPlayerTeleportControllerTests
public readonly FakeLoginLifecycleSource LoginLifecycle = new();
/// <summary>
/// Logout round (2026-08-17): the logout pump's Runtime seams —
/// mutable wire/confirmation/transaction outcomes so tests drive the
/// full request → hold → wormhole → confirmation → handoff flow.
/// </summary>
public readonly FakeLogoutOperations Logout = new();
public Harness(
int centerX = 0x20,
int centerY = 0x21,
@ -1064,7 +1071,8 @@ public sealed class LocalPlayerTeleportControllerTests
Session,
Presentation,
AcceptedPositionDrive,
LoginLifecycle);
LoginLifecycle,
Logout);
}
/// <summary>
@ -1483,6 +1491,8 @@ public sealed class LocalPlayerTeleportControllerTests
}
public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++;
public void ArmLoginTunnel() => LoginTunnelArms++;
public int LogoutRequests;
public void RequestLogout() => LogoutRequests++;
public void ResetSession()
{
}
@ -1871,6 +1881,164 @@ public sealed class LocalPlayerTeleportControllerTests
Assert.True(harness.Presentation.IsPortalViewportVisible);
}
// ── Logout round (2026-08-17): the logout arm of the wormhole machine.
//
// Retail: CPlayerSystem::RequestLogOff @ 0x00562DD0 (chat + 0xF653 +
// 3 s hold (+20 PK) + interpreter Disable) →
// gmSmartBoxUI::UseTime @ 0x004D6E64 hold-elapsed →
// BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @ 0x004D6E83 (enter cue,
// @ 0x004D638E) → TunnelFadeIn → Tunnel hold → inbound 0xF653 echo →
// ExecuteLogOff @ 0x0055D780 + CharacterList-driven char-select swap.
// No exit cue on logout (the swap preempts the tail) — AD-110.
[Fact]
public void LogoutRequest_SendsWireThenHoldsThreeSeconds_ThenBeginsWormhole()
{
var order = new List<string>();
var harness = new Harness(worldReady: true, order: order);
Assert.True(harness.Controller.TryRequestLogout());
Assert.Equal(1, harness.Logout.BeginCalls);
Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage);
Assert.Equal(1, harness.Input.EndCount);
// The 3 s hold: no presentation, the server-broadcast LogOut motion
// is playing in-world.
harness.Controller.Tick(1.0f);
harness.Controller.Tick(1.0f);
Assert.DoesNotContain("presentation-begin-logout", order);
Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage);
// Hold elapses → the wormhole begins at WorldFadeOut with the enter
// cue (BeginTeleportAnimation plays it unconditionally).
harness.Presentation.Enqueue(TeleportAnimEvent.PlayEnterSound);
harness.Controller.Tick(1.05f);
Assert.Contains("presentation-begin-logout", order);
Assert.Equal(
RuntimeLogoutStage.PresentationActive,
harness.Transit.LogoutStage);
Assert.Equal(["enter"], harness.Presentation.Cues);
// The tunnel edge arrives on its own sequencer event.
harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel);
harness.Controller.Tick(0.016f);
Assert.True(harness.Presentation.IsPortalViewportVisible);
Assert.All(
harness.Presentation.WorldReadyValues,
value => Assert.False(value));
}
[Fact]
public void LogoutConfirmation_RunsTheHandoffOnceAndCompletesTheLifecycle()
{
var order = new List<string>();
var harness = new Harness(worldReady: true, order: order);
Assert.True(harness.Controller.TryRequestLogout());
harness.Presentation.Enqueue(
TeleportAnimEvent.PlayEnterSound,
TeleportAnimEvent.EnterTunnel);
harness.Controller.Tick(3.05f);
Assert.True(harness.Presentation.IsPortalViewportVisible);
// The server's opcode-only 0xF653 echo lands (ACE sends it >= 6 s
// after the request, tunnel always up by then): the next tick
// acknowledges and runs the handoff IMMEDIATELY — retail's
// ExecuteLogOff-on-echo (AD-110's composed edge).
harness.Logout.IsCharacterLogOffConfirmed = true;
harness.Logout.OnComplete = () =>
harness.Controller.ResetGenerationPresentation();
harness.Controller.Tick(0.016f);
Assert.Equal(1, harness.Logout.CompleteCalls);
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
// The transaction's world reset retired the presentation.
Assert.Contains("presentation-reset", order);
Assert.False(harness.Presentation.IsPortalViewportVisible);
// No exit cue on logout — the swap preempts the tail.
Assert.Equal(["enter"], harness.Presentation.Cues);
// The retired lifecycle stays quiet.
harness.Controller.Tick(0.016f);
Assert.Equal(1, harness.Logout.CompleteCalls);
}
[Fact]
public void LogoutConfirmationBeforeHoldEnd_SkipsTheWormholeEntirely()
{
// Retail's ExecuteLogOff clears logOffRequested — a confirmation
// landing before the hold elapses cancels the pending wormhole and
// the CharacterList swap happens directly (unreachable against
// ACE's >= 6 s floor, but the machine is total).
var order = new List<string>();
var harness = new Harness(worldReady: true, order: order);
Assert.True(harness.Controller.TryRequestLogout());
harness.Logout.IsCharacterLogOffConfirmed = true;
harness.Logout.OnComplete = () =>
harness.Controller.ResetGenerationPresentation();
harness.Controller.Tick(0.016f);
Assert.Equal(1, harness.Logout.CompleteCalls);
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
Assert.DoesNotContain("presentation-begin-logout", order);
Assert.Empty(harness.Presentation.Cues);
}
[Fact]
public void LogoutRequest_RefusedWireRollsTheLifecycleBack()
{
var harness = new Harness(worldReady: true);
harness.Logout.BeginResult = false;
Assert.False(harness.Controller.TryRequestLogout());
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
Assert.Equal(1, harness.Logout.BeginCalls);
}
[Fact]
public void LogoutRequest_UsesThePlayerKillerHold()
{
var harness = new Harness(worldReady: true);
harness.Logout.IsLocalPlayerKiller = true;
Assert.True(harness.Controller.TryRequestLogout());
// 3 s is NOT enough for a PK — retail adds +20 s
// (RequestLogOff @ 0x00562E67).
harness.Controller.Tick(3.5f);
Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage);
harness.Controller.Tick(19.6f);
Assert.Equal(
RuntimeLogoutStage.PresentationActive,
harness.Transit.LogoutStage);
}
[Fact]
public void TeleportStart_IsIgnoredWhileLogoutIsActive()
{
var harness = new Harness(worldReady: true);
Assert.True(harness.Controller.TryRequestLogout());
harness.Controller.OnTeleportStarted(7);
Assert.False(harness.Controller.IsActive);
Assert.False(harness.Transit.HasPendingTeleportStart);
Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage);
}
[Fact]
public void LogoutRequest_RefusedDuringTeleportOrLogin()
{
var harness = new Harness(worldReady: true);
harness.Controller.OnTeleportStarted(3);
Assert.False(harness.Controller.TryRequestLogout());
Assert.Equal(0, harness.Logout.BeginCalls);
var loginHarness = new Harness(worldReady: false);
loginHarness.Presentation.Enqueue(
TeleportAnimEvent.PlayEnterSound,
TeleportAnimEvent.EnterTunnel);
loginHarness.Controller.ArmLoginTunnel();
Assert.False(loginHarness.Controller.TryRequestLogout());
Assert.Equal(0, loginHarness.Logout.BeginCalls);
}
private sealed class StubLoginState : IRenderLoginStateSource
{
public bool IsWaitingForLogin { get; set; }
@ -1886,6 +2054,30 @@ public sealed class LocalPlayerTeleportControllerTests
} = RuntimeCharacterSelectionLifecycle.EnteringWorld;
}
private sealed class FakeLogoutOperations : ILocalPlayerLogoutOperations
{
public bool IsLocalPlayerKiller { get; set; }
public bool BeginResult = true;
public int BeginCalls;
public bool IsCharacterLogOffConfirmed { get; set; }
public bool CompleteResult = true;
public int CompleteCalls;
public Action? OnComplete;
public bool BeginCharacterLogOff()
{
BeginCalls++;
return BeginResult;
}
public bool CompleteCharacterLogOff()
{
CompleteCalls++;
OnComplete?.Invoke();
return CompleteResult;
}
}
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
{
private readonly List<string> _order;
@ -1910,6 +2102,12 @@ public sealed class LocalPlayerTeleportControllerTests
_order.Add("presentation-begin");
}
public void BeginLogout(Matrix4x4 projection)
{
BeginProjection = projection;
_order.Add("presentation-begin-logout");
}
public (TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
Tick(float deltaSeconds, bool worldReady)
{