acdream/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs
Erik 9cf6c52283 feat(chargen): Campaign CC slice CC7 — end-to-end create flow + connected checklist
Create button un-ghosts: retail's exact gate (gmCharacterManagementUI::
UpdateButtons @0x004ec240, roster count < allowed slot count) ported into
RuntimeCharacterSelectionButtons.CanCreate; the button's OnClick opens the
chargen screen through the same CharacterCreationUiController.Open() seam
the ACDREAM_OPEN_CHARGEN=1 dev path already used. Exit/Back confirm on
chargen needed no new return-path code — character-management is never
hidden while chargen is open on top of it — verified end-to-end by a new
cross-controller test rather than left as an inspection claim.

Full-flow test coverage: a new comprehensive test decodes every 0xF656
field (including the trailing checksum, recomputed via the production
CharacterCreate.ComputeChecksum) against a fully populated creation
(heritage/gender/all appearance slots/template/explicit skill command/
town/name); a new Theory drives the remaining six 0xF643 rejection codes
through the real wire decode path, closing the gap between the
already-covered isolated state-machine Theory and an actual WorldSession
round trip.

Launcher payload cycle: two new tests drive a real Runtime create/reject
through the real SessionStatusWriter (wired exactly as
LiveSessionRuntimeFactory/HeadlessSessionHost do in production) and read
the result back with the real Launcher.Core StatusFileTailer/
StatusEventParser — closing the one gap CC2's own per-layer tests never
reached. No gap was found in production wiring itself: GameWindow already
constructs a real, non-null SessionStatusWriter for both hosts.

Also fixes 4 pre-existing LiveSessionControllerTests assertions that
compared a full RuntimeCharacterSelectionButtons record and would have
failed once CanCreate started being computed; corrects register row
AP-211 to reflect that its own predicted resolution (the Create-button
gate landing) has now happened — both layers are intentionally kept as
retail-matching enforcement plus defense-in-depth, not one superseding
the other.

Adds docs/research/2026-08-16-campaign-cc-test-script.md, the user's
connected-gate script covering both the launcher and dev-shortcut launch
paths, the six-page create flow, every Finish outcome, and the known
cosmetic/behavioral divergences (AP-212/213/215/216/217/218/219/220/222/
224/226/228) so they aren't mistaken for new bugs during the gate.

Gates: full solution Release build green; Runtime 1735/0 (was 1726/0,
+9), App 5256/3 skips (was 5254/3, +2), Headless 166/0 (unchanged),
Launcher.Core 324/0, one full-solution pass across every project clean
(no known flakes reproduced this run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 02:35:21 +02:00

1162 lines
40 KiB
C#

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,
RestoreCorrelationExpired,
ErrorChanged,
EnteringWorld,
EnteredWorld,
/// <summary>
/// Campaign LA gate round 2 finding 3: <see cref="ServerName"/> arrived —
/// see <see cref="RuntimeCharacterSelectionState.ApplyWorldName"/>.
/// </summary>
WorldNameChanged,
}
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,
/// <summary>
/// Campaign CC slice CC7: retail's Create-character gate
/// (<c>gmCharacterManagementUI::UpdateButtons @ 0x004ec240</c>,
/// ~0x004ec319-0x004ec32e) — unconditional on selection, purely
/// <c>_charSet.set_.m_num &lt; _charSet.numAllowedCharacters_</c> (the
/// live roster count against the allowed-slot ceiling). Mirrors
/// <see cref="RuntimeCharacterSelectionSnapshot.RosterCount"/> &lt;
/// <see cref="RuntimeCharacterSelectionSnapshot.SlotCount"/>.
/// </summary>
bool CanCreate = false)
{
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,
string WorldName,
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
{
internal static readonly TimeSpan RestoreCorrelationTimeout =
TimeSpan.FromSeconds(5);
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 readonly TimeProvider _timeProvider;
private RuntimeCharacterSelectionEntry[] _entries = [];
private RuntimeGenerationToken _generation;
private RuntimeCharacterSelectionLifecycle _lifecycle;
private long _revision;
private string _accountName = string.Empty;
private int _slotCount;
private string _worldName = string.Empty;
private uint _highlightedCharacterId;
private uint _pendingDeleteCharacterId;
private uint _lastRestoreRequestedCharacterId;
private bool _restoreResponseArmed;
private long _restoreCorrelationStartedTimestamp;
private bool _flagOnlyRestoreResponseAmbiguous;
private RuntimeCharacterSelectionOperation _operation;
private RuntimeCharacterSelectionError? _error;
private bool _disposed;
public RuntimeCharacterSelectionState(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_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,
_worldName,
_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;
_restoreCorrelationStartedTimestamp = 0;
_flagOnlyRestoreResponseAmbiguous = false;
_operation = RuntimeCharacterSelectionOperation.None;
_error = null;
_highlightedCharacterId = Contains(previous)
? previous
: fallback;
selected = _highlightedCharacterId;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.RosterChanged,
selected);
}
/// <summary>
/// Campaign CC slice CC3 review-fix round (F2): appends ONE freshly
/// created character without re-deriving every entry's
/// <see cref="RuntimeCharacterSelectionEntry.ActiveIndex"/> from display
/// order the way <see cref="ApplyRoster"/> does. <c>ActiveIndex</c> is a
/// WIRE CONTRACT — <c>SendDeleteCharacter</c> sends it as the
/// CharacterSet slot and ACE indexes
/// <c>session.Characters[(int)characterSlot]</c>
/// (<c>references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:297</c>)
/// — so round-tripping the post-create roster through
/// <see cref="ApplyRoster"/>'s name-sort would silently retarget every
/// PRE-EXISTING character's delete slot to its alphabetical rank.
/// <paramref name="wireIndex"/> is the caller-supplied wire slot for the
/// NEW entry only (ACE appends to <c>session.Characters</c>, so the new
/// character's slot equals the wire roster's count BEFORE this create,
/// 0-based — the caller must read that from the cached wire source, not
/// from the sorted display mirror this class exposes). Every existing
/// entry's <see cref="RuntimeCharacterSelectionEntry.ActiveIndex"/> is
/// copied through untouched; only the array's DISPLAY order (name sort,
/// greyed-to-tail) is recomputed, exactly like <see cref="ApplyRoster"/>'s
/// own sort. Does not touch highlight — callers that want the new entry
/// selected still call <see cref="TryHighlight"/> afterward.
/// </summary>
internal void AppendCreatedCharacter(uint characterId, string name, int wireIndex)
{
ArgumentNullException.ThrowIfNull(name);
lock (_gate)
{
ThrowIfDisposed();
var appended = new RuntimeCharacterSelectionEntry[_entries.Length + 1];
Array.Copy(_entries, appended, _entries.Length);
appended[^1] = new RuntimeCharacterSelectionEntry(
wireIndex,
characterId,
name,
SecondsGreyedOut: 0u);
Array.Sort(
appended,
static (left, right) =>
string.CompareOrdinal(left.Name, right.Name));
_entries = StablePartitionGreyedToTail(appended);
_revision++;
}
Publish(RuntimeCharacterSelectionDeltaKind.RosterChanged, characterId);
}
/// <summary>
/// Campaign LA gate round 2 finding 3: retail's <c>UpdateWorldName</c>
/// (<c>0x004ec120</c>) / <c>RecvNotice_WorldName</c> (<c>0x004ec360</c>)
/// both just push <c>Client::GetWorldName()</c> onto element
/// <c>0x1000039B</c> — no lifecycle gate. ACE sends <c>ServerName</c> in
/// the same batch as <c>CharacterList</c>, so this may land slightly
/// before or after <see cref="ApplyRoster"/>; it is intentionally
/// ungated (beyond disposal) so neither arrival order loses the name.
/// </summary>
internal void ApplyWorldName(string worldName)
{
ArgumentNullException.ThrowIfNull(worldName);
lock (_gate)
{
if (_disposed || _worldName == worldName)
return;
_worldName = worldName;
_revision++;
}
Publish(RuntimeCharacterSelectionDeltaKind.WorldNameChanged);
}
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()
|| _restoreResponseArmed
|| index < 0
|| !_entries[index].IsPendingDelete)
{
character = default;
return false;
}
character = _entries[index];
_pendingDeleteCharacterId = 0u;
_lastRestoreRequestedCharacterId = character.CharacterId;
_restoreResponseArmed = true;
_restoreCorrelationStartedTimestamp =
_timeProvider.GetTimestamp();
_operation = RuntimeCharacterSelectionOperation.RestoreRequested;
_error = null;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.RestoreRequested,
character.CharacterId);
return true;
}
/// <summary>
/// Releases the one outstanding restore correlation after a bounded local
/// window. ACE can intentionally send no response for an unknown GUID;
/// later flag-only responses carry no GUID, so once a request expires
/// they remain ambiguous and are never attributed to a newer request.
/// </summary>
internal bool SweepRestoreCorrelation()
{
uint characterId;
lock (_gate)
{
if (_disposed
|| !_restoreResponseArmed
|| _timeProvider.GetElapsedTime(
_restoreCorrelationStartedTimestamp)
< RestoreCorrelationTimeout)
{
return false;
}
characterId = _lastRestoreRequestedCharacterId;
_restoreResponseArmed = false;
_restoreCorrelationStartedTimestamp = 0;
_flagOnlyRestoreResponseAmbiguous = true;
if (!HasConfirmedDelete())
_operation = RuntimeCharacterSelectionOperation.None;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.RestoreCorrelationExpired,
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;
bool deleteInFlight;
lock (_gate)
{
if (_disposed
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|| !_restoreResponseArmed)
return;
if (response.Guid is null
&& _flagOnlyRestoreResponseAmbiguous)
{
return;
}
if (response.Guid is { } responseGuid
&& responseGuid != _lastRestoreRequestedCharacterId)
{
return;
}
_restoreResponseArmed = false;
_restoreCorrelationStartedTimestamp = 0;
characterId = response.Guid
?? _lastRestoreRequestedCharacterId;
deleteInFlight = HasConfirmedDelete();
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);
}
if (!deleteInFlight)
{
_operation =
RuntimeCharacterSelectionOperation.RestoreSucceeded;
}
_error = null;
}
else
{
errorCode = response.VerificationFlag;
if (!deleteInFlight)
{
_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;
if (_restoreResponseArmed)
_flagOnlyRestoreResponseAmbiguous = true;
_restoreResponseArmed = false;
_restoreCorrelationStartedTimestamp = 0;
_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;
_restoreCorrelationStartedTimestamp = 0;
_flagOnlyRestoreResponseAmbiguous = 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)
{
// gmCharacterManagementUI::UpdateButtons @ 0x004ec240's Create gate
// is unconditional on the delete/selection state below it — it only
// ever compares the live roster count against the allowed-slot
// ceiling (~0x004ec319-0x004ec32e:
// `if (_charSet.set_.m_num < _charSet.numAllowedCharacters_)
// SetState(1); else SetState(0xd);`).
bool canCreate = _entries.Length < _slotCount;
if (_operation is RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged)
{
return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
}
if (selectedIndex < 0)
return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
RuntimeCharacterSelectionEntry selected = _entries[selectedIndex];
if (selected.IsPendingDelete)
{
return new RuntimeCharacterSelectionButtons(
CanEnter: false,
CanDelete: false,
CanRestore: !_restoreResponseArmed,
DeleteVisible: false,
RestoreVisible: true,
CanCreate: canCreate);
}
return new RuntimeCharacterSelectionButtons(
CanEnter: selected.CanEnter,
CanDelete: selected.CanEnter,
CanRestore: false,
DeleteVisible: true,
RestoreVisible: false,
CanCreate: canCreate);
}
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
|| HasConfirmedDelete();
private bool HasConfirmedDelete() =>
_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;
_worldName = string.Empty;
_highlightedCharacterId = 0u;
_pendingDeleteCharacterId = 0u;
_lastRestoreRequestedCharacterId = 0u;
_restoreResponseArmed = false;
_restoreCorrelationStartedTimestamp = 0;
_flagOnlyRestoreResponseAmbiguous = 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);
}
}