merge: Campaign LA LA8 - retail character screen review-closed

This commit is contained in:
Erik 2026-08-14 21:16:25 +02:00
commit fe63ce186a
20 changed files with 3431 additions and 37 deletions

View file

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

View file

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

View file

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

View file

@ -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
}
}
/// <summary>
/// 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 <see langword="null"/>,
/// while <c>CurrentGameRuntimeAdapter</c> keeps an already-borrowed reference
/// inert if disposal races the render-thread consumer.
/// </summary>
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

View file

@ -0,0 +1,705 @@
using System.Numerics;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Projects Runtime's one borrowed pre-world character-selection owner through
/// retail <c>gmCharacterManagementUI</c>'s authored retained layout. The list is
/// intentionally flat: the retail class owns no viewport or model preview.
/// </summary>
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<string, string> 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<UiButton> _rows = [];
private readonly Dictionary<UiButton, uint> _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<UiButton> 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<uint, uint, UiElement?> 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<uint, uint, UiElement?> 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<RuntimeCharacterSelectionEntry> roster,
int allowedSlotCount)
{
if (_rows.Count != roster.Count)
return false;
int rowHeight = ComputeRowHeight(
_list.Height,
roster.Count,
allowedSlotCount);
for (int i = 0; i < roster.Count; i++)
{
UiButton row = _rows[i];
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<RuntimeCharacterSelectionEntry> 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;
}
}
}

View file

@ -0,0 +1,106 @@
namespace AcDream.App.UI.Layout;
internal sealed record CharacterManagementUiMountResources(
uint LayoutId,
ImportedLayout Layout,
Func<uint, uint, UiElement?> TemplateResolver,
CharacterManagementUiController.DialogStrings Strings);
/// <summary>
/// Retryable, idempotent composition edge for the pre-world character screen.
/// DATs can become readable after the graphical runtime starts (installer copy,
/// mapped-file replacement, or a transient catalog miss), so an unavailable
/// dialog catalog, root, template, or string must not permanently suppress the
/// screen. Once bound, later ticks are no-ops and cannot duplicate the root or
/// controller lifetime.
/// </summary>
internal sealed class CharacterManagementUiMountCoordinator : IDisposable
{
private readonly UiRoot _host;
private readonly CharacterSelectionRuntimeBindings _bindings;
private readonly Func<RetailDialogFactory?> _ensureDialogs;
private readonly Func<CharacterManagementUiMountResources?> _loadResources;
private bool _disposed;
public CharacterManagementUiMountCoordinator(
UiRoot host,
CharacterSelectionRuntimeBindings bindings,
Func<RetailDialogFactory?> ensureDialogs,
Func<CharacterManagementUiMountResources?> loadResources)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_ensureDialogs = ensureDialogs
?? throw new ArgumentNullException(nameof(ensureDialogs));
_loadResources = loadResources
?? throw new ArgumentNullException(nameof(loadResources));
}
public CharacterManagementUiController? Controller { get; private set; }
public void Tick()
{
if (_disposed || Controller is not null)
return;
try
{
RetailDialogFactory? dialogs = _ensureDialogs();
if (dialogs is null)
return;
CharacterManagementUiMountResources? resources = _loadResources();
if (resources is null)
return;
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;
}
}

View file

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

View file

@ -0,0 +1,160 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail type-5 <c>ConfirmationTextInputDialog</c> (class type
/// <c>0x15</c>, catalog root <c>0x2C</c>). Accept stores the field text under
/// property <c>0x9C</c>; reject/Escape stores the empty string. Character
/// deletion is the first consumer and performs retail's case-insensitive
/// comparison with the localized <c>DELETE</c> response in its callback.
/// </summary>
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<uint> _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<uint> 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<string, float> measure = _message.DatFont is { } font
? font.MeasureWidth
: static value => value.Length * 8f;
IReadOnlyList<string> 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);
}
}

View file

@ -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;
/// <summary>
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
/// attribute <c>0x40</c>. 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);
}
}

View file

@ -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<RetailDialogData>? Callback { get; init; }
public IRetailDialogView? View { get; set; }
}
@ -24,8 +25,10 @@ public sealed class RetailDialogFactory : IDisposable
private readonly Dictionary<uint, DialogInfo> _activeQueued = new();
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
private readonly LinkedList<DialogInfo> _retryable = new();
private readonly List<DialogInfo> _openOrder = new();
private uint _globalContext;
private 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;
/// <summary>Exact root-element switch from <c>CreateDialog_ @ 0x00477AD0</c>.</summary>
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<DialogInfo> 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<RetailDialogData>? callback = null,
uint queueKey = DefaultQueueKey)
{
RetailDialogData data = RetailDialogData.Message(message)
.Set(RetailDialogProperty.QueueKey, queueKey);
return MakeDialog(data, callback);
}
public uint MakeConfirmationTextInput(
string message,
Action<RetailDialogData>? callback = null,
uint queueKey = DefaultQueueKey)
{
RetailDialogData data = RetailDialogData.ConfirmationTextInput(message)
.Set(RetailDialogProperty.QueueKey, queueKey);
return MakeDialog(data, callback);
}
/// <summary>
/// Retail <c>CloseDialog @ 0x00478160</c>. 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<DialogInfo>? retry = _retryable.First;
while (retry is not null && retry.Value.Context != context)
retry = retry.Next;
if (retry is not null)
{
DialogInfo failed = retry.Value;
_retryable.Remove(retry);
DialogDone(failed);
if (failed.QueueKey != NonQueuedKey)
OpenNextDialog(failed.QueueKey);
return true;
}
return false;
}
public void Tick()
{
RetryFailedDialogs();
foreach (DialogInfo info in _openOrder.ToArray())
info.View?.Tick();
}
@ -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<DialogInfo> PendingQueue(uint queueKey)
{
if (_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? 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<DialogInfo>? 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<DialogInfo> 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<DialogInfo>? 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()

View file

@ -0,0 +1,105 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail type-3 <c>MessageDialog</c> (class type <c>0x17</c>, catalog root
/// <c>0x24</c>). It shares the dialog catalog's popup/message pair with the
/// existing confirmation and wait presenters and closes from its authored OK
/// button <c>0x26</c> or Escape.
/// </summary>
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<uint> _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<uint> 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<string, float> measure = _message.DatFont is { } font
? font.MeasureWidth
: static value => value.Length * 8f;
IReadOnlyList<string> 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);
}
}

View file

@ -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);
/// <summary>
/// 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).
/// </summary>
public sealed record CharacterSelectionRuntimeBindings(
Func<IRuntimeCharacterSelectionView?> View,
Func<uint, RuntimeCommandResult> Highlight,
Func<RuntimeCommandResult> Enter,
Func<RuntimeCommandResult> RequestDelete,
Func<RuntimeCommandResult> ConfirmDelete,
Func<RuntimeCommandResult> Restore,
Func<RuntimeCommandResult> 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);
/// <summary>
/// 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<uint, string>
{
[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<uint, string>
{
[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);

View file

@ -49,6 +49,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; }
/// <summary>
/// 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).
/// </summary>
public Action? OnDoubleClick { get; set; }
/// <summary>
/// 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

View file

@ -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<ObjectDisposedException>(() => 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();

View file

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

View file

@ -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;
/// <summary>
/// Installed-retail-DAT acceptance gate for LA8. Opt in with
/// <c>ACDREAM_PROBE_LIVE_MOUNT=1</c>; <c>ACDREAM_DAT_DIR</c> can override the
/// ordinary Documents/Asheron's Call location. Reads the DATs read-only.
/// </summary>
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<ElementInfo>(
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<UiTemplateListBox>(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<ElementInfo>(
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<UiDialogRoot>(message.Root);
Assert.IsType<UiText>(message.FindElement(0x3Eu));
Assert.IsType<UiButton>(message.FindElement(0x26u));
ImportedLayout delete = BuildSelected(dats, dialogDid, 0x2Cu);
Assert.IsType<UiDialogRoot>(delete.Root);
Assert.IsType<UiField>(delete.FindElement(0x2Cu));
Assert.IsType<UiButton>(delete.FindElement(0x2Eu));
Assert.IsType<UiButton>(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<string>(strings.ResolveTemplate(
table,
"ID_CharacterManagement_DeleteCharacterConfirmation",
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = "Test Character",
}));
Assert.Contains("Test Character", confirmation);
Assert.Contains("'DELETE'", confirmation);
StringTable stringTable = Assert.IsType<StringTable>(dats.Get<StringTable>(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<ElementInfo>(
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<string>(strings.Resolve(
table,
DatStringResolver.ComputeHash(key)));
private static void AssertButton(
ImportedLayout layout,
uint elementId,
string label) => Assert.Equal(
label,
Assert.IsType<UiButton>(layout.FindElement(elementId)).Label);
private static IEnumerable<UiElement> 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}'.";
}
}

View file

@ -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<UiTemplateListBox>(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<UiTemplateListBox>(
environment.Screen.FindElement(
CharacterManagementUiController.ListElementId));
Assert.Equal(384, list.ContentHeight);
Assert.Equal(32, list.LineHeight);
}
[Fact]
public void MountCoordinator_RetriesCatalogRootAndStrings_ThenMountsOnce()
{
var host = new UiRoot();
var runtime = new FakeRuntime();
using var dialogs = new RetailDialogFactory(
host,
RetailDialogFactoryTests.BuildDialogLayout);
bool catalogAvailable = false;
bool rootAvailable = false;
bool stringsAvailable = false;
int dialogAttempts = 0;
int resourceAttempts = 0;
using var coordinator = new CharacterManagementUiMountCoordinator(
host,
runtime.Bindings,
() =>
{
dialogAttempts++;
return catalogAvailable ? dialogs : null;
},
() =>
{
resourceAttempts++;
if (!rootAvailable || !stringsAvailable)
return null;
return new CharacterManagementUiMountResources(
0x21000004u,
BuildScreen(),
static (layoutId, elementId) =>
layoutId == 0x21000004u
&& elementId == 0x100003A5u
? BuildRow()
: null,
TestStrings());
});
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Equal(1, dialogAttempts);
Assert.Equal(0, resourceAttempts);
catalogAvailable = true;
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Equal(2, dialogAttempts);
Assert.Equal(1, resourceAttempts);
rootAvailable = true;
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Equal(3, dialogAttempts);
Assert.Equal(2, resourceAttempts);
stringsAvailable = true;
coordinator.Tick();
CharacterManagementUiController controller = Assert.IsType<
CharacterManagementUiController>(coordinator.Controller);
Assert.Single(host.Children);
Assert.Same(controller.Root, host.Children[0]);
Assert.Equal(4, dialogAttempts);
Assert.Equal(3, resourceAttempts);
coordinator.Tick();
Assert.Same(controller, coordinator.Controller);
Assert.Single(host.Children);
Assert.Equal(4, dialogAttempts);
Assert.Equal(3, resourceAttempts);
coordinator.Dispose();
Assert.Empty(host.Children);
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Equal(4, dialogAttempts);
Assert.Equal(3, resourceAttempts);
}
[Fact]
public void 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<ImportedLayout>();
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<UiButton>(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>(
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<UiButton>(screen.FindElement(
CharacterManagementUiController.EnterElementId)).OnClick);
Assert.Null(Assert.IsType<UiButton>(screen.FindElement(
CharacterManagementUiController.DeleteElementId)).OnClick);
Assert.Null(Assert.IsType<UiButton>(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<UiElement> 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<UiButton>(layout.FindElement(id));
private static UiField Input(ImportedLayout layout) =>
Assert.IsType<UiField>(layout.FindElement(
RetailConfirmationTextInputDialogView.InputElementId));
private static string Message(ImportedLayout layout) => string.Join(
" ",
Assert.IsType<UiText>(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>(
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<UiButton>(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<RuntimeCharacterSelectionEntry?>()
.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<RuntimeCharacterSelectionSnapshot,
RuntimeCharacterSelectionSnapshot> 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() { }
}
}

View file

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

View file

@ -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<string>();
factory.MakeConfirmationTextInput(
"Type DELETE.",
data => results.Add(
data.GetString(RetailDialogProperty.TextInputResult) ?? "<null>"));
ImportedLayout accepted = layouts[^1].Layout;
var field = Assert.IsType<UiField>(
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) ?? "<null>"));
ImportedLayout rejected = layouts[^1].Layout;
UiDialogRoot rejectedRoot = Assert.IsType<UiDialogRoot>(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<uint, UiElement>()),
};
}
return BuildDialogLayout(type);
});
RetailDialogData data = type switch
{
RetailDialogType.Wait => RetailDialogData.Wait("Please Wait"),
RetailDialogType.Message => RetailDialogData.Message("Error"),
_ => RetailDialogData.ConfirmationTextInput("Type DELETE"),
};
uint context = 0u;
Exception? creationError = Record.Exception(
() => context = factory.MakeDialog(data));
Assert.Null(creationError);
Assert.NotEqual(0u, context);
Assert.Equal(0, factory.ActiveCount);
Assert.Equal(0, factory.PendingCount);
Assert.Equal(1, factory.RetryCount);
Assert.Null(root.Modal);
Assert.Empty(root.Children);
available = true;
factory.Tick();
Assert.Equal(2, attempts);
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(0, factory.PendingCount);
Assert.Equal(0, factory.RetryCount);
Assert.NotNull(root.Modal);
Assert.True(factory.CloseDialog(context));
Assert.False(factory.IsOpen);
}
[Fact]
public void PendingCatalogFailure_MovesOutOfQueue_ThenRecoversBeforeLaterWork()
{
var root = new UiRoot { Width = 800f, Height = 600f };
bool messageAvailable = false;
var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>();
using var factory = new RetailDialogFactory(root, type =>
{
if (type == RetailDialogType.Message && !messageAvailable)
return null;
ImportedLayout layout = BuildDialogLayout(type);
layouts.Add((type, layout));
return layout;
});
uint active = factory.MakeWait("active");
uint failed = factory.MakeMessage("recover me");
uint later = factory.MakeWait("later");
Assert.Equal(2, factory.PendingCount);
Assert.True(factory.CloseDialog(active));
Assert.Equal(0, factory.ActiveCount);
Assert.Equal(1, factory.PendingCount);
Assert.Equal(1, factory.RetryCount);
Assert.Null(root.Modal);
messageAvailable = true;
factory.Tick();
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(1, factory.PendingCount);
Assert.Equal(0, factory.RetryCount);
Assert.Equal(
"recover me",
MessageFromAnyDialog(layouts.Last(static entry =>
entry.Type == RetailDialogType.Message).Layout.Root));
Assert.True(factory.CloseDialog(failed));
Assert.Equal("later", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(later));
}
[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<ImportedLayout> layouts)
@ -401,4 +669,105 @@ public sealed class RetailDialogFactoryTests
private static string Message(ImportedLayout layout)
=> string.Join(" ", Assert.IsType<UiText>(layout.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text));
private static string MessageFromAnyDialog(UiElement root)
=> string.Join(
" ",
Assert.IsType<UiText>(UiElement.FindDescendant(
root,
RetailConfirmationDialogView.MessageElementId))
.LinesProvider()
.Select(static line => line.Text));
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);
}
}

View file

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