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
File diff suppressed because one or more lines are too long
|
|
@ -56,6 +56,11 @@ internal sealed record InteractionRetainedUiDependencies(
|
|||
HostQuiescenceGate HostQuiescence,
|
||||
RetainedUiInputCaptureSlot RetainedInputCapture,
|
||||
InputDispatcher? InputDispatcher,
|
||||
/// <summary>Logout round (2026-08-17): the construction-order teleport
|
||||
/// bridge — the retained UI is composed before the teleport controller,
|
||||
/// so the end-character-session binding reaches the wormhole owner
|
||||
/// through this deferred sink.</summary>
|
||||
AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink TeleportSink,
|
||||
// Campaign OP slice OP8: the portable keybinds.json path
|
||||
// (ApplicationPathSet.KeyBindingsFile) — the Configure Keyboard screen's
|
||||
// Save button writes here, same file GameWindow's startup load reads.
|
||||
|
|
@ -751,7 +756,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
() => late.Session.LinkStatus,
|
||||
d.ClientTime,
|
||||
() => late.Session.CurrentSession?.RequestLinkStatusPing(),
|
||||
d.Window.Close),
|
||||
// Logout round (2026-08-17): the in-world logoff flow
|
||||
// (retail EndCharacterSession — animation + reverse
|
||||
// wormhole + return to character select), forwarded
|
||||
// through the construction-order teleport-sink bridge.
|
||||
EndCharacterSession: d.TeleportSink.RequestLogout,
|
||||
// Exit Game keeps the app-exit: window close runs the
|
||||
// graceful-shutdown logoff in WorldSession.Dispose.
|
||||
ExitGame: d.Window.Close),
|
||||
Toolbar: new ToolbarRuntimeBindings(
|
||||
d.Inventory.Objects,
|
||||
d.Inventory.Shortcuts,
|
||||
|
|
|
|||
|
|
@ -1025,7 +1025,16 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
// armed pre-reveal tunnel projects the Runtime
|
||||
// character-selection lifecycle to disarm on a rejected
|
||||
// EnterWorld (see the controller's field doc).
|
||||
new RuntimeLoginLifecycleSource(d.Runtime));
|
||||
new RuntimeLoginLifecycleSource(d.Runtime),
|
||||
// Logout round (2026-08-17): the logout arm's Runtime seams
|
||||
// (wire begin, confirmation, return-to-selection
|
||||
// transaction, PK hold fact, interpreter disable).
|
||||
new RuntimeLocalPlayerLogoutOperations(
|
||||
d.Runtime,
|
||||
d.PlayerController,
|
||||
liveSessionSource,
|
||||
d.Inventory.Objects,
|
||||
d.PlayerIdentity));
|
||||
|
||||
LocalPlayerTeleportController CreateLocalTeleportWithTunnel(
|
||||
PortalTunnelPresentation portalTunnel)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,15 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
|
|||
|
||||
public MovementInput Capture()
|
||||
{
|
||||
// Logout round (2026-08-17): retail disables the command interpreter
|
||||
// the moment the logoff request goes on the wire
|
||||
// (CPlayerSystem::RequestLogOff @ 0x00562E6D ->
|
||||
// CommandInterpreter::HandleLogOff @ 0x006B3330 -> Disable) — held
|
||||
// keys stop producing movement while the server-broadcast LogOut
|
||||
// motion plays.
|
||||
if (_movement.CommandInterpreterDisabled)
|
||||
return default;
|
||||
|
||||
// Devtools owns the whole gameplay keyboard while active, including
|
||||
// a latched autorun. Retained chat owns physical key state only;
|
||||
// retail's autorun latch continues until an explicit cancel action.
|
||||
|
|
|
|||
|
|
@ -1397,6 +1397,7 @@ public sealed class GameWindow :
|
|||
_hostQuiescence,
|
||||
_retainedInputCapture,
|
||||
hostInputCamera.InputDispatcher,
|
||||
_localPlayerTeleportSink,
|
||||
_applicationPaths.KeyBindingsFile,
|
||||
_runtimeSettings,
|
||||
_runtime,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,18 @@ internal interface ILocalPlayerTeleportNetworkSink
|
|||
/// </summary>
|
||||
void ArmLoginTunnel();
|
||||
|
||||
/// <summary>
|
||||
/// Logout round (2026-08-17): the confirmed exit-to-character-select
|
||||
/// click — retail's <c>gmGamePlayUI::UseTime @ 0x004EA454</c> →
|
||||
/// <c>CPlayerSystem::LogOffCharacter(0)</c> drain, forwarded through
|
||||
/// this construction-order bridge so the retained UI (built before the
|
||||
/// controller) can reach the one wormhole owner. Refusals are logged by
|
||||
/// the controller; the grounded gate runs upstream in
|
||||
/// <c>RetailUiRuntime</c> (the <c>transient_state & CONTACT</c>
|
||||
/// branch @ 0x004EA445).
|
||||
/// </summary>
|
||||
void RequestLogout();
|
||||
|
||||
void ResetSession();
|
||||
|
||||
void ResetGenerationPresentation();
|
||||
|
|
@ -103,6 +115,8 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
|
|||
|
||||
public void ArmLoginTunnel() => Required().ArmLoginTunnel();
|
||||
|
||||
public void RequestLogout() => Required().RequestLogout();
|
||||
|
||||
public void ResetSession() => Required().ResetSession();
|
||||
|
||||
public void ResetGenerationPresentation() =>
|
||||
|
|
@ -185,6 +199,105 @@ internal sealed class RuntimeLoginLifecycleSource
|
|||
_runtime.CharacterSelection.Snapshot.Lifecycle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout round (2026-08-17): the logout pump's typed seams — the wire/session
|
||||
/// transaction pair on the canonical Runtime session owner, the local-player
|
||||
/// PK fact, and the retail side effects of the request itself (chat line +
|
||||
/// command-interpreter disable). See <c>CPlayerSystem::RequestLogOff
|
||||
/// @ 0x00562DD0</c> for the retail body these mirror.
|
||||
/// </summary>
|
||||
internal interface ILocalPlayerLogoutOperations
|
||||
{
|
||||
/// <summary>
|
||||
/// Retail <c>ACCWeenieObject::IsPlayerKiller @ 0x0058C910</c>: PWD
|
||||
/// bitfield <c>0x20</c> (PK) or <c>0x2000000</c> (PKLite). Drives the
|
||||
/// +20 s logoff hold (<c>RequestLogOff @ 0x00562E4E-0x00562E67</c>).
|
||||
/// </summary>
|
||||
bool IsLocalPlayerKiller { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPlayerSystem::LogOffCharacter(0) @ 0x00563520</c> +
|
||||
/// <c>RequestLogOff @ 0x00562DD0</c>: options flush, "Logging off..."
|
||||
/// chat line (type 0, <c>AddTextToScroll @ 0x00562DF2</c>), the 0xF653
|
||||
/// wire send, and the command-interpreter disable
|
||||
/// (<c>HandleLogOff @ 0x006B3330</c>).
|
||||
/// </summary>
|
||||
bool BeginCharacterLogOff();
|
||||
|
||||
/// <summary>The server's opcode-only 0xF653 echo has landed.</summary>
|
||||
bool IsCharacterLogOffConfirmed { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The return-to-character-select session transaction
|
||||
/// (<see cref="AcDream.Runtime.Session.LiveSessionController.CompleteCharacterLogOff"/>).
|
||||
/// </summary>
|
||||
bool CompleteCharacterLogOff();
|
||||
}
|
||||
|
||||
/// <summary>Production adapter over the canonical Runtime owners.</summary>
|
||||
internal sealed class RuntimeLocalPlayerLogoutOperations
|
||||
: ILocalPlayerLogoutOperations
|
||||
{
|
||||
private readonly GameRuntime _runtime;
|
||||
private readonly RuntimeLocalPlayerMovementState _movement;
|
||||
private readonly ILiveWorldSessionSource _session;
|
||||
private readonly AcDream.Core.Items.ClientObjectTable _objects;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
|
||||
public RuntimeLocalPlayerLogoutOperations(
|
||||
GameRuntime runtime,
|
||||
RuntimeLocalPlayerMovementState movement,
|
||||
ILiveWorldSessionSource session,
|
||||
AcDream.Core.Items.ClientObjectTable objects,
|
||||
ILocalPlayerIdentitySource identity)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
}
|
||||
|
||||
public bool IsLocalPlayerKiller
|
||||
{
|
||||
get
|
||||
{
|
||||
uint bitfield = _objects.Get(_identity.ServerGuid)
|
||||
?.PublicWeenieBitfield ?? 0u;
|
||||
// IsPlayerKiller @ 0x0058C910: (bitfield & 0x20) | (bitfield &
|
||||
// 0x2000000) — the PK and PKLite PWD bits.
|
||||
return (bitfield & 0x20u) != 0u || (bitfield & 0x2000000u) != 0u;
|
||||
}
|
||||
}
|
||||
|
||||
public bool BeginCharacterLogOff()
|
||||
{
|
||||
// The Runtime command runs retail's SaveToServer-first ordering
|
||||
// (the pre-logoff flush) then sends 0xF653.
|
||||
if (!_runtime.Session.BeginCharacterLogOff(_runtime.Generation)
|
||||
.Accepted)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Retail RequestLogOff's own side effects, in its order: the chat
|
||||
// line (@ 0x00562DF2, AddTextToScroll(str, 0, 1, 0)) and the
|
||||
// command-interpreter disable (@ 0x00562E6D).
|
||||
_runtime.CommunicationOwner.AddText(
|
||||
"Logging off...",
|
||||
AcDream.Core.Chat.RetailLogTextType.Default);
|
||||
_movement.DisableCommandInterpreter();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool IsCharacterLogOffConfirmed =>
|
||||
_session.CurrentSession?.IsCharacterLogOffConfirmed == true;
|
||||
|
||||
public bool CompleteCharacterLogOff() =>
|
||||
_runtime.Session.CompleteCharacterLogOff(_runtime.Generation)
|
||||
.Accepted;
|
||||
}
|
||||
|
||||
internal sealed class LiveLocalPlayerTeleportAuthority
|
||||
: ILocalPlayerTeleportAuthority
|
||||
{
|
||||
|
|
@ -381,6 +494,21 @@ internal interface ILocalPlayerTeleportPresentation : IDisposable
|
|||
bool IsPortalViewportVisible { get; }
|
||||
int CurrentTunnelFrame { get; }
|
||||
void Begin(Matrix4x4 projection);
|
||||
|
||||
/// <summary>
|
||||
/// Logout round (2026-08-17): the wormhole run in REVERSE ORDER —
|
||||
/// retail's <c>BeginTeleportAnimation(TAS_WORLD_FADE_OUT)
|
||||
/// @ 0x004D6E83</c>. The sequencer enters at WorldFadeOut (the world
|
||||
/// stays drawn while the view plane pulls in), then TunnelFadeIn →
|
||||
/// Tunnel; the tunnel scene itself plays the SAME forward 40 fps
|
||||
/// animation (<c>set_sequence_animation @ 0x004D6F70</c> runs
|
||||
/// identically for every tunnel-family entry — nothing renders
|
||||
/// backwards), and the enter cue plays at this begin
|
||||
/// (<c>Sound_UI_EnterPortal @ 0x004D638E</c>, unconditional). No exit
|
||||
/// cue ever plays on logout: the character-select swap preempts
|
||||
/// retail's TunnelContinue/FadeOut tail.
|
||||
/// </summary>
|
||||
void BeginLogout(Matrix4x4 projection);
|
||||
(TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
|
||||
Tick(float deltaSeconds, bool worldReady);
|
||||
void TickTunnel(float deltaSeconds);
|
||||
|
|
@ -414,6 +542,12 @@ internal sealed class LocalPlayerTeleportPresentation
|
|||
_animation.Begin(TeleportEntryKind.Portal);
|
||||
}
|
||||
|
||||
public void BeginLogout(Matrix4x4 projection)
|
||||
{
|
||||
_viewPlane.Begin(projection);
|
||||
_animation.Begin(TeleportEntryKind.Logout);
|
||||
}
|
||||
|
||||
public (TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
|
||||
Tick(float deltaSeconds, bool worldReady)
|
||||
{
|
||||
|
|
@ -579,6 +713,15 @@ internal sealed class LocalPlayerTeleportController
|
|||
/// </summary>
|
||||
private readonly ILocalPlayerLoginLifecycleSource _loginLifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Logout round (2026-08-17): the logout pump's Runtime seams — see
|
||||
/// <see cref="ILocalPlayerLogoutOperations"/>. The pump itself is the
|
||||
/// third arm of retail's ONE wormhole machine
|
||||
/// (<c>gmSmartBoxUI::UseTime @ 0x004D6E30</c> drives login, teleport,
|
||||
/// and logout from the same function).
|
||||
/// </summary>
|
||||
private readonly ILocalPlayerLogoutOperations _logout;
|
||||
|
||||
public LocalPlayerTeleportController(
|
||||
ILocalPlayerTeleportAuthority authority,
|
||||
ILocalPlayerTeleportInputLifetime input,
|
||||
|
|
@ -590,7 +733,8 @@ internal sealed class LocalPlayerTeleportController
|
|||
ILocalPlayerTeleportSession session,
|
||||
ILocalPlayerTeleportPresentation presentation,
|
||||
RuntimeAcceptedPositionDriveController acceptedPositionDrive,
|
||||
ILocalPlayerLoginLifecycleSource loginLifecycle)
|
||||
ILocalPlayerLoginLifecycleSource loginLifecycle,
|
||||
ILocalPlayerLogoutOperations logout)
|
||||
{
|
||||
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
|
||||
_input = input ?? throw new ArgumentNullException(nameof(input));
|
||||
|
|
@ -605,6 +749,7 @@ internal sealed class LocalPlayerTeleportController
|
|||
?? throw new ArgumentNullException(nameof(acceptedPositionDrive));
|
||||
_loginLifecycle = loginLifecycle
|
||||
?? throw new ArgumentNullException(nameof(loginLifecycle));
|
||||
_logout = logout ?? throw new ArgumentNullException(nameof(logout));
|
||||
}
|
||||
|
||||
public bool IsActive => _transit.IsTeleportActive;
|
||||
|
|
@ -642,6 +787,16 @@ internal sealed class LocalPlayerTeleportController
|
|||
public void OnTeleportStarted(uint sequence)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
// Logout round (2026-08-17): a logoff in flight owns the wormhole;
|
||||
// the character is leaving the world and no F751 may supersede the
|
||||
// logout presentation (ACE does not teleport a logging-off player —
|
||||
// Player.LogOut sets IsBusy/IsLoggingOut before any motion runs).
|
||||
if (_transit.IsLogoutActive)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: teleport start ignored during logout (seq={sequence})");
|
||||
return;
|
||||
}
|
||||
ushort teleportSequence = (ushort)sequence;
|
||||
if (!_authority.IsFreshStart(teleportSequence)
|
||||
|| !_transit.CanQueueTeleportStart(teleportSequence))
|
||||
|
|
@ -768,9 +923,199 @@ internal sealed class LocalPlayerTeleportController
|
|||
return true;
|
||||
}
|
||||
|
||||
// ── Logout round (2026-08-17): the logout arm of retail's ONE wormhole
|
||||
// machine. Retail derivation:
|
||||
// gmGamePlayUI::UseTime @ 0x004EA3A0 — confirmed Yes drains into
|
||||
// CPlayerSystem::LogOffCharacter(0) when the player is grounded
|
||||
// (transient_state & CONTACT — the upstream RetailUiRuntime gate).
|
||||
// CPlayerSystem::LogOffCharacter @ 0x00563520 — SaveToServer first.
|
||||
// CPlayerSystem::RequestLogOff @ 0x00562DD0 — "Logging off..." chat,
|
||||
// 0xF653 send, logOffRequestTime = now + 3.0 (+20.0 PK),
|
||||
// CommandInterpreter::HandleLogOff @ 0x006B3330 → Disable. The
|
||||
// SERVER then broadcasts the LogOut motion (ACE Player.cs:596 →
|
||||
// SendMotionAsCommands), which plays on the local player through
|
||||
// the ordinary inbound movement funnel during this hold.
|
||||
// gmSmartBoxUI::UseTime @ 0x004D6E64 — hold elapsed →
|
||||
// BeginTeleportAnimation(TAS_WORLD_FADE_OUT) (enter cue) →
|
||||
// TunnelFadeIn → Tunnel.
|
||||
// Inbound 0xF653 echo (dispatch case @ 0x0055C963) →
|
||||
// CPlayerSystem::ExecuteLogOff @ 0x0055D780 — world teardown with
|
||||
// the logon connection kept; the fresh CharacterList in the same
|
||||
// server batch re-shows character management
|
||||
// (gmGamePlayUI::Update @ 0x004E9CD0 → QueueUIMode(0x1000000a)).
|
||||
// No exit cue: the swap preempts the TunnelContinue/FadeOut tail.
|
||||
|
||||
/// <summary>The sink-forwarded UI entry — see
|
||||
/// <see cref="ILocalPlayerTeleportNetworkSink.RequestLogout"/>.</summary>
|
||||
public void RequestLogout()
|
||||
{
|
||||
if (!TryRequestLogout())
|
||||
Console.WriteLine("live: character logoff request refused");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The confirmed exit-to-character-select click. Returns false when a
|
||||
/// logout, teleport, or login presentation already owns the machine or
|
||||
/// the wire request refused.
|
||||
/// </summary>
|
||||
public bool TryRequestLogout()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (_transit.IsLogoutActive
|
||||
|| _transit.IsTeleportActive
|
||||
|| _transit.HasPendingTeleportStart
|
||||
|| _loginPresentationActive
|
||||
|| _loginTunnelArmed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_transit.TryBeginLogoutRequest(_logout.IsLocalPlayerKiller))
|
||||
return false;
|
||||
|
||||
long generation = _lifetimeGeneration;
|
||||
if (!_logout.BeginCharacterLogOff())
|
||||
{
|
||||
// Nothing went on the wire — roll the request back rather than
|
||||
// running a wormhole for a logoff the server never heard.
|
||||
if (_lifetimeGeneration == generation)
|
||||
_transit.CancelLogoutRequest();
|
||||
return false;
|
||||
}
|
||||
if (_lifetimeGeneration != generation)
|
||||
return true;
|
||||
|
||||
// Retail's Disable() also ends any mouse-driven turning; the same
|
||||
// input-lifetime call every teleport start already makes.
|
||||
_input.EndMouseLook();
|
||||
Console.WriteLine("live: character logoff requested");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame logout pump — the third arm of the wormhole machine (see
|
||||
/// the derivation block above). Confirmation is polled every tick in
|
||||
/// every pre-confirmed stage; on the Confirmed edge the handoff runs
|
||||
/// IMMEDIATELY, exactly like retail's ExecuteLogOff-on-echo — the
|
||||
/// character-select swap tears down whatever presentation state exists
|
||||
/// (normally the held tunnel; on a fast confirmation, less).
|
||||
/// </summary>
|
||||
private void TickLogout(float deltaSeconds)
|
||||
{
|
||||
long generation = _lifetimeGeneration;
|
||||
|
||||
if (_transit.LogoutStage is RuntimeLogoutStage.Requested
|
||||
or RuntimeLogoutStage.PresentationActive
|
||||
&& _logout.IsCharacterLogOffConfirmed)
|
||||
{
|
||||
_transit.AcknowledgeLogoutConfirmed();
|
||||
}
|
||||
|
||||
switch (_transit.LogoutStage)
|
||||
{
|
||||
case RuntimeLogoutStage.Requested:
|
||||
// The 3 s (23 s PK) hold: the server-broadcast LogOut
|
||||
// motion is playing on the player in-world.
|
||||
if (_transit.AdvanceLogoutHold(deltaSeconds))
|
||||
{
|
||||
_presentation.BeginLogout(_mode.Projection);
|
||||
if (_lifetimeGeneration != generation)
|
||||
return;
|
||||
PumpLogoutPresentation(0f, generation);
|
||||
}
|
||||
return;
|
||||
case RuntimeLogoutStage.PresentationActive:
|
||||
PumpLogoutPresentation(deltaSeconds, generation);
|
||||
return;
|
||||
case RuntimeLogoutStage.Confirmed:
|
||||
CompleteLogoutHandoff(generation);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void PumpLogoutPresentation(float deltaSeconds, long generation)
|
||||
{
|
||||
var (_, events) = _presentation.Tick(deltaSeconds, worldReady: false);
|
||||
if (_lifetimeGeneration != generation)
|
||||
return;
|
||||
|
||||
foreach (TeleportAnimEvent teleportEvent in events)
|
||||
{
|
||||
switch (teleportEvent)
|
||||
{
|
||||
case TeleportAnimEvent.PlayEnterSound:
|
||||
// Sound_UI_EnterPortal @ 0x004D638E — unconditional at
|
||||
// BeginTeleportAnimation, INCLUDING the logout's
|
||||
// TAS_WORLD_FADE_OUT entry.
|
||||
Console.WriteLine(
|
||||
"live: logout portal-space enter cue "
|
||||
+ "(Sound_UI_EnterPortal)");
|
||||
_presentation.PlayEnterCue();
|
||||
if (_lifetimeGeneration != generation)
|
||||
return;
|
||||
break;
|
||||
case TeleportAnimEvent.EnterTunnel:
|
||||
_presentation.EnterTunnel();
|
||||
if (_lifetimeGeneration != generation)
|
||||
return;
|
||||
break;
|
||||
default:
|
||||
// worldReady is pinned false: Place / PlayExitSound /
|
||||
// FireLoginComplete cannot fire (the sequencer holds in
|
||||
// Tunnel), matching retail's preempted logout tail.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_presentation.TickTunnel(deltaSeconds);
|
||||
}
|
||||
|
||||
private void CompleteLogoutHandoff(long generation)
|
||||
{
|
||||
if (!_transit.CompleteLogout() || _lifetimeGeneration != generation)
|
||||
return;
|
||||
|
||||
Console.WriteLine(
|
||||
"live: logout confirmed — returning to character select");
|
||||
if (_logout.CompleteCharacterLogOff())
|
||||
{
|
||||
// The transaction's world reset already ran this controller's
|
||||
// ResetGenerationPresentation (retiring the tunnel) and the
|
||||
// fresh selection state re-shows the character-management
|
||||
// screen — retail's QueueUIMode(0x1000000a) analogue.
|
||||
return;
|
||||
}
|
||||
|
||||
// The transaction refused or degraded to a full stop. If a reset
|
||||
// reached this controller the lifetime moved and everything is
|
||||
// already clean; otherwise retire the presentation here so a
|
||||
// refused transaction can never leave a stranded tunnel over a
|
||||
// still-running world.
|
||||
if (_lifetimeGeneration == generation)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"live: return-to-character-select refused — retiring the "
|
||||
+ "logout presentation");
|
||||
_presentation.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick(float deltaSeconds)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
// Logout round (2026-08-17): an active logout owns the whole
|
||||
// wormhole machine, exactly as retail's one teleportInProgress flag
|
||||
// does (SetTeleportInProgress(1) at the logout begin,
|
||||
// gmSmartBoxUI::UseTime @ 0x004D6E8C). Teleport starts are refused
|
||||
// while it runs (OnTeleportStarted's own guard).
|
||||
if (_transit.IsLogoutActive)
|
||||
{
|
||||
TickLogout(deltaSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
TryActivatePendingPresentation();
|
||||
TryAimAcceptedDestination();
|
||||
if (!_transit.IsTeleportActive)
|
||||
|
|
|
|||
|
|
@ -117,7 +117,15 @@ public sealed record IndicatorRuntimeBindings(
|
|||
Func<LinkStatusSnapshot> LinkStatus,
|
||||
Func<double> CurrentTime,
|
||||
Action RequestLinkStatusPing,
|
||||
Action EndCharacterSession);
|
||||
/// <summary>Logout round (2026-08-17): the IN-WORLD character logoff —
|
||||
/// retail's end-character-session flow (log-off animation, reverse
|
||||
/// wormhole, return to character select on the live connection). Was
|
||||
/// the window-close action before the logout flow existed.</summary>
|
||||
Action EndCharacterSession,
|
||||
/// <summary>The app-exit action (window close → the graceful-shutdown
|
||||
/// logoff in WorldSession.Dispose) — the Options panel's Exit Game
|
||||
/// button, retail's m_shouldQuitOnLogout arm.</summary>
|
||||
Action ExitGame);
|
||||
|
||||
public sealed record ToolbarRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
|
|
@ -2461,18 +2469,52 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
private void RequestEndCharacterSession()
|
||||
{
|
||||
ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted =>
|
||||
// Logout round (2026-08-17): retail funnels BOTH end-session
|
||||
// surfaces (the indicator bar's control and the Options panel's
|
||||
// Exit to Character Selection) into gmGamePlayUI's ONE
|
||||
// m_doEndSession drain, so the grounded three-way branch applies
|
||||
// here too — the original OP3 port carried it only on the Options
|
||||
// button.
|
||||
ShowConfirmation(
|
||||
ResolveEndCharacterSessionConfirmMessage(),
|
||||
accepted =>
|
||||
{
|
||||
if (accepted)
|
||||
EndCharacterSessionWithRetailGates();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shared confirmed-Yes drain — <c>gmGamePlayUI::UseTime
|
||||
/// @ 0x004EA3A0</c>'s exact three-way branch (see
|
||||
/// <see cref="RequestExitToCharacterSelection"/>'s doc for the
|
||||
/// pseudocode): grounded → <c>CPlayerSystem::LogOffCharacter(0)</c>;
|
||||
/// airborne → the mid-air refusal; no player → silent no-op.
|
||||
/// </summary>
|
||||
private void EndCharacterSessionWithRetailGates()
|
||||
{
|
||||
switch (_bindings.Options.IsGrounded())
|
||||
{
|
||||
if (accepted)
|
||||
case true:
|
||||
_bindings.Indicators.EndCharacterSession();
|
||||
});
|
||||
break;
|
||||
case false:
|
||||
_bindings.Options.DisplaySystemMessage(
|
||||
ClientTextRefusals.CantLogOffMidAir);
|
||||
break;
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP3: the Options panel's Exit to Character
|
||||
/// Selection button (element <c>0x10000203</c>) — D6's "behaves as Exit
|
||||
/// Game" adaptation (register row, same commit), PLUS retail's
|
||||
/// confirmation dialog and mid-air refusal, which DO port exactly.
|
||||
/// Selection button (element <c>0x10000203</c>), with retail's
|
||||
/// confirmation dialog and mid-air refusal. Logout round (2026-08-17):
|
||||
/// the D6 "behaves as Exit Game" adaptation (AD-76) is RETIRED — the
|
||||
/// grounded confirmed exit now runs the real in-world logoff flow
|
||||
/// (log-off animation, reverse wormhole, return to character select on
|
||||
/// the live connection).
|
||||
/// <c>gmGamePlayUI::UseTime @0x004EA3A0</c>'s drain, its EXACT three-way
|
||||
/// branch (review-fix round, 2026-08-11 — the original port collapsed
|
||||
/// this to a two-way `if/else` that fired the refusal outside player
|
||||
|
|
@ -2490,26 +2532,18 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
/// </summary>
|
||||
private void RequestExitToCharacterSelection()
|
||||
{
|
||||
ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted =>
|
||||
{
|
||||
if (!accepted) return;
|
||||
|
||||
switch (_bindings.Options.IsGrounded())
|
||||
// Logout round (2026-08-17): the D6 "behaves as Exit Game"
|
||||
// adaptation (AD-76, retired with this change) is gone — the
|
||||
// confirmed, grounded exit now runs the REAL retail flow through
|
||||
// EndCharacterSessionWithRetailGates: log-off animation, reverse
|
||||
// wormhole, return to character select on the live connection.
|
||||
ShowConfirmation(
|
||||
ResolveEndCharacterSessionConfirmMessage(),
|
||||
accepted =>
|
||||
{
|
||||
case true:
|
||||
_bindings.Indicators.EndCharacterSession();
|
||||
break;
|
||||
case false:
|
||||
_bindings.Options.DisplaySystemMessage(ClientTextRefusals.CantLogOffMidAir);
|
||||
break;
|
||||
case null:
|
||||
// Retail's `else if (smartbox->player)` gate: outside
|
||||
// player mode (or with no live controller) there is no
|
||||
// player object for UseTime to test at all, so neither
|
||||
// the airborne refusal nor the logoff itself ever runs.
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (accepted)
|
||||
EndCharacterSessionWithRetailGates();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -2575,7 +2609,10 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
var callbacks = new Layout.OptionsPanelController.Callbacks(
|
||||
Toggle: () => ToggleWindow(WindowNames.Options),
|
||||
RequestExitToCharacterSelection: RequestExitToCharacterSelection,
|
||||
ExitGame: _bindings.Indicators.EndCharacterSession,
|
||||
// Logout round (2026-08-17): Exit Game keeps the app-exit
|
||||
// (window close → graceful-shutdown logoff); the in-world
|
||||
// return-to-charselect flow lives on EndCharacterSession.
|
||||
ExitGame: _bindings.Indicators.ExitGame,
|
||||
UseMouseTurningSettings: ApplyMouseTurningSettingsMacro,
|
||||
DisplaySystemMessage: _bindings.Options.DisplaySystemMessage,
|
||||
AfterApply: () => _bindings.Options.CommandBus().Publish(
|
||||
|
|
|
|||
|
|
@ -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"/>].
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -238,6 +238,8 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
HostQuiescence: null!,
|
||||
RetainedInputCapture: null!,
|
||||
InputDispatcher: null,
|
||||
TeleportSink:
|
||||
new AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink(),
|
||||
KeyBindingsFilePath: "keybinds.json",
|
||||
Settings: null!,
|
||||
Runtime: runtime,
|
||||
|
|
|
|||
|
|
@ -1829,6 +1829,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
|
||||
public void ArmLoginTunnel() { }
|
||||
|
||||
public void RequestLogout() { }
|
||||
|
||||
public void ResetSession() { }
|
||||
|
||||
public void ResetGenerationPresentation() { }
|
||||
|
|
|
|||
|
|
@ -1063,6 +1063,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
|
|||
|
||||
public void ArmLoginTunnel() { }
|
||||
|
||||
public void RequestLogout() { }
|
||||
|
||||
public void ResetSession() { }
|
||||
|
||||
public void ResetGenerationPresentation() { }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1050,6 +1050,104 @@ public sealed class RuntimeWorldTransitStateTests
|
|||
state.ResetSession();
|
||||
}
|
||||
|
||||
// ── Logout round (2026-08-17): the character-logoff lifecycle —
|
||||
// retail CPlayerSystem::RequestLogOff @ 0x00562DD0 (3 s hold, +20 PK),
|
||||
// gmSmartBoxUI::UseTime @ 0x004D6E64 (hold-elapsed presentation begin),
|
||||
// ExecuteLogOff @ 0x0055D780 (the 0xF653 echo), and the char-select
|
||||
// handoff. ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void LogoutLifecycle_RequestHoldPresentationConfirmComplete()
|
||||
{
|
||||
var state = new RuntimeWorldTransitState();
|
||||
Assert.Equal(RuntimeLogoutStage.None, state.LogoutStage);
|
||||
|
||||
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
Assert.Equal(RuntimeLogoutStage.Requested, state.LogoutStage);
|
||||
Assert.Equal(1, state.CaptureOwnership().ActiveLogoutCount);
|
||||
Assert.False(state.CaptureOwnership().IsSessionIdle);
|
||||
|
||||
// A second request while one is in flight refuses.
|
||||
Assert.False(state.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
|
||||
// The retail 3.0 s hold: not elapsed at 2.9, elapsed at 3.0+.
|
||||
Assert.False(state.AdvanceLogoutHold(2.9d));
|
||||
Assert.Equal(RuntimeLogoutStage.Requested, state.LogoutStage);
|
||||
Assert.True(state.AdvanceLogoutHold(0.2d));
|
||||
Assert.Equal(
|
||||
RuntimeLogoutStage.PresentationActive,
|
||||
state.LogoutStage);
|
||||
// The begin edge fires exactly once.
|
||||
Assert.False(state.AdvanceLogoutHold(1.0d));
|
||||
|
||||
Assert.True(state.AcknowledgeLogoutConfirmed());
|
||||
Assert.Equal(RuntimeLogoutStage.Confirmed, state.LogoutStage);
|
||||
Assert.False(state.AcknowledgeLogoutConfirmed());
|
||||
|
||||
Assert.True(state.CompleteLogout());
|
||||
Assert.Equal(RuntimeLogoutStage.None, state.LogoutStage);
|
||||
Assert.False(state.CompleteLogout());
|
||||
Assert.True(state.CaptureOwnership().IsSessionIdle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutHold_PlayerKillerAddsTwentySeconds()
|
||||
{
|
||||
var state = new RuntimeWorldTransitState();
|
||||
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: true));
|
||||
|
||||
// 3 s is not enough for a PK (RequestLogOff @ 0x00562E67: +20.0).
|
||||
Assert.False(state.AdvanceLogoutHold(3.5d));
|
||||
Assert.False(state.AdvanceLogoutHold(19.0d));
|
||||
Assert.True(state.AdvanceLogoutHold(0.6d));
|
||||
Assert.Equal(
|
||||
RuntimeLogoutStage.PresentationActive,
|
||||
state.LogoutStage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutConfirmation_LegalFromTheRequestHold()
|
||||
{
|
||||
// ACE's >= 6 s confirmation floor makes this unreachable live, but
|
||||
// the machine is total: a confirmation during the hold cancels the
|
||||
// pending wormhole (retail ExecuteLogOff clears logOffRequested).
|
||||
var state = new RuntimeWorldTransitState();
|
||||
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
Assert.True(state.AcknowledgeLogoutConfirmed());
|
||||
Assert.Equal(RuntimeLogoutStage.Confirmed, state.LogoutStage);
|
||||
// The hold no longer advances a confirmed lifecycle.
|
||||
Assert.False(state.AdvanceLogoutHold(10.0d));
|
||||
Assert.True(state.CompleteLogout());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutRequest_RefusedDuringTeleportAndCancelRollsBack()
|
||||
{
|
||||
var state = new RuntimeWorldTransitState();
|
||||
_ = BeginPortal(state, OutdoorCell, sequence: 3);
|
||||
Assert.False(state.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
|
||||
var idle = new RuntimeWorldTransitState();
|
||||
Assert.True(idle.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
Assert.True(idle.CancelLogoutRequest());
|
||||
Assert.Equal(RuntimeLogoutStage.None, idle.LogoutStage);
|
||||
Assert.True(idle.CaptureOwnership().IsSessionIdle);
|
||||
// Cancel is only legal from Requested.
|
||||
Assert.False(idle.CancelLogoutRequest());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutLifecycle_ClearsOnSessionReset()
|
||||
{
|
||||
var state = new RuntimeWorldTransitState();
|
||||
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
state.ResetSession();
|
||||
Assert.Equal(RuntimeLogoutStage.None, state.LogoutStage);
|
||||
Assert.True(state.CaptureOwnership().IsSessionIdle);
|
||||
// A fresh session can log out again.
|
||||
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
}
|
||||
|
||||
private static long BeginPortal(
|
||||
RuntimeWorldTransitState state,
|
||||
uint cell,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue