From aeac874dab136eceecaa69e85f98534885724f8b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 20:59:10 +0200 Subject: [PATCH] Harden retail character selection recovery --- .../2026-08-14-la8-character-management-ui.md | 23 +- .../Layout/CharacterManagementUiController.cs | 90 +++++++- .../CharacterManagementUiMountCoordinator.cs | 87 ++++++++ .../UI/Layout/RetailDialogFactory.cs | 196 ++++++++++++++--- src/AcDream.App/UI/RetailUiRuntime.cs | 77 ++++--- .../Layout/CharacterManagementLiveDatTests.cs | 25 ++- .../CharacterManagementUiControllerTests.cs | 208 ++++++++++++++++++ .../UI/Layout/RetailDialogFactoryTests.cs | 111 ++++++++++ 8 files changed, 736 insertions(+), 81 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs diff --git a/docs/research/2026-08-14-la8-character-management-ui.md b/docs/research/2026-08-14-la8-character-management-ui.md index ac64abb8..1bde915c 100644 --- a/docs/research/2026-08-14-la8-character-management-ui.md +++ b/docs/research/2026-08-14-la8-character-management-ui.md @@ -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 diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index 5a2759a0..02f27ce5 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -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 roster) + IReadOnlyList 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 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) { diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs new file mode 100644 index 00000000..e297d5ac --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs @@ -0,0 +1,87 @@ +namespace AcDream.App.UI.Layout; + +internal sealed record CharacterManagementUiMountResources( + uint LayoutId, + ImportedLayout Layout, + Func TemplateResolver, + CharacterManagementUiController.DialogStrings Strings); + +/// +/// 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. +/// +internal sealed class CharacterManagementUiMountCoordinator : IDisposable +{ + private readonly UiRoot _host; + private readonly CharacterSelectionRuntimeBindings _bindings; + private readonly Func _ensureDialogs; + private readonly Func _loadResources; + private bool _disposed; + + public CharacterManagementUiMountCoordinator( + UiRoot host, + CharacterSelectionRuntimeBindings bindings, + Func ensureDialogs, + Func 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; + } +} diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index 33a3bf3a..f631adb1 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -24,6 +24,7 @@ public sealed class RetailDialogFactory : IDisposable private readonly Dictionary _activeQueued = new(); private readonly Dictionary _activeNonQueued = new(); private readonly Dictionary> _pending = new(); + private readonly LinkedList _retryable = new(); private readonly List _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; + /// Exact root-element switch from CreateDialog_ @ 0x00477AD0. 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? 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? 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() diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index df70aa93..d8253c91 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -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(), diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs index 15c705ae..88ceb7b0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs @@ -15,12 +15,9 @@ namespace AcDream.App.Tests.UI.Layout; /// 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}'."; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index 93920e74..9cc8a664 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -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( + 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); } diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs index 5e495c0c..76510dd9 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -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()), + }; + } + 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 layouts) @@ -464,6 +566,15 @@ public sealed class RetailDialogFactoryTests => string.Join(" ", Assert.IsType(layout.FindElement( RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)); + private static string MessageFromAnyDialog(UiElement root) + => string.Join( + " ", + Assert.IsType(UiElement.FindDescendant( + root, + RetailConfirmationDialogView.MessageElementId)) + .LinesProvider() + .Select(static line => line.Text)); + internal static ImportedLayout BuildDialogLayout(RetailDialogType type) { uint rootId = RetailDialogFactory.RootElementId(type);