acdream/src/AcDream.Runtime/Session/LiveSessionController.cs
Erik 3a6b7e3115 fix(runtime)+docs: CC3 re-review CLOSED — R1 second-create wire slot, risk-8 measured LATENT
The CC3 narrow re-review returned CLOSED (both lenses PASS, merge
recommended) with residual R1: the post-create wire-slot assignment read
the cached wire CharacterList count, which ACE never refreshes after a
create — correct for the first create, off by one for a second create in
the same session (reachable via create Ok -> server-rejected guid enter
-> ReturnToSelection -> create again), the same wire-contract failure
class F2 fixed. Root fix now rather than carried: a
creates-since-CharacterList counter (the equivalent of retail's own
CharacterSet growing via AddIdentity per create), reset on every fresh
wire CharacterList apply and at generation reset, applied only to the
cached-wire branch since the display-roster fallback already contains
prior appends. Regression test drives the full
create->Ok->rejected-enter->create-again flow and pins wire slots
0/1/2/3.

Docs: CC3 ledger row flipped to REVIEW-CLOSED with real shas (re-review
R2); CC7 risk item 8 downgraded to LATENT with measured installed-DAT
data (user-prompted): every heritage's single cost override is Arcane
Lore at NormalCost=0/PrimaryCost=2 vs global 4/6, so ACE's over-deduction
(= NormalCost = 0) cannot fire with end-of-retail data — the earlier
"may be rejected" claim was inferred from code without measuring.

Runtime 1707/0 Release.

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

1928 lines
73 KiB
C#

using System.Net;
using System.Net.Sockets;
using AcDream.Core.CharGen;
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>
/// Campaign CC slice CC3: reported once per successful character
/// creation (the <c>0xF643</c> Ok identity), right before the roster is
/// re-reported with the new entry appended and the reused enter-selected
/// path runs — the same spot <see cref="ReportRoster"/> and
/// <see cref="ApplyEnteredWorld"/> already occupy. Default no-op: a host
/// that wants status-stream parity with
/// <c>SessionStatusWriter.CharacterCreated</c> overrides this.
/// </summary>
void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) { }
/// <summary>Campaign CC slice CC3: reported once per non-Ok <c>0xF643</c>
/// creation response. Default no-op — see <see cref="ApplyCharacterCreated"/>.
/// </summary>
void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) { }
}
/// <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);
/// <summary>
/// Campaign CC slice CC3 review-fix round (F1): mirrors
/// <see cref="EnterWorld(WorldSession,int)"/> but enters by the exact
/// guid the <c>0xF643</c> Ok reply carried, bypassing the (by-design
/// post-create-stale) cached roster — see
/// <see cref="WorldSession.EnterWorld(uint,string,TimeSpan?)"/>'s doc
/// comment for the full retail citation.
/// </summary>
void EnterWorldByGuid(
WorldSession session,
uint characterGuid,
string accountName) =>
session.EnterWorld(characterGuid, accountName);
void DeleteCharacter(
WorldSession session,
string accountName,
int activeCharacterIndex) =>
session.SendDeleteCharacter(accountName, activeCharacterIndex);
void RestoreCharacter(WorldSession session, uint characterId) =>
session.SendRestoreCharacter(characterId);
/// <summary>Campaign CC slice CC3: retail's <c>Proto_UI::SendCharGenResult
/// @ 0x00546A70</c> outbound send, reached from <c>gmCharGenMainUI::DoFinish</c>.
/// </summary>
void CreateCharacter(
WorldSession session,
string accountName,
CharacterCreate.Request request,
ReadOnlySpan<uint> skillAdvancementClasses) =>
session.SendCharacterCreation(accountName, request, skillAdvancementClasses);
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,
IRuntimeCharacterCreationCommands
{
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;
private readonly Action<CharGenVerificationResponse.Parsed> _created;
public CharacterSelectionWireBinding(
WorldSession session,
Action<CharacterList.Parsed> roster,
Action delete,
Action<CharacterRestore.Parsed> restore,
Action<CharacterError.Parsed> error,
Action<ServerName.Parsed> worldName,
Action<CharGenVerificationResponse.Parsed> created)
{
_session = session;
_roster = roster;
_delete = delete;
_restore = restore;
_error = error;
_worldName = worldName;
_created = created;
session.CharacterListReceived += roster;
session.CharacterDeleteAcknowledged += delete;
session.CharacterRestoreReceived += restore;
session.CharacterErrorReceived += error;
session.ServerNameReceived += worldName;
session.CharacterCreateResponseReceived += created;
}
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;
session.CharacterCreateResponseReceived -= _created;
}
}
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;
/// <summary>
/// CC3 re-review R1: creates accepted since the last wire
/// CharacterList was applied. ACE appends each created character to
/// its own session list but never resends the list, so the cached
/// wire count under-counts by exactly this number — the equivalent
/// of retail's own CharacterSet growing via AddIdentity per create.
/// Guarded by <c>_gate</c> like every sibling field.
/// </summary>
private int _createsSinceCharacterList;
private LiveSessionCharacterSelection? _activeSelection;
private Action<WorldSession>? _autoSaveTickHook;
private Action<WorldSession>? _preLogoffFlushHook;
public LiveSessionController()
: this(ProductionLiveSessionOperations.Instance, null)
{
}
public LiveSessionController(
ILiveSessionOperations operations,
TimeProvider? timeProvider = null,
ChargenOptions? chargenOptions = null)
{
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
CharacterSelectionState = new RuntimeCharacterSelectionState(
timeProvider);
// Campaign CC slice CC3: defaults to ChargenOptions.Empty (no
// heritages configured) — loading the installed DAT's chargen table
// and threading it through is a future App/GameRuntime wiring slice,
// not this one. A caller that never supplies real options simply
// gets an inert chargen surface (every heritage lookup misses).
CharacterCreationState = new RuntimeCharacterCreationState(
chargenOptions ?? ChargenOptions.Empty);
}
public RuntimeCharacterSelectionState CharacterSelectionState { get; }
public IRuntimeCharacterSelectionView CharacterSelection =>
CharacterSelectionState.View;
public RuntimeCharacterCreationState CharacterCreationState { get; }
public IRuntimeCharacterCreationView CharacterCreation =>
CharacterCreationState.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);
CharacterCreationState.Reset(activeGeneration);
_createsSinceCharacterList = 0;
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);
CharacterCreationState.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)
{
_createsSinceCharacterList = 0;
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).
// IsCurrent-gated like every neighbouring step (batch review F8):
// a generation flip here must not write the old server's name
// into the new generation's state.
if (IsCurrent(scope, generation)
&& _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;
_createsSinceCharacterList = 0;
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);
}
},
created =>
{
lock (_gate)
{
if (IsCurrent(scope, generation))
HandleCharacterCreationResponse(scope, generation, created);
}
});
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() =>
EnterHighlightedCore(static (operations, session, character, _) =>
operations.EnterWorld(session, character.ActiveIndex));
/// <summary>
/// Campaign CC slice CC3 review-fix round (F1): the log-straight-in
/// half of <see cref="HandleCharacterCreationResponse"/> — identical
/// transaction shape to <see cref="EnterSelectedCore"/>, but sends the
/// EnterWorld wire request by the exact created guid rather than by a
/// roster index (see <see cref="ILiveSessionOperations.EnterWorldByGuid"/>).
/// </summary>
private RuntimeCommandResult EnterCreatedCharacterCore(
RuntimeCharacterCreationIdentity identity) =>
EnterHighlightedCore((operations, session, _, accountName) =>
operations.EnterWorldByGuid(session, identity.Guid, accountName));
/// <summary>
/// Shared transaction for "the highlighted character is about to enter
/// the world": select it, send the caller-supplied EnterWorld wire
/// request, activate commands, and publish the entered-world state.
/// <paramref name="sendEnterWorld"/> is the only thing that differs
/// between the ordinary index-based selection flow
/// (<see cref="EnterSelectedCore"/>) and the post-create guid-based flow
/// (<see cref="EnterCreatedCharacterCore"/>).
/// </summary>
private RuntimeCommandResult EnterHighlightedCore(
Action<ILiveSessionOperations, WorldSession, RuntimeCharacterSelectionEntry, string> sendEnterWorld)
{
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);
sendEnterWorld(_operations, scope.Session, character, snapshot.AccountName);
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);
// ── Character creation (Campaign CC slice CC3) ─────────────────────
/// <summary>Collects <see cref="RuntimeCharacterSelectionEntry"/> rows
/// into <see cref="LiveSessionRosterEntry"/> form for
/// <see cref="HandleCharacterCreationResponse"/>'s roster-append
/// composition.</summary>
private sealed class RosterCollector(List<LiveSessionRosterEntry> entries)
: IRuntimeCharacterSelectionVisitor
{
public void Visit(in RuntimeCharacterSelectionEntry character) =>
entries.Add(new LiveSessionRosterEntry(
character.CharacterId,
character.Name,
character.SecondsGreyedOut));
}
/// <summary>
/// Ports the Ok half of <c>Handle_CharGenVerificationResponse @
/// 0x0055E8B0</c> case 1 (the PENDING/create branch —
/// <c>CharacterSet::AddIdentity</c>) composed with
/// <c>gmCharGenMainUI::Update @ 0x004E8460</c>'s per-frame roster scan
/// (which finds the freshly appended identity by name and calls
/// <c>CPlayerSystem::LogOnCharacter</c> directly): a fresh
/// <see cref="RuntimeCharacterCreationState"/> is disambiguated locally
/// (we already have the exact created guid/name from the SAME reply
/// that triggered the roster append, so there is no need to re-scan for
/// it the way retail's per-frame poll does — an equivalent, not a
/// divergent, substitution).
///
/// <para>
/// Campaign CC slice CC3 review-fix round (F1/F2): the roster append no
/// longer round-trips through <see cref="RuntimeCharacterSelectionState.ApplyRoster"/>
/// — that re-derives EVERY entry's <c>ActiveIndex</c> from display
/// (name-sorted) order, and <c>ActiveIndex</c> is a wire contract
/// (<c>SendDeleteCharacter</c> sends it as the CharacterSet slot; ACE
/// indexes <c>session.Characters[(int)characterSlot]</c> —
/// <c>references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:297</c>),
/// so a full re-sort would silently retarget every PRE-EXISTING
/// character's delete slot to its alphabetical rank. Instead
/// <see cref="RuntimeCharacterSelectionState.AppendCreatedCharacter"/>
/// preserves every existing entry's <c>ActiveIndex</c> and assigns the
/// new entry's from the wire count BEFORE this create (ACE appends to
/// <c>session.Characters</c>, so the new character's slot equals that
/// pre-create count, 0-based) — read from the cached wire list
/// (<see cref="ILiveSessionOperations.GetCharacters"/>), the SAME source
/// the ordinary index-enter path reads, not the sorted display mirror.
/// The subsequent log-straight-in also no longer goes through
/// <see cref="EnterSelectedCore"/>'s roster-index EnterWorld send — that
/// cached wire list is BY DESIGN stale for the just-created character
/// (ACE never resends CharacterList post-create), so it uses
/// <see cref="EnterCreatedCharacterCore"/>'s guid-based send instead
/// (see that method's and <see cref="ILiveSessionOperations.EnterWorldByGuid"/>'s
/// doc comments).
/// </para>
///
/// A non-Ok reply only needs the state-machine update already performed
/// by <see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>
/// — no roster/enter side effects.
/// </summary>
private void HandleCharacterCreationResponse(
SessionScope scope,
ulong generation,
CharGenVerificationResponse.Parsed response)
{
CharacterCreationState.ApplyCreationResponse(response);
RuntimeCharacterCreationSnapshot creation = CharacterCreationState.Snapshot;
if (creation.LastCreated is { } created)
scope.Host.ApplyCharacterCreated(created);
else if (creation.LastRejection is { } rejection)
scope.Host.ApplyCreationFailed(rejection);
if (creation.LastCreated is not { } identity)
return;
RuntimeCharacterSelectionSnapshot before = CharacterSelectionState.Snapshot;
var entries = new List<LiveSessionRosterEntry>(before.RosterCount + 1);
CharacterSelectionState.View.Visit(new RosterCollector(entries));
entries.Add(new LiveSessionRosterEntry(
identity.Guid,
identity.Name,
SecondsGreyedOut: 0u));
var report = new LiveSessionRosterReport(
before.AccountName,
before.SlotCount,
entries);
// F2: the new character's wire slot is the pre-create count of the
// cached wire roster (ACE appends; that cached list is stale for
// THIS character by design, but its COUNT is still exactly the
// 0-based slot ACE assigned). Falls back to the display roster
// count only if the cached wire list is unexpectedly unavailable.
// CC3 re-review R1: the cached count is stale by the number of
// creates since the last CharacterList (ACE never resends one
// post-create), so a SECOND create in the same session must add
// the creates the cache hasn't seen — retail's own CharacterSet
// grows via AddIdentity per create, keeping GetSlot correct the
// same way. The counter applies ONLY to the cached-wire branch:
// the display-roster fallback already contains every prior
// create (AppendCreatedCharacter added them), so adding the
// counter there would double-count. Resets whenever a fresh wire
// CharacterList is applied and at generation reset.
int wireIndex =
_operations.GetCharacters(scope.Session)?.Characters.Count
is int cachedWireCount
? cachedWireCount + _createsSinceCharacterList
: before.RosterCount;
_createsSinceCharacterList++;
CharacterSelectionState.AppendCreatedCharacter(
identity.Guid,
identity.Name,
wireIndex);
scope.Host.ReportRoster(report);
if (!IsCurrent(scope, generation))
return;
if (!CharacterSelectionState.TryHighlight(identity.Guid))
return;
if (!IsCurrent(scope, generation))
return;
// Inline, not through the public Enter() command: we are already
// running inside Tick()'s top-level operation (this handler fires
// synchronously from _operations.Tick's inbound processing), exactly
// the same calling convention StartCore's own inline enter uses.
_ = EnterCreatedCharacterCore(identity);
}
public RuntimeCommandResult SelectHeritage(
RuntimeGenerationToken expectedGeneration,
uint heritageId)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySelectHeritage(heritageId));
}
}
public RuntimeCommandResult SelectGender(
RuntimeGenerationToken expectedGeneration,
uint genderKey)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySelectGender(genderKey));
}
}
public RuntimeCommandResult SelectTemplate(
RuntimeGenerationToken expectedGeneration,
uint templateIndex)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySelectTemplate(templateIndex));
}
}
public RuntimeCommandResult SetAttribute(
RuntimeGenerationToken expectedGeneration,
ChargenAttributeId attributeId,
int value)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySetAttribute(attributeId, value));
}
}
public RuntimeCommandResult SetAttributeLock(
RuntimeGenerationToken expectedGeneration,
ChargenAttributeId attributeId,
bool locked)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySetAttributeLock(attributeId, locked));
}
}
public RuntimeCommandResult TrainSkill(
RuntimeGenerationToken expectedGeneration,
uint skillId)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(CharacterCreationState.TryTrainSkill(skillId));
}
}
public RuntimeCommandResult SpecializeSkill(
RuntimeGenerationToken expectedGeneration,
uint skillId)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(CharacterCreationState.TrySpecializeSkill(skillId));
}
}
public RuntimeCommandResult UntrainSkill(
RuntimeGenerationToken expectedGeneration,
uint skillId)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(CharacterCreationState.TryUntrainSkill(skillId));
}
}
public RuntimeCommandResult SetAppearanceIndex(
RuntimeGenerationToken expectedGeneration,
ChargenAppearanceSlot slot,
uint index)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySetAppearanceIndex(slot, index));
}
}
public RuntimeCommandResult SetShade(
RuntimeGenerationToken expectedGeneration,
ChargenShadeSlot slot,
double value)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(CharacterCreationState.TrySetShade(slot, value));
}
}
public RuntimeCommandResult SelectStartArea(
RuntimeGenerationToken expectedGeneration,
int startAreaIndex)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySelectStartArea(startAreaIndex));
}
}
public RuntimeCommandResult SetName(
RuntimeGenerationToken expectedGeneration,
string name)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(CharacterCreationState.TrySetName(name ?? string.Empty));
}
}
public RuntimeCommandResult SetSlot(
RuntimeGenerationToken expectedGeneration,
uint slot)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(CharacterCreationState.TrySetSlot(slot));
}
}
/// <summary>
/// Ports <c>gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170</c>'s
/// send half: the local gates live in
/// <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>; this
/// method supplies the roster/slot-cap inputs from
/// <see cref="CharacterSelectionState"/> and, on acceptance, sends the
/// wire request via <c>Proto_UI::SendCharGenResult</c>'s port
/// (<see cref="ILiveSessionOperations.CreateCharacter"/>).
/// <paramref name="confirmUnspentCredits"/> is retail's <c>arg2 == 0</c>
/// case — see <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>'s
/// doc comment for the full credit-warning-dialog citation; the ordinary
/// caller passes <c>false</c> (retail's <c>arg2 = 1</c> button click). A
/// transport failure resets the verification latch the same way an
/// unsolicited Undef/Pending reply does
/// (<see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>)
/// rather than leaving it stuck Pending forever.
/// </summary>
public RuntimeCommandResult Finish(
RuntimeGenerationToken expectedGeneration,
bool confirmUnspentCredits = false)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
SessionScope scope = _scope!;
RuntimeCharacterSelectionSnapshot selection = CharacterSelectionState.Snapshot;
if (!CharacterCreationState.TryBeginFinish(
selection.RosterCount,
selection.SlotCount,
out CharacterCreate.Request request,
out uint[] skillAdvancementClasses,
out _,
confirmUnspentCredits))
{
return CharacterCreationResult(RuntimeCommandStatus.Rejected);
}
try
{
_operations.CreateCharacter(
scope.Session,
selection.AccountName,
request,
skillAdvancementClasses);
return CharacterCreationResult(RuntimeCommandStatus.Accepted);
}
// F13: narrowed to what SendCharacterCreation's send path
// actually throws — WorldSession.SendGameMessage's own
// InvalidOperationException (transport not yet negotiated) and
// whatever the underlying UDP send raises (SocketException).
// Anything else is a genuine bug, not a transport hiccup, and
// should propagate rather than being silently swallowed into a
// rejection.
catch (Exception error) when (
error is InvalidOperationException
or System.Net.Sockets.SocketException)
{
CharacterCreationState.ApplyCreationResponse(
new CharGenVerificationResponse.Parsed(
(uint)CharGenVerificationResponse.Code.Undef,
null,
null,
null));
return CharacterCreationResult(RuntimeCommandStatus.Rejected);
}
}
}
public RuntimeCommandResult AcknowledgeRejection(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TryAcknowledgeRejection());
}
}
private RuntimeCommandStatus ValidateCharacterCreationCommand(
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 CharacterCreationResult(bool accepted) =>
CharacterCreationResult(
accepted ? RuntimeCommandStatus.Accepted : RuntimeCommandStatus.Rejected);
private RuntimeCommandResult CharacterCreationResult(RuntimeCommandStatus status) =>
new(status, new RuntimeGenerationToken(_generation));
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));
CharacterCreationState.Reset(new RuntimeGenerationToken(_generation));
_createsSinceCharacterList = 0;
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();
CharacterCreationState.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));
}
}