using System.Net; using System.Net.Sockets; using AcDream.Core.Net; using AcDream.Core.Net.Messages; namespace AcDream.Runtime.Session; public enum LiveSessionStartStatus { Disabled, MissingCredentials, NoCharacters, Connected, Deferred, Failed, /// /// The account transport is connected and Runtime owns a live roster, /// but EnterWorld has deliberately not run yet. /// AwaitingCharacterSelection, /// /// Campaign LA slice LA2: a /// session connected, received (and reported) the character roster, and /// gracefully disconnected BEFORE selection/EnterWorld — deliberately a /// SUCCESS variant of the early-exit shape /// (same StopCore teardown), not a failure. /// ProbeComplete, } public readonly record struct LiveSessionOwnershipSnapshot( bool IsDisposed, bool IsDisposeRequested, bool IsInWorld, bool HasActiveSession, bool HasRetiredSession, bool HasPendingInitialReset, bool HasPendingOperation, int OperationDepth, ulong Generation, RuntimeTeardownStage LastTeardownStages) { public bool IsConverged => IsDisposed && IsDisposeRequested && !IsInWorld && !HasActiveSession && !HasRetiredSession && !HasPendingInitialReset && !HasPendingOperation && OperationDepth == 0 && LastTeardownStages == RuntimeTeardownStage.Complete; } public sealed record LiveSessionCharacterSelection( int ActiveIndex, uint CharacterId, string CharacterName, string AccountName); public sealed record LiveSessionStartResult( LiveSessionStartStatus Status, LiveSessionCharacterSelection? Selection = null, Exception? Error = null); /// /// Campaign LA slice LA1: one roster entry as reported by /// — decoupled from the wire type so the /// lifecycle-host seam does not leak AcDream.Core.Net.Messages shapes /// into every consumer. /// public readonly record struct LiveSessionRosterEntry( uint Id, string Name, uint SecondsGreyedOut); /// /// Campaign LA slice LA1: the account's active-character roster, reported to /// right after /// CharacterList arrives and BEFORE selection — see /// docs/plans/2026-08-14-launcher-campaign.md LA1 item 2. Hosts forward /// this to their status stream (characterList event) and, later /// (LA7/LA8), to the character-select screen. Deleted characters are /// deliberately excluded — the same candidate set /// already uses. /// public sealed record LiveSessionRosterReport( string AccountName, int SlotCount, IReadOnlyList Entries); /// /// Runtime boundary for the domain and presentation sinks attached to one /// exact generation. The controller owns the /// connect/enter/stop transaction and never reaches into graphical state. /// public interface ILiveSessionLifecycleHost { LiveSessionBinding BindSession(WorldSession session); void ResetSessionState(RuntimeGenerationToken retiringGeneration); void ReportConnecting(string host, int port, string user); void ReportConnected(); /// Campaign LA slice LA1: reported once per successful /// CharacterList receipt, right before character selection. See /// . void ReportRoster(LiveSessionRosterReport roster); void ApplySelectedCharacter(LiveSessionCharacterSelection selection); void ApplyEnteredWorld(LiveSessionCharacterSelection selection); void DetachSession(WorldSession session); } /// /// Owns routing and commands for one exact session. Teardown is ordered and /// retryable: outbound commands become inert before inbound subscriptions are /// detached. /// public sealed class LiveSessionBinding : IDisposable { private readonly Action _activateCommands; private readonly Action _deactivateCommands; private readonly Action _detachEvents; private bool _commandsDeactivated; private bool _eventsDetached; private bool _commandsActivated; public LiveSessionBinding( WorldSession session, Action activateCommands, Action deactivateCommands, Action detachEvents) { Session = session ?? throw new ArgumentNullException(nameof(session)); _activateCommands = activateCommands ?? throw new ArgumentNullException(nameof(activateCommands)); _deactivateCommands = deactivateCommands ?? throw new ArgumentNullException(nameof(deactivateCommands)); _detachEvents = detachEvents ?? throw new ArgumentNullException(nameof(detachEvents)); } public WorldSession Session { get; } public bool CommandsDeactivated => _commandsDeactivated; public bool EventsDetached => _eventsDetached; public void ActivateCommands() { if (_commandsDeactivated || _eventsDetached) throw new ObjectDisposedException(nameof(LiveSessionBinding)); if (_commandsActivated) return; _activateCommands(); if (_commandsDeactivated || _eventsDetached) return; _commandsActivated = true; } public void Dispose() { if (!_commandsDeactivated) { _deactivateCommands(); _commandsDeactivated = true; } if (!_eventsDetached) { _detachEvents(); _eventsDetached = true; } } } public interface ILiveSessionOperations { IPEndPoint ResolveEndpoint(string host, int port); WorldSession CreateSession(IPEndPoint endpoint); void Connect(WorldSession session, string user, string password); CharacterList.Parsed? GetCharacters(WorldSession session); /// /// Campaign LA gate round 2 (world-name live fix): ACE sends ServerName /// (0xF7E1) in the SAME connect-response batch as CharacterList, so the /// ServerNameReceived event fires during the handshake pump — /// BEFORE the controller's binding subscribes. The durable /// field is therefore read /// synchronously after Connect, exactly like /// reads the durable roster; the event remains for post-connect updates. /// ServerName.Parsed? GetServerInfo(WorldSession session) => session.ServerInfo; void StartCharacterSelectionReceive(WorldSession session) => session.StartCharacterSelectionReceive(); void EnterWorld(WorldSession session, int activeCharacterIndex); void DeleteCharacter( WorldSession session, string accountName, int activeCharacterIndex) => session.SendDeleteCharacter(accountName, activeCharacterIndex); void RestoreCharacter(WorldSession session, uint characterId) => session.SendRestoreCharacter(characterId); void Tick(WorldSession session); void DisposeSession(WorldSession session); } internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations { public static ProductionLiveSessionOperations Instance { get; } = new(); private ProductionLiveSessionOperations() { } public IPEndPoint ResolveEndpoint(string host, int port) { IPAddress ip; if (!IPAddress.TryParse(host, out ip!)) { IPAddress[] addresses = Dns.GetHostAddresses(host); ip = Array.Find( addresses, static address => address.AddressFamily == AddressFamily.InterNetwork) ?? (addresses.Length != 0 ? addresses[0] : throw new InvalidOperationException( $"DNS resolved no addresses for '{host}'")); Console.WriteLine($"live: resolved {host} → {ip}"); } return new IPEndPoint(ip, port); } public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint); public void Connect(WorldSession session, string user, string password) => session.Connect(user, password); public CharacterList.Parsed? GetCharacters(WorldSession session) => session.Characters; public void StartCharacterSelectionReceive(WorldSession session) => session.StartCharacterSelectionReceive(); public void EnterWorld(WorldSession session, int activeCharacterIndex) => session.EnterWorld(activeCharacterIndex); public void Tick(WorldSession session) => session.Tick(); public void DisposeSession(WorldSession session) => session.Dispose(); } /// /// Sole runtime owner of a live WorldSession lifetime: endpoint resolution, /// complete pre-Connect routing, character validation/entry, active command /// publication, exact-generation Tick, graceful replacement, and convergent /// teardown. Retail wire behavior remains inside WorldSession. /// public sealed class LiveSessionController : IDisposable, IRuntimeLiveSessionFramePhase, IRuntimeCharacterSelectionCommands { private sealed class CharacterSelectionWireBinding : IDisposable { private WorldSession? _session; private readonly Action _roster; private readonly Action _delete; private readonly Action _restore; private readonly Action _error; private readonly Action _worldName; public CharacterSelectionWireBinding( WorldSession session, Action roster, Action delete, Action restore, Action error, Action worldName) { _session = session; _roster = roster; _delete = delete; _restore = restore; _error = error; _worldName = worldName; session.CharacterListReceived += roster; session.CharacterDeleteAcknowledged += delete; session.CharacterRestoreReceived += restore; session.CharacterErrorReceived += error; session.ServerNameReceived += worldName; } public bool IsDisposed => _session is null; public void Dispose() { WorldSession? session = Interlocked.Exchange(ref _session, null); if (session is null) return; session.CharacterListReceived -= _roster; session.CharacterDeleteAcknowledged -= _delete; session.CharacterRestoreReceived -= _restore; session.CharacterErrorReceived -= _error; session.ServerNameReceived -= _worldName; } } private sealed class SessionScope( WorldSession session, ILiveSessionLifecycleHost host, RuntimeGenerationToken generation) { private int _teardownStage; public WorldSession Session { get; } = session; public ILiveSessionLifecycleHost Host { get; } = host; public RuntimeGenerationToken Generation { get; } = generation; public LiveSessionBinding? Binding { get; set; } public CharacterSelectionWireBinding? CharacterSelectionBinding { get; set; } public bool HostAttached { get; set; } public RuntimeTeardownStage CompletedStages { get; private set; } public void DrainTeardown(ILiveSessionOperations operations) { if (_teardownStage == 0) { try { Binding?.Dispose(); CharacterSelectionBinding?.Dispose(); } finally { if (Binding is null || Binding.CommandsDeactivated) CompletedStages |= RuntimeTeardownStage.CommandsInert; if ((Binding is null || Binding.EventsDetached) && (CharacterSelectionBinding is null || CharacterSelectionBinding.IsDisposed)) { CompletedStages |= RuntimeTeardownStage.InboundDetached; } } _teardownStage = 1; } if (_teardownStage == 1) { operations.DisposeSession(Session); CompletedStages |= RuntimeTeardownStage.TransportDisposed; _teardownStage = 2; } if (_teardownStage == 2) { if (HostAttached) Host.DetachSession(Session); _teardownStage = 3; } if (_teardownStage == 3) { Host.ResetSessionState(Generation); CompletedStages |= RuntimeTeardownStage.HostReset; _teardownStage = 4; } } public bool IsTeardownComplete => _teardownStage == 4; } private enum PendingKind { Stop, Reconnect, Dispose, } private sealed record PendingOperation( PendingKind Kind, LiveSessionConnectOptions? Options = null, ILiveSessionLifecycleHost? Host = null); private sealed record PendingHostReset( ILiveSessionLifecycleHost Host, RuntimeGenerationToken Generation); private readonly object _gate = new(); private readonly ILiveSessionOperations _operations; private SessionScope? _scope; private SessionScope? _retiredScope; private PendingHostReset? _pendingInitialReset; private PendingOperation? _pendingOperation; private int _operationDepth; private bool _inWorld; private bool _disposeRequested; private bool _disposed; private ulong _generation; private RuntimeTeardownStage _lastTeardownStages; private LiveSessionCharacterSelection? _activeSelection; private Action? _autoSaveTickHook; private Action? _preLogoffFlushHook; public LiveSessionController() : this(ProductionLiveSessionOperations.Instance, null) { } public LiveSessionController( ILiveSessionOperations operations, TimeProvider? timeProvider = null) { _operations = operations ?? throw new ArgumentNullException(nameof(operations)); CharacterSelectionState = new RuntimeCharacterSelectionState( timeProvider); } public RuntimeCharacterSelectionState CharacterSelectionState { get; } public IRuntimeCharacterSelectionView CharacterSelection => CharacterSelectionState.View; public WorldSession? CurrentSession { get { lock (_gate) return _scope?.Session; } } public bool IsInWorld { get { lock (_gate) return _inWorld; } } public ulong SessionGeneration { get { lock (_gate) return _generation; } } public RuntimeGenerationToken Generation { get { lock (_gate) return new RuntimeGenerationToken(_generation); } } /// /// MUST-FIX 1 (Campaign OP OP1 review fix, 2026-08-11 — mechanism lens /// finding): reaches retail's CPlayerModule::UseTime (the 480 s /// batched character-option auto-save) from the SAME per-session tick /// both the graphical host (RetailLiveFrameCoordinator) and the /// no-window host (HeadlessSessionHost.Tick) already call — /// — with ZERO host edits. GameRuntime wires /// this once, after constructing CharacterOwner/ /// InventoryOwner, with a flush body that talks to the /// directly rather than through App's /// LiveSessionCommandRouter/LiveCommandBus — which is what /// keeps this wiring off the S2 lock-order hazard the blast-lens finding /// named (this hook never touches the router's own gate). A hook /// throwing is caught and logged (), never treated /// as a tick failure — a transient send error on a background auto-save /// must not tear down the whole live session. /// internal void ConfigureAutoSaveTick(Action hook) => _autoSaveTickHook = hook ?? throw new ArgumentNullException(nameof(hook)); /// /// MUST-FIX 1: reaches retail's CPlayerSystem::LogOffCharacter → /// SaveToServer ordering — flush the batched option module BEFORE /// the character-logoff wire request goes out — from Runtime's own /// Stop/teardown transaction (), using the /// CURRENT (not-yet-retired) session. Caught and logged rather than /// propagated: a failed flush must not block the graceful-shutdown /// sequence CLAUDE.md flags as ACE-timing-sensitive. /// internal void ConfigurePreLogoffFlush(Action hook) => _preLogoffFlushHook = hook ?? throw new ArgumentNullException(nameof(hook)); public bool IsDisposalComplete { get { lock (_gate) return _disposed; } } public LiveSessionOwnershipSnapshot CaptureOwnership() { lock (_gate) { return new LiveSessionOwnershipSnapshot( _disposed, _disposeRequested, _inWorld, _scope is not null, _retiredScope is not null, _pendingInitialReset is not null, _pendingOperation is not null, _operationDepth, _generation, _lastTeardownStages); } } public LiveSessionStartResult Start( LiveSessionConnectOptions options, ILiveSessionLifecycleHost host) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(host); lock (_gate) { ThrowIfDisposing(); if (_operationDepth != 0) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); if (_inWorld) return ConnectedResult(); if (_scope is not null) { return new LiveSessionStartResult( LiveSessionStartStatus.AwaitingCharacterSelection); } return RunTopLevel(() => StartCore(options, host, resetHost: true)); } } public LiveSessionStartResult Reconnect( LiveSessionConnectOptions options, ILiveSessionLifecycleHost host) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(host); lock (_gate) { ThrowIfDisposing(); if (_operationDepth != 0) { Schedule(new PendingOperation(PendingKind.Reconnect, options, host)); return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); } return RunTopLevel(() => ReconnectCore(options, host)); } } public void Stop() { lock (_gate) { if (_disposed) return; if (_operationDepth != 0) { Schedule(new PendingOperation(PendingKind.Stop)); return; } RunTopLevel(StopCore); } } public RuntimeTeardownAcknowledgement Stop( RuntimeGenerationToken expectedGeneration) { lock (_gate) { RuntimeGenerationToken current = new(_generation); if (_disposed) { return new RuntimeTeardownAcknowledgement( expectedGeneration, current, RuntimeCommandStatus.Inactive, _lastTeardownStages); } if (expectedGeneration != current) { return new RuntimeTeardownAcknowledgement( expectedGeneration, current, RuntimeCommandStatus.StaleGeneration, RuntimeTeardownStage.None); } try { Stop(); return new RuntimeTeardownAcknowledgement( expectedGeneration, new RuntimeGenerationToken(_generation), RuntimeCommandStatus.Accepted, _lastTeardownStages); } catch (Exception error) { return new RuntimeTeardownAcknowledgement( expectedGeneration, new RuntimeGenerationToken(_generation), RuntimeCommandStatus.Rejected, _retiredScope?.CompletedStages ?? _lastTeardownStages, error); } } } public void Tick() { lock (_gate) { if (_disposed) return; if (_scope is null || _operationDepth != 0) return; RunTopLevel(() => { SessionScope scope = _scope; ulong generation = _generation; try { _operations.Tick(scope.Session); if (!IsCurrent(scope, generation)) return; CharacterSelectionState.SweepRestoreCorrelation(); } catch (Exception tickError) { Exception error = StopAfterFailure(tickError); if (ReferenceEquals(error, tickError)) throw; throw error; } // MUST-FIX 1: TS-71's 480 s auto-save timer half. Runs after // the protocol pump above and only when the scope/generation // are still current — a reconnect that happened mid-tick // must not flush against a retired session. if (_inWorld) InvokeAutoSaveTick(scope.Session); }); } } private void InvokeAutoSaveTick(WorldSession session) { if (_autoSaveTickHook is not { } hook) return; try { hook(session); } catch (Exception error) { Console.Error.WriteLine( $"live: auto-save character-options tick failed: {error.Message}"); } } public void Dispose() { lock (_gate) { if (_disposed) return; _disposeRequested = true; if (_operationDepth != 0) { Schedule(new PendingOperation(PendingKind.Dispose)); return; } RunTopLevel(DisposeCore); } } private LiveSessionStartResult ReconnectCore( LiveSessionConnectOptions options, ILiveSessionLifecycleHost host) { ILiveSessionLifecycleHost? oldHost = _scope?.Host ?? _retiredScope?.Host ?? _pendingInitialReset?.Host; ulong generationBeforeStop = _generation; try { StopCore(); } catch (Exception error) { return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error); } if (_generation != unchecked(generationBeforeStop + 1)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); return StartCore(options, host, resetHost: !ReferenceEquals(oldHost, host)); } private LiveSessionStartResult StartCore( LiveSessionConnectOptions options, ILiveSessionLifecycleHost host, bool resetHost) { RuntimeGenerationToken resetGeneration = new(_generation); ulong generation = ++_generation; RuntimeGenerationToken activeGeneration = new(generation); CharacterSelectionState.Reset(activeGeneration); try { DrainRetiredScope(); if (_generation != generation) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); if (resetHost) { ResetHostBeforeStart( host, generation, resetGeneration); } if (_generation != generation) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); } catch (Exception error) { return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error); } if (!options.Enabled) return new LiveSessionStartResult(LiveSessionStartStatus.Disabled); if (string.IsNullOrEmpty(options.User) || string.IsNullOrEmpty(options.Password)) return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials); CharacterSelectionState.Begin(activeGeneration); SessionScope? scope = null; try { IPEndPoint endpoint = _operations.ResolveEndpoint(options.Host, options.Port); if (_generation != generation) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); Console.WriteLine($"live: connecting to {endpoint} as {options.User}"); WorldSession session = _operations.CreateSession(endpoint); scope = new SessionScope( session, host, new RuntimeGenerationToken(generation)); _scope = scope; _inWorld = false; if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); LiveSessionBinding binding = host.BindSession(session); scope.Binding = binding; scope.HostAttached = true; if (!ReferenceEquals(binding.Session, session)) { throw new InvalidOperationException( "The live-session host returned a binding for a different session."); } if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); host.ReportConnecting(options.Host, options.Port, options.User); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); _operations.Connect(session, options.User, options.Password); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); scope.CharacterSelectionBinding = BindCharacterSelection( scope, generation); host.ReportConnected(); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); CharacterList.Parsed? characters = _operations.GetCharacters(session); if (characters is not null) { LiveSessionRosterReport roster = BuildRosterReport(characters); CharacterSelectionState.ApplyRoster(roster); host.ReportRoster(roster); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); } // World name arrives in the SAME connect batch as CharacterList, // during the handshake pump — before the binding's // ServerNameReceived subscription exists — so the durable field is // read here exactly like the roster above (gate-round-2 live fix: // the event-only wiring left the World box empty against ACE). if (_operations.GetServerInfo(session) is { } serverInfo) CharacterSelectionState.ApplyWorldName(serverInfo.WorldName); // Campaign LA slice LA2: the probe short-circuit lands here — // only after a real CharacterList was returned and its roster was // reported, before TrySelectCharacter ever runs. A missing // CharacterList falls through to the existing NoCharacters // non-success path below; connectivity by itself is not a // successful character-roster probe. Non-probe callers continue // through the unchanged selection/enter path. if (options.Probe && characters is not null) { Console.WriteLine( "live: probe complete — disconnecting before EnterWorld"); StopCore(); return new LiveSessionStartResult(LiveSessionStartStatus.ProbeComplete); } if (options.AwaitCharacterSelection && options.Character is null && characters is not null) { _operations.StartCharacterSelectionReceive(session); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); Console.WriteLine( "live: awaiting character selection before EnterWorld"); return new LiveSessionStartResult( LiveSessionStartStatus.AwaitingCharacterSelection); } if (characters is null || !TrySelectCharacter( characters, options.Character, out CharacterList.Selection selected)) { Console.WriteLine("live: no available characters on account; disconnecting"); StopCore(); return new LiveSessionStartResult(LiveSessionStartStatus.NoCharacters); } var selection = new LiveSessionCharacterSelection( selected.ActiveIndex, selected.Character.Id, selected.Character.Name, characters.AccountName); if (!CharacterSelectionState.TryHighlight(selection.CharacterId) || !CharacterSelectionState.BeginEnter(out _)) { throw new InvalidOperationException( "Runtime character selection rejected the validated active character."); } host.ApplySelectedCharacter(selection); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); Console.WriteLine( $"live: entering world as 0x{selection.CharacterId:X8} {selection.CharacterName}"); _operations.EnterWorld(session, selection.ActiveIndex); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); binding.ActivateCommands(); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); _inWorld = true; _activeSelection = selection; CharacterSelectionState.CompleteEnter(selection.CharacterId); host.ApplyEnteredWorld(selection); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); Console.WriteLine("live: in world — CreateObject stream active"); return new LiveSessionStartResult( LiveSessionStartStatus.Connected, selection); } catch (Exception startError) { return new LiveSessionStartResult( LiveSessionStartStatus.Failed, Error: StopAfterFailure(startError)); } } private Exception StopAfterFailure(Exception operationError) { try { StopCore(); return operationError; } catch (Exception cleanupError) { return new AggregateException( "Live-session operation and cleanup both failed.", operationError, cleanupError); } } private CharacterSelectionWireBinding BindCharacterSelection( SessionScope scope, ulong generation) => new( scope.Session, roster => { lock (_gate) { if (!IsCurrent(scope, generation)) return; LiveSessionRosterReport report = BuildRosterReport(roster); CharacterSelectionState.ApplyRoster(report); scope.Host.ReportRoster(report); } }, () => { lock (_gate) { if (IsCurrent(scope, generation)) CharacterSelectionState.ApplyDeleteAcknowledged(); } }, restore => { lock (_gate) { if (IsCurrent(scope, generation)) CharacterSelectionState.ApplyRestore(restore); } }, error => { lock (_gate) { if (IsCurrent(scope, generation)) CharacterSelectionState.ApplyError(error); } }, worldName => { lock (_gate) { if (IsCurrent(scope, generation)) CharacterSelectionState.ApplyWorldName(worldName.WorldName); } }); public RuntimeCommandResult Highlight( RuntimeGenerationToken expectedGeneration, uint characterId) { lock (_gate) { RuntimeCommandStatus gate = ValidateCharacterSelectionCommand( expectedGeneration); if (gate != RuntimeCommandStatus.Accepted) return CharacterSelectionResult(gate); RuntimeCommandStatus status = CharacterSelectionState.TryHighlight(characterId) ? RuntimeCommandStatus.Accepted : RuntimeCommandStatus.Rejected; return CharacterSelectionResult(status, characterId); } } public RuntimeCommandResult Enter( RuntimeGenerationToken expectedGeneration) { lock (_gate) { RuntimeCommandStatus gate = ValidateCharacterSelectionCommand( expectedGeneration); if (gate != RuntimeCommandStatus.Accepted) return CharacterSelectionResult(gate); if (_operationDepth != 0) return CharacterSelectionResult(RuntimeCommandStatus.Rejected); return RunTopLevel(EnterSelectedCore); } } public RuntimeCommandResult RequestDelete( RuntimeGenerationToken expectedGeneration) { lock (_gate) { RuntimeCommandStatus gate = ValidateCharacterSelectionCommand( expectedGeneration); if (gate != RuntimeCommandStatus.Accepted) return CharacterSelectionResult(gate); RuntimeCommandStatus status = CharacterSelectionState.TryRequestDelete(out uint characterId) ? RuntimeCommandStatus.Accepted : RuntimeCommandStatus.Rejected; return CharacterSelectionResult(status, characterId); } } public RuntimeCommandResult ConfirmDelete( RuntimeGenerationToken expectedGeneration) { lock (_gate) { RuntimeCommandStatus gate = ValidateCharacterSelectionCommand( expectedGeneration); if (gate != RuntimeCommandStatus.Accepted) return CharacterSelectionResult(gate); if (!CharacterSelectionState.TryTakeDeleteConfirmation( out RuntimeCharacterSelectionEntry character, out string accountName)) { return CharacterSelectionResult(RuntimeCommandStatus.Rejected); } try { _operations.DeleteCharacter( _scope!.Session, accountName, character.ActiveIndex); return CharacterSelectionResult( RuntimeCommandStatus.Accepted, character.CharacterId); } catch { CharacterSelectionState.ApplyError( new CharacterError.Parsed( (uint)CharacterError.Code.Delete)); return CharacterSelectionResult( RuntimeCommandStatus.Rejected, character.CharacterId); } } } public RuntimeCommandResult Restore( RuntimeGenerationToken expectedGeneration) { lock (_gate) { RuntimeCommandStatus gate = ValidateCharacterSelectionCommand( expectedGeneration); if (gate != RuntimeCommandStatus.Accepted) return CharacterSelectionResult(gate); if (!CharacterSelectionState.TryBeginRestore( out RuntimeCharacterSelectionEntry character)) { return CharacterSelectionResult(RuntimeCommandStatus.Rejected); } try { _operations.RestoreCharacter( _scope!.Session, character.CharacterId); return CharacterSelectionResult( RuntimeCommandStatus.Accepted, character.CharacterId); } catch { CharacterSelectionState.ApplyError( new CharacterError.Parsed( (uint)CharacterError.Code.Undefined)); return CharacterSelectionResult( RuntimeCommandStatus.Rejected, character.CharacterId); } } } public RuntimeCommandResult Cancel( RuntimeGenerationToken expectedGeneration) { lock (_gate) { RuntimeCommandStatus gate = ValidateCharacterSelectionCommand( expectedGeneration); if (gate != RuntimeCommandStatus.Accepted) return CharacterSelectionResult(gate); return CharacterSelectionResult( CharacterSelectionState.Cancel() ? RuntimeCommandStatus.Accepted : RuntimeCommandStatus.Rejected); } } private RuntimeCommandResult EnterSelectedCore() { SessionScope scope = _scope!; ulong generation = _generation; if (!CharacterSelectionState.BeginEnter( out RuntimeCharacterSelectionEntry character)) { return CharacterSelectionResult(RuntimeCommandStatus.Rejected); } RuntimeCharacterSelectionSnapshot snapshot = CharacterSelectionState.Snapshot; var selection = new LiveSessionCharacterSelection( character.ActiveIndex, character.CharacterId, character.Name, snapshot.AccountName); try { scope.Host.ApplySelectedCharacter(selection); if (!IsCurrent(scope, generation)) return CharacterSelectionResult(RuntimeCommandStatus.Inactive); _operations.EnterWorld(scope.Session, character.ActiveIndex); if (!IsCurrent(scope, generation)) return CharacterSelectionResult(RuntimeCommandStatus.Inactive); scope.Binding!.ActivateCommands(); if (!IsCurrent(scope, generation)) return CharacterSelectionResult(RuntimeCommandStatus.Inactive); _inWorld = true; _activeSelection = selection; CharacterSelectionState.CompleteEnter(character.CharacterId); scope.Host.ApplyEnteredWorld(selection); if (!IsCurrent(scope, generation)) return CharacterSelectionResult(RuntimeCommandStatus.Inactive); Console.WriteLine("live: in world — CreateObject stream active"); return CharacterSelectionResult( RuntimeCommandStatus.Accepted, character.CharacterId); } catch (CharacterSelectionRejectedException rejected) { if (CharacterSelectionState.Snapshot.Error?.RawCode != rejected.Error.RawErrorCode) { CharacterSelectionState.ApplyError(rejected.Error); } CharacterSelectionState.ReturnToSelection(); return CharacterSelectionResult( RuntimeCommandStatus.Rejected, character.CharacterId); } catch (Exception error) { _ = StopAfterFailure(error); return CharacterSelectionResult( RuntimeCommandStatus.Rejected, character.CharacterId); } } private RuntimeCommandStatus ValidateCharacterSelectionCommand( RuntimeGenerationToken expectedGeneration) { RuntimeGenerationToken current = new(_generation); if (expectedGeneration != current) return RuntimeCommandStatus.StaleGeneration; if (_disposed || _disposeRequested || _scope is null || _inWorld) return RuntimeCommandStatus.Inactive; return CharacterSelectionState.Snapshot.Lifecycle == RuntimeCharacterSelectionLifecycle.AwaitingSelection ? RuntimeCommandStatus.Accepted : RuntimeCommandStatus.Inactive; } private RuntimeCommandResult CharacterSelectionResult( RuntimeCommandStatus status, uint characterId = 0u) => new( status, new RuntimeGenerationToken(_generation), characterId); private void StopCore() { // MUST-FIX 1: TS-71's logout-flush half — retail's // CPlayerSystem::LogOffCharacter calls SaveToServer BEFORE the // character-logoff wire request, so this runs before anything below // touches the scope (teardown, generation bump). Gated on _inWorld // so a failed/never-entered-world Stop (still connecting, no // character session) never fires it — matching retail's own call // site, which only exists on an actual in-world character. if (_inWorld && _scope is { } activeScope) InvokePreLogoffFlush(activeScope.Session); ++_generation; _inWorld = false; _activeSelection = null; CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation)); if (_scope is { } scope) { _scope = null; if (_retiredScope is not null && !ReferenceEquals(_retiredScope, scope)) throw new InvalidOperationException( "A second live-session scope cannot retire before the first converges."); _retiredScope = scope; } DrainRetiredScope(); DrainPendingInitialReset(); if (_retiredScope is null && _pendingInitialReset is null) _lastTeardownStages = RuntimeTeardownStage.Complete; } private void InvokePreLogoffFlush(WorldSession session) { if (_preLogoffFlushHook is not { } hook) return; try { hook(session); } catch (Exception error) { Console.Error.WriteLine( $"live: pre-logoff character-options flush failed: {error.Message}"); } } private void DrainRetiredScope() { if (_retiredScope is not { } retired) return; retired.DrainTeardown(_operations); if (retired.IsTeardownComplete) { _lastTeardownStages = retired.CompletedStages; _retiredScope = null; } } private void ResetHostBeforeStart( ILiveSessionLifecycleHost host, ulong generation, RuntimeGenerationToken resetGeneration) { bool requestedHostAlreadyReset = false; if (_pendingInitialReset is { } pending) { pending.Host.ResetSessionState(pending.Generation); _pendingInitialReset = null; requestedHostAlreadyReset = ReferenceEquals(pending.Host, host); } if (requestedHostAlreadyReset || _generation != generation) return; try { host.ResetSessionState(resetGeneration); } catch { _pendingInitialReset = new PendingHostReset(host, resetGeneration); throw; } } private void DrainPendingInitialReset() { if (_pendingInitialReset is not { } pending) return; pending.Host.ResetSessionState(pending.Generation); _pendingInitialReset = null; } private void Schedule(PendingOperation operation) { if (_pendingOperation?.Kind == PendingKind.Dispose) return; _pendingOperation = operation; ++_generation; _inWorld = false; _scope?.Binding?.Dispose(); } private T RunTopLevel(Func operation) { _operationDepth++; try { return operation(); } finally { _operationDepth--; if (_operationDepth == 0) DrainPendingOperations(); } } private void RunTopLevel(Action operation) => RunTopLevel(() => { operation(); return true; }); private void DrainPendingOperations() { while (_pendingOperation is { } pending) { _pendingOperation = null; _operationDepth++; try { switch (pending.Kind) { case PendingKind.Stop: StopCore(); break; case PendingKind.Reconnect: _ = ReconnectCore(pending.Options!, pending.Host!); break; case PendingKind.Dispose: DisposeCore(); break; } } finally { _operationDepth--; } } } private void DisposeCore() { StopCore(); CharacterSelectionState.Dispose(); _disposed = true; } private bool IsCurrent(SessionScope scope, ulong generation) => ReferenceEquals(_scope, scope) && _generation == generation; private LiveSessionStartResult ConnectedResult() => new(LiveSessionStartStatus.Connected, _activeSelection); /// Campaign LA slice LA1: projects the wire-shaped /// into the decoupled /// . Deleted characters are /// excluded, matching 's /// candidate set. private static LiveSessionRosterReport BuildRosterReport( CharacterList.Parsed characters) { var entries = new LiveSessionRosterEntry[characters.Characters.Count]; for (int i = 0; i < entries.Length; i++) { CharacterList.Character character = characters.Characters[i]; entries[i] = new LiveSessionRosterEntry( character.Id, character.Name, character.SecondsGreyedOut); } return new LiveSessionRosterReport( characters.AccountName, characters.SlotCount, entries); } private static bool TrySelectCharacter( CharacterList.Parsed characters, LiveSessionCharacterSelector? selector, out CharacterList.Selection selection) { ArgumentNullException.ThrowIfNull(characters); if (selector is null) { return CharacterList.TrySelectFirstAvailable( characters, out selection); } int selectorCount = (selector.ActiveIndex.HasValue ? 1 : 0) + (selector.CharacterId.HasValue ? 1 : 0) + (!string.IsNullOrWhiteSpace(selector.CharacterName) ? 1 : 0); if (selectorCount != 1) { selection = default; return false; } for (int index = 0; index < characters.Characters.Count; index++) { CharacterList.Character character = characters.Characters[index]; if (!CharacterList.IsAvailableActiveIdentity(character)) continue; if (selector.ActiveIndex is { } requestedIndex && requestedIndex != index) { continue; } if (selector.CharacterId is { } requestedId && requestedId != character.Id) { continue; } if (selector.CharacterName is { } requestedName && !string.Equals( requestedName, character.Name, StringComparison.OrdinalIgnoreCase)) { continue; } selection = new CharacterList.Selection(index, character); return true; } selection = default; return false; } private void ThrowIfDisposing() { if (_disposeRequested || _disposed) throw new ObjectDisposedException(nameof(LiveSessionController)); } }