feat(runtime): own character selection flow

This commit is contained in:
Erik 2026-08-14 18:22:17 +02:00
parent 6c4cd2bbc6
commit 0e82cbf700
20 changed files with 2396 additions and 33 deletions

View file

@ -1139,7 +1139,9 @@ internal sealed class SessionPlayerCompositionPhase
d.Options.LivePort,
d.Options.LiveUser ?? string.Empty,
d.Options.LivePass ?? string.Empty,
d.Options.LiveCharacterSelector));
d.Options.LiveCharacterSelector,
AwaitCharacterSelection:
d.Options.LiveCharacterSelector is null));
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
// The ImGui developer-tools debug toast sink was removed at Campaign V

View file

@ -89,6 +89,8 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimeCharacterView Character => _runtime.Character;
public IRuntimeSocialView Social => _runtime.Social;
public IRuntimeChatView Chat => _runtime.Chat;
public IRuntimeCharacterSelectionView CharacterSelection =>
_runtime.CharacterSelection;
public IRuntimeFellowshipView Fellowship => _runtime.Fellowship;
public IRuntimeAllegianceView Allegiance => _runtime.Allegiance;
public IRuntimeActionView Actions => _runtime.Actions;
@ -97,6 +99,10 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimePortalView Portal => _runtime.Portal;
public IRuntimeSessionCommands Session => _commands;
public IRuntimeCharacterSelectionCommands CharacterSelectionCommands =>
_runtime.Session;
IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection =>
_runtime.Session;
public IRuntimeSelectionCommands Selection => _commands;
public IRuntimeCombatCommands Combat => _commands;
public IRuntimeMagicCommands Magic => _commands;

View file

@ -12,6 +12,19 @@ using AcDream.Core.Net.Transport;
namespace AcDream.Core.Net;
/// <summary>
/// EnterWorld was rejected by the server while the transport remains a valid
/// character-select session. Callers may surface <see cref="Error"/> and let
/// the user choose another character instead of tearing down the connection.
/// </summary>
public sealed class CharacterSelectionRejectedException(
CharacterError.Parsed error)
: InvalidOperationException(
$"The server rejected character entry with error 0x{error.RawErrorCode:X8}.")
{
public CharacterError.Parsed Error { get; } = error;
}
internal interface IWorldSessionTransport : IDisposable
{
void Send(ReadOnlySpan<byte> datagram);
@ -576,6 +589,17 @@ public sealed class WorldSession : IDisposable
/// <summary>Raised every time the state machine transitions.</summary>
public event Action<State>? StateChanged;
/// <summary>
/// Pre-world character-management replies. All are decoded on the same
/// caller thread and in the same fragment order as ordinary world events.
/// ACE routes these replies on UIQueue; the queue is consumed by the
/// transport before this typed boundary.
/// </summary>
public event Action<CharacterList.Parsed>? CharacterListReceived;
public event Action? CharacterDeleteAcknowledged;
public event Action<CharacterRestore.Parsed>? CharacterRestoreReceived;
public event Action<CharacterError.Parsed>? CharacterErrorReceived;
/// <summary>
/// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode
/// handler is registered here (by GameWindow / UI layer / chat
@ -667,6 +691,7 @@ public sealed class WorldSession : IDisposable
}
public CharacterList.Parsed? Characters { get; private set; }
private CharacterError.Parsed? _lastCharacterSelectionError;
private readonly IWorldSessionTransport _net;
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
@ -1019,6 +1044,22 @@ public sealed class WorldSession : IDisposable
SweepTransport();
}
if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); }
}
/// <summary>
/// Starts the sole asynchronous receive loop while the session remains at
/// character selection. Graphical hosts call this only when they actually
/// pause before <see cref="EnterWorld"/>; immediate and headless entry keep
/// the original blocking handshake pump until ServerReady is accepted.
/// </summary>
public void StartCharacterSelectionReceive()
{
if (CurrentState != State.InCharacterSelect)
throw new InvalidOperationException(
"character-selection receive requires InCharacterSelect state");
EnsureNetReceiveLoopStarted();
}
/// <summary>
@ -1045,14 +1086,56 @@ public sealed class WorldSession : IDisposable
// the blocking pump (campaign landmine #8): the EnterWorld
// CreateObject flood — and any NAK it provokes — precedes the
// first Tick().
bool serverReady = false;
while (DateTime.UtcNow < deadline && !serverReady)
_lastCharacterSelectionError = null;
bool serverReady;
if (_netReceiveTask is null)
{
var drained = PumpOnce(out var opcodes);
SweepTransport();
if (!drained) continue;
foreach (var op in opcodes)
if (op == 0xF7DFu) { serverReady = true; break; }
// Immediate/headless entry deliberately preserves the blocking
// transport pump. Besides matching the established handshake
// contract, Receive supplies the clock edge used by the reliable
// transport's resend/NAK sweep on otherwise quiet connections.
serverReady = false;
while (DateTime.UtcNow < deadline
&& !serverReady
&& _lastCharacterSelectionError is null)
{
bool drained = PumpOnce(out List<uint> opcodes);
SweepTransport();
if (!drained)
continue;
foreach (uint opcode in opcodes)
{
if (opcode == 0xF7DFu)
{
serverReady = true;
break;
}
}
}
}
else
{
TimeSpan remaining = deadline - DateTime.UtcNow;
serverReady = remaining > TimeSpan.Zero
&& WaitForCharacterLogOffConfirmation(
_inboundQueue.Reader,
remaining,
datagram =>
{
var opcodes = new List<uint>();
ProcessDatagram(datagram.Memory, opcodes);
SweepTransport();
return opcodes.Contains(0xF7DFu)
|| _lastCharacterSelectionError is not null;
},
ReturnInboundDatagram);
}
if (_lastCharacterSelectionError is { } selectionError)
{
Transition(State.InCharacterSelect);
EnsureNetReceiveLoopStarted();
throw new CharacterSelectionRejectedException(selectionError);
}
if (!serverReady) { Transition(State.Failed); throw new TimeoutException("ServerReady not received"); }
@ -1067,14 +1150,15 @@ public sealed class WorldSession : IDisposable
// Hidden/pink-bubble login state.
Transition(State.InWorld);
// Phase A.3: start the background receive thread now that the
// handshake is complete and the session is fully established.
// During Connect() and EnterWorld(), PumpOnce() read directly
// from the socket (blocking). From here on, Tick() drains the
// channel instead.
_netReceiveTask = NetReceiveLoopAsync();
// A paused selector already owns the socket through the background
// receiver. Immediate/headless entry starts that same sole receiver
// only after its blocking ServerReady handshake has completed.
EnsureNetReceiveLoopStarted();
}
private void EnsureNetReceiveLoopStarted() =>
_netReceiveTask ??= NetReceiveLoopAsync();
internal readonly record struct EnterWorldSelection(
CharacterList.Character Character,
byte[] EnterWorldBody);
@ -1139,8 +1223,9 @@ public sealed class WorldSession : IDisposable
ReturnInboundDatagram(datagram);
}
processed++;
// Bound ONLY in-world: the handshake uses the blocking PumpOnce path, never Tick
// (the async receive owner starts at Transition(State.InWorld)).
// Bound ONLY in-world: immediate/headless handshakes use blocking
// PumpOnce, while a deliberately paused selector uses Tick without
// an in-world flood budget so management replies drain promptly.
// Acks and NAKs are NOT per-packet: the end-of-Tick sweep below emits them on
// the scheduler's 2.0 s / 0.6 s gates, and it runs after the budget break, so a
// deferred inbound tail never defers a due ack, NAK, or resend. The tail itself
@ -1687,10 +1772,53 @@ public sealed class WorldSession : IDisposable
if (!dispatchWorldEvents)
continue;
if (op == CharacterList.Opcode && Characters is null)
if (op == CharacterList.Opcode)
{
try { Characters = CharacterList.Parse(body); }
catch { /* malformed — ignore and keep draining */ }
CharacterList.Parsed parsed;
try
{
parsed = CharacterList.Parse(body);
}
catch
{
// Malformed management messages do not poison the
// remaining ordered UIQueue fragments.
continue;
}
Characters = parsed;
CharacterListReceived?.Invoke(parsed);
}
else if (op == CharacterDelete.Opcode
&& CharacterDelete.IsAcknowledgement(body))
{
CharacterDeleteAcknowledged?.Invoke();
}
else if (op == CharacterRestore.ResponseOpcode)
{
CharacterRestore.Parsed parsed;
try
{
parsed = CharacterRestore.Parse(body);
}
catch
{
continue;
}
CharacterRestoreReceived?.Invoke(parsed);
}
else if (op == CharacterError.Opcode)
{
CharacterError.Parsed parsed;
try
{
parsed = CharacterError.Parse(body);
}
catch
{
continue;
}
_lastCharacterSelectionError = parsed;
CharacterErrorReceived?.Invoke(parsed);
}
else if (op == 0xF7E5u) // DddInterrogation — server asks "what dat list versions do you have?"
{
@ -2040,6 +2168,29 @@ public sealed class WorldSession : IDisposable
SendGameMessage(gameActionBody);
}
/// <summary>
/// Send retail CharacterDelete through the login/logon queue. The caller
/// supplies the selected entry's active CharacterSet slot, not its guid.
/// </summary>
public void SendDeleteCharacter(string accountName, int activeIndex)
{
ArgumentNullException.ThrowIfNull(accountName);
if (activeIndex < 0)
throw new ArgumentOutOfRangeException(nameof(activeIndex));
SendGameMessage(
CharacterDelete.BuildRequestBody(
accountName,
checked((uint)activeIndex)),
GameMessageGroup.LoginQueue);
}
/// <summary>
/// Send retail CharacterRestore through the control queue. This is
/// deliberately non-blocking because ACE silently drops unknown guids.
/// </summary>
public void SendRestoreCharacter(uint characterId) =>
SendControlMessage(CharacterRestore.BuildRequestBody(characterId));
/// <summary>
/// Phase I.3: test-only hook. When non-null, <see cref="SendGameAction"/>
/// invokes this instead of writing to the wire. Lets unit tests verify
@ -2049,6 +2200,9 @@ public sealed class WorldSession : IDisposable
/// </summary>
internal Action<byte[]>? GameActionCapture { get; set; }
/// <summary>LA7b unit-test seam for queue-sensitive pre-world sends.</summary>
internal Action<byte[], GameMessageGroup>? GameMessageCapture { get; set; }
/// <summary>
/// Phase B.2: get and increment the game-action sequence counter.
/// Call once per outbound movement message; pass the returned value
@ -2895,6 +3049,11 @@ public sealed class WorldSession : IDisposable
private void SendGameMessage(byte[] gameMessageBody, GameMessageGroup queue)
{
if (GameMessageCapture is { } capture)
{
capture(gameMessageBody, queue);
return;
}
// #260 probe: log the send BEFORE the sequence counters are consumed
// so the line carries the values this datagram will actually use. The
// exception filter below logs a wire-write fault WITHOUT catching it

View file

@ -527,6 +527,8 @@ public sealed class GameRuntime
public IRuntimeCharacterView Character => CharacterOwner.View;
public IRuntimeSocialView Social => CommunicationOwner.SocialView;
public IRuntimeChatView Chat => CommunicationOwner.View;
public IRuntimeCharacterSelectionView CharacterSelection =>
Session.CharacterSelection;
public IRuntimeFellowshipView Fellowship => FellowshipOwner.View;
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;

View file

@ -35,6 +35,7 @@ public enum RuntimeSessionStartStatus
/// exit-code mapping, not a failure.
/// </summary>
ProbeComplete,
AwaitingCharacterSelection,
}
public readonly record struct RuntimeSessionStartResult(
@ -381,6 +382,10 @@ public interface IGameRuntimeCommands
{
IRuntimeSessionCommands Session { get; }
Session.IRuntimeCharacterSelectionCommands CharacterSelection =>
throw new NotSupportedException(
"This command adapter does not project character selection.");
IRuntimeSelectionCommands Selection { get; }
IRuntimeCombatCommands Combat { get; }

View file

@ -1,5 +1,6 @@
using AcDream.Core.Physics;
using AcDream.Runtime.World;
using AcDream.Runtime.Session;
namespace AcDream.Runtime;
@ -275,6 +276,10 @@ public interface IGameRuntimeView
IRuntimeChatView Chat { get; }
IRuntimeCharacterSelectionView CharacterSelection =>
throw new NotSupportedException(
"This runtime view does not project character selection.");
IRuntimeFellowshipView Fellowship { get; }
IRuntimeAllegianceView Allegiance { get; }

View file

@ -72,6 +72,8 @@ public sealed class DirectGameRuntimeCommandAdapter
}
public IRuntimeSessionCommands Session => this;
public IRuntimeCharacterSelectionCommands CharacterSelection =>
_runtime.Session;
public IRuntimeSelectionCommands Selection => this;
public IRuntimeCombatCommands Combat => this;
public IRuntimeMagicCommands Magic => this;

View file

@ -29,7 +29,14 @@ public sealed record LiveSessionConnectOptions(
/// itself does not enforce that pairing — the headless config loader
/// does, before a <see cref="LiveSessionConnectOptions"/> is ever built.
/// </summary>
bool Probe = false);
bool Probe = false,
/// <summary>
/// Keep the connected transport in Runtime's pre-world selection state
/// instead of applying the legacy first-available fallback. Graphical
/// composition enables this only when no explicit selector was supplied;
/// direct/headless callers retain the existing default behavior.
/// </summary>
bool AwaitCharacterSelection = false);
public interface IRuntimeLiveSessionFramePhase
{

View file

@ -14,6 +14,11 @@ public enum LiveSessionStartStatus
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
@ -167,7 +172,16 @@ public interface ILiveSessionOperations
WorldSession CreateSession(IPEndPoint endpoint);
void Connect(WorldSession session, string user, string password);
CharacterList.Parsed? GetCharacters(WorldSession session);
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);
}
@ -203,6 +217,9 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
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);
@ -219,8 +236,49 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
/// </summary>
public sealed class LiveSessionController
: IDisposable,
IRuntimeLiveSessionFramePhase
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;
public CharacterSelectionWireBinding(
WorldSession session,
Action<CharacterList.Parsed> roster,
Action delete,
Action<CharacterRestore.Parsed> restore,
Action<CharacterError.Parsed> error)
{
_session = session;
_roster = roster;
_delete = delete;
_restore = restore;
_error = error;
session.CharacterListReceived += roster;
session.CharacterDeleteAcknowledged += delete;
session.CharacterRestoreReceived += restore;
session.CharacterErrorReceived += error;
}
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;
}
}
private sealed class SessionScope(
WorldSession session,
ILiveSessionLifecycleHost host,
@ -232,6 +290,11 @@ public sealed class LiveSessionController
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; }
@ -242,13 +305,18 @@ public sealed class LiveSessionController
try
{
Binding?.Dispose();
CharacterSelectionBinding?.Dispose();
}
finally
{
if (Binding is null || Binding.CommandsDeactivated)
CompletedStages |= RuntimeTeardownStage.CommandsInert;
if (Binding is null || Binding.EventsDetached)
if ((Binding is null || Binding.EventsDetached)
&& (CharacterSelectionBinding is null
|| CharacterSelectionBinding.IsDisposed))
{
CompletedStages |= RuntimeTeardownStage.InboundDetached;
}
}
_teardownStage = 1;
}
@ -315,8 +383,14 @@ public sealed class LiveSessionController
public LiveSessionController(ILiveSessionOperations operations)
{
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
CharacterSelectionState = new RuntimeCharacterSelectionState();
}
public RuntimeCharacterSelectionState CharacterSelectionState { get; }
public IRuntimeCharacterSelectionView CharacterSelection =>
CharacterSelectionState.View;
public WorldSession? CurrentSession
{
get { lock (_gate) return _scope?.Session; }
@ -405,6 +479,11 @@ public sealed class LiveSessionController
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));
}
}
@ -492,7 +571,7 @@ public sealed class LiveSessionController
{
if (_disposed)
return;
if (!_inWorld || _scope is null || _operationDepth != 0)
if (_scope is null || _operationDepth != 0)
return;
RunTopLevel(() =>
@ -517,7 +596,8 @@ public sealed class LiveSessionController
// the protocol pump above and only when the scope/generation
// are still current — a reconnect that happened mid-tick
// must not flush against a retired session.
InvokeAutoSaveTick(scope.Session);
if (_inWorld)
InvokeAutoSaveTick(scope.Session);
});
}
}
@ -583,6 +663,8 @@ public sealed class LiveSessionController
{
RuntimeGenerationToken resetGeneration = new(_generation);
ulong generation = ++_generation;
RuntimeGenerationToken activeGeneration = new(generation);
CharacterSelectionState.Reset(activeGeneration);
try
{
DrainRetiredScope();
@ -608,6 +690,8 @@ public sealed class LiveSessionController
if (string.IsNullOrEmpty(options.User) || string.IsNullOrEmpty(options.Password))
return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials);
CharacterSelectionState.Begin(activeGeneration);
SessionScope? scope = null;
try
{
@ -643,6 +727,9 @@ public sealed class LiveSessionController
_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);
@ -650,7 +737,9 @@ public sealed class LiveSessionController
CharacterList.Parsed? characters = _operations.GetCharacters(session);
if (characters is not null)
{
host.ReportRoster(BuildRosterReport(characters));
LiveSessionRosterReport roster = BuildRosterReport(characters);
CharacterSelectionState.ApplyRoster(roster);
host.ReportRoster(roster);
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
}
@ -670,6 +759,19 @@ public sealed class LiveSessionController
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,
@ -686,6 +788,12 @@ public sealed class LiveSessionController
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);
@ -701,6 +809,7 @@ public sealed class LiveSessionController
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);
@ -734,6 +843,273 @@ public sealed class LiveSessionController
}
}
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);
}
});
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
@ -749,6 +1125,7 @@ public sealed class LiveSessionController
++_generation;
_inWorld = false;
_activeSelection = null;
CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation));
if (_scope is { } scope)
{
_scope = null;
@ -889,6 +1266,7 @@ public sealed class LiveSessionController
private void DisposeCore()
{
StopCore();
CharacterSelectionState.Dispose();
_disposed = true;
}

View file

@ -318,6 +318,8 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
RuntimeSessionStartStatus.Failed,
LiveSessionStartStatus.ProbeComplete =>
RuntimeSessionStartStatus.ProbeComplete,
LiveSessionStartStatus.AwaitingCharacterSelection =>
RuntimeSessionStartStatus.AwaitingCharacterSelection,
_ => throw new ArgumentOutOfRangeException(
nameof(result),
result.Status,

View file

@ -0,0 +1,991 @@
using AcDream.Core.Net.Messages;
namespace AcDream.Runtime.Session;
public enum RuntimeCharacterSelectionLifecycle
{
Inactive,
Connecting,
AwaitingSelection,
EnteringWorld,
InWorld,
}
public enum RuntimeCharacterSelectionOperation
{
None,
DeleteRequested,
DeleteAcknowledged,
RestoreRequested,
RestoreSucceeded,
RestoreRejected,
}
public enum RuntimeCharacterSelectionDeltaKind
{
Reset,
RosterChanged,
HighlightChanged,
DeleteConfirmationOpened,
DeleteConfirmationCancelled,
DeleteRequested,
DeleteAcknowledged,
RestoreRequested,
RestoreCompleted,
ErrorChanged,
EnteringWorld,
EnteredWorld,
}
public readonly record struct RuntimeCharacterSelectionEntry(
int ActiveIndex,
uint CharacterId,
string Name,
uint SecondsGreyedOut)
{
/// <summary>
/// ACE currently sends a constant one throughout the delete grace window.
/// The retail field is therefore exposed, but its only supported semantic
/// is the non-zero pending-delete predicate.
/// </summary>
public bool IsPendingDelete => SecondsGreyedOut != 0u;
public bool CanEnter => CharacterId != 0u && !IsPendingDelete;
}
public readonly record struct RuntimeCharacterSelectionButtons(
bool CanEnter,
bool CanDelete,
bool CanRestore,
bool DeleteVisible,
bool RestoreVisible)
{
public static RuntimeCharacterSelectionButtons None { get; } =
new(false, false, false, true, false);
}
public readonly record struct RuntimeCharacterSelectionError(
uint RawCode,
CharacterError.Code Code,
string Message);
public readonly record struct RuntimeCharacterSelectionSnapshot(
RuntimeGenerationToken Generation,
RuntimeCharacterSelectionLifecycle Lifecycle,
long Revision,
string AccountName,
int SlotCount,
int RosterCount,
uint HighlightedCharacterId,
int HighlightedDisplayIndex,
uint PendingDeleteCharacterId,
uint LastRestoreRequestedCharacterId,
RuntimeCharacterSelectionOperation Operation,
RuntimeCharacterSelectionError? Error,
RuntimeCharacterSelectionButtons Buttons)
{
public bool IsActive =>
Lifecycle is RuntimeCharacterSelectionLifecycle.AwaitingSelection
or RuntimeCharacterSelectionLifecycle.EnteringWorld;
}
public readonly record struct RuntimeCharacterSelectionDelta(
RuntimeGenerationToken Generation,
ulong Sequence,
long Revision,
RuntimeCharacterSelectionDeltaKind Kind,
uint CharacterId = 0u,
uint ErrorCode = 0u);
public interface IRuntimeCharacterSelectionVisitor
{
void Visit(in RuntimeCharacterSelectionEntry character);
}
public interface IRuntimeCharacterSelectionObserver
{
void OnCharacterSelectionChanged(
in RuntimeCharacterSelectionDelta delta);
}
public interface IRuntimeCharacterSelectionEventSource
{
IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer);
}
/// <summary>
/// Borrowed view of the one Runtime-owned pre-world selection graph. Roster
/// entries are in retail display order (ordinal name order, then every
/// greyed/pending-delete entry moved to the tail); <see cref=
/// "RuntimeCharacterSelectionEntry.ActiveIndex"/> retains the original wire
/// slot used by character delete and EnterWorld.
/// </summary>
public interface IRuntimeCharacterSelectionView
: IRuntimeCharacterSelectionEventSource
{
RuntimeCharacterSelectionSnapshot Snapshot { get; }
bool TryGetAt(
int displayIndex,
out RuntimeCharacterSelectionEntry character);
bool TryGet(
uint characterId,
out RuntimeCharacterSelectionEntry character);
void Visit(IRuntimeCharacterSelectionVisitor visitor);
}
public interface IRuntimeCharacterSelectionCommands
{
RuntimeCommandResult Highlight(
RuntimeGenerationToken expectedGeneration,
uint characterId);
RuntimeCommandResult Enter(
RuntimeGenerationToken expectedGeneration);
RuntimeCommandResult RequestDelete(
RuntimeGenerationToken expectedGeneration);
RuntimeCommandResult ConfirmDelete(
RuntimeGenerationToken expectedGeneration);
RuntimeCommandResult Restore(
RuntimeGenerationToken expectedGeneration);
RuntimeCommandResult Cancel(
RuntimeGenerationToken expectedGeneration);
}
/// <summary>
/// Sole mutable owner of character-selection state. It contains no App or UI
/// types; presentation borrows <see cref="View"/> and issues generation-gated
/// commands through <see cref="LiveSessionController"/>.
/// </summary>
public sealed class RuntimeCharacterSelectionState : IDisposable
{
private sealed class ViewProjection(RuntimeCharacterSelectionState owner)
: IRuntimeCharacterSelectionView
{
public RuntimeCharacterSelectionSnapshot Snapshot => owner.Snapshot;
public bool TryGetAt(
int displayIndex,
out RuntimeCharacterSelectionEntry character) =>
owner.TryGetAt(displayIndex, out character);
public bool TryGet(
uint characterId,
out RuntimeCharacterSelectionEntry character) =>
owner.TryGet(characterId, out character);
public void Visit(IRuntimeCharacterSelectionVisitor visitor) =>
owner.Visit(visitor);
public IDisposable Subscribe(
IRuntimeCharacterSelectionObserver observer) =>
owner._events.Subscribe(observer);
}
private readonly object _gate = new();
private readonly RuntimeCharacterSelectionEventStream _events = new();
private readonly ViewProjection _view;
private RuntimeCharacterSelectionEntry[] _entries = [];
private RuntimeGenerationToken _generation;
private RuntimeCharacterSelectionLifecycle _lifecycle;
private long _revision;
private string _accountName = string.Empty;
private int _slotCount;
private uint _highlightedCharacterId;
private uint _pendingDeleteCharacterId;
private uint _lastRestoreRequestedCharacterId;
private bool _restoreResponseArmed;
private RuntimeCharacterSelectionOperation _operation;
private RuntimeCharacterSelectionError? _error;
private bool _disposed;
public RuntimeCharacterSelectionState()
{
_view = new ViewProjection(this);
}
public IRuntimeCharacterSelectionView View => _view;
public RuntimeCharacterSelectionSnapshot Snapshot
{
get
{
lock (_gate)
{
int selectedIndex = FindDisplayIndex(_highlightedCharacterId);
RuntimeCharacterSelectionButtons buttons =
BuildButtons(selectedIndex);
return new RuntimeCharacterSelectionSnapshot(
_generation,
_lifecycle,
_revision,
_accountName,
_slotCount,
_entries.Length,
_highlightedCharacterId,
selectedIndex,
_pendingDeleteCharacterId,
_lastRestoreRequestedCharacterId,
_operation,
_error,
buttons);
}
}
}
internal void Begin(RuntimeGenerationToken generation)
{
lock (_gate)
{
ThrowIfDisposed();
_generation = generation;
_lifecycle = RuntimeCharacterSelectionLifecycle.Connecting;
ClearSessionState();
_revision++;
}
Publish(RuntimeCharacterSelectionDeltaKind.Reset);
}
/// <summary>
/// Ports the stateful parts of retail
/// gmCharacterManagementUI::RebuildCharacterList @ 0x004ec3a0: retain the
/// prior selected guid when it still exists; otherwise choose the first
/// non-greyed active entry in CharacterSet wire order (or the first entry
/// when every entry is greyed, which keeps Restore reachable); sort
/// displayed names with wcscmp semantics and then move greyed entries to
/// the tail.
/// </summary>
internal void ApplyRoster(LiveSessionRosterReport roster)
{
ArgumentNullException.ThrowIfNull(roster);
uint selected;
lock (_gate)
{
ThrowIfDisposed();
uint previous = _highlightedCharacterId;
var wireEntries = new RuntimeCharacterSelectionEntry[
roster.Entries.Count];
uint fallback = 0u;
bool foundAvailableFallback = false;
for (int i = 0; i < wireEntries.Length; i++)
{
LiveSessionRosterEntry source = roster.Entries[i];
wireEntries[i] = new RuntimeCharacterSelectionEntry(
i,
source.Id,
source.Name,
source.SecondsGreyedOut);
if (fallback == 0u && source.Id != 0u)
fallback = source.Id;
if (!foundAvailableFallback
&& source.Id != 0u
&& source.SecondsGreyedOut == 0u)
{
fallback = source.Id;
foundAvailableFallback = true;
}
}
Array.Sort(
wireEntries,
static (left, right) =>
string.CompareOrdinal(left.Name, right.Name));
_entries = StablePartitionGreyedToTail(wireEntries);
_accountName = roster.AccountName;
_slotCount = roster.SlotCount;
_lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection;
_pendingDeleteCharacterId = 0u;
_lastRestoreRequestedCharacterId = 0u;
_restoreResponseArmed = false;
_operation = RuntimeCharacterSelectionOperation.None;
_error = null;
_highlightedCharacterId = Contains(previous)
? previous
: fallback;
selected = _highlightedCharacterId;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.RosterChanged,
selected);
}
internal bool TryHighlight(uint characterId)
{
lock (_gate)
{
if (_disposed
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|| HasDeleteModalOrRequest()
|| !Contains(characterId))
{
return false;
}
if (_highlightedCharacterId == characterId)
return true;
_highlightedCharacterId = characterId;
_pendingDeleteCharacterId = 0u;
_error = null;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.HighlightChanged,
characterId);
return true;
}
internal bool TryRequestDelete(out uint characterId)
{
lock (_gate)
{
int index = FindDisplayIndex(_highlightedCharacterId);
if (_disposed
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|| HasDeleteModalOrRequest()
|| index < 0
|| !_entries[index].CanEnter)
{
characterId = 0u;
return false;
}
characterId = _entries[index].CharacterId;
_pendingDeleteCharacterId = characterId;
_error = null;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.DeleteConfirmationOpened,
characterId);
return true;
}
internal bool TryTakeDeleteConfirmation(
out RuntimeCharacterSelectionEntry character,
out string accountName)
{
lock (_gate)
{
int index = FindDisplayIndex(_pendingDeleteCharacterId);
if (_disposed
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|| _pendingDeleteCharacterId == 0u
|| _operation is RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
|| index < 0
|| !_entries[index].CanEnter)
{
character = default;
accountName = string.Empty;
return false;
}
character = _entries[index];
accountName = _accountName;
_pendingDeleteCharacterId = 0u;
_operation = RuntimeCharacterSelectionOperation.DeleteRequested;
_error = null;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.DeleteRequested,
character.CharacterId);
return true;
}
internal bool TryBeginRestore(
out RuntimeCharacterSelectionEntry character)
{
lock (_gate)
{
int index = FindDisplayIndex(_highlightedCharacterId);
if (_disposed
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|| HasDeleteModalOrRequest()
|| index < 0
|| !_entries[index].IsPendingDelete)
{
character = default;
return false;
}
character = _entries[index];
_pendingDeleteCharacterId = 0u;
_lastRestoreRequestedCharacterId = character.CharacterId;
_restoreResponseArmed = true;
_operation = RuntimeCharacterSelectionOperation.RestoreRequested;
_error = null;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.RestoreRequested,
character.CharacterId);
return true;
}
internal bool Cancel()
{
RuntimeCharacterSelectionDeltaKind kind;
uint characterId;
lock (_gate)
{
if (_disposed
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection)
{
return false;
}
if (_pendingDeleteCharacterId != 0u)
{
characterId = _pendingDeleteCharacterId;
_pendingDeleteCharacterId = 0u;
kind = RuntimeCharacterSelectionDeltaKind.DeleteConfirmationCancelled;
}
else if (_error is not null)
{
characterId = 0u;
_error = null;
kind = RuntimeCharacterSelectionDeltaKind.ErrorChanged;
}
else
{
return false;
}
_revision++;
}
Publish(kind, characterId);
return true;
}
internal void ApplyDeleteAcknowledged()
{
uint characterId;
lock (_gate)
{
if (_disposed
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|| _operation != RuntimeCharacterSelectionOperation.DeleteRequested)
{
return;
}
_operation = RuntimeCharacterSelectionOperation.DeleteAcknowledged;
characterId = _highlightedCharacterId;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.DeleteAcknowledged,
characterId);
}
internal void ApplyRestore(CharacterRestore.Parsed response)
{
uint characterId;
uint errorCode = 0u;
lock (_gate)
{
if (_disposed
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|| !_restoreResponseArmed)
return;
if (response.Guid is { } responseGuid
&& responseGuid != _lastRestoreRequestedCharacterId)
{
return;
}
_restoreResponseArmed = false;
characterId = response.Guid
?? _lastRestoreRequestedCharacterId;
if (response.IsOk
&& response.Guid is { } guid
&& response.SecondsGreyedOut is { } seconds)
{
int index = FindDisplayIndex(guid);
if (index >= 0)
{
RuntimeCharacterSelectionEntry current = _entries[index];
_entries[index] = current with
{
Name = response.Name ?? current.Name,
SecondsGreyedOut = seconds,
};
Array.Sort(
_entries,
static (left, right) =>
string.CompareOrdinal(left.Name, right.Name));
_entries = StablePartitionGreyedToTail(_entries);
}
_operation = RuntimeCharacterSelectionOperation.RestoreSucceeded;
_error = null;
}
else
{
errorCode = response.VerificationFlag;
_operation = RuntimeCharacterSelectionOperation.RestoreRejected;
_error = new RuntimeCharacterSelectionError(
response.VerificationFlag,
CharacterError.Code.Undefined,
$"The character could not be restored (verification 0x{response.VerificationFlag:X8}).");
}
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.RestoreCompleted,
characterId,
errorCode);
}
internal void ApplyError(CharacterError.Parsed error)
{
if (error.AsCode == CharacterError.Code.NumErrors)
return;
string message = MapError(error.RawErrorCode, error.AsCode);
lock (_gate)
{
if (_disposed
|| _lifecycle is not (
RuntimeCharacterSelectionLifecycle.AwaitingSelection
or RuntimeCharacterSelectionLifecycle.EnteringWorld))
return;
_pendingDeleteCharacterId = 0u;
_restoreResponseArmed = false;
_operation = RuntimeCharacterSelectionOperation.None;
_error = new RuntimeCharacterSelectionError(
error.RawErrorCode,
error.AsCode,
message);
if (_lifecycle == RuntimeCharacterSelectionLifecycle.EnteringWorld)
_lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.ErrorChanged,
errorCode: error.RawErrorCode);
}
internal bool BeginEnter(out RuntimeCharacterSelectionEntry character)
{
lock (_gate)
{
int index = FindDisplayIndex(_highlightedCharacterId);
if (_disposed
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|| HasDeleteModalOrRequest()
|| index < 0
|| !_entries[index].CanEnter)
{
character = default;
return false;
}
character = _entries[index];
_pendingDeleteCharacterId = 0u;
_lastRestoreRequestedCharacterId = 0u;
_restoreResponseArmed = false;
_operation = RuntimeCharacterSelectionOperation.None;
_error = null;
_lifecycle = RuntimeCharacterSelectionLifecycle.EnteringWorld;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.EnteringWorld,
character.CharacterId);
return true;
}
internal void CompleteEnter(uint characterId)
{
lock (_gate)
{
if (_disposed)
return;
_lifecycle = RuntimeCharacterSelectionLifecycle.InWorld;
_highlightedCharacterId = characterId;
_operation = RuntimeCharacterSelectionOperation.None;
_error = null;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.EnteredWorld,
characterId);
}
internal void ReturnToSelection()
{
lock (_gate)
{
if (_disposed
|| _lifecycle != RuntimeCharacterSelectionLifecycle.EnteringWorld)
{
return;
}
_lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection;
_revision++;
}
}
internal void Reset(RuntimeGenerationToken generation)
{
lock (_gate)
{
if (_disposed)
return;
_generation = generation;
_lifecycle = RuntimeCharacterSelectionLifecycle.Inactive;
ClearSessionState();
_revision++;
}
Publish(RuntimeCharacterSelectionDeltaKind.Reset);
}
public void Dispose()
{
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
_lifecycle = RuntimeCharacterSelectionLifecycle.Inactive;
ClearSessionState();
_revision++;
}
_events.Dispose();
}
private bool TryGetAt(
int displayIndex,
out RuntimeCharacterSelectionEntry character)
{
lock (_gate)
{
if ((uint)displayIndex >= (uint)_entries.Length)
{
character = default;
return false;
}
character = _entries[displayIndex];
return true;
}
}
private bool TryGet(
uint characterId,
out RuntimeCharacterSelectionEntry character)
{
lock (_gate)
{
int index = FindDisplayIndex(characterId);
if (index < 0)
{
character = default;
return false;
}
character = _entries[index];
return true;
}
}
private void Visit(IRuntimeCharacterSelectionVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
lock (_gate)
{
foreach (RuntimeCharacterSelectionEntry character in _entries)
visitor.Visit(in character);
}
}
private RuntimeCharacterSelectionButtons BuildButtons(int selectedIndex)
{
if (_operation is RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged)
{
return RuntimeCharacterSelectionButtons.None;
}
if (selectedIndex < 0)
return RuntimeCharacterSelectionButtons.None;
RuntimeCharacterSelectionEntry selected = _entries[selectedIndex];
if (selected.IsPendingDelete)
{
return new RuntimeCharacterSelectionButtons(
CanEnter: false,
CanDelete: false,
CanRestore: true,
DeleteVisible: false,
RestoreVisible: true);
}
return new RuntimeCharacterSelectionButtons(
CanEnter: selected.CanEnter,
CanDelete: selected.CanEnter,
CanRestore: false,
DeleteVisible: true,
RestoreVisible: false);
}
private int FindDisplayIndex(uint characterId)
{
if (characterId == 0u)
return -1;
for (int i = 0; i < _entries.Length; i++)
{
if (_entries[i].CharacterId == characterId)
return i;
}
return -1;
}
private bool Contains(uint characterId) =>
FindDisplayIndex(characterId) >= 0;
private bool HasDeleteModalOrRequest() =>
_pendingDeleteCharacterId != 0u
|| _operation is RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged;
private static RuntimeCharacterSelectionEntry[] StablePartitionGreyedToTail(
RuntimeCharacterSelectionEntry[] sorted)
{
if (sorted.Length < 2)
return sorted;
var result = new RuntimeCharacterSelectionEntry[sorted.Length];
int position = 0;
foreach (RuntimeCharacterSelectionEntry entry in sorted)
{
if (!entry.IsPendingDelete)
result[position++] = entry;
}
foreach (RuntimeCharacterSelectionEntry entry in sorted)
{
if (entry.IsPendingDelete)
result[position++] = entry;
}
return result;
}
private static string MapError(
uint rawCode,
CharacterError.Code code) =>
code switch
{
CharacterError.Code.Logon =>
"Another account is already logged on from this client.",
CharacterError.Code.LoggedOn =>
"This account is already logged on.",
CharacterError.Code.AccountLogon =>
"The server could not access the account. Please try again shortly.",
CharacterError.Code.ServerCrash or CharacterError.Code.AccountInUse =>
"The server disconnected. Please try again shortly.",
CharacterError.Code.Logoff =>
"The server could not log off the character.",
CharacterError.Code.Delete =>
"The server could not delete the character.",
CharacterError.Code.NoPremade =>
"No premade character is available.",
CharacterError.Code.AccountInvalid =>
"The account name is not valid.",
CharacterError.Code.AccountDoesntExist =>
"The account does not exist.",
CharacterError.Code.EnterGameGeneric =>
"The character could not enter the world.",
CharacterError.Code.EnterGameStressAccount =>
"A stress-test character cannot enter the world.",
CharacterError.Code.EnterGameCharacterInWorld =>
"One of this account's characters is still in the world. Please try again shortly.",
CharacterError.Code.EnterGamePlayerAccountMissing =>
"The server could not find the player account. Please try again later.",
CharacterError.Code.EnterGameCharacterNotOwned =>
"This account does not own the selected character.",
CharacterError.Code.EnterGameCharacterInWorldServer =>
"One of this account's characters is already in the world.",
CharacterError.Code.EnterGameOldCharacter =>
"The selected character must be updated before entering the world.",
CharacterError.Code.EnterGameCorruptCharacter =>
"The selected character's data is corrupt.",
CharacterError.Code.EnterGameStartServerDown =>
"The selected character's starting server is unavailable.",
CharacterError.Code.EnterGameCouldntPlaceCharacter =>
"The selected character could not be placed in the world. Please try again shortly.",
CharacterError.Code.LogonServerFull =>
"The server is currently full. Please try again later.",
CharacterError.Code.CharacterIsBooted =>
"The selected character is temporarily unavailable.",
CharacterError.Code.EnterGameCharacterLocked =>
"A save of the selected character is still in progress. Please try again later.",
CharacterError.Code.SubscriptionExpired =>
"The account subscription has expired.",
_ => $"Character selection failed (error 0x{rawCode:X8}).",
};
private void ClearSessionState()
{
_entries = [];
_accountName = string.Empty;
_slotCount = 0;
_highlightedCharacterId = 0u;
_pendingDeleteCharacterId = 0u;
_lastRestoreRequestedCharacterId = 0u;
_restoreResponseArmed = false;
_operation = RuntimeCharacterSelectionOperation.None;
_error = null;
}
private void Publish(
RuntimeCharacterSelectionDeltaKind kind,
uint characterId = 0u,
uint errorCode = 0u)
{
RuntimeGenerationToken generation;
long revision;
lock (_gate)
{
if (_disposed)
return;
generation = _generation;
revision = _revision;
}
_events.Publish(generation, revision, kind, characterId, errorCode);
}
private void ThrowIfDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this);
}
internal sealed class RuntimeCharacterSelectionEventStream : IDisposable
{
private readonly object _gate = new();
private readonly List<RuntimeCharacterSelectionDelta> _pending = [];
private IRuntimeCharacterSelectionObserver[] _observers = [];
private ulong _sequence;
private bool _dispatching;
private bool _disposed;
public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer)
{
ArgumentNullException.ThrowIfNull(observer);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (Array.IndexOf(_observers, observer) >= 0)
{
throw new InvalidOperationException(
"The character-selection observer is already subscribed.");
}
var replacement = new IRuntimeCharacterSelectionObserver[
_observers.Length + 1];
Array.Copy(_observers, replacement, _observers.Length);
replacement[^1] = observer;
Volatile.Write(ref _observers, replacement);
}
return new Subscription(this, observer);
}
public void Publish(
RuntimeGenerationToken generation,
long revision,
RuntimeCharacterSelectionDeltaKind kind,
uint characterId,
uint errorCode)
{
lock (_gate)
{
if (_disposed)
return;
_pending.Add(new RuntimeCharacterSelectionDelta(
generation,
unchecked(++_sequence),
revision,
kind,
characterId,
errorCode));
if (_dispatching)
return;
_dispatching = true;
}
int index = 0;
while (true)
{
RuntimeCharacterSelectionDelta delta;
lock (_gate)
{
if (index >= _pending.Count)
{
_pending.Clear();
_dispatching = false;
return;
}
delta = _pending[index++];
}
foreach (IRuntimeCharacterSelectionObserver observer
in Volatile.Read(ref _observers))
{
try
{
observer.OnCharacterSelectionChanged(in delta);
}
catch (Exception error)
{
Console.Error.WriteLine(
$"runtime: character-selection observer failed: {error.Message}");
}
}
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
_pending.Clear();
_dispatching = false;
Volatile.Write(ref _observers, []);
}
}
private void Unsubscribe(IRuntimeCharacterSelectionObserver observer)
{
lock (_gate)
{
int index = Array.IndexOf(_observers, observer);
if (index < 0)
return;
var replacement = new IRuntimeCharacterSelectionObserver[
_observers.Length - 1];
if (index > 0)
Array.Copy(_observers, 0, replacement, 0, index);
if (index < _observers.Length - 1)
{
Array.Copy(
_observers,
index + 1,
replacement,
index,
_observers.Length - index - 1);
}
Volatile.Write(ref _observers, replacement);
}
}
private sealed class Subscription(
RuntimeCharacterSelectionEventStream owner,
IRuntimeCharacterSelectionObserver observer)
: IDisposable
{
private RuntimeCharacterSelectionEventStream? _owner = owner;
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer);
}
}