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
|
|
@ -95,6 +95,7 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
private RuntimeLocalPlayerPhysicsPublicationState? _physicsPublication;
|
||||
private bool _autoRunActive;
|
||||
private bool _hasCommandInput;
|
||||
private bool _commandInterpreterDisabled;
|
||||
private MovementInput _commandInput;
|
||||
private bool _disposed;
|
||||
private long _revision;
|
||||
|
|
@ -378,13 +379,39 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
return _controller?.PrepareForAttackRequest() == true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout round (2026-08-17): retail's
|
||||
/// <c>CommandInterpreter::HandleLogOff @ 0x006B3330</c> →
|
||||
/// <c>Disable()</c>, fired from <c>CPlayerSystem::RequestLogOff
|
||||
/// @ 0x00562E6D</c> the moment the logoff request goes on the wire.
|
||||
/// While disabled the graphical input source produces NO movement
|
||||
/// intent (the server-broadcast LogOut motion animates the player) and
|
||||
/// the autorun latch is cancelled. Session-scoped: cleared by
|
||||
/// <see cref="ResetInputIntent"/>/<see cref="ResetSession"/> (the
|
||||
/// generation reset the return-to-character-select transaction runs).
|
||||
/// </summary>
|
||||
public bool CommandInterpreterDisabled => _commandInterpreterDisabled;
|
||||
|
||||
public void DisableCommandInterpreter()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_commandInterpreterDisabled)
|
||||
return;
|
||||
_commandInterpreterDisabled = true;
|
||||
_autoRunActive = false;
|
||||
_hasCommandInput = false;
|
||||
_commandInput = default;
|
||||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
|
||||
public void ResetInputIntent()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_autoRunActive && !_hasCommandInput)
|
||||
if (!_autoRunActive && !_hasCommandInput && !_commandInterpreterDisabled)
|
||||
return;
|
||||
_autoRunActive = false;
|
||||
_hasCommandInput = false;
|
||||
_commandInterpreterDisabled = false;
|
||||
_commandInput = default;
|
||||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
|
|
@ -402,10 +429,12 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
bool changed =
|
||||
_autoRunActive
|
||||
|| _hasCommandInput
|
||||
|| _commandInterpreterDisabled
|
||||
|| _controller is not null
|
||||
|| _preparingMotionOwner is not null;
|
||||
_autoRunActive = false;
|
||||
_hasCommandInput = false;
|
||||
_commandInterpreterDisabled = false;
|
||||
_commandInput = default;
|
||||
if (_controller is not null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue