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

@ -169,6 +169,30 @@ public sealed class LiveSessionControllerTests
}
DisposeCounts[session] = DisposeCounts.GetValueOrDefault(session) + 1;
}
// Logout round (2026-08-17): the in-world logoff pair — recorded
// here so the return-to-selection transaction is testable without a
// live in-world WorldSession state machine.
public int RequestCharacterLogOffCount { get; private set; }
public int ReturnToCharacterSelectCount { get; private set; }
public bool ThrowOnRequestCharacterLogOff { get; set; }
public bool ThrowOnReturnToCharacterSelect { get; set; }
public void RequestCharacterLogOff(WorldSession session)
{
calls.Add("request-character-logoff");
RequestCharacterLogOffCount++;
if (ThrowOnRequestCharacterLogOff)
throw new InvalidOperationException("logoff request failure");
}
public void ReturnToCharacterSelect(WorldSession session)
{
calls.Add("return-to-character-select");
ReturnToCharacterSelectCount++;
if (ThrowOnReturnToCharacterSelect)
throw new InvalidOperationException("return failure");
}
}
private sealed class TestHost(List<string> calls) : ILiveSessionLifecycleHost
@ -1619,6 +1643,144 @@ public sealed class LiveSessionControllerTests
Assert.Single(operations.DisposeCounts);
}
// ── Logout round (2026-08-17): the in-world logoff transaction pair —
// retail CPlayerSystem::LogOffCharacter(0) @ 0x00563520 (flush-first
// 0xF653 request) and ExecuteLogOff @ 0x0055D780 composed with the
// CharacterList-driven character-select re-show (AD-110). ─────────────
[Fact]
public void BeginCharacterLogOff_FlushesFirstThenSendsTheRequest()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.ConfigurePreLogoffFlush(_ => calls.Add("flush"));
Assert.Equal(
LiveSessionStartStatus.Connected,
controller.Start(LiveOptions(), host).Status);
RuntimeCommandResult result =
controller.BeginCharacterLogOff(controller.Generation);
Assert.True(result.Accepted);
Assert.Equal(1, operations.RequestCharacterLogOffCount);
// SaveToServer BEFORE the wire request — LogOffCharacter @ 0x00563528.
Assert.True(
calls.IndexOf("flush") < calls.IndexOf("request-character-logoff"));
// No teardown of any kind at request time (the single reset on
// record is Start's own initial host reset).
Assert.True(controller.IsInWorld);
Assert.Equal(1, host.ResetCount);
}
[Fact]
public void BeginCharacterLogOff_RefusesOutsideTheWorld()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Assert.Equal(
RuntimeCommandStatus.Inactive,
controller.BeginCharacterLogOff(controller.Generation).Status);
Assert.Equal(0, operations.RequestCharacterLogOffCount);
Assert.Equal(
LiveSessionStartStatus.Connected,
controller.Start(LiveOptions(), host).Status);
Assert.Equal(
RuntimeCommandStatus.StaleGeneration,
controller.BeginCharacterLogOff(default).Status);
}
[Fact]
public void CompleteCharacterLogOff_ReturnsToSelectionOnTheLiveSession()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Assert.Equal(
LiveSessionStartStatus.Connected,
controller.Start(LiveOptions(), host).Status);
WorldSession session = operations.Sessions[0];
RuntimeGenerationToken worldGeneration = controller.Generation;
calls.Clear();
RuntimeCommandResult result =
controller.CompleteCharacterLogOff(worldGeneration);
Assert.True(result.Accepted);
// The retiring world generation's routes died first, then the host
// reset THAT generation, then the same live session flipped back —
// no transport disposal anywhere.
Assert.Equal(
[
"deactivate", "detach-events", "detach-session", "reset",
"return-to-character-select", "bind", "roster",
],
calls);
// ResetGenerations[0] is Start's own initial host reset; the
// transaction's reset targets exactly the retiring world generation.
Assert.Equal(2, host.ResetCount);
Assert.Equal(worldGeneration, host.ResetGenerations[^1]);
Assert.Empty(operations.DisposeCounts);
Assert.False(controller.IsInWorld);
Assert.Same(session, controller.CurrentSession);
Assert.NotEqual(worldGeneration, controller.Generation);
Assert.Equal(result.Generation, controller.Generation);
// The fresh generation owns an AwaitingSelection roster re-applied
// from the session cache ACE's post-logoff CharacterList filled.
RuntimeCharacterSelectionSnapshot snapshot =
controller.CharacterSelectionState.View.Snapshot;
Assert.Equal(
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
snapshot.Lifecycle);
Assert.Equal(controller.Generation, snapshot.Generation);
Assert.Equal(2, host.Rosters.Count);
// The round trip: a second Enter works on the SAME session.
RuntimeCommandResult enter = controller.Enter(controller.Generation);
Assert.True(enter.Accepted);
Assert.True(controller.IsInWorld);
Assert.Equal(2, operations.EnterWorldCount);
Assert.True(host.CommandBuses[^1].Active);
}
[Fact]
public void CompleteCharacterLogOff_RefusalsAndFailureDegradeToStop()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Assert.Equal(
RuntimeCommandStatus.Inactive,
controller.CompleteCharacterLogOff(controller.Generation).Status);
Assert.Equal(
LiveSessionStartStatus.Connected,
controller.Start(LiveOptions(), host).Status);
Assert.Equal(
RuntimeCommandStatus.StaleGeneration,
controller.CompleteCharacterLogOff(default).Status);
// A mid-transaction failure must not leave a half-reset session:
// the transaction degrades to the full StopCore teardown.
operations.ThrowOnReturnToCharacterSelect = true;
WorldSession session = operations.Sessions[0];
RuntimeCommandResult result =
controller.CompleteCharacterLogOff(controller.Generation);
Assert.Equal(RuntimeCommandStatus.Rejected, result.Status);
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[session]);
}
private static LiveSessionConnectOptions LiveOptions(
bool live = true,
string? user = "user",