feat(runtime): own character selection flow

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

View file

@ -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

View file

@ -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`). |

View file

@ -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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,11 @@ public enum LiveSessionStartStatus
Deferred,
Failed,
/// <summary>
/// The account transport is connected and Runtime owns a live roster,
/// but EnterWorld has deliberately not run yet.
/// </summary>
AwaitingCharacterSelection,
/// <summary>
/// Campaign LA slice LA2: a <see cref="LiveSessionConnectOptions.Probe"/>
/// session connected, received (and reported) the character roster, and
/// gracefully disconnected BEFORE selection/EnterWorld — deliberately a
@ -167,7 +172,16 @@ public interface ILiveSessionOperations
WorldSession CreateSession(IPEndPoint endpoint);
void Connect(WorldSession session, string user, string password);
CharacterList.Parsed? GetCharacters(WorldSession session);
void StartCharacterSelectionReceive(WorldSession session) =>
session.StartCharacterSelectionReceive();
void EnterWorld(WorldSession session, int activeCharacterIndex);
void DeleteCharacter(
WorldSession session,
string accountName,
int activeCharacterIndex) =>
session.SendDeleteCharacter(accountName, activeCharacterIndex);
void RestoreCharacter(WorldSession session, uint characterId) =>
session.SendRestoreCharacter(characterId);
void Tick(WorldSession session);
void DisposeSession(WorldSession session);
}
@ -203,6 +217,9 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
public CharacterList.Parsed? GetCharacters(WorldSession session) => session.Characters;
public void StartCharacterSelectionReceive(WorldSession session) =>
session.StartCharacterSelectionReceive();
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
session.EnterWorld(activeCharacterIndex);
@ -219,8 +236,49 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
/// </summary>
public sealed class LiveSessionController
: IDisposable,
IRuntimeLiveSessionFramePhase
IRuntimeLiveSessionFramePhase,
IRuntimeCharacterSelectionCommands
{
private sealed class CharacterSelectionWireBinding : IDisposable
{
private WorldSession? _session;
private readonly Action<CharacterList.Parsed> _roster;
private readonly Action _delete;
private readonly Action<CharacterRestore.Parsed> _restore;
private readonly Action<CharacterError.Parsed> _error;
public CharacterSelectionWireBinding(
WorldSession session,
Action<CharacterList.Parsed> roster,
Action delete,
Action<CharacterRestore.Parsed> restore,
Action<CharacterError.Parsed> error)
{
_session = session;
_roster = roster;
_delete = delete;
_restore = restore;
_error = error;
session.CharacterListReceived += roster;
session.CharacterDeleteAcknowledged += delete;
session.CharacterRestoreReceived += restore;
session.CharacterErrorReceived += error;
}
public bool IsDisposed => _session is null;
public void Dispose()
{
WorldSession? session = Interlocked.Exchange(ref _session, null);
if (session is null)
return;
session.CharacterListReceived -= _roster;
session.CharacterDeleteAcknowledged -= _delete;
session.CharacterRestoreReceived -= _restore;
session.CharacterErrorReceived -= _error;
}
}
private sealed class SessionScope(
WorldSession session,
ILiveSessionLifecycleHost host,
@ -232,6 +290,11 @@ public sealed class LiveSessionController
public ILiveSessionLifecycleHost Host { get; } = host;
public RuntimeGenerationToken Generation { get; } = generation;
public LiveSessionBinding? Binding { get; set; }
public CharacterSelectionWireBinding? CharacterSelectionBinding
{
get;
set;
}
public bool HostAttached { get; set; }
public RuntimeTeardownStage CompletedStages { get; private set; }
@ -242,14 +305,19 @@ 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;
}
if (_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,6 +596,7 @@ 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.
if (_inWorld)
InvokeAutoSaveTick(scope.Session);
});
}
@ -583,6 +663,8 @@ public sealed class LiveSessionController
{
RuntimeGenerationToken resetGeneration = new(_generation);
ulong generation = ++_generation;
RuntimeGenerationToken activeGeneration = new(generation);
CharacterSelectionState.Reset(activeGeneration);
try
{
DrainRetiredScope();
@ -608,6 +690,8 @@ public sealed class LiveSessionController
if (string.IsNullOrEmpty(options.User) || string.IsNullOrEmpty(options.Password))
return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials);
CharacterSelectionState.Begin(activeGeneration);
SessionScope? scope = null;
try
{
@ -643,6 +727,9 @@ public sealed class LiveSessionController
_operations.Connect(session, options.User, options.Password);
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
scope.CharacterSelectionBinding = BindCharacterSelection(
scope,
generation);
host.ReportConnected();
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
@ -650,7 +737,9 @@ public sealed class LiveSessionController
CharacterList.Parsed? characters = _operations.GetCharacters(session);
if (characters is not null)
{
host.ReportRoster(BuildRosterReport(characters));
LiveSessionRosterReport roster = BuildRosterReport(characters);
CharacterSelectionState.ApplyRoster(roster);
host.ReportRoster(roster);
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
}
@ -670,6 +759,19 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.ProbeComplete);
}
if (options.AwaitCharacterSelection
&& options.Character is null
&& characters is not null)
{
_operations.StartCharacterSelectionReceive(session);
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
Console.WriteLine(
"live: awaiting character selection before EnterWorld");
return new LiveSessionStartResult(
LiveSessionStartStatus.AwaitingCharacterSelection);
}
if (characters is null
|| !TrySelectCharacter(
characters,
@ -686,6 +788,12 @@ public sealed class LiveSessionController
selected.Character.Id,
selected.Character.Name,
characters.AccountName);
if (!CharacterSelectionState.TryHighlight(selection.CharacterId)
|| !CharacterSelectionState.BeginEnter(out _))
{
throw new InvalidOperationException(
"Runtime character selection rejected the validated active character.");
}
host.ApplySelectedCharacter(selection);
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
@ -701,6 +809,7 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
_inWorld = true;
_activeSelection = selection;
CharacterSelectionState.CompleteEnter(selection.CharacterId);
host.ApplyEnteredWorld(selection);
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
@ -734,6 +843,273 @@ public sealed class LiveSessionController
}
}
private CharacterSelectionWireBinding BindCharacterSelection(
SessionScope scope,
ulong generation) =>
new(
scope.Session,
roster =>
{
lock (_gate)
{
if (!IsCurrent(scope, generation))
return;
LiveSessionRosterReport report = BuildRosterReport(roster);
CharacterSelectionState.ApplyRoster(report);
scope.Host.ReportRoster(report);
}
},
() =>
{
lock (_gate)
{
if (IsCurrent(scope, generation))
CharacterSelectionState.ApplyDeleteAcknowledged();
}
},
restore =>
{
lock (_gate)
{
if (IsCurrent(scope, generation))
CharacterSelectionState.ApplyRestore(restore);
}
},
error =>
{
lock (_gate)
{
if (IsCurrent(scope, generation))
CharacterSelectionState.ApplyError(error);
}
});
public RuntimeCommandResult Highlight(
RuntimeGenerationToken expectedGeneration,
uint characterId)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterSelectionResult(gate);
RuntimeCommandStatus status =
CharacterSelectionState.TryHighlight(characterId)
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Rejected;
return CharacterSelectionResult(status, characterId);
}
}
public RuntimeCommandResult Enter(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterSelectionResult(gate);
if (_operationDepth != 0)
return CharacterSelectionResult(RuntimeCommandStatus.Rejected);
return RunTopLevel(EnterSelectedCore);
}
}
public RuntimeCommandResult RequestDelete(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterSelectionResult(gate);
RuntimeCommandStatus status =
CharacterSelectionState.TryRequestDelete(out uint characterId)
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Rejected;
return CharacterSelectionResult(status, characterId);
}
}
public RuntimeCommandResult ConfirmDelete(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterSelectionResult(gate);
if (!CharacterSelectionState.TryTakeDeleteConfirmation(
out RuntimeCharacterSelectionEntry character,
out string accountName))
{
return CharacterSelectionResult(RuntimeCommandStatus.Rejected);
}
try
{
_operations.DeleteCharacter(
_scope!.Session,
accountName,
character.ActiveIndex);
return CharacterSelectionResult(
RuntimeCommandStatus.Accepted,
character.CharacterId);
}
catch
{
CharacterSelectionState.ApplyError(
new CharacterError.Parsed(
(uint)CharacterError.Code.Delete));
return CharacterSelectionResult(
RuntimeCommandStatus.Rejected,
character.CharacterId);
}
}
}
public RuntimeCommandResult Restore(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterSelectionResult(gate);
if (!CharacterSelectionState.TryBeginRestore(
out RuntimeCharacterSelectionEntry character))
{
return CharacterSelectionResult(RuntimeCommandStatus.Rejected);
}
try
{
_operations.RestoreCharacter(
_scope!.Session,
character.CharacterId);
return CharacterSelectionResult(
RuntimeCommandStatus.Accepted,
character.CharacterId);
}
catch
{
CharacterSelectionState.ApplyError(
new CharacterError.Parsed(
(uint)CharacterError.Code.Undefined));
return CharacterSelectionResult(
RuntimeCommandStatus.Rejected,
character.CharacterId);
}
}
}
public RuntimeCommandResult Cancel(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterSelectionCommand(
expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterSelectionResult(gate);
return CharacterSelectionResult(
CharacterSelectionState.Cancel()
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Rejected);
}
}
private RuntimeCommandResult EnterSelectedCore()
{
SessionScope scope = _scope!;
ulong generation = _generation;
if (!CharacterSelectionState.BeginEnter(
out RuntimeCharacterSelectionEntry character))
{
return CharacterSelectionResult(RuntimeCommandStatus.Rejected);
}
RuntimeCharacterSelectionSnapshot snapshot =
CharacterSelectionState.Snapshot;
var selection = new LiveSessionCharacterSelection(
character.ActiveIndex,
character.CharacterId,
character.Name,
snapshot.AccountName);
try
{
scope.Host.ApplySelectedCharacter(selection);
if (!IsCurrent(scope, generation))
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
_operations.EnterWorld(scope.Session, character.ActiveIndex);
if (!IsCurrent(scope, generation))
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
scope.Binding!.ActivateCommands();
if (!IsCurrent(scope, generation))
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
_inWorld = true;
_activeSelection = selection;
CharacterSelectionState.CompleteEnter(character.CharacterId);
scope.Host.ApplyEnteredWorld(selection);
if (!IsCurrent(scope, generation))
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
Console.WriteLine("live: in world — CreateObject stream active");
return CharacterSelectionResult(
RuntimeCommandStatus.Accepted,
character.CharacterId);
}
catch (CharacterSelectionRejectedException rejected)
{
if (CharacterSelectionState.Snapshot.Error?.RawCode
!= rejected.Error.RawErrorCode)
{
CharacterSelectionState.ApplyError(rejected.Error);
}
CharacterSelectionState.ReturnToSelection();
return CharacterSelectionResult(
RuntimeCommandStatus.Rejected,
character.CharacterId);
}
catch (Exception error)
{
_ = StopAfterFailure(error);
return CharacterSelectionResult(
RuntimeCommandStatus.Rejected,
character.CharacterId);
}
}
private RuntimeCommandStatus ValidateCharacterSelectionCommand(
RuntimeGenerationToken expectedGeneration)
{
RuntimeGenerationToken current = new(_generation);
if (expectedGeneration != current)
return RuntimeCommandStatus.StaleGeneration;
if (_disposed || _disposeRequested || _scope is null || _inWorld)
return RuntimeCommandStatus.Inactive;
return CharacterSelectionState.Snapshot.Lifecycle
== RuntimeCharacterSelectionLifecycle.AwaitingSelection
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Inactive;
}
private RuntimeCommandResult CharacterSelectionResult(
RuntimeCommandStatus status,
uint characterId = 0u) =>
new(
status,
new RuntimeGenerationToken(_generation),
characterId);
private void StopCore()
{
// MUST-FIX 1: TS-71's logout-flush half — retail's
@ -749,6 +1125,7 @@ public sealed class LiveSessionController
++_generation;
_inWorld = false;
_activeSelection = null;
CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation));
if (_scope is { } scope)
{
_scope = null;
@ -889,6 +1266,7 @@ public sealed class LiveSessionController
private void DisposeCore()
{
StopCore();
CharacterSelectionState.Dispose();
_disposed = true;
}

View file

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

View file

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

View file

@ -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<string> calls,

View file

@ -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

View file

@ -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)
{
}

View file

@ -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<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
ValueTask.FromCanceled<NetReceiveResult>(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<string>();
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<byte>(datagram), null, true]);
}
}

View file

@ -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<WorldSession> Sessions { get; } = [];
public Dictionary<WorldSession, int> DisposeCounts { get; } = [];
public List<(string Account, int ActiveIndex)> DeleteRequests { get; } = [];
public List<uint> 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<string>();
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<string>();
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<string>();
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<string>();
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<string>();
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,

View file

@ -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<RuntimeCharacterSelectionEntry> Entries { get; } = [];
public void Visit(in RuntimeCharacterSelectionEntry character) =>
Entries.Add(character);
}
private sealed class Observer(
Action<RuntimeCharacterSelectionDelta> 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<RuntimeCharacterSelectionDelta>();
using IDisposable subscription = state.View.Subscribe(
new Observer(deltas.Add));
state.ApplyError(new CharacterError.Parsed(
(uint)CharacterError.Code.Delete));
RuntimeCharacterSelectionError known =
Assert.IsType<RuntimeCharacterSelectionError>(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<RuntimeCharacterSelectionError>(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<RuntimeCharacterSelectionDelta>();
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);
}