Harden retail character selection recovery

This commit is contained in:
Erik 2026-08-14 20:59:10 +02:00
parent 6cfab727f1
commit aeac874dab
8 changed files with 736 additions and 81 deletions

View file

@ -23,7 +23,10 @@ The implementation was derived from
those four button pointers, the selected row/guid, and four dialog contexts.
It declares no viewport, `gmCG3DView`, or preview owner.
- `RebuildCharacterList` (`0x004EC3A0`) creates each row through
`AddItemFromTemplateList`, retains character identity, displays pending
`AddItemFromTemplateList`, then resizes it using signed integer division:
`max(listHeight / max(rosterCount, allowedSlots), listHeight / 10)`. Thus a
320-pixel list with five allowed slots uses 64-pixel rows, while rosters over
ten clamp at 32 pixels. It retains character identity, displays pending
deletion in red, sorts by ordinal name, moves greyed entries to the tail,
and restores/falls back selection. LA8 preserves the already canonical LA7b
display order and identity instead of sorting an App copy.
@ -55,6 +58,8 @@ with `ACDREAM_PROBE_LIVE_MOUNT=1`; it reads the ordinary
`%USERPROFILE%/Documents/Asheron's Call` DAT set unless `ACDREAM_DAT_DIR`
overrides the location. It uses production `DatCollection`,
`RetailDataIdResolver`, and `LayoutImporter`; it does not write the DATs.
When the opt-in flag or installed data is absent, discovery records an explicit
skip rather than adding a no-op pass to default suite totals.
The installed September-2013 data proves:
@ -89,11 +94,17 @@ The controller instantiates the authored row template in Runtime display
order, projects red pending-delete rows and the exact button matrix, and opens
the shared retail dialogs. Delete wait survives the opcode-only acknowledgement
until the fresh roster arrives. Restore is fire-and-observe: a silent ACE
no-reply ends only when Runtime expires its correlation. Entering-world wait
opens before the existing synchronous Enter command; error, reset, reconnect,
missing/displaced adapter, and disposal close owned contexts without re-entrant
commands. A failed transient row-template import leaves the Runtime revision
unconsumed and retries on the next frame.
no-reply ends only when Runtime expires its correlation; retail's Please Wait
opens before the synchronous restore command and closes immediately if that
command rejects or throws. Entering-world wait opens before the existing
synchronous Enter command; error, reset, reconnect, missing/displaced adapter,
and disposal close owned contexts without re-entrant commands. A failed
transient row-template import leaves the Runtime revision unconsumed and
retries on the next frame. Initial dialog-catalog, character root, and string
misses likewise retry on later ticks without mounting a duplicate root or
controller. Dialog presenter/catalog failures move their contexts to an
internal retry ledger, so UI callbacks do not retain poisoned active/queued
entries and the same context can appear after resource recovery.
There is deliberately no 3D preview and no claimed character-select background
scene. The screen root remains neutral with respect to render-loop background

View file

@ -45,6 +45,7 @@ internal sealed class CharacterManagementUiController : IDisposable
private uint _enterWaitContext;
private uint _errorDialogContext;
private bool _active;
private bool _restoreCommandInFlight;
private bool _suppressDialogCallbacks;
private bool _disposed;
@ -197,7 +198,7 @@ internal sealed class CharacterManagementUiController : IDisposable
if (TryCaptureRoster(view, snapshot, out RuntimeCharacterSelectionEntry[] roster))
{
bool rowsReady;
if (RowsMatchRoster(roster))
if (RowsMatchRoster(roster, snapshot.SlotCount))
{
ApplyHighlight(snapshot.HighlightedCharacterId);
rowsReady = true;
@ -206,6 +207,7 @@ internal sealed class CharacterManagementUiController : IDisposable
{
rowsReady = RebuildRows(
roster,
snapshot.SlotCount,
snapshot.HighlightedCharacterId);
}
@ -280,11 +282,16 @@ internal sealed class CharacterManagementUiController : IDisposable
}
private bool RowsMatchRoster(
IReadOnlyList<RuntimeCharacterSelectionEntry> roster)
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
int allowedSlotCount)
{
if (_rows.Count != roster.Count)
return false;
int rowHeight = ComputeRowHeight(
_list.Height,
roster.Count,
allowedSlotCount);
for (int i = 0; i < roster.Count; i++)
{
UiButton row = _rows[i];
@ -292,6 +299,7 @@ internal sealed class CharacterManagementUiController : IDisposable
if (!_rowIds.TryGetValue(row, out uint characterId)
|| characterId != character.CharacterId
|| !string.Equals(row.Label, character.Name, StringComparison.Ordinal)
|| (int)row.Height != rowHeight
|| row.LabelColor != (character.IsPendingDelete
? new Vector4(1f, 0f, 0f, 1f)
: Vector4.One))
@ -305,6 +313,7 @@ internal sealed class CharacterManagementUiController : IDisposable
private bool RebuildRows(
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
int allowedSlotCount,
uint highlightedCharacterId)
{
foreach (UiButton row in _rows)
@ -316,15 +325,34 @@ internal sealed class CharacterManagementUiController : IDisposable
_rowIds.Clear();
_list.Flush();
bool complete = true;
int rowHeight = ComputeRowHeight(
_list.Height,
roster.Count,
allowedSlotCount);
_list.LineHeight = rowHeight;
bool complete = _list.Templates.Count > 0
&& _list.TemplateResolver is not null;
foreach (RuntimeCharacterSelectionEntry character in roster)
{
if (_list.AddItemFromTemplateList(0) is not UiButton row)
if (!complete)
break;
UiTemplateListEntry template = _list.Templates[0];
if (_list.TemplateResolver!(
template.TemplateLayoutId,
template.TemplateElementId) is not UiButton row)
{
complete = false;
break;
}
// AddItemFromTemplateList creates the same template, but its
// retained viewport stacks at the template's authored 16px
// height. Retail establishes the computed size on every row; our
// list fixes Top during insertion, so build and resize first to
// make every subsequent Top exact.
row.Height = rowHeight;
_list.AddPrebuiltRow(row);
uint characterId = character.CharacterId;
row.Label = character.Name;
row.LabelColor = character.IsPendingDelete
@ -354,6 +382,21 @@ internal sealed class CharacterManagementUiController : IDisposable
return false;
}
internal static int ComputeRowHeight(
float listHeight,
int rosterCount,
int allowedSlotCount)
{
// RebuildCharacterList @ 0x004EC3A0 uses integer UIRegion height and
// signed integer division for both terms. The 0x66666667 multiply/
// shift sequence is compiler output for height / 10.
int height = (int)MathF.Truncate(listHeight);
int denominator = Math.Max(rosterCount, allowedSlotCount);
if (denominator <= 0)
return height / 10;
return Math.Max(height / denominator, height / 10);
}
private void ApplyHighlight(uint highlightedCharacterId)
{
foreach (UiButton row in _rows)
@ -407,9 +450,39 @@ internal sealed class CharacterManagementUiController : IDisposable
{
if (_disposed)
return;
RuntimeCommandResult result = _bindings.Restore();
if (result.Accepted)
EnsureOperationWait();
// ListenToElementMessage @ 0x004ED5A0 opens Please Wait before it
// calls CPlayerSystem::RestoreCharacter. Keep it modal even if a
// synchronous command callback re-enters Tick before Runtime has
// returned its accepted projection.
EnsureOperationWait();
RuntimeCommandResult result = default;
Exception? failure = null;
_restoreCommandInFlight = true;
try
{
result = _bindings.Restore();
}
catch (Exception error)
{
failure = error;
}
finally
{
_restoreCommandInFlight = false;
}
if (failure is not null)
{
Console.WriteLine(
$"[UI] character restore command failed: {failure.Message}");
CloseContext(ref _operationWaitContext, suppressCallback: true);
InvalidateAndTick();
return;
}
if (!result.Accepted)
CloseContext(ref _operationWaitContext, suppressCallback: true);
InvalidateAndTick();
}
@ -446,7 +519,8 @@ internal sealed class CharacterManagementUiController : IDisposable
CloseContext(ref _deleteDialogContext, suppressCallback: true);
}
if (snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested
if (_restoreCommandInFlight
|| snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
or RuntimeCharacterSelectionOperation.RestoreRequested)
{

View file

@ -0,0 +1,87 @@
namespace AcDream.App.UI.Layout;
internal sealed record CharacterManagementUiMountResources(
uint LayoutId,
ImportedLayout Layout,
Func<uint, uint, UiElement?> TemplateResolver,
CharacterManagementUiController.DialogStrings Strings);
/// <summary>
/// Retryable, idempotent composition edge for the pre-world character screen.
/// DATs can become readable after the graphical runtime starts (installer copy,
/// mapped-file replacement, or a transient catalog miss), so an unavailable
/// dialog catalog, root, template, or string must not permanently suppress the
/// screen. Once bound, later ticks are no-ops and cannot duplicate the root or
/// controller lifetime.
/// </summary>
internal sealed class CharacterManagementUiMountCoordinator : IDisposable
{
private readonly UiRoot _host;
private readonly CharacterSelectionRuntimeBindings _bindings;
private readonly Func<RetailDialogFactory?> _ensureDialogs;
private readonly Func<CharacterManagementUiMountResources?> _loadResources;
private bool _disposed;
public CharacterManagementUiMountCoordinator(
UiRoot host,
CharacterSelectionRuntimeBindings bindings,
Func<RetailDialogFactory?> ensureDialogs,
Func<CharacterManagementUiMountResources?> loadResources)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_ensureDialogs = ensureDialogs
?? throw new ArgumentNullException(nameof(ensureDialogs));
_loadResources = loadResources
?? throw new ArgumentNullException(nameof(loadResources));
}
public CharacterManagementUiController? Controller { get; private set; }
public void Tick()
{
if (_disposed || Controller is not null)
return;
try
{
RetailDialogFactory? dialogs = _ensureDialogs();
if (dialogs is null)
return;
CharacterManagementUiMountResources? resources = _loadResources();
if (resources is null)
return;
Controller = CharacterManagementUiController.Bind(
_host,
resources.Layout,
resources.TemplateResolver,
dialogs,
_bindings,
resources.Strings);
if (Controller is not null)
{
Console.WriteLine(
$"[UI] retail character management from enum table 5 "
+ $"(0x10000005 -> 0x{resources.LayoutId:X8}, "
+ "root 0x1000039A; flat list, no viewport).");
}
}
catch (Exception error)
{
Console.WriteLine(
"[UI] character management mount will retry after resource "
+ $"recovery: {error.Message}");
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Controller?.Dispose();
Controller = null;
}
}

View file

@ -24,6 +24,7 @@ public sealed class RetailDialogFactory : IDisposable
private readonly Dictionary<uint, DialogInfo> _activeQueued = new();
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
private readonly LinkedList<DialogInfo> _retryable = new();
private readonly List<DialogInfo> _openOrder = new();
private uint _globalContext;
private bool _resetting;
@ -51,6 +52,8 @@ public sealed class RetailDialogFactory : IDisposable
public int PendingCount => _pending.Values.Sum(static queue => queue.Count);
internal int RetryCount => _retryable.Count;
/// <summary>Exact root-element switch from <c>CreateDialog_ @ 0x00477AD0</c>.</summary>
public static uint RootElementId(RetailDialogType type)
=> type switch
@ -93,14 +96,28 @@ public sealed class RetailDialogFactory : IDisposable
if (queueKey == NonQueuedKey)
{
_activeNonQueued.Add(context, info);
CreateDialog(info);
if (!TryCreateDialog(info))
{
_activeNonQueued.Remove(context);
QueueRetry(info);
}
return context;
}
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
{
if (HasRetry(queueKey))
{
PendingQueue(queueKey).AddLast(info);
return context;
}
_activeQueued.Add(queueKey, info);
CreateDialog(info);
if (!TryCreateDialog(info))
{
_activeQueued.Remove(queueKey);
QueueRetry(info);
}
return context;
}
@ -118,7 +135,15 @@ public sealed class RetailDialogFactory : IDisposable
Suspend(current);
queue.AddFirst(current);
_activeQueued[queueKey] = info;
CreateDialog(info);
if (!TryCreateDialog(info))
{
_activeQueued.Remove(queueKey);
queue.Remove(current);
if (queue.Count == 0)
_pending.Remove(queueKey);
OpenSpecificDialog(current);
QueueRetry(info);
}
return context;
}
@ -211,11 +236,25 @@ public sealed class RetailDialogFactory : IDisposable
return true;
}
LinkedListNode<DialogInfo>? retry = _retryable.First;
while (retry is not null && retry.Value.Context != context)
retry = retry.Next;
if (retry is not null)
{
DialogInfo failed = retry.Value;
_retryable.Remove(retry);
DialogDone(failed);
if (failed.QueueKey != NonQueuedKey)
OpenNextDialog(failed.QueueKey);
return true;
}
return false;
}
public void Tick()
{
RetryFailedDialogs();
foreach (DialogInfo info in _openOrder.ToArray())
info.View?.Tick();
}
@ -238,6 +277,7 @@ public sealed class RetailDialogFactory : IDisposable
DialogInfo[] infos = _activeNonQueued.Values
.Concat(_activeQueued.Values)
.Concat(_pending.Values.SelectMany(static queue => queue))
.Concat(_retryable)
.Distinct()
.ToArray();
if (infos.Length == 0)
@ -249,6 +289,7 @@ public sealed class RetailDialogFactory : IDisposable
_activeNonQueued.Clear();
_activeQueued.Clear();
_pending.Clear();
_retryable.Clear();
foreach (DialogInfo info in infos)
{
try { DialogDone(info); }
@ -292,41 +333,57 @@ public sealed class RetailDialogFactory : IDisposable
return queue;
}
private void CreateDialog(DialogInfo info)
private bool TryCreateDialog(DialogInfo info)
{
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
if (type is not (RetailDialogType.Confirmation
or RetailDialogType.Wait
or RetailDialogType.Message
or RetailDialogType.ConfirmationTextInput))
throw new NotSupportedException(
$"Retail dialog type {(uint)type} does not have a ported presenter yet.");
ImportedLayout layout = _createLayout(type)
?? throw new InvalidOperationException(
$"Retail dialog catalog could not create type {(uint)type}.");
IRetailDialogView view = type switch
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(
RetailDialogProperty.Type);
try
{
RetailDialogType.Wait => new RetailWaitDialogView(_host, layout, info.Data),
RetailDialogType.Message => new RetailMessageDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context)),
RetailDialogType.ConfirmationTextInput =>
new RetailConfirmationTextInputDialogView(
if (type is not (RetailDialogType.Confirmation
or RetailDialogType.Wait
or RetailDialogType.Message
or RetailDialogType.ConfirmationTextInput))
{
throw new NotSupportedException(
$"Retail dialog type {(uint)type} does not have a ported presenter yet.");
}
ImportedLayout layout = _createLayout(type)
?? throw new InvalidOperationException(
$"Retail dialog catalog could not create type {(uint)type}.");
IRetailDialogView view = type switch
{
RetailDialogType.Wait => new RetailWaitDialogView(
_host, layout, info.Data),
RetailDialogType.Message => new RetailMessageDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context)),
_ => new RetailConfirmationDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context)),
};
info.View = view;
_host.AddChild(view.Root);
_host.BringToFront(view.Root);
_openOrder.Add(info);
_host.Modal = view.Root;
view.Tick();
UpdatePendingDialogDisplays();
DialogOpened?.Invoke(info.Context);
RetailDialogType.ConfirmationTextInput =>
new RetailConfirmationTextInputDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context)),
_ => new RetailConfirmationDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context)),
};
info.View = view;
_host.AddChild(view.Root);
_host.BringToFront(view.Root);
_openOrder.Add(info);
_host.Modal = view.Root;
view.Tick();
UpdatePendingDialogDisplays();
DialogOpened?.Invoke(info.Context);
return true;
}
catch (Exception error)
{
RemoveView(info);
Console.WriteLine(
$"[UI] retail dialog type {(uint)type} context {info.Context} "
+ $"will retry after catalog recovery: {error.Message}");
return false;
}
}
private void Suspend(DialogInfo info)
@ -380,6 +437,9 @@ public sealed class RetailDialogFactory : IDisposable
if (_activeQueued.ContainsKey(queueKey))
return;
if (TryActivateRetry(queueKey))
return;
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|| queue.First is null)
return;
@ -389,7 +449,73 @@ public sealed class RetailDialogFactory : IDisposable
if (queue.Count == 0)
_pending.Remove(queueKey);
_activeQueued.Add(queueKey, next);
CreateDialog(next);
if (!TryCreateDialog(next))
{
_activeQueued.Remove(queueKey);
QueueRetry(next);
}
}
private void OpenSpecificDialog(DialogInfo info)
{
_activeQueued.Add(info.QueueKey, info);
if (!TryCreateDialog(info))
{
_activeQueued.Remove(info.QueueKey);
QueueRetry(info);
}
}
private void RetryFailedDialogs()
{
foreach (DialogInfo info in _retryable.ToArray())
{
if (info.QueueKey == NonQueuedKey)
{
_activeNonQueued.Add(info.Context, info);
if (TryCreateDialog(info))
_retryable.Remove(info);
else
_activeNonQueued.Remove(info.Context);
continue;
}
if (!_activeQueued.ContainsKey(info.QueueKey)
&& ReferenceEquals(FirstRetry(info.QueueKey), info))
{
TryActivateRetry(info.QueueKey);
}
}
}
private bool TryActivateRetry(uint queueKey)
{
DialogInfo? info = FirstRetry(queueKey);
if (info is null)
return false;
_activeQueued.Add(queueKey, info);
if (TryCreateDialog(info))
_retryable.Remove(info);
else
_activeQueued.Remove(queueKey);
return true;
}
private DialogInfo? FirstRetry(uint queueKey)
{
foreach (DialogInfo info in _retryable)
if (info.QueueKey == queueKey)
return info;
return null;
}
private bool HasRetry(uint queueKey) => FirstRetry(queueKey) is not null;
private void QueueRetry(DialogInfo info)
{
if (!_retryable.Contains(info))
_retryable.AddLast(info);
}
private void UpdatePendingDialogDisplays()

View file

@ -434,6 +434,7 @@ public sealed class RetailUiRuntime : IDisposable
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
private ItemCooldownUiController? _itemCooldownController;
private VividTargetIndicatorController? _vividTargetIndicator;
private CharacterManagementUiMountCoordinator? _characterManagementMount;
private IDisposable? _characterSheetSubscription;
private ResourceShutdownTransaction? _shutdown;
private bool _disposed;
@ -500,7 +501,8 @@ public sealed class RetailUiRuntime : IDisposable
MountVendor();
MountSecureTrade();
MountItemCooldowns();
MountCharacterManagement();
ConfigureCharacterManagement();
_characterManagementMount?.Tick();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
SyncToolbarWindowButtons();
@ -595,7 +597,8 @@ public sealed class RetailUiRuntime : IDisposable
public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller;
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
@ -641,6 +644,7 @@ public sealed class RetailUiRuntime : IDisposable
ExternalContainerController?.Tick();
SocialPanelController?.Tick();
_itemCooldownController?.Tick();
_characterManagementMount?.Tick();
CharacterManagementController?.Tick();
DialogFactory?.Tick();
Host.Tick(deltaSeconds);
@ -3051,13 +3055,29 @@ public sealed class RetailUiRuntime : IDisposable
private void MountDialogFactory()
{
if (DialogFactory is not null)
return;
uint layoutId;
lock (_bindings.Assets.DatLock)
try
{
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
// creates a fresh type-specific root from that catalog.
layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u);
lock (_bindings.Assets.DatLock)
{
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
// creates a fresh type-specific root from that catalog.
layoutId = RetailDataIdResolver.Resolve(
_bindings.Assets.Dats,
2u,
5u);
}
}
catch (Exception error)
{
Console.WriteLine(
"[UI] retail dialog catalog will retry after resource "
+ $"recovery: {error.Message}");
return;
}
if (layoutId == 0u)
@ -3690,19 +3710,28 @@ public sealed class RetailUiRuntime : IDisposable
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
}
private void MountCharacterManagement()
private void ConfigureCharacterManagement()
{
CharacterSelectionRuntimeBindings? bindings =
_bindings.CharacterSelection;
if (bindings is null)
if (bindings is null || _characterManagementMount is not null)
return;
if (DialogFactory is null)
{
Console.WriteLine(
"[UI] character management: retail DialogFactory is unavailable.");
return;
}
_characterManagementMount = new CharacterManagementUiMountCoordinator(
Host.Root,
bindings,
EnsureDialogFactory,
LoadCharacterManagementResources);
}
private RetailDialogFactory? EnsureDialogFactory()
{
MountDialogFactory();
return DialogFactory;
}
private CharacterManagementUiMountResources? LoadCharacterManagementResources()
{
const uint stringTableId = 0x23000002u;
uint layoutId;
ImportedLayout? layout;
@ -3730,7 +3759,7 @@ public sealed class RetailUiRuntime : IDisposable
{
Console.WriteLine(
"[UI] character management: enum-table-5 root could not be imported.");
return;
return null;
}
string? deleteResponse;
@ -3767,7 +3796,7 @@ public sealed class RetailUiRuntime : IDisposable
{
Console.WriteLine(
"[UI] character management: required retail strings are unavailable.");
return;
return null;
}
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
@ -3798,23 +3827,15 @@ public sealed class RetailUiRuntime : IDisposable
}
}
CharacterManagementController = CharacterManagementUiController.Bind(
Host.Root,
return new CharacterManagementUiMountResources(
layoutId,
layout,
ResolveTemplate,
DialogFactory,
bindings,
new CharacterManagementUiController.DialogStrings(
ComposeDeleteConfirmation,
deleteResponse,
pleaseWait,
enteringWorld));
if (CharacterManagementController is null)
return;
Console.WriteLine(
$"[UI] retail character management from enum table 5 "
+ $"(0x10000005 -> 0x{layoutId:X8}, root 0x1000039A; flat list, no viewport).");
}
private static string? ResolveCharacterManagementString(
@ -3873,7 +3894,7 @@ public sealed class RetailUiRuntime : IDisposable
() => _itemConfirmationController?.Dispose(),
() =>
{
CharacterManagementController?.Dispose();
_characterManagementMount?.Dispose();
_gameplayConfirmationController?.Dispose();
},
() => DialogFactory?.Dispose(),

View file

@ -15,12 +15,9 @@ namespace AcDream.App.Tests.UI.Layout;
/// </summary>
public sealed class CharacterManagementLiveDatTests
{
[Fact]
[InstalledDatFact]
public void EnumTable5_ResolvesAndImportsTheExactRetailScreenAndDialogs()
{
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
return;
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
@ -162,3 +159,23 @@ public sealed class CharacterManagementLiveDatTests
yield return descendant;
}
}
internal sealed class InstalledDatFactAttribute : FactAttribute
{
public InstalledDatFactAttribute()
{
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
{
Skip = "Set ACDREAM_PROBE_LIVE_MOUNT=1 to run the installed-DAT LA8 gate.";
return;
}
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
if (!File.Exists(Path.Combine(datDirectory, "client_portal.dat")))
Skip = $"Installed client_portal.dat is required at '{datDirectory}'.";
}
}

View file

@ -44,6 +44,9 @@ public sealed class CharacterManagementUiControllerTests
Assert.Equal(
["Alpha", "Zulu", "Aaron (pending)"],
controller.Rows.Select(static row => row.Label!).ToArray());
Assert.All(controller.Rows, static row => Assert.Equal(64f, row.Height));
Assert.Equal([0f, 64f, 128f],
controller.Rows.Select(static row => row.Top).ToArray());
Assert.True(controller.Rows[0].Selected);
Assert.False(controller.Rows[1].Selected);
Assert.Equal(Vector4.One, controller.Rows[0].LabelColor);
@ -65,6 +68,126 @@ public sealed class CharacterManagementUiControllerTests
Assert.True(restore.Enabled);
}
[Fact]
public void RowHeight_UsesAllowedSlotsAndClampsAtOneTenthForLargeRosters()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
Assert.Equal(64, CharacterManagementUiController.ComputeRowHeight(
320f,
rosterCount: 3,
allowedSlotCount: 5));
Assert.Equal(63, CharacterManagementUiController.ComputeRowHeight(
319f,
rosterCount: 5,
allowedSlotCount: 5));
Assert.Equal(31, CharacterManagementUiController.ComputeRowHeight(
319f,
rosterCount: 11,
allowedSlotCount: 5));
RuntimeCharacterSelectionEntry[] large = Enumerable.Range(0, 12)
.Select(index => new RuntimeCharacterSelectionEntry(
index,
(uint)(0x50000100 + index),
$"Character {index:D2}",
0u))
.ToArray();
environment.Runtime.ReplaceRoster(
large,
highlightedCharacterId: large[0].CharacterId);
controller.Tick();
Assert.Equal(12, controller.Rows.Count);
Assert.All(controller.Rows, static row => Assert.Equal(32f, row.Height));
Assert.Equal(
Enumerable.Range(0, 12).Select(static index => index * 32f),
controller.Rows.Select(static row => row.Top));
UiTemplateListBox list = Assert.IsType<UiTemplateListBox>(
environment.Screen.FindElement(
CharacterManagementUiController.ListElementId));
Assert.Equal(384, list.ContentHeight);
Assert.Equal(32, list.LineHeight);
}
[Fact]
public void MountCoordinator_RetriesCatalogRootAndStrings_ThenMountsOnce()
{
var host = new UiRoot();
var runtime = new FakeRuntime();
using var dialogs = new RetailDialogFactory(
host,
RetailDialogFactoryTests.BuildDialogLayout);
bool catalogAvailable = false;
bool rootAvailable = false;
bool stringsAvailable = false;
int dialogAttempts = 0;
int resourceAttempts = 0;
using var coordinator = new CharacterManagementUiMountCoordinator(
host,
runtime.Bindings,
() =>
{
dialogAttempts++;
return catalogAvailable ? dialogs : null;
},
() =>
{
resourceAttempts++;
if (!rootAvailable || !stringsAvailable)
return null;
return new CharacterManagementUiMountResources(
0x21000004u,
BuildScreen(),
static (layoutId, elementId) =>
layoutId == 0x21000004u
&& elementId == 0x100003A5u
? BuildRow()
: null,
TestStrings());
});
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Equal(1, dialogAttempts);
Assert.Equal(0, resourceAttempts);
catalogAvailable = true;
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Equal(2, dialogAttempts);
Assert.Equal(1, resourceAttempts);
rootAvailable = true;
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Equal(3, dialogAttempts);
Assert.Equal(2, resourceAttempts);
stringsAvailable = true;
coordinator.Tick();
CharacterManagementUiController controller = Assert.IsType<
CharacterManagementUiController>(coordinator.Controller);
Assert.Single(host.Children);
Assert.Same(controller.Root, host.Children[0]);
Assert.Equal(4, dialogAttempts);
Assert.Equal(3, resourceAttempts);
coordinator.Tick();
Assert.Same(controller, coordinator.Controller);
Assert.Single(host.Children);
Assert.Equal(4, dialogAttempts);
Assert.Equal(3, resourceAttempts);
coordinator.Dispose();
Assert.Empty(host.Children);
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Equal(4, dialogAttempts);
Assert.Equal(3, resourceAttempts);
}
[Fact]
public void DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster()
{
@ -218,6 +341,78 @@ public sealed class CharacterManagementUiControllerTests
Assert.False(environment.Dialogs.IsOpen);
}
[Fact]
public void Restore_OpensWaitBeforeCommand_AndKeepsOneModalAcrossReentrantTick()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
controller.Rows[2].OnClick!();
uint observedContext = 0u;
environment.Runtime.BeforeRestore = () =>
{
observedContext = controller.OperationWaitContext;
Assert.NotEqual(0u, observedContext);
Assert.True(environment.Dialogs.IsOpen);
Assert.Same(
environment.LastDialog(RetailDialogType.Wait).Root,
environment.Host.Modal);
// A synchronous callback can pump the presentation before the
// command has returned. The in-flight edge must retain the one
// wait context instead of closing/reopening it.
controller.Tick();
Assert.Equal(observedContext, controller.OperationWaitContext);
};
environment.Runtime.AfterRestoreProjection = () =>
{
controller.Tick();
Assert.Equal(observedContext, controller.OperationWaitContext);
};
environment.Button(
CharacterManagementUiController.RestoreElementId).OnClick!();
Assert.Equal(1, environment.Runtime.RestoreCalls);
Assert.Equal(observedContext, controller.OperationWaitContext);
Assert.Equal(
1,
environment.DialogLayouts.Count(static entry =>
entry.Type == RetailDialogType.Wait));
Assert.Same(
environment.LastDialog(RetailDialogType.Wait).Root,
environment.Host.Modal);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Restore_ImmediateRejectionOrFailure_ClosesPreopenedWait(
bool throwFailure)
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
controller.Rows[2].OnClick!();
environment.Runtime.RestoreStatus = RuntimeCommandStatus.Rejected;
environment.Runtime.ThrowOnRestore = throwFailure;
environment.Runtime.BeforeRestore = () =>
{
Assert.NotEqual(0u, controller.OperationWaitContext);
Assert.NotNull(environment.Host.Modal);
};
Exception? error = Record.Exception(() => environment.Button(
CharacterManagementUiController.RestoreElementId).OnClick!());
Assert.Null(error);
Assert.Equal(1, environment.Runtime.RestoreCalls);
Assert.Equal(0u, controller.OperationWaitContext);
Assert.False(environment.Dialogs.IsOpen);
Assert.Null(environment.Host.Modal);
Assert.Equal(
RuntimeCharacterSelectionOperation.None,
environment.Runtime.View.Snapshot.Operation);
}
[Fact]
public void MissingOrDisposedBorrowedView_ClosesDialogsFlushesRowsAndDisposesSafely()
{
@ -475,6 +670,12 @@ public sealed class CharacterManagementUiControllerTests
public int EnterCalls { get; private set; }
public int ConfirmDeleteCalls { get; private set; }
public int CancelCalls { get; private set; }
public int RestoreCalls { get; private set; }
public RuntimeCommandStatus RestoreStatus { get; set; } =
RuntimeCommandStatus.Accepted;
public bool ThrowOnRestore { get; set; }
public Action? BeforeRestore { get; set; }
public Action? AfterRestoreProjection { get; set; }
public void SetOperation(RuntimeCharacterSelectionOperation operation)
{
@ -578,7 +779,13 @@ public sealed class CharacterManagementUiControllerTests
private RuntimeCommandResult Restore()
{
RestoreCalls++;
uint id = View.Snapshot.HighlightedCharacterId;
BeforeRestore?.Invoke();
if (ThrowOnRestore)
throw new InvalidOperationException("restore transport failed");
if (RestoreStatus != RuntimeCommandStatus.Accepted)
return Result(RestoreStatus, id);
Update(snapshot => snapshot with
{
LastRestoreRequestedCharacterId = id,
@ -590,6 +797,7 @@ public sealed class CharacterManagementUiControllerTests
false,
true),
});
AfterRestoreProjection?.Invoke();
return Result(RuntimeCommandStatus.Accepted, id);
}

View file

@ -440,6 +440,108 @@ public sealed class RetailDialogFactoryTests
Assert.Null(root.KeyboardFocus);
}
[Theory]
[InlineData(RetailDialogType.Wait, 0)]
[InlineData(RetailDialogType.Message, 1)]
[InlineData(RetailDialogType.ConfirmationTextInput, 2)]
public void CatalogFailure_DoesNotPoisonActiveQueue_AndTickRecovers(
RetailDialogType type,
int failureKind)
{
var root = new UiRoot { Width = 800f, Height = 600f };
bool available = false;
int attempts = 0;
using var factory = new RetailDialogFactory(root, requested =>
{
Assert.Equal(type, requested);
attempts++;
if (!available)
{
return failureKind switch
{
0 => throw new InvalidOperationException("catalog unavailable"),
1 => null,
_ => new ImportedLayout(
new UiDialogRoot(),
new Dictionary<uint, UiElement>()),
};
}
return BuildDialogLayout(type);
});
RetailDialogData data = type switch
{
RetailDialogType.Wait => RetailDialogData.Wait("Please Wait"),
RetailDialogType.Message => RetailDialogData.Message("Error"),
_ => RetailDialogData.ConfirmationTextInput("Type DELETE"),
};
uint context = 0u;
Exception? creationError = Record.Exception(
() => context = factory.MakeDialog(data));
Assert.Null(creationError);
Assert.NotEqual(0u, context);
Assert.Equal(0, factory.ActiveCount);
Assert.Equal(0, factory.PendingCount);
Assert.Equal(1, factory.RetryCount);
Assert.Null(root.Modal);
Assert.Empty(root.Children);
available = true;
factory.Tick();
Assert.Equal(2, attempts);
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(0, factory.PendingCount);
Assert.Equal(0, factory.RetryCount);
Assert.NotNull(root.Modal);
Assert.True(factory.CloseDialog(context));
Assert.False(factory.IsOpen);
}
[Fact]
public void PendingCatalogFailure_MovesOutOfQueue_ThenRecoversBeforeLaterWork()
{
var root = new UiRoot { Width = 800f, Height = 600f };
bool messageAvailable = false;
var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>();
using var factory = new RetailDialogFactory(root, type =>
{
if (type == RetailDialogType.Message && !messageAvailable)
return null;
ImportedLayout layout = BuildDialogLayout(type);
layouts.Add((type, layout));
return layout;
});
uint active = factory.MakeWait("active");
uint failed = factory.MakeMessage("recover me");
uint later = factory.MakeWait("later");
Assert.Equal(2, factory.PendingCount);
Assert.True(factory.CloseDialog(active));
Assert.Equal(0, factory.ActiveCount);
Assert.Equal(1, factory.PendingCount);
Assert.Equal(1, factory.RetryCount);
Assert.Null(root.Modal);
messageAvailable = true;
factory.Tick();
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(1, factory.PendingCount);
Assert.Equal(0, factory.RetryCount);
Assert.Equal(
"recover me",
MessageFromAnyDialog(layouts.Last(static entry =>
entry.Type == RetailDialogType.Message).Layout.Root));
Assert.True(factory.CloseDialog(failed));
Assert.Equal("later", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(later));
}
private static RetailDialogFactory CreateFactory(
UiRoot root,
List<ImportedLayout> layouts)
@ -464,6 +566,15 @@ public sealed class RetailDialogFactoryTests
=> string.Join(" ", Assert.IsType<UiText>(layout.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text));
private static string MessageFromAnyDialog(UiElement root)
=> string.Join(
" ",
Assert.IsType<UiText>(UiElement.FindDescendant(
root,
RetailConfirmationDialogView.MessageElementId))
.LinesProvider()
.Select(static line => line.Text));
internal static ImportedLayout BuildDialogLayout(RetailDialogType type)
{
uint rootId = RetailDialogFactory.RootElementId(type);