diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 2fcef60c..900f68a4 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -165,6 +165,16 @@ window registration, plugin mounts, cursor feedback, layout persistence, and the
retained tick/draw/restore/dispose paths. Panel-specific construction must not
move back into `GameWindow.OnLoad`.
+The graphical no-selector launch projects Runtime's sole
+`RuntimeCharacterSelectionState` through the retained character-management root
+resolved from DAT enum table 5 (`0x10000005` -> `0x21000004`, selected root
+`0x1000039A`). App borrows the view and routes generation-capturing typed
+commands; it owns no roster, highlight, operation, error, or lifecycle mirror.
+The authored screen is a flat ListBox and buttons, with the shared retail dialog
+catalog for confirmation, wait, and error presentation. It contains no viewport
+or character preview. Explicit-selector graphical launches and no-window hosts
+do not mount this presentation.
+
Magic follows the same boundary. Core `Spellbook` is the one learned/favorite/
desired/enchantment state projection; Core.Net owns exact manifest and live
message parsing; Runtime `RuntimeActionState.SpellCast` owns validated cast
diff --git a/docs/research/2026-08-14-la8-character-management-ui.md b/docs/research/2026-08-14-la8-character-management-ui.md
new file mode 100644
index 00000000..a769c5e0
--- /dev/null
+++ b/docs/research/2026-08-14-la8-character-management-ui.md
@@ -0,0 +1,120 @@
+# LA8 retained character-management UI evidence
+
+Date: 2026-08-14
+
+This note records the retail and installed-DAT evidence for Campaign LA slice
+LA8, plus the exact ownership and presentation boundary implemented by the
+slice. LA7b remains the authority for pre-world Runtime and wire behavior.
+
+## Named-retail evidence
+
+The implementation was derived from
+`docs/research/named-retail/acclient_2013_pseudo_c.txt` and the corresponding
+`acclient.h` definition before the screen was written.
+
+- `DBObj::GetDIDByEnum` (`0x004153A0`) forwards to
+ `DBCache::GetDIDFromEnumStatic`; retail resolves the category/table mapping
+ before loading a LayoutDesc.
+- `gmCharacterManagementUI::gmCharacterManagementUI` (`0x004EC8F0`) calls
+ `UIMainFramework::CreateAndAddRootElement(0x10000005, 0x1000039A)`, then
+ binds ListBox `0x1000039D`, Create `0x100003A0`, Enter `0x100003A2`, Delete
+ `0x1000039F`, and Restore `0x1000039E`.
+- The verbatim header at `acclient.h:56545` declares exactly that ListBox,
+ 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`, 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.
+- `SelectCharacter` (`0x004EC160`) and `UpdateButtons` (`0x004EC240`) establish
+ the highlight and button matrix: no or greyed selection disables Enter and
+ Delete; an active selection shows/enables Delete; a greyed selection hides
+ Delete and shows/enables Restore.
+- `ListenToElementMessage` (`0x004ED5A0`) routes the list selection message,
+ button clicks, and row-template `0x100003A5` activation message `0x1A`.
+ Double-activating a row calls `EnterGame` (`0x004ED440`).
+- `MakeDeleteCharacterConfirmationDialog` (`0x004ECCA0`) uses retail dialog
+ type 5 and compares the typed response with the localized DELETE response
+ case-insensitively. `MakePleaseWaitDialog` (`0x004ECED0`) and
+ `MakeEnteringWorldDialog` (`0x004ED090`) use the wait machinery. Error
+ presentation enters through `MakeErrorMessageDialog` (`0x004ECB10`). The
+ destructor (`0x004EC080`) closes every owned dialog context.
+
+The shared dialog factory switch supplies catalog roots/classes used here:
+message type 3 is root `0x24` / class `0x17`; confirmation-text-input type 5
+is root `0x2C` / class `0x15`; the existing wait type 2 is root `0x31` /
+class `0x19`. The message button is `0x26`. Type 5 uses field `0x2C`, accept
+`0x2E`, reject `0x2F`, and result property `0x9C`.
+
+## Installed-DAT proof
+
+The permanent read-only acceptance probe is
+`tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs`. Run it
+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:
+
+- enum category/table 5 maps `0x10000005` to concrete LayoutDesc DID
+ **`0x21000004`**;
+- selected root `0x1000039A` is 800 x 600 with eight authored children;
+- the root itself authors image media `0x06007576`; that proves a retained
+ layout asset, not a separate render-loop background scene;
+- its ListBox template is `{ 0x21000004, 0x100003A5 }`;
+- the template is a 160 x 16 `UiButton`, font `0x40000009`, with Normal,
+ NormalRollover, NormalPressed, Highlight, HighlightRollover, and the authored
+ `0xFFFFFFFF` default state;
+- the authored captions are Create Character, ENTER, DELETE, and RESTORE;
+- neither the selected root nor any descendant is a `UiViewport`;
+- enum-table-5 dialog key 2 maps to catalog DID `0x2100003C`, containing the
+ type-3 and type-5 roots/children above;
+- string table `0x23000002` contains DELETE, Please Wait, Entering World, and
+ the delete-confirmation template. The template has the PLAYER variable and
+ resolves it into the selected character name.
+
+## Ownership, composition, and lifecycle
+
+`RetailUiRuntime` imports the exact enum-resolved root only for a graphical
+launch with no explicit character selector. Its focused binding borrows
+`IRuntimeCharacterSelectionView`; every highlight, enter, delete-request,
+delete-confirm, restore, and cancel action crosses the existing deferred
+adapter as a generation-capturing Runtime command. App retains no gameplay
+mirror. Explicit-selector graphical launches keep their existing flow, and
+headless does not compose this App presentation.
+
+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; 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. Priority
+contexts remain ahead of ordinary retries and preserve retail's nested
+preemption order when creation recovers. The mount coordinator owns a detached
+controller before attaching its root or running the first template-resolving
+tick; any partial failure disposes that exact controller before retry, so roots
+and handlers cannot accumulate.
+
+There is deliberately no 3D preview and no claimed character-select background
+scene. The screen root remains neutral with respect to render-loop background
+composition. LA11's user visual gate owns that unresolved visual choice, plus
+the live local-ACE delete/restore check. Because Enter currently completes its
+established ServerReady transaction synchronously, LA11 must also verify that
+the entering-world wait is perceptible on the real frame path; this slice does
+not introduce a second queue or lifecycle owner merely to force a paint.
diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
index 194eeb91..4b16c5fd 100644
--- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
+++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
@@ -21,6 +21,7 @@ using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
+using AcDream.Runtime.Session;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Vitals;
@@ -937,7 +938,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
(action, held) =>
d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
late.Automation),
- Keyboard: new KeyboardRuntimeBindings(d.InputDispatcher, d.KeyBindingsFilePath));
+ Keyboard: new KeyboardRuntimeBindings(
+ d.InputDispatcher,
+ d.KeyBindingsFilePath),
+ CharacterSelection: d.Options.LiveCharacterSelector is null
+ ? new CharacterSelectionRuntimeBindings(
+ () => late.GameRuntime.CharacterSelection,
+ late.GameRuntime.CharacterSelectionHighlight,
+ late.GameRuntime.CharacterSelectionEnter,
+ late.GameRuntime.CharacterSelectionRequestDelete,
+ late.GameRuntime.CharacterSelectionConfirmDelete,
+ late.GameRuntime.CharacterSelectionRestore,
+ late.GameRuntime.CharacterSelectionCancel)
+ : null);
RetailUiRuntime runtime = lease.Mount(
() => RetailUiRuntime.CreateUninitialized(bindings));
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
index 18ea229f..d7ddb795 100644
--- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
+++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
@@ -10,6 +10,7 @@ using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
+using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
using Silk.NET.Windowing;
@@ -37,6 +38,24 @@ internal sealed class DeferredGameRuntimeStateCommands
}
}
+ ///
+ /// Borrows the current adapter's character-selection projection. The
+ /// reference is deliberately not cached here: releasing or displacing the
+ /// exact late binding makes the next read return ,
+ /// while CurrentGameRuntimeAdapter keeps an already-borrowed reference
+ /// inert if disposal races the render-thread consumer.
+ ///
+ public IRuntimeCharacterSelectionView? CharacterSelection
+ {
+ get
+ {
+ lock (_gate)
+ return !_deactivated && _view is not null
+ ? _view.CharacterSelection
+ : null;
+ }
+ }
+
public IDisposable Bind(
IGameRuntimeView view,
IGameRuntimeCommands commands)
@@ -118,6 +137,35 @@ internal sealed class DeferredGameRuntimeStateCommands
generation,
new RuntimeAdvancementCommand(kind, statId, cost)));
+ // Campaign LA slice LA8: the retained character-management screen uses
+ // the same generation-capturing late seam as every gameplay panel. The
+ // screen never receives GameRuntime or WorldSession and cannot retain a
+ // stale generation across reconnect.
+
+ public RuntimeCommandResult CharacterSelectionHighlight(uint characterId) =>
+ Invoke((commands, generation) =>
+ commands.CharacterSelection.Highlight(generation, characterId));
+
+ public RuntimeCommandResult CharacterSelectionEnter() =>
+ Invoke((commands, generation) =>
+ commands.CharacterSelection.Enter(generation));
+
+ public RuntimeCommandResult CharacterSelectionRequestDelete() =>
+ Invoke((commands, generation) =>
+ commands.CharacterSelection.RequestDelete(generation));
+
+ public RuntimeCommandResult CharacterSelectionConfirmDelete() =>
+ Invoke((commands, generation) =>
+ commands.CharacterSelection.ConfirmDelete(generation));
+
+ public RuntimeCommandResult CharacterSelectionRestore() =>
+ Invoke((commands, generation) =>
+ commands.CharacterSelection.Restore(generation));
+
+ public RuntimeCommandResult CharacterSelectionCancel() =>
+ Invoke((commands, generation) =>
+ commands.CharacterSelection.Cancel(generation));
+
// ── Campaign FA slice FA4: fellowship page commands ─────────────────
// Same "capture view+commands under one generation" shape as every
// method above — a displaced session (reconnect mid-click) can never
diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
new file mode 100644
index 00000000..cd5aa341
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
@@ -0,0 +1,705 @@
+using System.Numerics;
+using AcDream.Runtime;
+using AcDream.Runtime.Session;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Projects Runtime's one borrowed pre-world character-selection owner through
+/// retail gmCharacterManagementUI's authored retained layout. The list is
+/// intentionally flat: the retail class owns no viewport or model preview.
+///
+internal sealed class CharacterManagementUiController : IDisposable
+{
+ internal const uint RootEnum = 0x10000005u;
+ internal const uint RootElementId = 0x1000039Au;
+ internal const uint ListElementId = 0x1000039Du;
+ internal const uint CreateElementId = 0x100003A0u;
+ internal const uint EnterElementId = 0x100003A2u;
+ internal const uint DeleteElementId = 0x1000039Fu;
+ internal const uint RestoreElementId = 0x1000039Eu;
+
+ internal sealed record DialogStrings(
+ Func DeleteConfirmation,
+ string DeleteResponse,
+ string PleaseWait,
+ string EnteringWorld);
+
+ private readonly UiRoot _host;
+ private readonly ImportedLayout _layout;
+ private readonly UiTemplateListBox _list;
+ private readonly UiButton _create;
+ private readonly UiButton _enter;
+ private readonly UiButton _delete;
+ private readonly UiButton _restore;
+ private readonly RetailDialogFactory _dialogs;
+ private readonly CharacterSelectionRuntimeBindings _bindings;
+ private readonly DialogStrings _strings;
+ private readonly List _rows = [];
+ private readonly Dictionary _rowIds = [];
+
+ private RuntimeGenerationToken _lastGeneration;
+ private long _lastRevision = long.MinValue;
+ private uint _deleteDialogContext;
+ private uint _operationWaitContext;
+ private uint _enterWaitContext;
+ private uint _errorDialogContext;
+ private bool _active;
+ private bool _restoreCommandInFlight;
+ private bool _suppressDialogCallbacks;
+ private bool _disposed;
+
+ private CharacterManagementUiController(
+ UiRoot host,
+ ImportedLayout layout,
+ UiTemplateListBox list,
+ UiButton create,
+ UiButton enter,
+ UiButton delete,
+ UiButton restore,
+ RetailDialogFactory dialogs,
+ CharacterSelectionRuntimeBindings bindings,
+ DialogStrings strings)
+ {
+ _host = host;
+ _layout = layout;
+ _list = list;
+ _create = create;
+ _enter = enter;
+ _delete = delete;
+ _restore = restore;
+ _dialogs = dialogs;
+ _bindings = bindings;
+ _strings = strings;
+
+ Root.Left = 0f;
+ Root.Top = 0f;
+ Root.Anchors = AnchorEdges.Left | AnchorEdges.Top
+ | AnchorEdges.Right | AnchorEdges.Bottom;
+ if (host.Width > 0f)
+ Root.Width = host.Width;
+ if (host.Height > 0f)
+ Root.Height = host.Height;
+ Root.ClickThrough = false;
+ Root.Visible = false;
+
+ // Create Character belongs to a future campaign. Keep retail's
+ // authored control in place and visibly ghosted; do not hide it or
+ // invent an action.
+ _create.Visible = true;
+ _create.Enabled = false;
+ _create.OnClick = null;
+ _enter.OnClick = EnterSelected;
+ _delete.OnClick = RequestDelete;
+ _restore.OnClick = RestoreSelected;
+ }
+
+ internal UiElement Root => _layout.Root;
+ internal IReadOnlyList Rows => _rows;
+ internal uint DeleteDialogContext => _deleteDialogContext;
+ internal uint OperationWaitContext => _operationWaitContext;
+ internal uint EnterWaitContext => _enterWaitContext;
+ internal uint ErrorDialogContext => _errorDialogContext;
+
+ internal void ResetSession()
+ {
+ if (_disposed)
+ return;
+ Deactivate();
+ _lastRevision = long.MinValue;
+ }
+
+ internal static CharacterManagementUiController? Bind(
+ UiRoot host,
+ ImportedLayout layout,
+ Func templateResolver,
+ RetailDialogFactory dialogs,
+ CharacterSelectionRuntimeBindings bindings,
+ DialogStrings strings)
+ {
+ CharacterManagementUiController? controller = CreateDetached(
+ host,
+ layout,
+ templateResolver,
+ dialogs,
+ bindings,
+ strings);
+ if (controller is null)
+ return null;
+
+ try
+ {
+ controller.AttachAndTick();
+ return controller;
+ }
+ catch
+ {
+ controller.Dispose();
+ throw;
+ }
+ }
+
+ internal static CharacterManagementUiController? CreateDetached(
+ UiRoot host,
+ ImportedLayout layout,
+ Func templateResolver,
+ RetailDialogFactory dialogs,
+ CharacterSelectionRuntimeBindings bindings,
+ DialogStrings strings)
+ {
+ ArgumentNullException.ThrowIfNull(host);
+ ArgumentNullException.ThrowIfNull(layout);
+ ArgumentNullException.ThrowIfNull(templateResolver);
+ ArgumentNullException.ThrowIfNull(dialogs);
+ ArgumentNullException.ThrowIfNull(bindings);
+ ArgumentNullException.ThrowIfNull(strings);
+
+ if (ContainsViewport(layout.Root))
+ {
+ Console.WriteLine(
+ "[UI] character management: refusing an unapproved model-preview viewport.");
+ return null;
+ }
+
+ if (layout.Root.DatElementId != RootElementId
+ || layout.FindElement(ListElementId) is not UiTemplateListBox list
+ || layout.FindElement(CreateElementId) is not UiButton create
+ || layout.FindElement(EnterElementId) is not UiButton enter
+ || layout.FindElement(DeleteElementId) is not UiButton delete
+ || layout.FindElement(RestoreElementId) is not UiButton restore)
+ {
+ Console.WriteLine(
+ "[UI] character management: the authored root/list/button contract is incomplete.");
+ return null;
+ }
+
+ list.TemplateResolver = templateResolver;
+ try
+ {
+ return new CharacterManagementUiController(
+ host,
+ layout,
+ list,
+ create,
+ enter,
+ delete,
+ restore,
+ dialogs,
+ bindings,
+ strings);
+ }
+ catch
+ {
+ list.TemplateResolver = null;
+ create.OnClick = null;
+ enter.OnClick = null;
+ delete.OnClick = null;
+ restore.OnClick = null;
+ throw;
+ }
+ }
+
+ internal void AttachAndTick()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (Root.Parent is null)
+ _host.AddChild(Root);
+ Tick();
+ }
+
+ private static bool ContainsViewport(UiElement element)
+ {
+ if (element is UiViewport)
+ return true;
+ foreach (UiElement child in element.Children)
+ if (ContainsViewport(child))
+ return true;
+ return false;
+ }
+
+ internal void Tick()
+ {
+ if (_disposed)
+ return;
+
+ IRuntimeCharacterSelectionView? view = _bindings.View();
+ RuntimeCharacterSelectionSnapshot snapshot = view?.Snapshot ?? default;
+ if (view is null || !snapshot.IsActive)
+ {
+ Deactivate();
+ _lastGeneration = snapshot.Generation;
+ _lastRevision = snapshot.Revision;
+ return;
+ }
+
+ if (!_active)
+ {
+ _active = true;
+ Root.Visible = true;
+ _host.BringToFront(Root);
+ }
+
+ if (_lastGeneration != snapshot.Generation
+ || _lastRevision != snapshot.Revision)
+ {
+ if (TryCaptureRoster(view, snapshot, out RuntimeCharacterSelectionEntry[] roster))
+ {
+ bool rowsReady;
+ if (RowsMatchRoster(roster, snapshot.SlotCount))
+ {
+ ApplyHighlight(snapshot.HighlightedCharacterId);
+ rowsReady = true;
+ }
+ else
+ {
+ rowsReady = RebuildRows(
+ roster,
+ snapshot.SlotCount,
+ snapshot.HighlightedCharacterId);
+ }
+
+ if (rowsReady)
+ {
+ _lastGeneration = snapshot.Generation;
+ _lastRevision = snapshot.Revision;
+ }
+ }
+ else
+ {
+ // A receive-thread roster/reset raced the borrowed snapshot.
+ // Leave the revision unconsumed so the next frame retries from
+ // one coherent view; never present a partially mixed roster.
+ _lastRevision = long.MinValue;
+ snapshot = view.Snapshot;
+ if (!snapshot.IsActive)
+ {
+ Deactivate();
+ _lastGeneration = snapshot.Generation;
+ _lastRevision = snapshot.Revision;
+ return;
+ }
+ }
+ }
+ else
+ {
+ ApplyHighlight(snapshot.HighlightedCharacterId);
+ }
+
+ ApplyButtons(snapshot.Buttons);
+ ReconcileDialogs(view, snapshot);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ try
+ {
+ CloseAllDialogs(suppressCallbacks: true);
+ }
+ finally
+ {
+ _enter.OnClick = null;
+ _delete.OnClick = null;
+ _restore.OnClick = null;
+ foreach (UiButton row in _rows)
+ {
+ row.OnClick = null;
+ row.OnDoubleClick = null;
+ }
+ _rows.Clear();
+ _rowIds.Clear();
+ _list.Flush();
+ _list.TemplateResolver = null;
+ _host.RemoveChild(Root);
+ }
+ }
+
+ private static bool TryCaptureRoster(
+ IRuntimeCharacterSelectionView view,
+ RuntimeCharacterSelectionSnapshot expected,
+ out RuntimeCharacterSelectionEntry[] roster)
+ {
+ roster = new RuntimeCharacterSelectionEntry[expected.RosterCount];
+ for (int i = 0; i < roster.Length; i++)
+ {
+ if (!view.TryGetAt(i, out roster[i]))
+ return false;
+ }
+
+ RuntimeCharacterSelectionSnapshot after = view.Snapshot;
+ return after.Generation == expected.Generation
+ && after.Revision == expected.Revision
+ && after.RosterCount == expected.RosterCount;
+ }
+
+ private bool RowsMatchRoster(
+ 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];
+ RuntimeCharacterSelectionEntry character = roster[i];
+ 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))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool RebuildRows(
+ IReadOnlyList roster,
+ int allowedSlotCount,
+ uint highlightedCharacterId)
+ {
+ foreach (UiButton row in _rows)
+ {
+ row.OnClick = null;
+ row.OnDoubleClick = null;
+ }
+ _rows.Clear();
+ _rowIds.Clear();
+ _list.Flush();
+
+ 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 (!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
+ ? new Vector4(1f, 0f, 0f, 1f)
+ : Vector4.One;
+ row.Enabled = true;
+ row.SuppressSelfToggle = true;
+ row.Selected = characterId == highlightedCharacterId;
+ row.OnClick = () => Highlight(characterId);
+ row.OnDoubleClick = EnterSelected;
+ _rows.Add(row);
+ _rowIds.Add(row, characterId);
+ }
+
+ if (complete)
+ return true;
+
+ foreach (UiButton row in _rows)
+ {
+ row.OnClick = null;
+ row.OnDoubleClick = null;
+ }
+ _rows.Clear();
+ _rowIds.Clear();
+ _list.Flush();
+ _lastRevision = long.MinValue;
+ 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)
+ row.Selected = _rowIds.TryGetValue(row, out uint characterId)
+ && characterId == highlightedCharacterId;
+ }
+
+ private void ApplyButtons(RuntimeCharacterSelectionButtons buttons)
+ {
+ _create.Visible = true;
+ _create.Enabled = false;
+ _enter.Enabled = buttons.CanEnter;
+ _delete.Visible = buttons.DeleteVisible;
+ _delete.Enabled = buttons.CanDelete;
+ _restore.Visible = buttons.RestoreVisible;
+ _restore.Enabled = buttons.CanRestore;
+ }
+
+ private void Highlight(uint characterId)
+ {
+ if (_disposed)
+ return;
+ _bindings.Highlight(characterId);
+ InvalidateAndTick();
+ }
+
+ private void EnterSelected()
+ {
+ if (_disposed)
+ return;
+
+ // Open retail's wait context before the synchronous Runtime command
+ // starts its existing ServerReady transaction. The state projection
+ // remains authoritative and closes it on InWorld/error/reset.
+ EnsureEnterWait();
+ RuntimeCommandResult result = _bindings.Enter();
+ if (!result.Accepted)
+ CloseContext(ref _enterWaitContext, suppressCallback: true);
+ InvalidateAndTick();
+ }
+
+ private void RequestDelete()
+ {
+ if (_disposed)
+ return;
+ _bindings.RequestDelete();
+ InvalidateAndTick();
+ }
+
+ private void RestoreSelected()
+ {
+ if (_disposed)
+ return;
+
+ // 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();
+ }
+
+ private void ReconcileDialogs(
+ IRuntimeCharacterSelectionView view,
+ RuntimeCharacterSelectionSnapshot snapshot)
+ {
+ if (snapshot.Error is { } error)
+ {
+ CloseContext(ref _deleteDialogContext, suppressCallback: true);
+ CloseContext(ref _operationWaitContext, suppressCallback: true);
+ CloseContext(ref _enterWaitContext, suppressCallback: true);
+ EnsureError(error.Message);
+ return;
+ }
+
+ CloseContext(ref _errorDialogContext, suppressCallback: true);
+ if (snapshot.Lifecycle == RuntimeCharacterSelectionLifecycle.EnteringWorld)
+ {
+ CloseContext(ref _deleteDialogContext, suppressCallback: true);
+ CloseContext(ref _operationWaitContext, suppressCallback: true);
+ EnsureEnterWait();
+ return;
+ }
+
+ CloseContext(ref _enterWaitContext, suppressCallback: true);
+ if (snapshot.PendingDeleteCharacterId != 0u
+ && view.TryGet(snapshot.PendingDeleteCharacterId, out RuntimeCharacterSelectionEntry pending))
+ {
+ EnsureDeleteConfirmation(pending.Name);
+ }
+ else
+ {
+ CloseContext(ref _deleteDialogContext, suppressCallback: true);
+ }
+
+ if (_restoreCommandInFlight
+ || snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested
+ or RuntimeCharacterSelectionOperation.DeleteAcknowledged
+ or RuntimeCharacterSelectionOperation.RestoreRequested)
+ {
+ EnsureOperationWait();
+ }
+ else
+ {
+ CloseContext(ref _operationWaitContext, suppressCallback: true);
+ }
+ }
+
+ private void EnsureDeleteConfirmation(string characterName)
+ {
+ if (_deleteDialogContext != 0u)
+ return;
+
+ _deleteDialogContext = _dialogs.MakeConfirmationTextInput(
+ _strings.DeleteConfirmation(characterName),
+ data =>
+ {
+ _deleteDialogContext = 0u;
+ if (_disposed || _suppressDialogCallbacks)
+ return;
+
+ string response = data.GetString(
+ RetailDialogProperty.TextInputResult) ?? string.Empty;
+ if (string.Equals(
+ response,
+ _strings.DeleteResponse,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ _bindings.ConfirmDelete();
+ }
+ else
+ {
+ _bindings.Cancel();
+ }
+ InvalidateAndTick();
+ });
+ }
+
+ private void EnsureOperationWait()
+ {
+ if (_operationWaitContext == 0u)
+ _operationWaitContext = _dialogs.MakeWait(_strings.PleaseWait);
+ }
+
+ private void EnsureEnterWait()
+ {
+ if (_enterWaitContext == 0u)
+ _enterWaitContext = _dialogs.MakeWait(_strings.EnteringWorld);
+ }
+
+ private void EnsureError(string message)
+ {
+ if (_errorDialogContext != 0u)
+ return;
+ _errorDialogContext = _dialogs.MakeMessage(
+ message,
+ _ =>
+ {
+ _errorDialogContext = 0u;
+ if (_disposed || _suppressDialogCallbacks)
+ return;
+ _bindings.Cancel();
+ InvalidateAndTick();
+ });
+ }
+
+ private void InvalidateAndTick()
+ {
+ _lastRevision = long.MinValue;
+ Tick();
+ }
+
+ private void Deactivate()
+ {
+ if (_active)
+ {
+ _active = false;
+ Root.Visible = false;
+ }
+ foreach (UiButton row in _rows)
+ {
+ row.OnClick = null;
+ row.OnDoubleClick = null;
+ }
+ _rows.Clear();
+ _rowIds.Clear();
+ _list.Flush();
+ CloseAllDialogs(suppressCallbacks: true);
+ }
+
+ private void CloseAllDialogs(bool suppressCallbacks)
+ {
+ bool previous = _suppressDialogCallbacks;
+ _suppressDialogCallbacks |= suppressCallbacks;
+ try
+ {
+ CloseContext(ref _deleteDialogContext, suppressCallback: false);
+ CloseContext(ref _operationWaitContext, suppressCallback: false);
+ CloseContext(ref _enterWaitContext, suppressCallback: false);
+ CloseContext(ref _errorDialogContext, suppressCallback: false);
+ }
+ finally
+ {
+ _suppressDialogCallbacks = previous;
+ }
+ }
+
+ private void CloseContext(ref uint context, bool suppressCallback)
+ {
+ uint closing = context;
+ if (closing == 0u)
+ return;
+ context = 0u;
+
+ bool previous = _suppressDialogCallbacks;
+ _suppressDialogCallbacks |= suppressCallback;
+ try
+ {
+ _dialogs.CloseDialog(closing);
+ }
+ finally
+ {
+ _suppressDialogCallbacks = previous;
+ }
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs
new file mode 100644
index 00000000..e2748193
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs
@@ -0,0 +1,106 @@
+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;
+
+ CharacterManagementUiController? candidate =
+ CharacterManagementUiController.CreateDetached(
+ _host,
+ resources.Layout,
+ resources.TemplateResolver,
+ dialogs,
+ _bindings,
+ resources.Strings);
+ if (candidate is null)
+ return;
+
+ // Take ownership before the first attach/tick. Template resolution
+ // happens inside that tick and can throw after the root and button
+ // handlers are live; the catch below can therefore always retire
+ // the exact partial controller before a later retry.
+ Controller = candidate;
+ candidate.AttachAndTick();
+ Console.WriteLine(
+ $"[UI] retail character management from enum table 5 "
+ + $"(0x10000005 -> 0x{resources.LayoutId:X8}, "
+ + "root 0x1000039A; flat list, no viewport).");
+ }
+ catch (Exception error)
+ {
+ CharacterManagementUiController? partial = Controller;
+ Controller = null;
+ try
+ {
+ partial?.Dispose();
+ }
+ catch (Exception cleanupError)
+ {
+ Console.WriteLine(
+ "[UI] character management partial-mount cleanup failed: "
+ + cleanupError.Message);
+ }
+ 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/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 01804e44..5991ba57 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -122,6 +122,8 @@ public static class DatWidgetFactory
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
0x13 => new UiDialogRoot(), // ConfirmationDialog
+ 0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog
+ 0x17 => new UiDialogRoot(), // MessageDialog
0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396)
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
0x10000035u => BuildCheckbox(
diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs
new file mode 100644
index 00000000..e21d330b
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs
@@ -0,0 +1,160 @@
+namespace AcDream.App.UI.Layout;
+
+///
+/// Retail type-5 ConfirmationTextInputDialog (class type
+/// 0x15, catalog root 0x2C). Accept stores the field text under
+/// property 0x9C; reject/Escape stores the empty string. Character
+/// deletion is the first consumer and performs retail's case-insensitive
+/// comparison with the localized DELETE response in its callback.
+///
+internal sealed class RetailConfirmationTextInputDialogView : IRetailDialogView
+{
+ public const uint RootElementId = 0x2Cu;
+ public const uint InputElementId = 0x2Cu;
+ public const uint AcceptButtonId = 0x2Eu;
+ public const uint RejectButtonId = 0x2Fu;
+ public const uint PopupElementId = 0x3Du;
+ public const uint MessageElementId = 0x3Eu;
+
+ private readonly UiRoot _host;
+ private readonly RetailDialogData _data;
+ private readonly uint _context;
+ private readonly Action _closeDialog;
+ private readonly UiElement _popup;
+ private readonly UiText _message;
+ private readonly UiField _input;
+ private readonly UiButton _accept;
+ private readonly UiButton _reject;
+ private readonly float _basePopupHeight;
+ private readonly float _baseMessageHeight;
+ private bool _focusPending = true;
+
+ public RetailConfirmationTextInputDialogView(
+ UiRoot host,
+ ImportedLayout layout,
+ RetailDialogData data,
+ uint context,
+ Action closeDialog)
+ {
+ _host = host ?? throw new ArgumentNullException(nameof(host));
+ ArgumentNullException.ThrowIfNull(layout);
+ _data = data ?? throw new ArgumentNullException(nameof(data));
+ _context = context;
+ _closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog));
+
+ Root = layout.Root as UiDialogRoot
+ ?? throw new ArgumentException(
+ "Confirmation-text-input layout root is not a UiDialogRoot.",
+ nameof(layout));
+ _popup = layout.FindElement(PopupElementId)
+ ?? throw new ArgumentException(
+ "Confirmation-text-input layout is missing popup element 0x3D.",
+ nameof(layout));
+ _message = layout.FindElement(MessageElementId) as UiText
+ ?? throw new ArgumentException(
+ "Confirmation-text-input layout is missing text element 0x3E.",
+ nameof(layout));
+ // The field deliberately repeats the root's numeric id. ImportedLayout
+ // registers descendants after ancestors, matching GetChildRecursive's
+ // effective result for this catalog shape.
+ _input = layout.FindElement(InputElementId) as UiField
+ ?? throw new ArgumentException(
+ "Confirmation-text-input layout is missing input field 0x2C.",
+ nameof(layout));
+ _accept = layout.FindElement(AcceptButtonId) as UiButton
+ ?? throw new ArgumentException(
+ "Confirmation-text-input layout is missing accept button 0x2E.",
+ nameof(layout));
+ _reject = layout.FindElement(RejectButtonId) as UiButton
+ ?? throw new ArgumentException(
+ "Confirmation-text-input layout is missing reject button 0x2F.",
+ nameof(layout));
+
+ _basePopupHeight = _popup.Height;
+ _baseMessageHeight = _message.Height;
+ _popup.LayoutPolicy = null;
+ _popup.Anchors = AnchorEdges.None;
+ _message.LayoutPolicy = null;
+ _message.Anchors = AnchorEdges.None;
+ _message.Padding = 0f;
+ _message.Selectable = false;
+ _input.ClearOnSubmit = false;
+ _input.RecordHistory = false;
+
+ if (_data.GetString(RetailDialogProperty.TextInputAcceptLabel) is { } acceptLabel)
+ _accept.Label = acceptLabel;
+ if (_data.GetString(RetailDialogProperty.TextInputRejectLabel) is { } rejectLabel)
+ _reject.Label = rejectLabel;
+
+ Root.Cancel = Reject;
+ _accept.OnClick = Accept;
+ _reject.OnClick = Reject;
+ _input.OnSubmit = _ => Accept();
+ SetMessage(_data.GetString(RetailDialogProperty.Message) ?? string.Empty);
+ SizeAndCenter();
+ }
+
+ public UiDialogRoot Root { get; }
+
+ public void Tick()
+ {
+ SizeAndCenter();
+ if (_focusPending && Root.Parent is not null)
+ {
+ _host.SetKeyboardFocus(_input);
+ _focusPending = false;
+ }
+ }
+
+ public void SetPendingCount(int count)
+ {
+ // This catalog root authors no pending-count display.
+ }
+
+ public void DetachHandlers()
+ {
+ Root.Cancel = null;
+ _accept.OnClick = null;
+ _reject.OnClick = null;
+ _input.OnSubmit = null;
+ }
+
+ private void Accept()
+ {
+ _data.Set(RetailDialogProperty.TextInputResult, _input.Text);
+ _closeDialog(_context);
+ }
+
+ private void Reject()
+ {
+ _data.Set(RetailDialogProperty.TextInputResult, string.Empty);
+ _closeDialog(_context);
+ }
+
+ private void SetMessage(string text)
+ {
+ float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
+ Func measure = _message.DatFont is { } font
+ ? font.MeasureWidth
+ : static value => value.Length * 8f;
+ IReadOnlyList wrapped = UiText.WrapWords(text, measure, maximumWidth);
+ var lines = new UiText.Line[wrapped.Count];
+ for (int i = 0; i < wrapped.Count; i++)
+ lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor);
+ _message.LinesProvider = () => lines;
+
+ float lineHeight = _message.DatFont?.LineHeight ?? 16f;
+ _message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight);
+ _popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight);
+ }
+
+ private void SizeAndCenter()
+ {
+ Root.Left = 0f;
+ Root.Top = 0f;
+ Root.Width = _host.Width;
+ Root.Height = _host.Height;
+ _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
+ _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/RetailDialogData.cs b/src/AcDream.App/UI/Layout/RetailDialogData.cs
index 72512826..d8b61558 100644
--- a/src/AcDream.App/UI/Layout/RetailDialogData.cs
+++ b/src/AcDream.App/UI/Layout/RetailDialogData.cs
@@ -11,6 +11,9 @@ public static class RetailDialogProperty
public const uint AcceptLabel = 0x90u;
public const uint RejectLabel = 0x91u;
public const uint ConfirmationResult = 0x92u;
+ public const uint TextInputAcceptLabel = 0x9Au;
+ public const uint TextInputRejectLabel = 0x9Bu;
+ public const uint TextInputResult = 0x9Cu;
///
/// When true, Dialog::SetData @ 0x00476BE0 sets UIElement boolean
/// attribute 0x40. The Keystone-owned attribute name is unavailable.
@@ -123,4 +126,21 @@ public sealed class RetailDialogData
.Set(RetailDialogProperty.ElementAttribute40, true)
.Set(RetailDialogProperty.Message, message);
}
+
+ public static RetailDialogData Message(string message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+ return new RetailDialogData()
+ .Set(RetailDialogProperty.Type, RetailDialogType.Message)
+ .Set(RetailDialogProperty.Message, message);
+ }
+
+ public static RetailDialogData ConfirmationTextInput(string message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+ return new RetailDialogData()
+ .Set(RetailDialogProperty.Type, RetailDialogType.ConfirmationTextInput)
+ .Set(RetailDialogProperty.ElementAttribute40, true)
+ .Set(RetailDialogProperty.Message, message);
+ }
}
diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs
index 554c525c..f72d7283 100644
--- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs
+++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs
@@ -15,6 +15,7 @@ public sealed class RetailDialogFactory : IDisposable
public required RetailDialogData Data { get; init; }
public required uint Context { get; init; }
public required uint QueueKey { get; init; }
+ public required ulong Sequence { get; init; }
public Action? Callback { get; init; }
public IRetailDialogView? View { get; set; }
}
@@ -24,8 +25,10 @@ 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 ulong _globalSequence;
private bool _resetting;
private bool _disposed;
@@ -51,6 +54,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
@@ -87,25 +92,40 @@ public sealed class RetailDialogFactory : IDisposable
Data = ownedData,
Context = context,
QueueKey = queueKey,
+ Sequence = NextSequence(),
Callback = callback,
};
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) && !IsPriority(info))
+ {
+ PendingQueue(queueKey).AddLast(info);
+ return context;
+ }
+
_activeQueued.Add(queueKey, info);
- CreateDialog(info);
+ if (!TryCreateDialog(info))
+ {
+ _activeQueued.Remove(queueKey);
+ QueueRetry(info);
+ }
return context;
}
LinkedList queue = PendingQueue(queueKey);
- if (!ownedData.GetBoolean(RetailDialogProperty.Priority))
+ if (!IsPriority(info))
{
queue.AddLast(info);
UpdatePendingDialogDisplays();
@@ -118,7 +138,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;
}
@@ -148,6 +176,26 @@ public sealed class RetailDialogFactory : IDisposable
return MakeDialog(data, callback: null);
}
+ public uint MakeMessage(
+ string message,
+ Action? callback = null,
+ uint queueKey = DefaultQueueKey)
+ {
+ RetailDialogData data = RetailDialogData.Message(message)
+ .Set(RetailDialogProperty.QueueKey, queueKey);
+ return MakeDialog(data, callback);
+ }
+
+ public uint MakeConfirmationTextInput(
+ string message,
+ Action? callback = null,
+ uint queueKey = DefaultQueueKey)
+ {
+ RetailDialogData data = RetailDialogData.ConfirmationTextInput(message)
+ .Set(RetailDialogProperty.QueueKey, queueKey);
+ return MakeDialog(data, callback);
+ }
+
///
/// Retail CloseDialog @ 0x00478160. The context can identify an active
/// nonqueued dialog, an active queued dialog, or an item still pending in a queue.
@@ -191,11 +239,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();
}
@@ -218,6 +280,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)
@@ -229,6 +292,7 @@ public sealed class RetailDialogFactory : IDisposable
_activeNonQueued.Clear();
_activeQueued.Clear();
_pending.Clear();
+ _retryable.Clear();
foreach (DialogInfo info in infos)
{
try { DialogDone(info); }
@@ -263,6 +327,14 @@ public sealed class RetailDialogFactory : IDisposable
return _globalContext;
}
+ private ulong NextSequence()
+ {
+ _globalSequence++;
+ if (_globalSequence == 0uL)
+ _globalSequence++;
+ return _globalSequence;
+ }
+
private LinkedList PendingQueue(uint queueKey)
{
if (_pending.TryGetValue(queueKey, out LinkedList? queue))
@@ -272,30 +344,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))
- 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),
- _ => 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;
- UpdatePendingDialogDisplays();
- DialogOpened?.Invoke(info.Context);
+ 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)),
+ 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)
@@ -349,6 +448,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;
@@ -358,7 +460,115 @@ 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 (!ReferenceEquals(FirstRetry(info.QueueKey), info))
+ continue;
+
+ if (!_activeQueued.TryGetValue(
+ info.QueueKey,
+ out DialogInfo? active))
+ TryActivateRetry(info.QueueKey);
+ else if (IsPriority(info)
+ && (!IsPriority(active) || info.Sequence > active.Sequence))
+ TryPreemptWithRetry(info, active);
+ }
+ }
+
+ private void TryPreemptWithRetry(DialogInfo priority, DialogInfo current)
+ {
+ LinkedList queue = PendingQueue(priority.QueueKey);
+ Suspend(current);
+ queue.AddFirst(current);
+ _activeQueued[priority.QueueKey] = priority;
+ _retryable.Remove(priority);
+ if (TryCreateDialog(priority))
+ return;
+
+ _activeQueued.Remove(priority.QueueKey);
+ queue.Remove(current);
+ if (queue.Count == 0)
+ _pending.Remove(priority.QueueKey);
+ OpenSpecificDialog(current);
+ QueueRetry(priority);
+ }
+
+ 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 static bool IsPriority(DialogInfo info) =>
+ info.Data.GetBoolean(RetailDialogProperty.Priority);
+
+ private void QueueRetry(DialogInfo info)
+ {
+ if (_retryable.Contains(info))
+ return;
+ if (!IsPriority(info))
+ {
+ _retryable.AddLast(info);
+ return;
+ }
+
+ LinkedListNode? existing = _retryable.First;
+ while (existing is not null
+ && existing.Value.QueueKey != info.QueueKey)
+ {
+ existing = existing.Next;
+ }
+ if (existing is null)
+ _retryable.AddLast(info);
+ else
+ _retryable.AddBefore(existing, info);
}
private void UpdatePendingDialogDisplays()
diff --git a/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs b/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs
new file mode 100644
index 00000000..a9246ac5
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs
@@ -0,0 +1,105 @@
+namespace AcDream.App.UI.Layout;
+
+///
+/// Retail type-3 MessageDialog (class type 0x17, catalog root
+/// 0x24). It shares the dialog catalog's popup/message pair with the
+/// existing confirmation and wait presenters and closes from its authored OK
+/// button 0x26 or Escape.
+///
+internal sealed class RetailMessageDialogView : IRetailDialogView
+{
+ public const uint RootElementId = 0x24u;
+ public const uint OkButtonId = 0x26u;
+ public const uint PopupElementId = 0x3Du;
+ public const uint MessageElementId = 0x3Eu;
+
+ private readonly UiRoot _host;
+ private readonly uint _context;
+ private readonly Action _closeDialog;
+ private readonly UiElement _popup;
+ private readonly UiText _message;
+ private readonly UiButton _ok;
+ private readonly float _basePopupHeight;
+ private readonly float _baseMessageHeight;
+
+ public RetailMessageDialogView(
+ UiRoot host,
+ ImportedLayout layout,
+ RetailDialogData data,
+ uint context,
+ Action closeDialog)
+ {
+ _host = host ?? throw new ArgumentNullException(nameof(host));
+ ArgumentNullException.ThrowIfNull(layout);
+ ArgumentNullException.ThrowIfNull(data);
+ _context = context;
+ _closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog));
+
+ Root = layout.Root as UiDialogRoot
+ ?? throw new ArgumentException("Message layout root is not a UiDialogRoot.", nameof(layout));
+ _popup = layout.FindElement(PopupElementId)
+ ?? throw new ArgumentException("Message layout is missing popup element 0x3D.", nameof(layout));
+ _message = layout.FindElement(MessageElementId) as UiText
+ ?? throw new ArgumentException("Message layout is missing text element 0x3E.", nameof(layout));
+ _ok = layout.FindElement(OkButtonId) as UiButton
+ ?? throw new ArgumentException("Message layout is missing OK button 0x26.", nameof(layout));
+
+ _basePopupHeight = _popup.Height;
+ _baseMessageHeight = _message.Height;
+ _popup.LayoutPolicy = null;
+ _popup.Anchors = AnchorEdges.None;
+ _message.LayoutPolicy = null;
+ _message.Anchors = AnchorEdges.None;
+ _message.Padding = 0f;
+ _message.Selectable = false;
+
+ Root.Cancel = Close;
+ _ok.OnClick = Close;
+ SetMessage(data.GetString(RetailDialogProperty.Message) ?? string.Empty);
+ SizeAndCenter();
+ }
+
+ public UiDialogRoot Root { get; }
+
+ public void Tick() => SizeAndCenter();
+
+ public void SetPendingCount(int count)
+ {
+ // MessageDialog has no pending-count subtree in the retail catalog.
+ }
+
+ public void DetachHandlers()
+ {
+ Root.Cancel = null;
+ _ok.OnClick = null;
+ }
+
+ private void Close() => _closeDialog(_context);
+
+ private void SetMessage(string text)
+ {
+ float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
+ Func measure = _message.DatFont is { } font
+ ? font.MeasureWidth
+ : static value => value.Length * 8f;
+ IReadOnlyList wrapped = UiText.WrapWords(text, measure, maximumWidth);
+ var lines = new UiText.Line[wrapped.Count];
+ for (int i = 0; i < wrapped.Count; i++)
+ lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor);
+ _message.LinesProvider = () => lines;
+
+ float lineHeight = _message.DatFont?.LineHeight ?? 16f;
+ _message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight);
+ _popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight);
+ }
+
+ private void SizeAndCenter()
+ {
+ Root.Left = 0f;
+ Root.Top = 0f;
+ Root.Width = _host.Width;
+ Root.Height = _host.Height;
+ _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
+ _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
+ }
+}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index b4164367..d8253c91 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -16,6 +16,7 @@ using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
+using AcDream.Runtime.Session;
using AcDream.Content;
using AcDream.Core.Input;
using AcDream.UI.Abstractions;
@@ -369,6 +370,21 @@ public sealed record KeyboardRuntimeBindings(
InputDispatcher? Dispatcher,
string KeyBindingsFilePath);
+///
+/// Borrowed LA7b character-selection projection and its generation-capturing
+/// typed command routes. App owns no roster, selection, operation, or error
+/// mirror; an absent view means the current adapter has not bound (or has
+/// already been released).
+///
+public sealed record CharacterSelectionRuntimeBindings(
+ Func View,
+ Func Highlight,
+ Func Enter,
+ Func RequestDelete,
+ Func ConfirmDelete,
+ Func Restore,
+ Func Cancel);
+
public sealed record RetailUiRuntimeBindings(
UiHost Host,
RetailUiAssets Assets,
@@ -395,7 +411,8 @@ public sealed record RetailUiRuntimeBindings(
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
RetailUiProbeBindings Probe,
- KeyboardRuntimeBindings? Keyboard = null);
+ KeyboardRuntimeBindings? Keyboard = null,
+ CharacterSelectionRuntimeBindings? CharacterSelection = null);
///
/// Composition owner for the production retained gameplay UI. GameWindow supplies
@@ -417,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;
@@ -483,6 +501,8 @@ public sealed class RetailUiRuntime : IDisposable
MountVendor();
MountSecureTrade();
MountItemCooldowns();
+ ConfigureCharacterManagement();
+ _characterManagementMount?.Tick();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
SyncToolbarWindowButtons();
@@ -577,6 +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 =>
+ _characterManagementMount?.Controller;
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
@@ -622,6 +644,8 @@ public sealed class RetailUiRuntime : IDisposable
ExternalContainerController?.Tick();
SocialPanelController?.Tick();
_itemCooldownController?.Tick();
+ _characterManagementMount?.Tick();
+ CharacterManagementController?.Tick();
DialogFactory?.Tick();
Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds);
@@ -788,6 +812,7 @@ public sealed class RetailUiRuntime : IDisposable
{
try
{
+ CharacterManagementController?.ResetSession();
DialogFactory?.Reset();
}
finally
@@ -3030,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)
@@ -3669,6 +3710,145 @@ public sealed class RetailUiRuntime : IDisposable
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
}
+ private void ConfigureCharacterManagement()
+ {
+ CharacterSelectionRuntimeBindings? bindings =
+ _bindings.CharacterSelection;
+ if (bindings is null || _characterManagementMount is not null)
+ 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;
+ var strings = new DatStringResolver(_bindings.Assets.Dats);
+ lock (_bindings.Assets.DatLock)
+ {
+ // gmCharacterManagementUI's framework call passes enum
+ // 0x10000005 and category/table 5, then selects root 0x1000039A.
+ layoutId = RetailDataIdResolver.Resolve(
+ _bindings.Assets.Dats,
+ CharacterManagementUiController.RootEnum,
+ 5u);
+ layout = layoutId == 0u
+ ? null
+ : LayoutImporter.Import(
+ _bindings.Assets.Dats,
+ layoutId,
+ CharacterManagementUiController.RootElementId,
+ _bindings.Assets.ResolveSprite,
+ _bindings.Assets.DefaultFont,
+ _bindings.Assets.ResolveFont);
+ }
+
+ if (layout is null)
+ {
+ Console.WriteLine(
+ "[UI] character management: enum-table-5 root could not be imported.");
+ return null;
+ }
+
+ string? deleteResponse;
+ string? deleteConfirmationProbe;
+ string? pleaseWait;
+ string? enteringWorld;
+ lock (_bindings.Assets.DatLock)
+ {
+ deleteConfirmationProbe = strings.ResolveTemplate(
+ stringTableId,
+ "ID_CharacterManagement_DeleteCharacterConfirmation",
+ new Dictionary
+ {
+ [DatStringResolver.PlayerVariable] = string.Empty,
+ });
+ deleteResponse = ResolveCharacterManagementString(
+ strings,
+ stringTableId,
+ "ID_CharacterManagement_DeleteCharacterResponse");
+ pleaseWait = ResolveCharacterManagementString(
+ strings,
+ stringTableId,
+ "ID_CharacterManagement_PleaseWait");
+ enteringWorld = ResolveCharacterManagementString(
+ strings,
+ stringTableId,
+ "ID_Character_EnteringWorld");
+ }
+
+ if (deleteConfirmationProbe is null
+ || deleteResponse is null
+ || pleaseWait is null
+ || enteringWorld is null)
+ {
+ Console.WriteLine(
+ "[UI] character management: required retail strings are unavailable.");
+ return null;
+ }
+
+ UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
+ {
+ lock (_bindings.Assets.DatLock)
+ {
+ return LayoutImporter.Import(
+ _bindings.Assets.Dats,
+ templateLayoutId,
+ templateElementId,
+ _bindings.Assets.ResolveSprite,
+ _bindings.Assets.DefaultFont,
+ _bindings.Assets.ResolveFont)?.Root;
+ }
+ }
+
+ string ComposeDeleteConfirmation(string characterName)
+ {
+ lock (_bindings.Assets.DatLock)
+ {
+ return NormalizeRetailNewlines(strings.ResolveTemplate(
+ stringTableId,
+ "ID_CharacterManagement_DeleteCharacterConfirmation",
+ new Dictionary
+ {
+ [DatStringResolver.PlayerVariable] = characterName,
+ })!);
+ }
+ }
+
+ return new CharacterManagementUiMountResources(
+ layoutId,
+ layout,
+ ResolveTemplate,
+ new CharacterManagementUiController.DialogStrings(
+ ComposeDeleteConfirmation,
+ deleteResponse,
+ pleaseWait,
+ enteringWorld));
+ }
+
+ private static string? ResolveCharacterManagementString(
+ DatStringResolver strings,
+ uint tableId,
+ string key) =>
+ strings.Resolve(tableId, DatStringResolver.ComputeHash(key)) is { } value
+ ? NormalizeRetailNewlines(value)
+ : null;
+
+ private static string NormalizeRetailNewlines(string value) =>
+ value.Replace("\\n", "\n", StringComparison.Ordinal);
+
private void MountItemCooldowns()
{
ItemCooldownAssets? assets;
@@ -3712,7 +3892,11 @@ public sealed class RetailUiRuntime : IDisposable
}
},
() => _itemConfirmationController?.Dispose(),
- () => _gameplayConfirmationController?.Dispose(),
+ () =>
+ {
+ _characterManagementMount?.Dispose();
+ _gameplayConfirmationController?.Dispose();
+ },
() => DialogFactory?.Dispose(),
_panelUi.Dispose,
Host.Dispose);
diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs
index 046f2973..b748894b 100644
--- a/src/AcDream.App/UI/UiButton.cs
+++ b/src/AcDream.App/UI/UiButton.cs
@@ -49,6 +49,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).
public Action? OnClick { get; set; }
+ ///
+ /// Optional left-button double-click handler. Null preserves the existing
+ /// bubbling behavior; character-management row template 0x100003A5 opts in
+ /// for retail's element message 0x1A (activate the selected character).
+ ///
+ public Action? OnDoubleClick { get; set; }
+
///
/// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard
/// screen: right-click a bound key button to erase that one binding —
@@ -551,6 +558,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
OnClick?.Invoke();
OnClickAt?.Invoke(e.Data1, e.Data2);
return OnClick is not null || OnClickAt is not null;
+ case UiEventType.DoubleClick:
+ if (OnDoubleClick is null) return false;
+ if (!Enabled) return true;
+ OnDoubleClick.Invoke();
+ return true;
case UiEventType.RightClick:
// S6 (2026-08-11 review): unlike Click (whose swallow-when-
// disabled is pre-existing, harmless-by-construction behavior
diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
index 6600398c..c9318931 100644
--- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
+++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
@@ -11,6 +11,7 @@ using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.World;
using AcDream.Runtime;
+using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.Composition;
@@ -106,6 +107,44 @@ public sealed class InteractionUiRuntimeSourcesTests
Assert.Throws(() => source.Bind(first, first));
}
+ [Fact]
+ public void CharacterSelectionProjectionBorrowsExactAdapterAndEveryRouteBecomesInert()
+ {
+ var source = new DeferredGameRuntimeStateCommands();
+ var target = new RuntimeTarget(new RuntimeGenerationToken(11));
+
+ Assert.Null(source.CharacterSelection);
+ Assert.Equal(RuntimeCommandStatus.Inactive,
+ source.CharacterSelectionEnter().Status);
+
+ IDisposable binding = source.Bind(target, target);
+ Assert.Same(target, source.CharacterSelection);
+ RuntimeCommandResult[] accepted =
+ [
+ source.CharacterSelectionHighlight(0x50000001u),
+ source.CharacterSelectionEnter(),
+ source.CharacterSelectionRequestDelete(),
+ source.CharacterSelectionConfirmDelete(),
+ source.CharacterSelectionRestore(),
+ source.CharacterSelectionCancel(),
+ ];
+ Assert.All(accepted, result =>
+ {
+ Assert.True(result.Accepted);
+ Assert.Equal(new RuntimeGenerationToken(11), result.Generation);
+ });
+
+ binding.Dispose();
+ Assert.Null(source.CharacterSelection);
+ Assert.Equal(RuntimeCommandStatus.Inactive,
+ source.CharacterSelectionRestore().Status);
+
+ source.Deactivate();
+ Assert.Null(source.CharacterSelection);
+ Assert.Equal(RuntimeCommandStatus.Inactive,
+ source.CharacterSelectionCancel().Status);
+ }
+
[Fact]
public void RadarNeverCachesAnUnboundBootstrapSnapshot()
{
@@ -216,7 +255,9 @@ public sealed class InteractionUiRuntimeSourcesTests
IGameRuntimeCommands,
IRuntimeInventoryStateCommands,
IRuntimeSpellbookCommands,
- IRuntimeCharacterCommands
+ IRuntimeCharacterCommands,
+ IRuntimeCharacterSelectionView,
+ IRuntimeCharacterSelectionCommands
{
public RuntimeTarget(RuntimeGenerationToken generation)
{
@@ -234,6 +275,7 @@ public sealed class InteractionUiRuntimeSourcesTests
public IRuntimeCharacterView Character => null!;
public IRuntimeSocialView Social => null!;
public IRuntimeChatView Chat => null!;
+ public IRuntimeCharacterSelectionView CharacterSelection => this;
public IRuntimeFellowshipView Fellowship => null!;
public IRuntimeAllegianceView Allegiance => null!;
public IRuntimeActionView Actions => null!;
@@ -242,6 +284,7 @@ public sealed class InteractionUiRuntimeSourcesTests
null!;
public IRuntimePortalView Portal => null!;
public IRuntimeSessionCommands Session => null!;
+ IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection => this;
public IRuntimeSelectionCommands Selection => null!;
public IRuntimeCombatCommands Combat => null!;
public IRuntimeMagicCommands Magic => null!;
@@ -257,6 +300,67 @@ public sealed class InteractionUiRuntimeSourcesTests
public RuntimeStateCheckpoint CaptureCheckpoint() => default;
+ RuntimeCharacterSelectionSnapshot IRuntimeCharacterSelectionView.Snapshot =>
+ new(
+ Generation,
+ RuntimeCharacterSelectionLifecycle.AwaitingSelection,
+ Revision: 1,
+ AccountName: "account",
+ SlotCount: 0,
+ RosterCount: 0,
+ HighlightedCharacterId: 0u,
+ HighlightedDisplayIndex: -1,
+ PendingDeleteCharacterId: 0u,
+ LastRestoreRequestedCharacterId: 0u,
+ Operation: RuntimeCharacterSelectionOperation.None,
+ Error: null,
+ Buttons: RuntimeCharacterSelectionButtons.None);
+
+ public bool TryGetAt(
+ int displayIndex,
+ out RuntimeCharacterSelectionEntry character)
+ {
+ character = default;
+ return false;
+ }
+
+ public bool TryGet(
+ uint characterId,
+ out RuntimeCharacterSelectionEntry character)
+ {
+ character = default;
+ return false;
+ }
+
+ public void Visit(IRuntimeCharacterSelectionVisitor visitor) { }
+
+ public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) =>
+ EmptyDisposable.Instance;
+
+ public RuntimeCommandResult Highlight(
+ RuntimeGenerationToken expectedGeneration,
+ uint characterId) => Accepted(expectedGeneration, characterId);
+
+ public RuntimeCommandResult Enter(
+ RuntimeGenerationToken expectedGeneration) =>
+ Accepted(expectedGeneration);
+
+ public RuntimeCommandResult RequestDelete(
+ RuntimeGenerationToken expectedGeneration) =>
+ Accepted(expectedGeneration);
+
+ public RuntimeCommandResult ConfirmDelete(
+ RuntimeGenerationToken expectedGeneration) =>
+ Accepted(expectedGeneration);
+
+ public RuntimeCommandResult Restore(
+ RuntimeGenerationToken expectedGeneration) =>
+ Accepted(expectedGeneration);
+
+ public RuntimeCommandResult Cancel(
+ RuntimeGenerationToken expectedGeneration) =>
+ Accepted(expectedGeneration);
+
public RuntimeCommandResult AddShortcut(
RuntimeGenerationToken expectedGeneration,
in RuntimeShortcutCommand command) =>
@@ -392,6 +496,12 @@ public sealed class InteractionUiRuntimeSourcesTests
}
}
+ private sealed class EmptyDisposable : IDisposable
+ {
+ public static EmptyDisposable Instance { get; } = new();
+ public void Dispose() { }
+ }
+
private sealed class EmptyRadarSource : ILiveEntityRadarSource
{
public static EmptyRadarSource Instance { get; } = new();
diff --git a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs
index 807c007e..319982fa 100644
--- a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs
@@ -137,12 +137,19 @@ public sealed class SessionPlayerCompositionTests
[Fact]
public void GraphicalCompositionPausesOnlyWhenCharacterSelectorIsAbsent()
{
+ string root = FindRepoRoot();
string phase = File.ReadAllText(Path.Combine(
- FindRepoRoot(),
+ root,
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
+ string retainedUi = File.ReadAllText(Path.Combine(
+ root,
+ "src",
+ "AcDream.App",
+ "Composition",
+ "InteractionRetainedUiComposition.cs"));
Assert.Contains(
"AwaitCharacterSelection:",
@@ -156,6 +163,18 @@ public sealed class SessionPlayerCompositionTests
"CharacterList.TrySelectFirstAvailable",
phase,
StringComparison.Ordinal);
+ Assert.Contains(
+ "CharacterSelection: d.Options.LiveCharacterSelector is null",
+ retainedUi,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "() => late.GameRuntime.CharacterSelection",
+ retainedUi,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "late.GameRuntime.CharacterSelectionEnter",
+ retainedUi,
+ StringComparison.Ordinal);
}
private sealed class RetryBinding(
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs
new file mode 100644
index 00000000..88ceb7b0
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs
@@ -0,0 +1,181 @@
+using System.IO;
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+using AcDream.Content;
+using DatReaderWriter;
+using DatReaderWriter.Options;
+using StringTable = DatReaderWriter.DBObjs.StringTable;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// Installed-retail-DAT acceptance gate for LA8. Opt in with
+/// ACDREAM_PROBE_LIVE_MOUNT=1; ACDREAM_DAT_DIR can override the
+/// ordinary Documents/Asheron's Call location. Reads the DATs read-only.
+///
+public sealed class CharacterManagementLiveDatTests
+{
+ [InstalledDatFact]
+ public void EnumTable5_ResolvesAndImportsTheExactRetailScreenAndDialogs()
+ {
+ string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
+ ?? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Documents",
+ "Asheron's Call");
+ using var dats = new DatCollection(datDirectory, DatAccessType.Read);
+
+ const uint expectedLayoutDid = 0x21000004u;
+ uint layoutDid = RetailDataIdResolver.Resolve(
+ dats,
+ CharacterManagementUiController.RootEnum,
+ 5u);
+ Assert.Equal(expectedLayoutDid, layoutDid);
+ Console.WriteLine(
+ "[LA8-DAT] category=5 enum=0x10000005 -> DID=0x21000004; "
+ + "selected-root=0x1000039A");
+
+ ElementInfo rootInfo = Assert.IsType(
+ LayoutImporter.ImportInfos(
+ dats,
+ layoutDid,
+ CharacterManagementUiController.RootElementId));
+ Assert.Equal(800f, rootInfo.Width);
+ Assert.Equal(600f, rootInfo.Height);
+ Assert.Equal(8, rootInfo.Children.Count);
+ Assert.Equal(0x06007576u, rootInfo.StateMedia[""].File);
+ ImportedLayout screen = LayoutImporter.Build(
+ rootInfo,
+ _ => (0u, 0, 0),
+ null,
+ null,
+ new DatStringResolver(dats).Resolve);
+
+ var list = Assert.IsType(screen.FindElement(
+ CharacterManagementUiController.ListElementId));
+ UiTemplateListEntry template = Assert.Single(list.Templates);
+ Assert.Equal(expectedLayoutDid, template.TemplateLayoutId);
+ Assert.Equal(0x100003A5u, template.TemplateElementId);
+ AssertButton(screen, CharacterManagementUiController.CreateElementId,
+ "Create Character");
+ AssertButton(screen, CharacterManagementUiController.EnterElementId,
+ "ENTER");
+ AssertButton(screen, CharacterManagementUiController.DeleteElementId,
+ "DELETE");
+ AssertButton(screen, CharacterManagementUiController.RestoreElementId,
+ "RESTORE");
+ Assert.DoesNotContain(
+ Descendants(screen.Root),
+ static element => element is UiViewport);
+
+ ElementInfo rowInfo = Assert.IsType(
+ LayoutImporter.ImportInfos(dats, layoutDid, template.TemplateElementId));
+ Assert.Equal(1u, rowInfo.Type);
+ Assert.Equal(160f, rowInfo.Width);
+ Assert.Equal(16f, rowInfo.Height);
+ Assert.Equal(0x40000009u, rowInfo.FontDid);
+ Assert.Equal(
+ [
+ UiButtonStateMachine.Normal,
+ UiButtonStateMachine.NormalRollover,
+ UiButtonStateMachine.NormalPressed,
+ UiButtonStateMachine.Highlight,
+ UiButtonStateMachine.HighlightRollover,
+ uint.MaxValue,
+ ],
+ rowInfo.States.Keys.Order().ToArray());
+
+ uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u);
+ Assert.Equal(0x2100003Cu, dialogDid);
+ ImportedLayout message = BuildSelected(dats, dialogDid, 0x24u);
+ Assert.IsType(message.Root);
+ Assert.IsType(message.FindElement(0x3Eu));
+ Assert.IsType(message.FindElement(0x26u));
+ ImportedLayout delete = BuildSelected(dats, dialogDid, 0x2Cu);
+ Assert.IsType(delete.Root);
+ Assert.IsType(delete.FindElement(0x2Cu));
+ Assert.IsType(delete.FindElement(0x2Eu));
+ Assert.IsType(delete.FindElement(0x2Fu));
+
+ var strings = new DatStringResolver(dats);
+ const uint table = 0x23000002u;
+ Assert.Equal("DELETE", Resolve(strings, table,
+ "ID_CharacterManagement_DeleteCharacterResponse"));
+ Assert.Equal("Please Wait", Resolve(strings, table,
+ "ID_CharacterManagement_PleaseWait"));
+ Assert.Equal("Entering World", Resolve(strings, table,
+ "ID_Character_EnteringWorld"));
+ string confirmation = Assert.IsType(strings.ResolveTemplate(
+ table,
+ "ID_CharacterManagement_DeleteCharacterConfirmation",
+ new Dictionary
+ {
+ [DatStringResolver.PlayerVariable] = "Test Character",
+ }));
+ Assert.Contains("Test Character", confirmation);
+ Assert.Contains("'DELETE'", confirmation);
+
+ StringTable stringTable = Assert.IsType(dats.Get(table));
+ var deleteEntry = stringTable.Strings[
+ DatStringResolver.ComputeHash(
+ "ID_CharacterManagement_DeleteCharacterConfirmation")];
+ Assert.Equal([DatStringResolver.PlayerVariable], deleteEntry.Variables);
+ }
+
+ private static ImportedLayout BuildSelected(
+ IDatReaderWriter dats,
+ uint layoutDid,
+ uint rootId)
+ {
+ ElementInfo info = Assert.IsType(
+ LayoutImporter.ImportInfos(dats, layoutDid, rootId));
+ return LayoutImporter.Build(
+ info,
+ _ => (0u, 0, 0),
+ null,
+ null,
+ new DatStringResolver(dats).Resolve);
+ }
+
+ private static string Resolve(
+ DatStringResolver strings,
+ uint table,
+ string key) => Assert.IsType(strings.Resolve(
+ table,
+ DatStringResolver.ComputeHash(key)));
+
+ private static void AssertButton(
+ ImportedLayout layout,
+ uint elementId,
+ string label) => Assert.Equal(
+ label,
+ Assert.IsType(layout.FindElement(elementId)).Label);
+
+ private static IEnumerable Descendants(UiElement root)
+ {
+ yield return root;
+ foreach (UiElement child in root.Children)
+ foreach (UiElement descendant in Descendants(child))
+ 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
new file mode 100644
index 00000000..d9708e38
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs
@@ -0,0 +1,995 @@
+using System.Numerics;
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+using AcDream.Runtime;
+using AcDream.Runtime.Session;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+public sealed class CharacterManagementUiControllerTests
+{
+ [Fact]
+ public void AuthoredChildContract_PreservesRuntimeOrderGreyTailHighlightAndButtonMatrix()
+ {
+ using var environment = new EnvironmentHarness();
+ CharacterManagementUiController controller = environment.Controller;
+
+ Assert.True(controller.Root.Visible);
+ Assert.Equal(
+ CharacterManagementUiController.RootElementId,
+ controller.Root.DatElementId);
+ Assert.IsType(environment.Screen.FindElement(
+ CharacterManagementUiController.ListElementId));
+ UiButton create = environment.Button(
+ CharacterManagementUiController.CreateElementId);
+ UiButton enter = environment.Button(
+ CharacterManagementUiController.EnterElementId);
+ UiButton delete = environment.Button(
+ CharacterManagementUiController.DeleteElementId);
+ UiButton restore = environment.Button(
+ CharacterManagementUiController.RestoreElementId);
+
+ Assert.True(create.Visible);
+ Assert.False(create.Enabled);
+ Assert.Null(create.OnClick);
+ Assert.True(enter.Enabled);
+ Assert.True(delete.Visible);
+ Assert.True(delete.Enabled);
+ Assert.False(restore.Visible);
+ Assert.False(restore.Enabled);
+
+ // Runtime owns wcscmp sorting and the stable grey-to-tail partition.
+ // "Aaron (pending)" would sort first if App incorrectly re-sorted it;
+ // the controller must preserve this exact borrowed display order.
+ 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);
+ Assert.Equal(new Vector4(1f, 0f, 0f, 1f), controller.Rows[2].LabelColor);
+ Assert.DoesNotContain(
+ Descendants(controller.Root),
+ static element => element is UiViewport);
+
+ controller.Rows[2].OnClick!();
+
+ Assert.Equal(1, environment.Runtime.HighlightCalls);
+ Assert.Equal(0x50000003u,
+ environment.Runtime.View.Snapshot.HighlightedCharacterId);
+ Assert.True(controller.Rows[2].Selected);
+ Assert.False(enter.Enabled);
+ Assert.False(delete.Visible);
+ Assert.False(delete.Enabled);
+ Assert.True(restore.Visible);
+ 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 MountCoordinator_PostAttachFailuresDisposeBeforeRetryAndRecovery()
+ {
+ var host = new UiRoot { Width = 800f, Height = 600f };
+ var runtime = new FakeRuntime();
+ using var dialogs = new RetailDialogFactory(
+ host,
+ RetailDialogFactoryTests.BuildDialogLayout);
+ var screens = new List();
+ int failingAttempts = 2;
+ using var coordinator = new CharacterManagementUiMountCoordinator(
+ host,
+ runtime.Bindings,
+ () => dialogs,
+ () =>
+ {
+ ImportedLayout screen = BuildScreen();
+ screens.Add(screen);
+ bool throwAfterAttach = failingAttempts-- > 0;
+ return new CharacterManagementUiMountResources(
+ 0x21000004u,
+ screen,
+ (_, _) => throwAfterAttach
+ ? throw new InvalidOperationException(
+ "template failed after root attach")
+ : BuildRow(),
+ TestStrings());
+ });
+
+ coordinator.Tick();
+ Assert.Null(coordinator.Controller);
+ Assert.Empty(host.Children);
+ Assert.Single(screens);
+ AssertDetachedAndUnbound(screens[0]);
+
+ coordinator.Tick();
+ Assert.Null(coordinator.Controller);
+ Assert.Empty(host.Children);
+ Assert.Equal(2, screens.Count);
+ Assert.All(screens, AssertDetachedAndUnbound);
+
+ coordinator.Tick();
+ CharacterManagementUiController mounted = Assert.IsType<
+ CharacterManagementUiController>(coordinator.Controller);
+ Assert.Equal(3, screens.Count);
+ Assert.Single(host.Children);
+ Assert.Same(mounted.Root, host.Children[0]);
+ Assert.NotNull(Assert.IsType(screens[2].FindElement(
+ CharacterManagementUiController.EnterElementId)).OnClick);
+
+ coordinator.Tick();
+ Assert.Equal(3, screens.Count);
+ Assert.Single(host.Children);
+
+ coordinator.Dispose();
+ Assert.Empty(host.Children);
+ Assert.Null(coordinator.Controller);
+ Assert.All(screens, AssertDetachedAndUnbound);
+ }
+
+ [Fact]
+ public void DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster()
+ {
+ using var environment = new EnvironmentHarness();
+ CharacterManagementUiController controller = environment.Controller;
+ UiButton delete = environment.Button(
+ CharacterManagementUiController.DeleteElementId);
+
+ // A wrong typed response closes the modal and cancels Runtime's exact
+ // pending-delete owner without sending the wire request.
+ delete.OnClick!();
+ ImportedLayout wrong = environment.LastDialog(
+ RetailDialogType.ConfirmationTextInput);
+ Assert.Contains("Alpha", Message(wrong));
+ Assert.Contains("Type DELETE", Message(wrong));
+ Input(wrong).SetText("not delete");
+ DialogButton(
+ wrong,
+ RetailConfirmationTextInputDialogView.AcceptButtonId).OnClick!();
+ Assert.Equal(1, environment.Runtime.CancelCalls);
+ Assert.Equal(0, environment.Runtime.ConfirmDeleteCalls);
+ Assert.Equal(0u, controller.DeleteDialogContext);
+ Assert.False(environment.Dialogs.IsOpen);
+
+ // Retail compares the localized response case-insensitively.
+ delete.OnClick!();
+ ImportedLayout accepted = environment.LastDialog(
+ RetailDialogType.ConfirmationTextInput);
+ Input(accepted).SetText("delete");
+ DialogButton(
+ accepted,
+ RetailConfirmationTextInputDialogView.AcceptButtonId).OnClick!();
+
+ Assert.Equal(1, environment.Runtime.ConfirmDeleteCalls);
+ Assert.Equal(
+ RuntimeCharacterSelectionOperation.DeleteRequested,
+ environment.Runtime.View.Snapshot.Operation);
+ uint waitContext = controller.OperationWaitContext;
+ Assert.NotEqual(0u, waitContext);
+ Assert.Equal(
+ RetailDialogType.Wait,
+ environment.DialogLayouts[^1].Type);
+ Assert.Equal("Please Wait", Message(environment.DialogLayouts[^1].Layout));
+
+ // Opcode-only ack does not close the wait. Neither does silence.
+ environment.Runtime.SetOperation(
+ RuntimeCharacterSelectionOperation.DeleteAcknowledged);
+ controller.Tick();
+ controller.Tick();
+ Assert.Equal(waitContext, controller.OperationWaitContext);
+
+ // Retail closes via the fresh CharacterList rebuild that follows ack.
+ environment.Runtime.ReplaceRoster(
+ [
+ new RuntimeCharacterSelectionEntry(1, 0x50000002u, "Zulu", 0u),
+ new RuntimeCharacterSelectionEntry(2, 0x50000003u, "Aaron (pending)", 1u),
+ ],
+ highlightedCharacterId: 0x50000002u);
+ controller.Tick();
+
+ Assert.Equal(0u, controller.OperationWaitContext);
+ Assert.False(environment.Dialogs.IsOpen);
+ Assert.Equal(
+ ["Zulu", "Aaron (pending)"],
+ controller.Rows.Select(static row => row.Label!).ToArray());
+ }
+
+ [Fact]
+ public void AuthoredRowDoubleActivation_EntersTheHighlightedCharacter()
+ {
+ using var environment = new EnvironmentHarness();
+ CharacterManagementUiController controller = environment.Controller;
+ UiButton row = controller.Rows[1];
+
+ Assert.True(row.OnEvent(new UiEvent(
+ row.EventId,
+ row,
+ UiEventType.Click)));
+ Assert.True(row.OnEvent(new UiEvent(
+ row.EventId,
+ row,
+ UiEventType.DoubleClick)));
+
+ Assert.Equal(1, environment.Runtime.HighlightCalls);
+ Assert.Equal(0x50000002u,
+ environment.Runtime.View.Snapshot.HighlightedCharacterId);
+ Assert.Equal(1, environment.Runtime.EnterCalls);
+ Assert.Equal(
+ RuntimeCharacterSelectionLifecycle.EnteringWorld,
+ environment.Runtime.View.Snapshot.Lifecycle);
+ Assert.NotEqual(0u, controller.EnterWaitContext);
+ }
+
+ [Fact]
+ public void RestoreSilenceExpires_EnterTransitions_AndErrorUsesMessageDialog()
+ {
+ using var environment = new EnvironmentHarness();
+ CharacterManagementUiController controller = environment.Controller;
+
+ controller.Rows[2].OnClick!();
+ environment.Button(CharacterManagementUiController.RestoreElementId).OnClick!();
+ uint restoreWait = controller.OperationWaitContext;
+ Assert.NotEqual(0u, restoreWait);
+ Assert.Equal(
+ RuntimeCharacterSelectionOperation.RestoreRequested,
+ environment.Runtime.View.Snapshot.Operation);
+
+ // ACE may send no restore response. Runtime's correlation expiry is
+ // represented by Operation=None; the presentation never owns a timer.
+ controller.Tick();
+ Assert.Equal(restoreWait, controller.OperationWaitContext);
+ environment.Runtime.SetOperation(RuntimeCharacterSelectionOperation.None);
+ controller.Tick();
+ Assert.Equal(0u, controller.OperationWaitContext);
+ Assert.False(environment.Dialogs.IsOpen);
+
+ controller.Rows[0].OnClick!();
+ environment.Button(CharacterManagementUiController.EnterElementId).OnClick!();
+ Assert.Equal(1, environment.Runtime.EnterCalls);
+ Assert.Equal(
+ RuntimeCharacterSelectionLifecycle.EnteringWorld,
+ environment.Runtime.View.Snapshot.Lifecycle);
+ Assert.NotEqual(0u, controller.EnterWaitContext);
+ Assert.Equal("Entering World", Message(
+ environment.LastDialog(RetailDialogType.Wait)));
+
+ environment.Runtime.SetError("That character is unavailable.");
+ controller.Tick();
+ Assert.Equal(0u, controller.EnterWaitContext);
+ Assert.NotEqual(0u, controller.ErrorDialogContext);
+ ImportedLayout error = environment.LastDialog(RetailDialogType.Message);
+ Assert.Equal("That character is unavailable.", Message(error));
+ DialogButton(error, RetailMessageDialogView.OkButtonId).OnClick!();
+ Assert.Equal(1, environment.Runtime.CancelCalls);
+ Assert.Null(environment.Runtime.View.Snapshot.Error);
+ Assert.False(environment.Dialogs.IsOpen);
+
+ // CharacterError.NumErrors is ignored by Runtime without a revision;
+ // an unchanged, error-free projection must not manufacture a dialog.
+ int createdBeforeSentinel = environment.DialogLayouts.Count;
+ controller.Tick();
+ Assert.Equal(createdBeforeSentinel, environment.DialogLayouts.Count);
+
+ environment.Button(CharacterManagementUiController.EnterElementId).OnClick!();
+ Assert.NotEqual(0u, controller.EnterWaitContext);
+ environment.Runtime.SetLifecycle(
+ RuntimeCharacterSelectionLifecycle.InWorld);
+ controller.Tick();
+ Assert.False(controller.Root.Visible);
+ Assert.Equal(0u, controller.EnterWaitContext);
+ 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()
+ {
+ using var environment = new EnvironmentHarness();
+ CharacterManagementUiController controller = environment.Controller;
+ environment.Button(CharacterManagementUiController.DeleteElementId).OnClick!();
+ Assert.NotEqual(0u, controller.DeleteDialogContext);
+
+ environment.Runtime.ProvideView = false;
+ controller.Tick();
+
+ Assert.False(controller.Root.Visible);
+ Assert.Empty(controller.Rows);
+ Assert.False(environment.Dialogs.IsOpen);
+ Assert.Equal(0, environment.Runtime.CancelCalls);
+
+ controller.Dispose();
+ Assert.Null(controller.Root.Parent);
+ Assert.Null(environment.Button(
+ CharacterManagementUiController.EnterElementId).OnClick);
+ controller.Tick();
+ }
+
+ [Fact]
+ public void SessionReset_ClosesOwnedContextsWithoutReentrantCancel()
+ {
+ using var environment = new EnvironmentHarness();
+ CharacterManagementUiController controller = environment.Controller;
+ environment.Button(CharacterManagementUiController.DeleteElementId).OnClick!();
+ Assert.NotEqual(0u, controller.DeleteDialogContext);
+
+ controller.ResetSession();
+
+ Assert.False(controller.Root.Visible);
+ Assert.Empty(controller.Rows);
+ Assert.False(environment.Dialogs.IsOpen);
+ Assert.Equal(0, environment.Runtime.CancelCalls);
+ }
+
+ [Fact]
+ public void PreviewViewport_IsRejectedBeforeTheAuthoredScreenIsMounted()
+ {
+ var host = new UiRoot { Width = 800f, Height = 600f };
+ ImportedLayout screen = BuildScreen(includePreview: true);
+ using var dialogs = new RetailDialogFactory(
+ host,
+ RetailDialogFactoryTests.BuildDialogLayout);
+ var runtime = new FakeRuntime();
+
+ CharacterManagementUiController? controller =
+ CharacterManagementUiController.Bind(
+ host,
+ screen,
+ static (_, _) => BuildRow(),
+ dialogs,
+ runtime.Bindings,
+ TestStrings());
+
+ Assert.Null(controller);
+ Assert.Empty(host.Children);
+ }
+
+ [Fact]
+ public void TransientTemplateMiss_DoesNotConsumeTheRuntimeRevision()
+ {
+ var host = new UiRoot { Width = 800f, Height = 600f };
+ ImportedLayout screen = BuildScreen();
+ using var dialogs = new RetailDialogFactory(
+ host,
+ RetailDialogFactoryTests.BuildDialogLayout);
+ var runtime = new FakeRuntime();
+ int resolveCalls = 0;
+ using CharacterManagementUiController controller =
+ Assert.IsType(
+ CharacterManagementUiController.Bind(
+ host,
+ screen,
+ (_, _) => ++resolveCalls == 1 ? null : BuildRow(),
+ dialogs,
+ runtime.Bindings,
+ TestStrings()));
+
+ Assert.Empty(controller.Rows);
+ controller.Tick();
+
+ Assert.Equal(3, controller.Rows.Count);
+ Assert.True(resolveCalls >= 4);
+ }
+
+ private static CharacterManagementUiController.DialogStrings TestStrings() =>
+ new(
+ name => $"WARNING! {name}\nType DELETE in the box below.",
+ "DELETE",
+ "Please Wait",
+ "Entering World");
+
+ private static void AssertDetachedAndUnbound(ImportedLayout screen)
+ {
+ Assert.Null(screen.Root.Parent);
+ Assert.Null(Assert.IsType(screen.FindElement(
+ CharacterManagementUiController.EnterElementId)).OnClick);
+ Assert.Null(Assert.IsType(screen.FindElement(
+ CharacterManagementUiController.DeleteElementId)).OnClick);
+ Assert.Null(Assert.IsType(screen.FindElement(
+ CharacterManagementUiController.RestoreElementId)).OnClick);
+ }
+
+ private static ImportedLayout BuildScreen(bool includePreview = false)
+ {
+ var root = new ElementInfo
+ {
+ Id = CharacterManagementUiController.RootElementId,
+ Type = 3u,
+ Width = 800f,
+ Height = 600f,
+ };
+ var list = new ElementInfo
+ {
+ Id = CharacterManagementUiController.ListElementId,
+ Type = 5u,
+ X = 42f,
+ Y = 212f,
+ Width = 160f,
+ Height = 320f,
+ };
+ list.TemplateList.Add(new UiTemplateListEntry(
+ 0x21000004u,
+ 0x100003A5u));
+ root.Children.Add(list);
+ root.Children.Add(ButtonInfo(
+ CharacterManagementUiController.CreateElementId));
+ root.Children.Add(ButtonInfo(
+ CharacterManagementUiController.EnterElementId));
+ root.Children.Add(ButtonInfo(
+ CharacterManagementUiController.DeleteElementId));
+ root.Children.Add(ButtonInfo(
+ CharacterManagementUiController.RestoreElementId));
+ if (includePreview)
+ {
+ root.Children.Add(new ElementInfo
+ {
+ Id = 0xDEADBEEFu,
+ Type = 0xDu,
+ Width = 100f,
+ Height = 100f,
+ });
+ }
+ return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
+ }
+
+ private static ElementInfo ButtonInfo(uint id) => new()
+ {
+ Id = id,
+ Type = 1u,
+ Width = 100f,
+ Height = 30f,
+ };
+
+ private static UiElement BuildRow() => LayoutImporter.Build(
+ new ElementInfo
+ {
+ Id = 0x100003A5u,
+ Type = 1u,
+ Width = 160f,
+ Height = 16f,
+ },
+ _ => (0u, 0, 0),
+ null).Root;
+
+ private static IEnumerable Descendants(UiElement root)
+ {
+ yield return root;
+ foreach (UiElement child in root.Children)
+ foreach (UiElement descendant in Descendants(child))
+ yield return descendant;
+ }
+
+ private static UiButton DialogButton(ImportedLayout layout, uint id) =>
+ Assert.IsType(layout.FindElement(id));
+
+ private static UiField Input(ImportedLayout layout) =>
+ Assert.IsType(layout.FindElement(
+ RetailConfirmationTextInputDialogView.InputElementId));
+
+ private static string Message(ImportedLayout layout) => string.Join(
+ " ",
+ Assert.IsType(layout.FindElement(0x3Eu))
+ .LinesProvider()
+ .Select(static line => line.Text));
+
+ private sealed class EnvironmentHarness : IDisposable
+ {
+ public EnvironmentHarness()
+ {
+ Host = new UiRoot { Width = 800f, Height = 600f };
+ Screen = BuildScreen();
+ Runtime = new FakeRuntime();
+ Dialogs = new RetailDialogFactory(Host, type =>
+ {
+ ImportedLayout layout =
+ RetailDialogFactoryTests.BuildDialogLayout(type);
+ DialogLayouts.Add((type, layout));
+ return layout;
+ });
+ Controller = Assert.IsType(
+ CharacterManagementUiController.Bind(
+ Host,
+ Screen,
+ static (_, _) => BuildRow(),
+ Dialogs,
+ Runtime.Bindings,
+ TestStrings()));
+ }
+
+ public UiRoot Host { get; }
+ public ImportedLayout Screen { get; }
+ public FakeRuntime Runtime { get; }
+ public RetailDialogFactory Dialogs { get; }
+ public List<(RetailDialogType Type, ImportedLayout Layout)> DialogLayouts { get; } = [];
+ public CharacterManagementUiController Controller { get; }
+
+ public UiButton Button(uint id) =>
+ Assert.IsType(Screen.FindElement(id));
+
+ public ImportedLayout LastDialog(RetailDialogType type) =>
+ DialogLayouts.Last(entry => entry.Type == type).Layout;
+
+ public void Dispose()
+ {
+ Controller.Dispose();
+ Dialogs.Dispose();
+ }
+ }
+
+ private sealed class FakeRuntime
+ {
+ private static readonly RuntimeGenerationToken Generation = new(7u);
+
+ public FakeRuntime()
+ {
+ View.Entries =
+ [
+ new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u),
+ new RuntimeCharacterSelectionEntry(1, 0x50000002u, "Zulu", 0u),
+ new RuntimeCharacterSelectionEntry(2, 0x50000003u, "Aaron (pending)", 1u),
+ ];
+ View.Snapshot = Snapshot(
+ RuntimeCharacterSelectionLifecycle.AwaitingSelection,
+ revision: 1,
+ highlightedCharacterId: 0x50000001u,
+ buttons: ButtonsFor(0x50000001u));
+ Bindings = new CharacterSelectionRuntimeBindings(
+ () => ProvideView ? View : null,
+ Highlight,
+ Enter,
+ RequestDelete,
+ ConfirmDelete,
+ Restore,
+ Cancel);
+ }
+
+ public FakeView View { get; } = new();
+ public CharacterSelectionRuntimeBindings Bindings { get; }
+ public bool ProvideView { get; set; } = true;
+ public int HighlightCalls { get; private set; }
+ 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)
+ {
+ RuntimeCharacterSelectionButtons buttons = operation is
+ RuntimeCharacterSelectionOperation.DeleteRequested
+ or RuntimeCharacterSelectionOperation.DeleteAcknowledged
+ ? RuntimeCharacterSelectionButtons.None
+ : ButtonsFor(View.Snapshot.HighlightedCharacterId);
+ Update(snapshot => snapshot with
+ {
+ Operation = operation,
+ Buttons = buttons,
+ });
+ }
+
+ public void ReplaceRoster(
+ RuntimeCharacterSelectionEntry[] entries,
+ uint highlightedCharacterId)
+ {
+ View.Entries = entries;
+ Update(snapshot => snapshot with
+ {
+ RosterCount = entries.Length,
+ HighlightedCharacterId = highlightedCharacterId,
+ HighlightedDisplayIndex = Array.FindIndex(
+ entries,
+ entry => entry.CharacterId == highlightedCharacterId),
+ PendingDeleteCharacterId = 0u,
+ Operation = RuntimeCharacterSelectionOperation.None,
+ Buttons = ButtonsFor(highlightedCharacterId),
+ });
+ }
+
+ public void SetLifecycle(RuntimeCharacterSelectionLifecycle lifecycle) =>
+ Update(snapshot => snapshot with { Lifecycle = lifecycle });
+
+ public void SetError(string message) => Update(snapshot => snapshot with
+ {
+ Lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection,
+ Error = new RuntimeCharacterSelectionError(
+ 1u,
+ AcDream.Core.Net.Messages.CharacterError.Code.Logon,
+ message),
+ PendingDeleteCharacterId = 0u,
+ Operation = RuntimeCharacterSelectionOperation.None,
+ });
+
+ private RuntimeCommandResult Highlight(uint characterId)
+ {
+ HighlightCalls++;
+ int index = Array.FindIndex(
+ View.Entries,
+ entry => entry.CharacterId == characterId);
+ if (index < 0)
+ return Result(RuntimeCommandStatus.Rejected);
+ Update(snapshot => snapshot with
+ {
+ HighlightedCharacterId = characterId,
+ HighlightedDisplayIndex = index,
+ Buttons = ButtonsFor(characterId),
+ });
+ return Result(RuntimeCommandStatus.Accepted, characterId);
+ }
+
+ private RuntimeCommandResult Enter()
+ {
+ EnterCalls++;
+ Update(snapshot => snapshot with
+ {
+ Lifecycle = RuntimeCharacterSelectionLifecycle.EnteringWorld,
+ Error = null,
+ });
+ return Result(
+ RuntimeCommandStatus.Accepted,
+ View.Snapshot.HighlightedCharacterId);
+ }
+
+ private RuntimeCommandResult RequestDelete()
+ {
+ uint id = View.Snapshot.HighlightedCharacterId;
+ Update(snapshot => snapshot with
+ {
+ PendingDeleteCharacterId = id,
+ Error = null,
+ });
+ return Result(RuntimeCommandStatus.Accepted, id);
+ }
+
+ private RuntimeCommandResult ConfirmDelete()
+ {
+ ConfirmDeleteCalls++;
+ uint id = View.Snapshot.HighlightedCharacterId;
+ Update(snapshot => snapshot with
+ {
+ PendingDeleteCharacterId = 0u,
+ Operation = RuntimeCharacterSelectionOperation.DeleteRequested,
+ Buttons = RuntimeCharacterSelectionButtons.None,
+ });
+ return Result(RuntimeCommandStatus.Accepted, id);
+ }
+
+ 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,
+ Operation = RuntimeCharacterSelectionOperation.RestoreRequested,
+ Buttons = new RuntimeCharacterSelectionButtons(
+ false,
+ false,
+ false,
+ false,
+ true),
+ });
+ AfterRestoreProjection?.Invoke();
+ return Result(RuntimeCommandStatus.Accepted, id);
+ }
+
+ private RuntimeCommandResult Cancel()
+ {
+ CancelCalls++;
+ Update(snapshot => snapshot with
+ {
+ PendingDeleteCharacterId = 0u,
+ Error = null,
+ Buttons = ButtonsFor(snapshot.HighlightedCharacterId),
+ });
+ return Result(RuntimeCommandStatus.Accepted);
+ }
+
+ private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId)
+ {
+ RuntimeCharacterSelectionEntry? selected = View.Entries
+ .Cast()
+ .FirstOrDefault(entry => entry?.CharacterId == characterId);
+ if (selected is null)
+ return RuntimeCharacterSelectionButtons.None;
+ if (selected.Value.IsPendingDelete)
+ {
+ return new RuntimeCharacterSelectionButtons(
+ false,
+ false,
+ true,
+ false,
+ true);
+ }
+ return new RuntimeCharacterSelectionButtons(
+ true,
+ true,
+ false,
+ true,
+ false);
+ }
+
+ private void Update(
+ Func update)
+ {
+ RuntimeCharacterSelectionSnapshot current = View.Snapshot;
+ RuntimeCharacterSelectionSnapshot next = update(current);
+ View.Snapshot = next with { Revision = current.Revision + 1 };
+ }
+
+ private RuntimeCharacterSelectionSnapshot Snapshot(
+ RuntimeCharacterSelectionLifecycle lifecycle,
+ long revision,
+ uint highlightedCharacterId,
+ RuntimeCharacterSelectionButtons buttons) => new(
+ Generation,
+ lifecycle,
+ revision,
+ "account",
+ SlotCount: 5,
+ RosterCount: View.Entries.Length,
+ highlightedCharacterId,
+ HighlightedDisplayIndex: Array.FindIndex(
+ View.Entries,
+ entry => entry.CharacterId == highlightedCharacterId),
+ PendingDeleteCharacterId: 0u,
+ LastRestoreRequestedCharacterId: 0u,
+ Operation: RuntimeCharacterSelectionOperation.None,
+ Error: null,
+ buttons);
+
+ private static RuntimeCommandResult Result(
+ RuntimeCommandStatus status,
+ uint objectId = 0u) => new(status, Generation, objectId);
+ }
+
+ private sealed class FakeView : IRuntimeCharacterSelectionView
+ {
+ public RuntimeCharacterSelectionEntry[] Entries { get; set; } = [];
+ public RuntimeCharacterSelectionSnapshot Snapshot { get; set; }
+
+ public bool TryGetAt(
+ int displayIndex,
+ out RuntimeCharacterSelectionEntry character)
+ {
+ if ((uint)displayIndex >= (uint)Entries.Length)
+ {
+ character = default;
+ return false;
+ }
+ character = Entries[displayIndex];
+ return true;
+ }
+
+ public bool TryGet(
+ uint characterId,
+ out RuntimeCharacterSelectionEntry character)
+ {
+ int index = Array.FindIndex(
+ Entries,
+ entry => entry.CharacterId == characterId);
+ if (index < 0)
+ {
+ character = default;
+ return false;
+ }
+ character = Entries[index];
+ return true;
+ }
+
+ public void Visit(IRuntimeCharacterSelectionVisitor visitor)
+ {
+ foreach (RuntimeCharacterSelectionEntry character in Entries)
+ visitor.Visit(in character);
+ }
+
+ public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) =>
+ NoopDisposable.Instance;
+ }
+
+ private sealed class NoopDisposable : IDisposable
+ {
+ public static NoopDisposable Instance { get; } = new();
+ public void Dispose() { }
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
index c06ae755..a77d51ff 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -31,6 +31,8 @@ public class DatWidgetFactoryTests
[Theory]
[InlineData(0x13u)] // ConfirmationDialog (catalog root 0x15)
+ [InlineData(0x15u)] // ConfirmationTextInputDialog (catalog root 0x2C)
+ [InlineData(0x17u)] // MessageDialog (catalog root 0x24)
[InlineData(0x19u)] // WaitDialog (catalog root 0x31 — OP8 #396's live
// crash: unmapped type built a plain UiDatElement and
// RetailWaitDialogView's ctor threw out of OnClick)
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs
index 75455e7b..8baca976 100644
--- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs
@@ -378,6 +378,274 @@ public sealed class RetailDialogFactoryTests
Assert.Equal("text", data.GetString(RetailDialogProperty.Message));
}
+ [Fact]
+ public void MessageDialog_UsesAuthoredOkButtonAndReturnsThroughFactoryCallback()
+ {
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>();
+ using var factory = new RetailDialogFactory(root, type =>
+ {
+ ImportedLayout layout = BuildDialogLayout(type);
+ layouts.Add((type, layout));
+ return layout;
+ });
+ bool completed = false;
+
+ factory.MakeMessage("Character selection failed.", _ => completed = true);
+
+ (RetailDialogType type, ImportedLayout layout) = Assert.Single(layouts);
+ Assert.Equal(RetailDialogType.Message, type);
+ Assert.Equal("Character selection failed.", Message(layout));
+ Button(layout, RetailMessageDialogView.OkButtonId).OnClick!();
+ Assert.True(completed);
+ Assert.False(factory.IsOpen);
+ Assert.Null(root.Modal);
+ }
+
+ [Fact]
+ public void ConfirmationTextInput_AcceptsTypedResultAndRejectsWithEmptyResult()
+ {
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>();
+ using var factory = new RetailDialogFactory(root, type =>
+ {
+ ImportedLayout layout = BuildDialogLayout(type);
+ layouts.Add((type, layout));
+ return layout;
+ });
+ var results = new List();
+
+ factory.MakeConfirmationTextInput(
+ "Type DELETE.",
+ data => results.Add(
+ data.GetString(RetailDialogProperty.TextInputResult) ?? ""));
+ ImportedLayout accepted = layouts[^1].Layout;
+ var field = Assert.IsType(
+ accepted.FindElement(RetailConfirmationTextInputDialogView.InputElementId));
+ Assert.Same(field, root.KeyboardFocus);
+ field.SetText("delete");
+ Button(accepted, RetailConfirmationTextInputDialogView.AcceptButtonId).OnClick!();
+
+ factory.MakeConfirmationTextInput(
+ "Type DELETE.",
+ data => results.Add(
+ data.GetString(RetailDialogProperty.TextInputResult) ?? ""));
+ ImportedLayout rejected = layouts[^1].Layout;
+ UiDialogRoot rejectedRoot = Assert.IsType(rejected.Root);
+ Assert.NotNull(rejectedRoot.Cancel);
+ rejectedRoot.Cancel!();
+
+ Assert.Equal(["delete", ""], results);
+ Assert.False(factory.IsOpen);
+ 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));
+ }
+
+ [Fact]
+ public void PriorityRequest_PreemptsOrdinaryRetryAndPreservesOrdinaryFifo()
+ {
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ bool waitsAvailable = false;
+ using var factory = new RetailDialogFactory(root, type =>
+ type == RetailDialogType.Wait && !waitsAvailable
+ ? null
+ : BuildDialogLayout(type));
+
+ uint failed = factory.MakeWait("failed ordinary");
+ uint later = factory.MakeWait("later ordinary");
+ uint priority = factory.MakeDialog(
+ Priority(RetailDialogData.Message("priority")));
+
+ Assert.Equal(1, factory.ActiveCount);
+ Assert.Equal(1, factory.RetryCount);
+ Assert.Equal(1, factory.PendingCount);
+ Assert.Equal("priority", MessageFromAnyDialog(root.Modal!));
+
+ Assert.True(factory.CloseDialog(priority));
+ Assert.Equal(0, factory.ActiveCount);
+ Assert.Equal(1, factory.RetryCount);
+ Assert.Equal(1, factory.PendingCount);
+
+ waitsAvailable = true;
+ factory.Tick();
+ Assert.Equal("failed ordinary", MessageFromAnyDialog(root.Modal!));
+ Assert.True(factory.CloseDialog(failed));
+ Assert.Equal("later ordinary", MessageFromAnyDialog(root.Modal!));
+ Assert.True(factory.CloseDialog(later));
+ }
+
+ [Fact]
+ public void FailedPriority_RetriesAheadOfRestoredActiveAndQueuedDialog()
+ {
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ bool priorityAvailable = false;
+ using var factory = new RetailDialogFactory(root, type =>
+ type == RetailDialogType.Message && !priorityAvailable
+ ? null
+ : BuildDialogLayout(type));
+
+ uint active = factory.MakeWait("active");
+ uint queued = factory.MakeWait("queued");
+ uint priority = factory.MakeDialog(
+ Priority(RetailDialogData.Message("priority")));
+
+ Assert.Equal(1, factory.ActiveCount);
+ Assert.Equal(1, factory.RetryCount);
+ Assert.Equal(1, factory.PendingCount);
+ Assert.Equal("active", MessageFromAnyDialog(root.Modal!));
+
+ priorityAvailable = true;
+ factory.Tick();
+
+ Assert.Equal(1, factory.ActiveCount);
+ Assert.Equal(0, factory.RetryCount);
+ Assert.Equal(2, factory.PendingCount);
+ Assert.Equal("priority", MessageFromAnyDialog(root.Modal!));
+
+ Assert.True(factory.CloseDialog(priority));
+ Assert.Equal("active", MessageFromAnyDialog(root.Modal!));
+ Assert.Equal(1, factory.PendingCount);
+ Assert.True(factory.CloseDialog(active));
+ Assert.Equal("queued", MessageFromAnyDialog(root.Modal!));
+ Assert.True(factory.CloseDialog(queued));
+ }
+
+ [Fact]
+ public void MultipleRetries_NewestPriorityFirstThenOlderPriorityThenOrdinaryFifo()
+ {
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ bool available = false;
+ using var factory = new RetailDialogFactory(root, type =>
+ !available ? null : BuildDialogLayout(type));
+
+ uint ordinary = factory.MakeWait("ordinary");
+ uint later = factory.MakeWait("later");
+ uint olderPriority = factory.MakeDialog(
+ Priority(RetailDialogData.Message("older priority")));
+ uint newerPriority = factory.MakeDialog(Priority(
+ RetailDialogData.ConfirmationTextInput("newer priority")));
+
+ Assert.Equal(0, factory.ActiveCount);
+ Assert.Equal(3, factory.RetryCount);
+ Assert.Equal(1, factory.PendingCount);
+
+ available = true;
+ factory.Tick();
+
+ Assert.Equal(1, factory.ActiveCount);
+ Assert.Equal(2, factory.RetryCount);
+ Assert.Equal("newer priority", MessageFromAnyDialog(root.Modal!));
+
+ Assert.True(factory.CloseDialog(newerPriority));
+ Assert.Equal("older priority", MessageFromAnyDialog(root.Modal!));
+ Assert.True(factory.CloseDialog(olderPriority));
+ Assert.Equal("ordinary", MessageFromAnyDialog(root.Modal!));
+ Assert.True(factory.CloseDialog(ordinary));
+ Assert.Equal("later", MessageFromAnyDialog(root.Modal!));
+ Assert.True(factory.CloseDialog(later));
+ }
+
private static RetailDialogFactory CreateFactory(
UiRoot root,
List layouts)
@@ -401,4 +669,105 @@ public sealed class RetailDialogFactoryTests
private static string Message(ImportedLayout layout)
=> 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));
+
+ private static RetailDialogData Priority(RetailDialogData data) =>
+ data.Set(RetailDialogProperty.Priority, true)
+ .Set(RetailDialogProperty.QueueKey,
+ RetailDialogFactory.DefaultQueueKey);
+
+ internal static ImportedLayout BuildDialogLayout(RetailDialogType type)
+ {
+ uint rootId = RetailDialogFactory.RootElementId(type);
+ uint rootType = type switch
+ {
+ RetailDialogType.Message => 0x17u,
+ RetailDialogType.ConfirmationTextInput => 0x15u,
+ RetailDialogType.Wait => 0x19u,
+ _ => 0x13u,
+ };
+ var root = new ElementInfo
+ {
+ Id = rootId,
+ Type = rootType,
+ Width = 800f,
+ Height = 600f,
+ };
+ var popup = new ElementInfo
+ {
+ Id = 0x3Du,
+ Type = 3u,
+ Width = 400f,
+ Height = type == RetailDialogType.ConfirmationTextInput ? 125f : 95f,
+ };
+ popup.Children.Add(new ElementInfo
+ {
+ Id = 0x3Eu,
+ Type = 12u,
+ X = 15f,
+ Y = 15f,
+ Width = 370f,
+ Height = 18f,
+ });
+ if (type == RetailDialogType.Message)
+ {
+ popup.Children.Add(new ElementInfo
+ {
+ Id = RetailMessageDialogView.OkButtonId,
+ Type = 1u,
+ X = 160f,
+ Y = 48f,
+ Width = 80f,
+ Height = 32f,
+ });
+ }
+ else if (type == RetailDialogType.ConfirmationTextInput)
+ {
+ var field = new ElementInfo
+ {
+ Id = RetailConfirmationTextInputDialogView.InputElementId,
+ Type = 12u,
+ X = 4f,
+ Y = 43f,
+ Width = 152f,
+ Height = 16f,
+ };
+ var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
+ direct.Properties.Values[0x16u] = new UiPropertyValue
+ {
+ Kind = UiPropertyKind.Bool,
+ BoolValue = true,
+ };
+ field.States.Add(UiStateInfo.DirectStateId, direct);
+ popup.Children.Add(field);
+ popup.Children.Add(new ElementInfo
+ {
+ Id = RetailConfirmationTextInputDialogView.AcceptButtonId,
+ Type = 1u,
+ X = 80f,
+ Y = 78f,
+ Width = 80f,
+ Height = 32f,
+ });
+ popup.Children.Add(new ElementInfo
+ {
+ Id = RetailConfirmationTextInputDialogView.RejectButtonId,
+ Type = 1u,
+ X = 240f,
+ Y = 78f,
+ Width = 80f,
+ Height = 32f,
+ });
+ }
+ root.Children.Add(popup);
+ return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs
index 4eab1d72..8baaaa38 100644
--- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs
@@ -30,6 +30,29 @@ public class UiButtonTests
Assert.Equal((17, 9), clicked);
}
+ [Fact]
+ public void DoubleClick_IsOptInAndDisabledButtonsSwallowWithoutInvoking()
+ {
+ int activations = 0;
+ var button = new UiButton(
+ new ElementInfo { Type = 1, Width = 46, Height = 18 },
+ NoTex);
+ var doubleClick = new UiEvent(
+ 0,
+ button,
+ UiEventType.DoubleClick);
+
+ Assert.False(button.OnEvent(doubleClick));
+
+ button.OnDoubleClick = () => activations++;
+ Assert.True(button.OnEvent(doubleClick));
+ Assert.Equal(1, activations);
+
+ button.Enabled = false;
+ Assert.True(button.OnEvent(doubleClick));
+ Assert.Equal(1, activations);
+ }
+
[Fact]
public void PointerDownAndUp_InvokeDistinctTransitionHandlers()
{