acdream/src/AcDream.Runtime/Session/LiveSessionController.cs
Erik 0a7dc7d626 fix(runtime,ui): Campaign LA gate round 2 — world name reads durably; dialogs center on the canvas
Two live-integration gaps the ef96c554 unit tests could not see:

1. World box stayed empty against ACE: ServerName (0xF7E1) arrives in the
   SAME connect batch as CharacterList, so ServerNameReceived fires during
   the handshake pump BEFORE the controller binding subscribes - the
   event-only wiring proved the state and controller but never the live
   ordering. StartCore now reads the durable WorldSession.ServerInfo after
   connect exactly like the roster (ILiveSessionOperations.GetServerInfo,
   default interface method so no fake breaks); the event remains for
   post-connect updates. Pinned by a Start-level test.

2. The exit confirmation rendered far right of the screen: all three
   retail dialog views centered against the raw window size while the
   active screen lays out in the fixed 800x600 canvas - center-of-1920
   is canvas-760, which the stretch pushes off-center. Views now center
   against UiRoot.EffectiveCanvasSize (canvas while a pre-world screen is
   active, window otherwise). Pinned by growing the window over the fixed
   canvas in the exit-dialog test and asserting the scrim spans the canvas
   with the popup centered at 400.

Runtime 1666, App 5100+6 skips, green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:02:33 +02:00

1399 lines
49 KiB
C#

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,
/// <summary>
/// The account transport is connected and Runtime owns a live roster,
/// but EnterWorld has deliberately not run yet.
/// </summary>
AwaitingCharacterSelection,
/// <summary>
/// Campaign LA slice LA2: a <see cref="LiveSessionConnectOptions.Probe"/>
/// session connected, received (and reported) the character roster, and
/// gracefully disconnected BEFORE selection/EnterWorld — deliberately a
/// SUCCESS variant of the <see cref="NoCharacters"/> early-exit shape
/// (same <c>StopCore</c> teardown), not a failure.
/// </summary>
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);
/// <summary>
/// Campaign LA slice LA1: one roster entry as reported by
/// <see cref="CharacterList.Parsed"/> — decoupled from the wire type so the
/// lifecycle-host seam does not leak <c>AcDream.Core.Net.Messages</c> shapes
/// into every consumer.
/// </summary>
public readonly record struct LiveSessionRosterEntry(
uint Id,
string Name,
uint SecondsGreyedOut);
/// <summary>
/// Campaign LA slice LA1: the account's active-character roster, reported to
/// <see cref="ILiveSessionLifecycleHost.ReportRoster"/> right after
/// <c>CharacterList</c> arrives and BEFORE selection — see
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1 item 2. Hosts forward
/// this to their status stream (<c>characterList</c> event) and, later
/// (LA7/LA8), to the character-select screen. Deleted characters are
/// deliberately excluded — the same candidate set
/// <see cref="CharacterList.TrySelectFirstAvailable"/> already uses.
/// </summary>
public sealed record LiveSessionRosterReport(
string AccountName,
int SlotCount,
IReadOnlyList<LiveSessionRosterEntry> Entries);
/// <summary>
/// Runtime boundary for the domain and presentation sinks attached to one
/// exact <see cref="WorldSession"/> generation. The controller owns the
/// connect/enter/stop transaction and never reaches into graphical state.
/// </summary>
public interface ILiveSessionLifecycleHost
{
LiveSessionBinding BindSession(WorldSession session);
void ResetSessionState(RuntimeGenerationToken retiringGeneration);
void ReportConnecting(string host, int port, string user);
void ReportConnected();
/// <summary>Campaign LA slice LA1: reported once per successful
/// <c>CharacterList</c> receipt, right before character selection. See
/// <see cref="LiveSessionRosterReport"/>.</summary>
void ReportRoster(LiveSessionRosterReport roster);
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
void ApplyEnteredWorld(LiveSessionCharacterSelection selection);
void DetachSession(WorldSession session);
}
/// <summary>
/// Owns routing and commands for one exact session. Teardown is ordered and
/// retryable: outbound commands become inert before inbound subscriptions are
/// detached.
/// </summary>
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);
/// <summary>
/// Campaign LA gate round 2 (world-name live fix): ACE sends ServerName
/// (0xF7E1) in the SAME connect-response batch as CharacterList, so the
/// <c>ServerNameReceived</c> event fires during the handshake pump —
/// BEFORE the controller's binding subscribes. The durable
/// <see cref="WorldSession.ServerInfo"/> field is therefore read
/// synchronously after Connect, exactly like <see cref="GetCharacters"/>
/// reads the durable roster; the event remains for post-connect updates.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
public sealed class LiveSessionController
: IDisposable,
IRuntimeLiveSessionFramePhase,
IRuntimeCharacterSelectionCommands
{
private sealed class CharacterSelectionWireBinding : IDisposable
{
private WorldSession? _session;
private readonly Action<CharacterList.Parsed> _roster;
private readonly Action _delete;
private readonly Action<CharacterRestore.Parsed> _restore;
private readonly Action<CharacterError.Parsed> _error;
private readonly Action<ServerName.Parsed> _worldName;
public CharacterSelectionWireBinding(
WorldSession session,
Action<CharacterList.Parsed> roster,
Action delete,
Action<CharacterRestore.Parsed> restore,
Action<CharacterError.Parsed> error,
Action<ServerName.Parsed> 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<WorldSession>? _autoSaveTickHook;
private Action<WorldSession>? _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); }
}
/// <summary>
/// MUST-FIX 1 (Campaign OP OP1 review fix, 2026-08-11 — mechanism lens
/// finding): reaches retail's <c>CPlayerModule::UseTime</c> (the 480 s
/// batched character-option auto-save) from the SAME per-session tick
/// both the graphical host (<c>RetailLiveFrameCoordinator</c>) and the
/// no-window host (<c>HeadlessSessionHost.Tick</c>) already call —
/// <see cref="Tick()"/> — with ZERO host edits. <c>GameRuntime</c> wires
/// this once, after constructing <c>CharacterOwner</c>/
/// <c>InventoryOwner</c>, with a flush body that talks to the
/// <see cref="WorldSession"/> directly rather than through App's
/// <c>LiveSessionCommandRouter</c>/<c>LiveCommandBus</c> — 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 (<see cref="Tick()"/>), never treated
/// as a tick failure — a transient send error on a background auto-save
/// must not tear down the whole live session.
/// </summary>
internal void ConfigureAutoSaveTick(Action<WorldSession> hook) =>
_autoSaveTickHook = hook ?? throw new ArgumentNullException(nameof(hook));
/// <summary>
/// MUST-FIX 1: reaches retail's <c>CPlayerSystem::LogOffCharacter</c> →
/// <c>SaveToServer</c> ordering — flush the batched option module BEFORE
/// the character-logoff wire request goes out — from Runtime's own
/// Stop/teardown transaction (<see cref="StopCore"/>), 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.
/// </summary>
internal void ConfigurePreLogoffFlush(Action<WorldSession> 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<T>(Func<T> 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);
/// <summary>Campaign LA slice LA1: projects the wire-shaped
/// <see cref="CharacterList.Parsed"/> into the decoupled
/// <see cref="LiveSessionRosterReport"/>. Deleted characters are
/// excluded, matching <see cref="CharacterList.TrySelectFirstAvailable"/>'s
/// candidate set.</summary>
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));
}
}