diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 4696577b..a5b4530d 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -219,6 +219,9 @@ src/
generation + teardown
Session/ -> J2 canonical session lifetime, ordered
inbound routing + retryable teardown
+ RuntimeCharacterSelectionState.cs -> sole generation-scoped pre-world
+ roster/highlight/delete/restore/error owner;
+ borrowed view + ordered deltas + typed commands
Entities/
RuntimeEntityDirectory.cs -> sole GUID/incarnation/local-ID authority
RuntimeEntityRecord.cs -> presentation-free accepted entity state
diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md
index a56b00b8..e4dedd97 100644
--- a/docs/architecture/code-structure.md
+++ b/docs/architecture/code-structure.md
@@ -467,7 +467,7 @@ useful ordering seam, but its ownership status is **partial**.
| Area | Status | Current truth |
|---|---|---|
| Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. |
-| Network session | **Complete Runtime ownership** | `RuntimeLiveSessionController` owns the sole `WorldSession` generation and resolve/create/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal transaction. Runtime route owners preserve exact inbound/outbound ordering and retryable teardown. App supplies immutable options, graphical/domain callbacks, and one borrowed inertable UI command projection—no mirrored session or reset plan (`75930787`). |
+| Network session | **Complete Runtime ownership** | `LiveSessionController` owns the sole `WorldSession` generation and resolve/create/Connect/pre-world selection/EnterWorld/Tick/stop/reconnect/disposal transaction. Its `RuntimeCharacterSelectionState` owns the full active roster (including greyed entries and retained wire slots), highlight, delete confirmation, restore/delete/error state, generation/lifecycle, borrowed view, ordered deltas, and typed commands. A selector-free graphical launch pauses on that owner; explicit and headless selection retain the established fallback. Runtime route owners preserve exact inbound/outbound ordering and retryable teardown. App supplies immutable options, graphical/domain callbacks, and borrowed projections—no mirrored session, selection state, or reset plan. |
| World environment | **J6.1 complete Runtime ownership** | `RuntimeWorldEnvironmentState` owns the instance-scoped Dereth calendar, synchronized clock, weather progression/state, selected day group, AdminEnvirons state, and typed debug overrides. App converts immutable DAT sky definitions once and projects the borrowed Runtime snapshot into rendering; no process-global Region origin or second App clock/weather owner remains (`902076c0`). TS-54/TS-55 register the remaining centered UI sound and full fog/ambient/radar behavior gaps. |
| Live identity/lifetime | **J3 complete** | `RuntimeEntityObjectLifetime` owns the sole `RuntimeEntityDirectory`, live `ClientObjectTable`, direct views, and ordered entity/object stream. The directory owns canonical GUID/incarnation/local-ID identity, accepted snapshots/timestamps, parent state, operation versions, and tombstones. `LiveEntityProjectionStore` owns App graphical sidecars by exact `RuntimeEntityKey`; hydration, presentation components, `GpuWorldState` residence/visibility, and retryable teardown preserve that key without another authority. Exact receipts precede fallible callbacks, re-entrant commits drain synchronously in sequence, and stable reset/disposal must converge the complete ledger to zero (`f46ddb5c`, `420e5eea`, `e937cc36`, `5ef8b537`, `ce3ac310`, `119b7c11`). |
| Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). |
diff --git a/docs/research/2026-08-14-la7b-character-selection-runtime.md b/docs/research/2026-08-14-la7b-character-selection-runtime.md
new file mode 100644
index 00000000..22a72cb8
--- /dev/null
+++ b/docs/research/2026-08-14-la7b-character-selection-runtime.md
@@ -0,0 +1,109 @@
+# LA7b character-selection Runtime evidence
+
+Date: 2026-08-14
+
+This note records the retail evidence and the presentation-independent state
+and flow implemented by Campaign LA slice LA7b. The retained character screen
+is deliberately deferred to LA8.
+
+## Named-retail evidence
+
+The implementation was derived from
+`docs/research/named-retail/acclient_2013_pseudo_c.txt` before code was
+written. The controlling functions are:
+
+- `gmCharacterManagementUI::ResetPreviouslySelectedCharacterSlot`
+ (`0x004ebff0`): clears the persisted selected avatar, selected guid, list
+ index, and character-generation slot.
+- `gmCharacterManagementUI::SelectCharacter` (`0x004ec160`): resolves the
+ clicked guid back to the `CharacterSet` slot and persists that exact slot.
+- `gmCharacterManagementUI::UpdateButtons` (`0x004ec240`): a missing or
+ greyed selection disables Enter and Delete; a selected greyed character
+ hides Delete and shows Restore.
+- `gmCharacterManagementUI::RebuildCharacterList` (`0x004ec3a0`): emits every
+ active `CharacterSet` entry, records the previous/slot selection, chooses
+ the first non-greyed entry as the ordinary fallback (the first row remains
+ the all-grey fallback), sorts names with `wcscmp`, and then moves greyed
+ rows to the tail without losing their identity.
+- `gmCharacterManagementUI::MakeDeleteCharacterConfirmationDialog`
+ (`0x004ecca0`) and `CloseDeleteCharacterDialog` (`0x004ed4a0`): deletion is
+ modal, and confirmation sends the persisted selected avatar.
+- `gmCharacterManagementUI::ListenToElementMessage` (`0x004ed5a0`): Restore
+ sends the selected guid, Enter is accepted from the button and list-row
+ activation, and both delete/restore open retail's wait dialog.
+- `gmCharacterManagementUI::EnterGame` (`0x004ed440`): entry is allowed only
+ for a non-zero selected guid whose `GetGreyedOutFor(GetSlot(guid))` value is
+ zero.
+- `CharacterSet::GetSlot` (`0x004fdf60`) and `GetGreyedOutFor`
+ (`0x004fdfa0`) prove that delete/enter retain the original wire slot even
+ after display sorting, and that any non-zero grey value is disabled.
+- `CPlayerSystem::Handle_CharacterError` (`0x0055d5d0`) clears the ready and
+ awaiting-logon latches before forwarding the typed error. The downstream
+ `gmUIFlow::RecvNotice_CharacterError` (`0x0047a7c0`) bounds its real display
+ switch to enum values below `CHAR_ERROR_NUM_ERRORS`; the `0x19` member is a
+ count sentinel, not a message.
+
+Equivalent state pseudocode:
+
+```text
+on roster:
+ remember selected guid
+ rows = every active CharacterSet entry, retaining original slot
+ fallback = first row, replaced by first non-greyed row in wire order
+ sort rows by ordinal/wcscmp name
+ stable-partition non-greyed before greyed
+ selected = previous guid if still present, otherwise fallback
+
+buttons(selected):
+ none or greyed -> Enter disabled, Delete disabled
+ greyed -> Delete hidden, Restore visible/enabled
+ active -> Delete visible/enabled, Restore hidden
+
+enter(selected):
+ require guid != 0 and grey == 0
+ use retained wire slot through the existing EnterWorld transaction
+```
+
+The `CharacterList.DeletedCharacters` array is not projected into this list:
+retail rebuilds from the active `CharacterSet`; pending-delete identities are
+the greyed entries in that active set. No entry is filtered merely because it
+is greyed.
+
+## Runtime ownership and flow
+
+`GameRuntime` owns one `LiveSessionController`, which owns one
+`RuntimeCharacterSelectionState` for the exact `WorldSession` generation.
+The owner contains the canonical roster, wire slots, highlight, delete
+confirmation, delete/restore progress, mapped error, revision, lifecycle, and
+generation. App receives only the borrowed `IRuntimeCharacterSelectionView`;
+all mutation uses synchronous generation-gated typed commands. Ordered deltas
+use one monotonic sequence and preserve re-entrant publication order while
+isolating observer failures.
+
+A graphical launch with no explicit selector sets
+`AwaitCharacterSelection`. Connect reports and adopts the roster, starts the
+sole pre-world receive loop, and returns `AwaitingCharacterSelection` without
+calling EnterWorld. A typed Enter command continues the same session and
+generation through the established two-phase EnterWorld path. Explicit
+graphical selectors and direct/headless callers retain the established
+first-available fallback because the new flag defaults to false.
+
+Immediate/headless entry keeps the original blocking receive/sweep pump until
+ServerReady. Only a host that actually pauses at character selection starts
+the asynchronous pre-world receiver. This preserves reliable-transport
+resend/NAK timing and avoids two concurrent socket readers.
+
+LA7a's wire contracts are routed as follows:
+
+- delete `0xF655`: account plus retained active slot, LoginQueue;
+- restore `0xF7D9`: selected guid, ControlQueue (AD-97 remains the recorded
+ guid-only adaptation);
+- delete ack, restore `0xF643`, refreshed `CharacterList`, and
+ `CharacterError 0xF659`: ordered UIQueue input.
+
+ACE can silently return from restore for an unknown guid. Restore is therefore
+a fire-and-observe command: it installs no command gate and never waits for a
+reply. A later matching response updates the same owner; a fresh roster,
+entry attempt, reset, reconnect, or disposal disarms stale response
+correlation. `CharacterError.NumErrors` is ignored without a revision or
+delta and can never become presentation text.
diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs
index cacc23d5..a2812b93 100644
--- a/src/AcDream.App/Composition/SessionPlayerComposition.cs
+++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs
@@ -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
diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
index e11df45d..56e89e2a 100644
--- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
+++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
@@ -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;
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 6833825b..cbe68e1c 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -12,6 +12,19 @@ using AcDream.Core.Net.Transport;
namespace AcDream.Core.Net;
+///
+/// EnterWorld was rejected by the server while the transport remains a valid
+/// character-select session. Callers may surface and let
+/// the user choose another character instead of tearing down the connection.
+///
+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 datagram);
@@ -576,6 +589,17 @@ public sealed class WorldSession : IDisposable
/// Raised every time the state machine transitions.
public event Action? StateChanged;
+ ///
+ /// 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.
+ ///
+ public event Action? CharacterListReceived;
+ public event Action? CharacterDeleteAcknowledged;
+ public event Action? CharacterRestoreReceived;
+ public event Action? CharacterErrorReceived;
+
///
/// 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"); }
+
+ }
+
+ ///
+ /// Starts the sole asynchronous receive loop while the session remains at
+ /// character selection. Graphical hosts call this only when they actually
+ /// pause before ; immediate and headless entry keep
+ /// the original blocking handshake pump until ServerReady is accepted.
+ ///
+ public void StartCharacterSelectionReceive()
+ {
+ if (CurrentState != State.InCharacterSelect)
+ throw new InvalidOperationException(
+ "character-selection receive requires InCharacterSelect state");
+
+ EnsureNetReceiveLoopStarted();
}
///
@@ -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 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();
+ 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);
}
+ ///
+ /// Send retail CharacterDelete through the login/logon queue. The caller
+ /// supplies the selected entry's active CharacterSet slot, not its guid.
+ ///
+ 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);
+ }
+
+ ///
+ /// Send retail CharacterRestore through the control queue. This is
+ /// deliberately non-blocking because ACE silently drops unknown guids.
+ ///
+ public void SendRestoreCharacter(uint characterId) =>
+ SendControlMessage(CharacterRestore.BuildRequestBody(characterId));
+
///
/// Phase I.3: test-only hook. When non-null,
/// invokes this instead of writing to the wire. Lets unit tests verify
@@ -2049,6 +2200,9 @@ public sealed class WorldSession : IDisposable
///
internal Action? GameActionCapture { get; set; }
+ /// LA7b unit-test seam for queue-sensitive pre-world sends.
+ internal Action? GameMessageCapture { get; set; }
+
///
/// 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
diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs
index 90ae8e84..5b6e7e60 100644
--- a/src/AcDream.Runtime/GameRuntime.cs
+++ b/src/AcDream.Runtime/GameRuntime.cs
@@ -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;
diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs
index db177ed1..017b24c4 100644
--- a/src/AcDream.Runtime/GameRuntimeCommands.cs
+++ b/src/AcDream.Runtime/GameRuntimeCommands.cs
@@ -35,6 +35,7 @@ public enum RuntimeSessionStartStatus
/// exit-code mapping, not a failure.
///
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; }
diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs
index ce17bcc9..ca973e34 100644
--- a/src/AcDream.Runtime/GameRuntimeViews.cs
+++ b/src/AcDream.Runtime/GameRuntimeViews.cs
@@ -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; }
diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs
index bc07ca53..0c6eb5ce 100644
--- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs
+++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs
@@ -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;
diff --git a/src/AcDream.Runtime/Session/LiveSessionContracts.cs b/src/AcDream.Runtime/Session/LiveSessionContracts.cs
index e8f81d4b..d38f1e2a 100644
--- a/src/AcDream.Runtime/Session/LiveSessionContracts.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionContracts.cs
@@ -29,7 +29,14 @@ public sealed record LiveSessionConnectOptions(
/// itself does not enforce that pairing — the headless config loader
/// does, before a is ever built.
///
- bool Probe = false);
+ bool Probe = false,
+ ///
+ /// 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.
+ ///
+ bool AwaitCharacterSelection = false);
public interface IRuntimeLiveSessionFramePhase
{
diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs
index 935de09d..b7c37555 100644
--- a/src/AcDream.Runtime/Session/LiveSessionController.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionController.cs
@@ -14,6 +14,11 @@ public enum LiveSessionStartStatus
Deferred,
Failed,
///
+ /// The account transport is connected and Runtime owns a live roster,
+ /// but EnterWorld has deliberately not run yet.
+ ///
+ AwaitingCharacterSelection,
+ ///
/// Campaign LA slice LA2: a
/// session connected, received (and reported) the character roster, and
/// gracefully disconnected BEFORE selection/EnterWorld — deliberately a
@@ -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
///
public sealed class LiveSessionController
: IDisposable,
- IRuntimeLiveSessionFramePhase
+ IRuntimeLiveSessionFramePhase,
+ IRuntimeCharacterSelectionCommands
{
+ private sealed class CharacterSelectionWireBinding : IDisposable
+ {
+ private WorldSession? _session;
+ private readonly Action _roster;
+ private readonly Action _delete;
+ private readonly Action _restore;
+ private readonly Action _error;
+
+ public CharacterSelectionWireBinding(
+ WorldSession session,
+ Action roster,
+ Action delete,
+ Action restore,
+ Action 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;
}
diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs
index 5ceb04e2..62cf5441 100644
--- a/src/AcDream.Runtime/Session/LiveSessionHost.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs
@@ -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,
diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs
new file mode 100644
index 00000000..db55e783
--- /dev/null
+++ b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs
@@ -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)
+{
+ ///
+ /// 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.
+ ///
+ 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);
+}
+
+///
+/// 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); retains the original wire
+/// slot used by character delete and EnterWorld.
+///
+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);
+}
+
+///
+/// Sole mutable owner of character-selection state. It contains no App or UI
+/// types; presentation borrows and issues generation-gated
+/// commands through .
+///
+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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 _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);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs
index e858eff3..807c007e 100644
--- a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs
@@ -134,6 +134,30 @@ public sealed class SessionPlayerCompositionTests
"LiveSessionRuntimeFactory.cs")), StringComparison.Ordinal);
}
+ [Fact]
+ public void GraphicalCompositionPausesOnlyWhenCharacterSelectorIsAbsent()
+ {
+ string phase = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "src",
+ "AcDream.App",
+ "Composition",
+ "SessionPlayerComposition.cs"));
+
+ Assert.Contains(
+ "AwaitCharacterSelection:",
+ phase,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "d.Options.LiveCharacterSelector is null",
+ phase,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "CharacterList.TrySelectFirstAvailable",
+ phase,
+ StringComparison.Ordinal);
+ }
+
private sealed class RetryBinding(
string name,
List calls,
diff --git a/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs b/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs
index 9cbe54e0..ceba50ad 100644
--- a/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs
+++ b/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs
@@ -62,7 +62,7 @@ public sealed class RuntimeOptionsSessionConfigTests
}
[Fact]
- public void AbsentCharacterSelectorLeavesFirstAvailableFallbackInEffect()
+ public void AbsentCharacterSelectorRemainsNullForGraphicalSelectionFlow()
{
var config = new SessionConfiguration { Version = 1 };
var session = new SessionDescriptor
diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
index 33dafd1b..493dd5d8 100644
--- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
+++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
@@ -173,6 +173,44 @@ public sealed class CurrentGameRuntimeAdapterTests
.ToArray());
}
+ [Fact]
+ public void AdapterBorrowsPreWorldSelectionAndCommandsEnterTheSameRuntimeOwner()
+ {
+ using var harness = new Harness(awaitCharacterSelection: true);
+ RuntimeGenerationToken initial = harness.Runtime.Generation;
+
+ RuntimeSessionStartResult start = harness.Runtime.Session.Start(initial);
+
+ Assert.Equal(
+ RuntimeSessionStartStatus.AwaitingCharacterSelection,
+ start.Status);
+ Assert.Equal(
+ RuntimeCharacterSelectionLifecycle.AwaitingSelection,
+ harness.Runtime.CharacterSelection.Snapshot.Lifecycle);
+ Assert.Equal(2, harness.Runtime.CharacterSelection.Snapshot.RosterCount);
+
+ RuntimeGenerationToken generation = harness.Runtime.Generation;
+ Assert.True(harness.Runtime.CharacterSelectionCommands.Highlight(
+ generation,
+ 0x50000001u).Accepted);
+ Assert.True(
+ harness.Runtime.CharacterSelection.Snapshot.Buttons.CanRestore);
+ Assert.Equal(
+ RuntimeCommandStatus.Rejected,
+ harness.Runtime.CharacterSelectionCommands.Enter(generation).Status);
+ Assert.True(harness.Runtime.CharacterSelectionCommands.Highlight(
+ generation,
+ Harness.PlayerGuid).Accepted);
+ Assert.True(harness.Runtime.CharacterSelectionCommands.Enter(
+ generation).Accepted);
+
+ Assert.Equal(RuntimeLifecycleState.InWorld, harness.Runtime.Lifecycle.State);
+ Assert.Equal(
+ RuntimeCharacterSelectionLifecycle.InWorld,
+ harness.Runtime.CharacterSelection.Snapshot.Lifecycle);
+ Assert.Equal(Harness.PlayerGuid, harness.Identity.ServerGuid);
+ }
+
[Fact]
public void GenerationGateRejectsStaleCommandsAndStopAcknowledgesTeardown()
{
@@ -698,7 +736,7 @@ public sealed class CurrentGameRuntimeAdapterTests
private readonly IDisposable _combatModeBinding;
private readonly GameRuntime _gameRuntime;
- public Harness()
+ public Harness(bool awaitCharacterSelection = false)
{
CombatAttackOperations = new CombatAttackOperationsSlot();
CombatModeOperations = new RuntimeCombatModeOperationsSlot();
@@ -758,7 +796,11 @@ public sealed class CurrentGameRuntimeAdapterTests
new SelectionTransport(() => _session?.IsInWorld == true),
new NoopInteractionMovement());
- Host = CreateHost(_session, Commands, Identity);
+ Host = CreateHost(
+ _session,
+ Commands,
+ Identity,
+ awaitCharacterSelection);
Runtime = new CurrentGameRuntimeAdapter(
_gameRuntime,
Host,
@@ -873,7 +915,8 @@ public sealed class CurrentGameRuntimeAdapterTests
private static LiveSessionHost CreateHost(
LiveSessionController controller,
RecordingCommandRouting commands,
- LocalPlayerIdentityState identity)
+ LocalPlayerIdentityState identity,
+ bool awaitCharacterSelection = false)
{
Action noop = static () => { };
var reset = new LiveSessionResetBindings
@@ -925,7 +968,8 @@ public sealed class CurrentGameRuntimeAdapterTests
"127.0.0.1",
9000,
"user",
- "password"));
+ "password",
+ AwaitCharacterSelection: awaitCharacterSelection));
}
private static RuntimeOptions LiveOptions()
@@ -1039,6 +1083,10 @@ public sealed class CurrentGameRuntimeAdapterTests
true,
true);
+ public void StartCharacterSelectionReceive(WorldSession session)
+ {
+ }
+
public void EnterWorld(WorldSession session, int activeCharacterIndex)
{
}
diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs
new file mode 100644
index 00000000..04ac26b4
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs
@@ -0,0 +1,175 @@
+using System.Buffers.Binary;
+using System.Net;
+using System.Reflection;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net.Tests;
+
+public sealed class WorldSessionCharacterSelectionTests
+{
+ private sealed class NullTransport : IWorldSessionTransport
+ {
+ public void Send(ReadOnlySpan datagram) { }
+ public void Send(IPEndPoint remote, ReadOnlySpan datagram) { }
+ public int Receive(
+ Span destination,
+ TimeSpan timeout,
+ out IPEndPoint? from)
+ {
+ from = null;
+ return -1;
+ }
+ public ValueTask ReceiveAsync(
+ Memory destination,
+ CancellationToken cancellationToken) =>
+ ValueTask.FromCanceled(cancellationToken);
+ public void Dispose() { }
+ }
+
+ [Fact]
+ public void CharacterManagementSends_UseRetailQueuesAndExactBodies()
+ {
+ using var session = CreateSession();
+ var sent = new List<(byte[] Body, GameMessageGroup Queue)>();
+ session.GameMessageCapture =
+ (body, queue) => sent.Add((body, queue));
+
+ session.SendDeleteCharacter("Canonical", activeIndex: 3);
+ session.SendRestoreCharacter(0x50000001u);
+
+ Assert.Collection(
+ sent,
+ delete =>
+ {
+ Assert.Equal(GameMessageGroup.LoginQueue, delete.Queue);
+ Assert.Equal(
+ CharacterDelete.BuildRequestBody("Canonical", 3u),
+ delete.Body);
+ },
+ restore =>
+ {
+ Assert.Equal(GameMessageGroup.ControlQueue, restore.Queue);
+ Assert.Equal(
+ CharacterRestore.BuildRequestBody(0x50000001u),
+ restore.Body);
+ });
+ }
+
+ [Fact]
+ public void UiQueueReplies_DispatchInWireOrderAndRosterRefreshReplacesCharacters()
+ {
+ using var session = CreateSession();
+ var events = new List();
+ session.CharacterListReceived += roster =>
+ events.Add($"roster:{roster.Characters[0].SecondsGreyedOut}");
+ session.CharacterDeleteAcknowledged += () => events.Add("delete");
+ session.CharacterRestoreReceived += restore =>
+ events.Add($"restore:{restore.Guid:X8}");
+ session.CharacterErrorReceived += error =>
+ events.Add($"error:{error.RawErrorCode:X}");
+
+ byte[] packet = BuildPacket(
+ BuildRoster(secondsGreyedOut: 0u),
+ BitConverter.GetBytes(CharacterDelete.Opcode),
+ BuildRestoreResponse(),
+ BuildCharacterError(CharacterError.Code.Delete),
+ BuildRoster(secondsGreyedOut: 1u));
+ InvokeProcessDatagram(session, packet);
+
+ Assert.Equal(
+ [
+ "roster:0",
+ "delete",
+ "restore:50000001",
+ "error:6",
+ "roster:1",
+ ],
+ events);
+ CharacterList.Character current =
+ Assert.Single(session.Characters!.Characters);
+ Assert.Equal(1u, current.SecondsGreyedOut);
+ }
+
+ private static WorldSession CreateSession() =>
+ new(
+ new IPEndPoint(IPAddress.Loopback, 9000),
+ new NullTransport());
+
+ private static byte[] BuildRoster(uint secondsGreyedOut)
+ {
+ var writer = new PacketWriter(96);
+ writer.WriteUInt32(CharacterList.Opcode);
+ writer.WriteUInt32(0u);
+ writer.WriteUInt32(1u);
+ writer.WriteUInt32(0x50000001u);
+ writer.WriteString16L("Character");
+ writer.WriteUInt32(secondsGreyedOut);
+ writer.WriteUInt32(0u);
+ writer.WriteUInt32(11u);
+ writer.WriteString16L("Canonical");
+ writer.WriteUInt32(1u);
+ writer.WriteUInt32(1u);
+ return writer.ToArray();
+ }
+
+ private static byte[] BuildRestoreResponse()
+ {
+ var writer = new PacketWriter(64);
+ writer.WriteUInt32(CharacterRestore.ResponseOpcode);
+ writer.WriteUInt32(1u);
+ writer.WriteUInt32(0x50000001u);
+ writer.WriteString16L("Character");
+ writer.WriteUInt32(0u);
+ return writer.ToArray();
+ }
+
+ private static byte[] BuildCharacterError(CharacterError.Code error)
+ {
+ byte[] body = new byte[8];
+ BinaryPrimitives.WriteUInt32LittleEndian(
+ body,
+ CharacterError.Opcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(
+ body.AsSpan(4),
+ (uint)error);
+ return body;
+ }
+
+ private static byte[] BuildPacket(params byte[][] messages)
+ {
+ int length = messages.Sum(message =>
+ MessageFragmentHeader.Size + message.Length);
+ var fragments = new byte[length];
+ int position = 0;
+ uint sequence = 1u;
+ foreach (byte[] message in messages)
+ {
+ position += GameMessageFragment.WriteSingleFragment(
+ fragments.AsSpan(position),
+ sequence++,
+ GameMessageGroup.UIQueue,
+ message);
+ }
+ return PacketCodec.Encode(
+ new PacketHeader
+ {
+ Sequence = 1u,
+ Flags = PacketHeaderFlags.BlobFragments,
+ },
+ fragments,
+ outboundIsaac: null);
+ }
+
+ private static void InvokeProcessDatagram(
+ WorldSession session,
+ byte[] datagram)
+ {
+ MethodInfo method = typeof(WorldSession).GetMethod(
+ "ProcessDatagram",
+ BindingFlags.NonPublic | BindingFlags.Instance)!;
+ method.Invoke(
+ session,
+ [new ReadOnlyMemory(datagram), null, true]);
+ }
+}
diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs
index 10b4e18f..124190b3 100644
--- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs
@@ -57,10 +57,13 @@ public sealed class LiveSessionControllerTests
public bool ThrowOnCreate { get; set; }
public bool ThrowOnCharacters { get; set; }
public bool ThrowOnEnterWorld { get; set; }
+ public CharacterError.Parsed? EnterWorldRejection { get; set; }
public bool ThrowOnTick { get; set; }
public bool FailDisposeOnce { get; set; }
public List Sessions { get; } = [];
public Dictionary DisposeCounts { get; } = [];
+ public List<(string Account, int ActiveIndex)> DeleteRequests { get; } = [];
+ public List RestoreRequests { get; } = [];
public int EnterWorldCount { get; private set; }
public int TickCount { get; private set; }
@@ -98,15 +101,35 @@ public sealed class LiveSessionControllerTests
return Characters;
}
+ public void StartCharacterSelectionReceive(WorldSession session) =>
+ calls.Add("start-character-selection-receive");
+
public void EnterWorld(WorldSession session, int activeCharacterIndex)
{
calls.Add($"enter:{activeCharacterIndex}");
EnterWorldCount++;
OnEnterWorld?.Invoke();
+ if (EnterWorldRejection is { } rejection)
+ throw new CharacterSelectionRejectedException(rejection);
if (ThrowOnEnterWorld)
throw new InvalidOperationException("enter failure");
}
+ public void DeleteCharacter(
+ WorldSession session,
+ string accountName,
+ int activeCharacterIndex)
+ {
+ calls.Add($"delete:{activeCharacterIndex}");
+ DeleteRequests.Add((accountName, activeCharacterIndex));
+ }
+
+ public void RestoreCharacter(WorldSession session, uint characterId)
+ {
+ calls.Add($"restore:{characterId:X8}");
+ RestoreRequests.Add(characterId);
+ }
+
public void Tick(WorldSession session)
{
calls.Add("tick");
@@ -340,6 +363,179 @@ public sealed class LiveSessionControllerTests
Assert.True(calls.IndexOf("roster") < calls.IndexOf("selected"));
}
+ [Fact]
+ public void Start_GraphicalNoSelectorStopsBeforeEnterWorldThenTypedEnterContinuesSameScope()
+ {
+ var calls = new List();
+ var operations = new TestOperations(calls);
+ var host = new TestHost(calls);
+ var controller = new LiveSessionController(operations);
+
+ LiveSessionStartResult result = controller.Start(
+ LiveOptions(awaitSelection: true),
+ host);
+
+ Assert.Equal(
+ LiveSessionStartStatus.AwaitingCharacterSelection,
+ result.Status);
+ Assert.False(controller.IsInWorld);
+ Assert.NotNull(controller.CurrentSession);
+ Assert.Equal(0, operations.EnterWorldCount);
+ Assert.False(host.CommandBuses[0].Active);
+ RuntimeGenerationToken generation = controller.Generation;
+ Assert.Equal(
+ RuntimeCharacterSelectionLifecycle.AwaitingSelection,
+ controller.CharacterSelection.Snapshot.Lifecycle);
+
+ // Greyed entries remain highlightable but cannot enter.
+ Assert.True(controller.Highlight(generation, 0x50000001u).Accepted);
+ Assert.Equal(
+ RuntimeCommandStatus.Rejected,
+ controller.Enter(generation).Status);
+ Assert.True(controller.Highlight(generation, 0x50000002u).Accepted);
+ Assert.True(controller.Enter(generation).Accepted);
+
+ Assert.True(controller.IsInWorld);
+ Assert.Equal(1, operations.EnterWorldCount);
+ Assert.Contains("enter:1", calls);
+ Assert.True(host.CommandBuses[0].Active);
+ Assert.Same(operations.Sessions[0], controller.CurrentSession);
+ }
+
+ [Fact]
+ public void Start_ExplicitSelectorStillEntersWhenGraphicalPauseFlagIsSet()
+ {
+ var calls = new List();
+ var operations = new TestOperations(calls);
+ var host = new TestHost(calls);
+ var controller = new LiveSessionController(operations);
+
+ LiveSessionStartResult result = controller.Start(
+ LiveOptions(
+ selector: new LiveSessionCharacterSelector(
+ CharacterId: 0x50000002u),
+ awaitSelection: true),
+ host);
+
+ Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
+ Assert.True(controller.IsInWorld);
+ Assert.Equal(1, operations.EnterWorldCount);
+ }
+
+ [Fact]
+ public void EnterRejection_SurfacesErrorAndKeepsExactPreWorldScopeRetryable()
+ {
+ var calls = new List();
+ var operations = new TestOperations(calls)
+ {
+ EnterWorldRejection = new CharacterError.Parsed(
+ (uint)CharacterError.Code.EnterGameCharacterLocked),
+ };
+ var host = new TestHost(calls);
+ var controller = new LiveSessionController(operations);
+ Assert.Equal(
+ LiveSessionStartStatus.AwaitingCharacterSelection,
+ controller.Start(LiveOptions(awaitSelection: true), host).Status);
+ RuntimeGenerationToken generation = controller.Generation;
+ WorldSession session = controller.CurrentSession!;
+
+ Assert.Equal(
+ RuntimeCommandStatus.Rejected,
+ controller.Enter(generation).Status);
+ Assert.Same(session, controller.CurrentSession);
+ Assert.Equal(generation, controller.Generation);
+ Assert.False(controller.IsInWorld);
+ RuntimeCharacterSelectionSnapshot rejected =
+ controller.CharacterSelection.Snapshot;
+ Assert.Equal(
+ RuntimeCharacterSelectionLifecycle.AwaitingSelection,
+ rejected.Lifecycle);
+ Assert.Equal(
+ CharacterError.Code.EnterGameCharacterLocked,
+ rejected.Error!.Value.Code);
+
+ operations.EnterWorldRejection = null;
+ Assert.True(controller.Enter(generation).Accepted);
+ Assert.True(controller.IsInWorld);
+ Assert.Same(session, controller.CurrentSession);
+ }
+
+ [Fact]
+ public async Task CharacterCommands_UseWireSlotAndRestoreNeverWaitsForReply()
+ {
+ var calls = new List();
+ var operations = new TestOperations(calls);
+ var host = new TestHost(calls);
+ var controller = new LiveSessionController(operations);
+ Assert.Equal(
+ LiveSessionStartStatus.AwaitingCharacterSelection,
+ controller.Start(LiveOptions(awaitSelection: true), host).Status);
+ RuntimeGenerationToken generation = controller.Generation;
+
+ Assert.True(controller.Highlight(generation, 0x50000002u).Accepted);
+ Assert.True(controller.RequestDelete(generation).Accepted);
+ Assert.Equal(0x50000002u,
+ controller.CharacterSelection.Snapshot.PendingDeleteCharacterId);
+ Assert.True(controller.Cancel(generation).Accepted);
+ Assert.True(controller.RequestDelete(generation).Accepted);
+ Assert.True(controller.ConfirmDelete(generation).Accepted);
+ Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
+ Assert.False(controller.CharacterSelection.Snapshot.Buttons.CanEnter);
+ controller.CharacterSelectionState.ApplyDeleteAcknowledged();
+ controller.CharacterSelectionState.ApplyRoster(new LiveSessionRosterReport(
+ "Canonical",
+ 11,
+ [
+ new(0x50000001u, "Grey", 10u),
+ new(0x50000002u, "Ready", 1u),
+ ]));
+
+ Assert.True(controller.Highlight(generation, 0x50000001u).Accepted);
+ RuntimeCommandResult restore = await Task.Run(
+ () => controller.Restore(generation))
+ .WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.True(restore.Accepted);
+ Assert.Equal([0x50000001u], operations.RestoreRequests);
+ // ACE may send no response for an unknown guid. The synchronous
+ // command has already returned and does not gate later commands.
+ Assert.True(controller.Highlight(generation, 0x50000002u).Accepted);
+ controller.Tick();
+ Assert.Equal(1, operations.TickCount);
+ Assert.False(controller.IsInWorld);
+ }
+
+ [Fact]
+ public void CharacterCommands_AreGenerationGatedAcrossReconnectAndStop()
+ {
+ var calls = new List();
+ var operations = new TestOperations(calls);
+ var host = new TestHost(calls);
+ var controller = new LiveSessionController(operations);
+ controller.Start(LiveOptions(awaitSelection: true), host);
+ RuntimeGenerationToken first = controller.Generation;
+
+ LiveSessionStartResult reconnect = controller.Reconnect(
+ LiveOptions(awaitSelection: true),
+ host);
+
+ Assert.Equal(
+ LiveSessionStartStatus.AwaitingCharacterSelection,
+ reconnect.Status);
+ Assert.Equal(
+ RuntimeCommandStatus.StaleGeneration,
+ controller.Highlight(first, 0x50000002u).Status);
+ RuntimeGenerationToken second = controller.Generation;
+ Assert.True(controller.Highlight(second, 0x50000002u).Accepted);
+ controller.Stop();
+ Assert.Equal(
+ RuntimeCommandStatus.StaleGeneration,
+ controller.Enter(second).Status);
+ Assert.Equal(
+ RuntimeCharacterSelectionLifecycle.Inactive,
+ controller.CharacterSelection.Snapshot.Lifecycle);
+ Assert.Equal(0, controller.CharacterSelection.Snapshot.RosterCount);
+ }
+
[Fact]
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
{
@@ -1232,7 +1428,8 @@ public sealed class LiveSessionControllerTests
bool live = true,
string? user = "user",
LiveSessionCharacterSelector? selector = null,
- bool probe = false) =>
+ bool probe = false,
+ bool awaitSelection = false) =>
new(
live,
"127.0.0.1",
@@ -1240,7 +1437,8 @@ public sealed class LiveSessionControllerTests
user ?? string.Empty,
"password",
selector,
- probe);
+ probe,
+ awaitSelection);
private static CharacterList.Parsed AvailableCharacters() => new(
0u,
diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs
new file mode 100644
index 00000000..2b5f43e1
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs
@@ -0,0 +1,247 @@
+using AcDream.Core.Net.Messages;
+using AcDream.Runtime;
+using AcDream.Runtime.Session;
+
+namespace AcDream.Runtime.Tests.Session;
+
+public sealed class RuntimeCharacterSelectionStateTests
+{
+ private sealed class Collector : IRuntimeCharacterSelectionVisitor
+ {
+ public List Entries { get; } = [];
+
+ public void Visit(in RuntimeCharacterSelectionEntry character) =>
+ Entries.Add(character);
+ }
+
+ private sealed class Observer(
+ Action onDelta)
+ : IRuntimeCharacterSelectionObserver
+ {
+ public void OnCharacterSelectionChanged(
+ in RuntimeCharacterSelectionDelta delta) =>
+ onDelta(delta);
+ }
+
+ [Fact]
+ public void ApplyRoster_PortsRetailOrderFallbackAndDisabledButtonMatrix()
+ {
+ using var state = new RuntimeCharacterSelectionState();
+ state.Begin(new RuntimeGenerationToken(7));
+
+ state.ApplyRoster(Roster(
+ new(0x50000001u, "Zulu", 1u),
+ new(0x50000002u, "Bravo", 0u),
+ new(0x50000003u, "Alpha", 0u),
+ new(0x50000004u, "Able", 9u)));
+
+ RuntimeCharacterSelectionSnapshot snapshot = state.Snapshot;
+ Assert.Equal(RuntimeCharacterSelectionLifecycle.AwaitingSelection, snapshot.Lifecycle);
+ // Retail falls back in CharacterSet wire order, independently of the
+ // alphabetized display order.
+ Assert.Equal(0x50000002u, snapshot.HighlightedCharacterId);
+ Assert.True(snapshot.Buttons.CanEnter);
+ Assert.True(snapshot.Buttons.CanDelete);
+ Assert.False(snapshot.Buttons.CanRestore);
+ Assert.True(snapshot.Buttons.DeleteVisible);
+ Assert.False(snapshot.Buttons.RestoreVisible);
+
+ var collector = new Collector();
+ state.View.Visit(collector);
+ Assert.Equal(
+ [
+ (0x50000003u, 2, false),
+ (0x50000002u, 1, false),
+ (0x50000004u, 3, true),
+ (0x50000001u, 0, true),
+ ],
+ collector.Entries.Select(entry =>
+ (entry.CharacterId, entry.ActiveIndex, entry.IsPendingDelete)));
+
+ Assert.True(state.TryHighlight(0x50000004u));
+ snapshot = state.Snapshot;
+ Assert.False(snapshot.Buttons.CanEnter);
+ Assert.False(snapshot.Buttons.CanDelete);
+ Assert.True(snapshot.Buttons.CanRestore);
+ Assert.False(snapshot.Buttons.DeleteVisible);
+ Assert.True(snapshot.Buttons.RestoreVisible);
+ }
+
+ [Fact]
+ public void ApplyRoster_PreservesHighlightedGuidAcrossDeleteRefresh()
+ {
+ using var state = new RuntimeCharacterSelectionState();
+ state.Begin(new RuntimeGenerationToken(3));
+ state.ApplyRoster(Roster(
+ new(0x50000001u, "One", 0u),
+ new(0x50000002u, "Two", 0u)));
+ Assert.True(state.TryHighlight(0x50000002u));
+ Assert.True(state.TryRequestDelete(out uint requested));
+ Assert.Equal(0x50000002u, requested);
+ Assert.True(state.TryTakeDeleteConfirmation(
+ out RuntimeCharacterSelectionEntry deleted,
+ out string account));
+ Assert.Equal(1, deleted.ActiveIndex);
+ Assert.Equal("Canonical", account);
+
+ state.ApplyDeleteAcknowledged();
+ Assert.Equal(
+ RuntimeCharacterSelectionOperation.DeleteAcknowledged,
+ state.Snapshot.Operation);
+ state.ApplyRoster(Roster(
+ new(0x50000001u, "One", 0u),
+ new(0x50000002u, "Two", 1u)));
+
+ RuntimeCharacterSelectionSnapshot snapshot = state.Snapshot;
+ Assert.Equal(0x50000002u, snapshot.HighlightedCharacterId);
+ Assert.Equal(RuntimeCharacterSelectionOperation.None, snapshot.Operation);
+ Assert.True(snapshot.Buttons.CanRestore);
+ Assert.False(snapshot.Buttons.CanEnter);
+ }
+
+ [Fact]
+ public void DeleteConfirmation_CancelIsIdempotentlyGenerationLocal()
+ {
+ using var state = new RuntimeCharacterSelectionState();
+ state.Begin(new RuntimeGenerationToken(4));
+ state.ApplyRoster(Roster(
+ new LiveSessionRosterEntry(0x50000001u, "One", 0u)));
+
+ Assert.True(state.TryRequestDelete(out _));
+ Assert.Equal(0x50000001u, state.Snapshot.PendingDeleteCharacterId);
+ Assert.False(state.TryHighlight(0x50000001u));
+ Assert.False(state.BeginEnter(out _));
+ Assert.True(state.Cancel());
+ Assert.Equal(0u, state.Snapshot.PendingDeleteCharacterId);
+ Assert.False(state.Cancel());
+
+ state.Reset(new RuntimeGenerationToken(5));
+ Assert.Equal(RuntimeCharacterSelectionLifecycle.Inactive, state.Snapshot.Lifecycle);
+ Assert.Equal(0, state.Snapshot.RosterCount);
+ Assert.Equal(new RuntimeGenerationToken(5), state.Snapshot.Generation);
+ }
+
+ [Fact]
+ public void Restore_IsFireAndObserveAndSuccessUpdatesTheCanonicalEntry()
+ {
+ using var state = new RuntimeCharacterSelectionState();
+ state.Begin(new RuntimeGenerationToken(11));
+ state.ApplyRoster(Roster(
+ new LiveSessionRosterEntry(0x50000001u, "Grey", 1u)));
+
+ Assert.True(state.TryBeginRestore(out RuntimeCharacterSelectionEntry request));
+ Assert.Equal(0x50000001u, request.CharacterId);
+ Assert.Equal(
+ RuntimeCharacterSelectionOperation.RestoreRequested,
+ state.Snapshot.Operation);
+ // No response is required to keep using the state. ACE's unknown-guid
+ // branch intentionally sends no reply.
+ Assert.True(state.TryHighlight(0x50000001u));
+
+ state.ApplyRestore(new CharacterRestore.Parsed(
+ VerificationFlag: 1u,
+ Guid: 0x50000001u,
+ Name: "Restored",
+ SecondsGreyedOut: 0u));
+
+ Assert.True(state.View.TryGet(0x50000001u, out var restored));
+ Assert.Equal("Restored", restored.Name);
+ Assert.False(restored.IsPendingDelete);
+ Assert.True(state.Snapshot.Buttons.CanEnter);
+ Assert.Equal(
+ RuntimeCharacterSelectionOperation.RestoreSucceeded,
+ state.Snapshot.Operation);
+ }
+
+ [Fact]
+ public void RestoreResponse_FromSupersededRequestCannotCompleteTheCurrentRequest()
+ {
+ using var state = new RuntimeCharacterSelectionState();
+ state.Begin(new RuntimeGenerationToken(12));
+ state.ApplyRoster(Roster(
+ new(0x50000001u, "First", 1u),
+ new(0x50000002u, "Second", 1u)));
+
+ Assert.True(state.TryBeginRestore(out _));
+ Assert.True(state.TryHighlight(0x50000002u));
+ Assert.True(state.TryBeginRestore(out _));
+ state.ApplyRestore(new CharacterRestore.Parsed(
+ 1u,
+ 0x50000001u,
+ "First",
+ 0u));
+
+ Assert.Equal(
+ RuntimeCharacterSelectionOperation.RestoreRequested,
+ state.Snapshot.Operation);
+ Assert.True(state.View.TryGet(0x50000001u, out var first));
+ Assert.True(first.IsPendingDelete);
+ }
+
+ [Fact]
+ public void CharacterErrors_MapKnownAndUnknownButNeverPublishNumErrorsSentinel()
+ {
+ using var state = new RuntimeCharacterSelectionState();
+ state.Begin(new RuntimeGenerationToken(2));
+ state.ApplyRoster(Roster(
+ new LiveSessionRosterEntry(0x50000001u, "One", 0u)));
+ var deltas = new List();
+ using IDisposable subscription = state.View.Subscribe(
+ new Observer(deltas.Add));
+
+ state.ApplyError(new CharacterError.Parsed(
+ (uint)CharacterError.Code.Delete));
+ RuntimeCharacterSelectionError known =
+ Assert.IsType(state.Snapshot.Error);
+ Assert.Equal(CharacterError.Code.Delete, known.Code);
+ Assert.Contains("delete", known.Message, StringComparison.OrdinalIgnoreCase);
+
+ Assert.True(state.Cancel());
+ state.ApplyError(new CharacterError.Parsed(0xDEADBEEFu));
+ RuntimeCharacterSelectionError unknown =
+ Assert.IsType(state.Snapshot.Error);
+ Assert.Contains("0xDEADBEEF", unknown.Message, StringComparison.Ordinal);
+
+ long revision = state.Snapshot.Revision;
+ int count = deltas.Count;
+ state.ApplyError(new CharacterError.Parsed(
+ (uint)CharacterError.Code.NumErrors));
+ Assert.Equal(revision, state.Snapshot.Revision);
+ Assert.Equal(count, deltas.Count);
+ Assert.Equal(0xDEADBEEFu, state.Snapshot.Error!.Value.RawCode);
+ }
+
+ [Fact]
+ public void Deltas_AreMonotonicAndReentrantMutationsStayOrdered()
+ {
+ using var state = new RuntimeCharacterSelectionState();
+ state.Begin(new RuntimeGenerationToken(9));
+ var deltas = new List();
+ var observer = new Observer(delta =>
+ {
+ deltas.Add(delta);
+ if (delta.Kind == RuntimeCharacterSelectionDeltaKind.RosterChanged)
+ Assert.True(state.TryHighlight(0x50000002u));
+ });
+ using IDisposable subscription = state.View.Subscribe(observer);
+
+ state.ApplyRoster(Roster(
+ new(0x50000001u, "Ready", 0u),
+ new(0x50000002u, "Grey", 1u)));
+
+ Assert.Equal(
+ [
+ RuntimeCharacterSelectionDeltaKind.RosterChanged,
+ RuntimeCharacterSelectionDeltaKind.HighlightChanged,
+ ],
+ deltas.Select(delta => delta.Kind));
+ Assert.True(deltas[0].Sequence < deltas[1].Sequence);
+ Assert.All(
+ deltas,
+ delta => Assert.Equal(new RuntimeGenerationToken(9), delta.Generation));
+ }
+
+ private static LiveSessionRosterReport Roster(
+ params LiveSessionRosterEntry[] entries) =>
+ new("Canonical", 11, entries);
+}