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, /// /// 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); void EnterWorld(WorldSession session, int activeCharacterIndex); 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 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 { 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 bool HostAttached { get; set; } public RuntimeTeardownStage CompletedStages { get; private set; } public void DrainTeardown(ILiveSessionOperations operations) { if (_teardownStage == 0) { try { Binding?.Dispose(); } finally { if (Binding is null || Binding.CommandsDeactivated) CompletedStages |= RuntimeTeardownStage.CommandsInert; if (Binding is null || Binding.EventsDetached) 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) { } public LiveSessionController(ILiveSessionOperations operations) { _operations = operations ?? throw new ArgumentNullException(nameof(operations)); } 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(); 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 (!_inWorld || _scope is null || _operationDepth != 0) return; RunTopLevel(() => { SessionScope scope = _scope; ulong generation = _generation; try { _operations.Tick(scope.Session); if (!IsCurrent(scope, generation)) return; } 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. 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; 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); 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); host.ReportConnected(); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); CharacterList.Parsed? characters = _operations.GetCharacters(session); if (characters is not null) { host.ReportRoster(BuildRosterReport(characters)); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); } // 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 (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); 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; 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 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; 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(); _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)); } }