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:
parent
2bc81480d4
commit
d233f81dce
17 changed files with 1399 additions and 37 deletions
|
|
@ -1139,6 +1139,77 @@ public sealed class WorldSession : IDisposable
|
|||
EnsureNetReceiveLoopStarted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The server's opcode-only <c>0xF653</c> logoff confirmation has been
|
||||
/// observed since the last <see cref="RequestCharacterLogOff"/>. Latched
|
||||
/// by the inbound datagram path regardless of world-event dispatch, so
|
||||
/// both the graceful-shutdown wait and the in-world logoff presentation
|
||||
/// read the same fact.
|
||||
/// </summary>
|
||||
public bool IsCharacterLogOffConfirmed =>
|
||||
Volatile.Read(ref _characterLogOffConfirmed) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Logout round (2026-08-17): the IN-WORLD character-logoff request —
|
||||
/// retail's <c>CPlayerSystem::RequestLogOff @ 0x00562DD0</c> →
|
||||
/// <c>Proto_UI::LogOffCharacter @ 0x00546A20</c> (opcode <c>0xF653</c> +
|
||||
/// active character id), sent the moment the exit confirmation is
|
||||
/// accepted, ~3 s BEFORE the client's own wormhole presentation begins.
|
||||
/// Non-blocking: the server's confirmation arrives through the ordinary
|
||||
/// <see cref="Tick"/> pump and is observed via
|
||||
/// <see cref="IsCharacterLogOffConfirmed"/> (retail's inbound dispatch
|
||||
/// case for the echo runs <c>CPlayerSystem::ExecuteLogOff @ 0x0055D780</c>).
|
||||
/// The graceful-shutdown path in <see cref="Dispose"/> is unchanged and
|
||||
/// independent; after <see cref="ReturnToCharacterSelect"/> its
|
||||
/// <c>BuildShutdownPlan</c> no longer requests a second logoff (state is
|
||||
/// not <see cref="State.InWorld"/>).
|
||||
/// </summary>
|
||||
public void RequestCharacterLogOff()
|
||||
{
|
||||
if (CurrentState != State.InWorld || _activeCharacterId == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"character logoff requires an in-world session with an "
|
||||
+ "active character");
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _characterLogOffConfirmed, 0);
|
||||
SendGameMessage(CharacterLogOff.BuildRequestBody(_activeCharacterId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout round (2026-08-17): the world half of retail's
|
||||
/// <c>CPlayerSystem::ExecuteLogOff @ 0x0055D780</c> →
|
||||
/// <c>ClientNet::ExitWorldDisconnect @ 0x00541E00</c> — return this LIVE
|
||||
/// session to character select WITHOUT touching the transport. Retail
|
||||
/// keeps the logon connection (ExitWorldDisconnect removes every
|
||||
/// connection EXCEPT <c>logonRecID_</c>; against ACE the logon and world
|
||||
/// connection are the same one) and resets the outbound event counter
|
||||
/// (<c>Proto_UI::SetEventCounter(0) @ 0x00541E79</c>); ACE mirrors it
|
||||
/// server-side — <c>Session.SendFinalLogOffMessages</c> leaves the
|
||||
/// session in <c>AuthConnected</c> and <c>InitSessionForWorldLogin</c>
|
||||
/// resets <c>GameEventSequence</c> on the next world entry
|
||||
/// (ACE Session.cs:249-278, CharacterHandler.cs:258). A second
|
||||
/// <see cref="EnterWorld(int,TimeSpan?)"/> then runs the same
|
||||
/// InCharacterSelect → EnteringWorld transition the enter-rejection
|
||||
/// retry path already exercises.
|
||||
/// </summary>
|
||||
public void ReturnToCharacterSelect()
|
||||
{
|
||||
if (CurrentState is not (State.InWorld or State.EnteringWorld))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"return-to-character-select requires an in-world session");
|
||||
}
|
||||
|
||||
_activeCharacterId = 0;
|
||||
// Proto_UI::SetEventCounter(0) @ 0x00541E79: the client's outbound
|
||||
// game-action sequence restarts for the next world session.
|
||||
_gameActionSequence = 0;
|
||||
Interlocked.Exchange(ref _characterLogOffConfirmed, 0);
|
||||
Transition(State.InCharacterSelect);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send CharacterEnterWorldRequest and CharacterEnterWorld for
|
||||
/// <see cref="Characters"/>[<paramref name="characterIndex"/>].
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue