Implement retail character management screen

This commit is contained in:
Erik 2026-08-14 20:29:19 +02:00
parent 5535d0adac
commit 6cfab727f1
19 changed files with 2435 additions and 6 deletions

View file

@ -157,6 +157,16 @@ window registration, plugin mounts, cursor feedback, layout persistence, and the
retained tick/draw/restore/dispose paths. Panel-specific construction must not retained tick/draw/restore/dispose paths. Panel-specific construction must not
move back into `GameWindow.OnLoad`. 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/ Magic follows the same boundary. Core `Spellbook` is the one learned/favorite/
desired/enchantment state projection; Core.Net owns exact manifest and live desired/enchantment state projection; Core.Net owns exact manifest and live
message parsing; Runtime `RuntimeActionState.SpellCast` owns validated cast message parsing; Runtime `RuntimeActionState.SpellCast` owns validated cast

View file

@ -0,0 +1,104 @@
# 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`, 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.
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. 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.
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.Core.Spells;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Vitals; using AcDream.UI.Abstractions.Panels.Vitals;
@ -937,7 +938,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
(action, held) => (action, held) =>
d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true, d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
late.Automation), 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 runtime = lease.Mount(
() => RetailUiRuntime.CreateUninitialized(bindings)); () => RetailUiRuntime.CreateUninitialized(bindings));
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);

View file

@ -10,6 +10,7 @@ using AcDream.Core.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
using Silk.NET.Windowing; 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( public IDisposable Bind(
IGameRuntimeView view, IGameRuntimeView view,
IGameRuntimeCommands commands) IGameRuntimeCommands commands)
@ -118,6 +137,35 @@ internal sealed class DeferredGameRuntimeStateCommands
generation, generation,
new RuntimeAdvancementCommand(kind, statId, cost))); 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 ───────────────── // ── Campaign FA slice FA4: fellowship page commands ─────────────────
// Same "capture view+commands under one generation" shape as every // Same "capture view+commands under one generation" shape as every
// method above — a displaced session (reconnect mid-click) can never // method above — a displaced session (reconnect mid-click) can never

View file

@ -0,0 +1,578 @@
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 _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)
{
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;
var controller = new CharacterManagementUiController(
host,
layout,
list,
create,
enter,
delete,
restore,
dialogs,
bindings,
strings);
host.AddChild(controller.Root);
controller.Tick();
return controller;
}
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))
{
ApplyHighlight(snapshot.HighlightedCharacterId);
rowsReady = true;
}
else
{
rowsReady = RebuildRows(
roster,
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;
CloseAllDialogs(suppressCallbacks: true);
_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)
{
if (_rows.Count != roster.Count)
return false;
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)
|| row.LabelColor != (character.IsPendingDelete
? new Vector4(1f, 0f, 0f, 1f)
: Vector4.One))
{
return false;
}
}
return true;
}
private bool RebuildRows(
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
uint highlightedCharacterId)
{
foreach (UiButton row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
}
_rows.Clear();
_rowIds.Clear();
_list.Flush();
bool complete = true;
foreach (RuntimeCharacterSelectionEntry character in roster)
{
if (_list.AddItemFromTemplateList(0) is not UiButton row)
{
complete = false;
break;
}
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;
}
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;
RuntimeCommandResult result = _bindings.Restore();
if (result.Accepted)
EnsureOperationWait();
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 (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

@ -122,6 +122,8 @@ public static class DatWidgetFactory
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137) 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text 12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
0x13 => new UiDialogRoot(), // ConfirmationDialog 0x13 => new UiDialogRoot(), // ConfirmationDialog
0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog
0x17 => new UiDialogRoot(), // MessageDialog
0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396) 0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396)
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots 0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
0x10000035u => BuildCheckbox( 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 AcceptLabel = 0x90u;
public const uint RejectLabel = 0x91u; public const uint RejectLabel = 0x91u;
public const uint ConfirmationResult = 0x92u; public const uint ConfirmationResult = 0x92u;
public const uint TextInputAcceptLabel = 0x9Au;
public const uint TextInputRejectLabel = 0x9Bu;
public const uint TextInputResult = 0x9Cu;
/// <summary> /// <summary>
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean /// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
/// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable. /// 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.ElementAttribute40, true)
.Set(RetailDialogProperty.Message, message); .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

@ -148,6 +148,26 @@ public sealed class RetailDialogFactory : IDisposable
return MakeDialog(data, callback: null); 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> /// <summary>
/// Retail <c>CloseDialog @ 0x00478160</c>. The context can identify an active /// 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. /// nonqueued dialog, an active queued dialog, or an item still pending in a queue.
@ -275,7 +295,10 @@ public sealed class RetailDialogFactory : IDisposable
private void CreateDialog(DialogInfo info) private void CreateDialog(DialogInfo info)
{ {
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type); RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait)) if (type is not (RetailDialogType.Confirmation
or RetailDialogType.Wait
or RetailDialogType.Message
or RetailDialogType.ConfirmationTextInput))
throw new NotSupportedException( throw new NotSupportedException(
$"Retail dialog type {(uint)type} does not have a ported presenter yet."); $"Retail dialog type {(uint)type} does not have a ported presenter yet.");
@ -285,6 +308,13 @@ public sealed class RetailDialogFactory : IDisposable
IRetailDialogView view = type switch IRetailDialogView view = type switch
{ {
RetailDialogType.Wait => new RetailWaitDialogView(_host, layout, info.Data), 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( _ => new RetailConfirmationDialogView(
_host, layout, info.Data, info.Context, _host, layout, info.Data, info.Context,
context => CloseDialog(context)), context => CloseDialog(context)),
@ -294,6 +324,7 @@ public sealed class RetailDialogFactory : IDisposable
_host.BringToFront(view.Root); _host.BringToFront(view.Root);
_openOrder.Add(info); _openOrder.Add(info);
_host.Modal = view.Root; _host.Modal = view.Root;
view.Tick();
UpdatePendingDialogDisplays(); UpdatePendingDialogDisplays();
DialogOpened?.Invoke(info.Context); DialogOpened?.Invoke(info.Context);
} }

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.Core.Spells;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Input; using AcDream.Core.Input;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
@ -369,6 +370,21 @@ public sealed record KeyboardRuntimeBindings(
InputDispatcher? Dispatcher, InputDispatcher? Dispatcher,
string KeyBindingsFilePath); 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( public sealed record RetailUiRuntimeBindings(
UiHost Host, UiHost Host,
RetailUiAssets Assets, RetailUiAssets Assets,
@ -395,7 +411,8 @@ public sealed record RetailUiRuntimeBindings(
BufferedUiRegistry? Plugins, BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence, RetailUiPersistenceBindings? Persistence,
RetailUiProbeBindings Probe, RetailUiProbeBindings Probe,
KeyboardRuntimeBindings? Keyboard = null); KeyboardRuntimeBindings? Keyboard = null,
CharacterSelectionRuntimeBindings? CharacterSelection = null);
/// <summary> /// <summary>
/// Composition owner for the production retained gameplay UI. GameWindow supplies /// Composition owner for the production retained gameplay UI. GameWindow supplies
@ -483,6 +500,7 @@ public sealed class RetailUiRuntime : IDisposable
MountVendor(); MountVendor();
MountSecureTrade(); MountSecureTrade();
MountItemCooldowns(); MountItemCooldowns();
MountCharacterManagement();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged; Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons(); BindToolbarPanelButtons();
SyncToolbarWindowButtons(); SyncToolbarWindowButtons();
@ -577,6 +595,7 @@ public sealed class RetailUiRuntime : IDisposable
public VendorUiController? VendorController { get; private set; } public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; } public SocialPanelController? SocialPanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController { get; private set; }
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{ {
@ -622,6 +641,7 @@ public sealed class RetailUiRuntime : IDisposable
ExternalContainerController?.Tick(); ExternalContainerController?.Tick();
SocialPanelController?.Tick(); SocialPanelController?.Tick();
_itemCooldownController?.Tick(); _itemCooldownController?.Tick();
CharacterManagementController?.Tick();
DialogFactory?.Tick(); DialogFactory?.Tick();
Host.Tick(deltaSeconds); Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds); _automation?.Tick(deltaSeconds);
@ -788,6 +808,7 @@ public sealed class RetailUiRuntime : IDisposable
{ {
try try
{ {
CharacterManagementController?.ResetSession();
DialogFactory?.Reset(); DialogFactory?.Reset();
} }
finally finally
@ -3669,6 +3690,144 @@ public sealed class RetailUiRuntime : IDisposable
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D."); "[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
} }
private void MountCharacterManagement()
{
CharacterSelectionRuntimeBindings? bindings =
_bindings.CharacterSelection;
if (bindings is null)
return;
if (DialogFactory is null)
{
Console.WriteLine(
"[UI] character management: retail DialogFactory is unavailable.");
return;
}
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;
}
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;
}
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,
})!);
}
}
CharacterManagementController = CharacterManagementUiController.Bind(
Host.Root,
layout,
ResolveTemplate,
DialogFactory,
bindings,
new CharacterManagementUiController.DialogStrings(
ComposeDeleteConfirmation,
deleteResponse,
pleaseWait,
enteringWorld));
if (CharacterManagementController is null)
return;
Console.WriteLine(
$"[UI] retail character management from enum table 5 "
+ $"(0x10000005 -> 0x{layoutId:X8}, root 0x1000039A; flat list, no viewport).");
}
private static string? ResolveCharacterManagementString(
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() private void MountItemCooldowns()
{ {
ItemCooldownAssets? assets; ItemCooldownAssets? assets;
@ -3712,7 +3871,11 @@ public sealed class RetailUiRuntime : IDisposable
} }
}, },
() => _itemConfirmationController?.Dispose(), () => _itemConfirmationController?.Dispose(),
() => _gameplayConfirmationController?.Dispose(), () =>
{
CharacterManagementController?.Dispose();
_gameplayConfirmationController?.Dispose();
},
() => DialogFactory?.Dispose(), () => DialogFactory?.Dispose(),
_panelUi.Dispose, _panelUi.Dispose,
Host.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> /// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; } 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> /// <summary>
/// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard /// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard
/// screen: right-click a bound key button to erase that one binding — /// 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(); OnClick?.Invoke();
OnClickAt?.Invoke(e.Data1, e.Data2); OnClickAt?.Invoke(e.Data1, e.Data2);
return OnClick is not null || OnClickAt is not null; 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: case UiEventType.RightClick:
// S6 (2026-08-11 review): unlike Click (whose swallow-when- // S6 (2026-08-11 review): unlike Click (whose swallow-when-
// disabled is pre-existing, harmless-by-construction behavior // 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.Net;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.Composition; namespace AcDream.App.Tests.Composition;
@ -106,6 +107,44 @@ public sealed class InteractionUiRuntimeSourcesTests
Assert.Throws<ObjectDisposedException>(() => source.Bind(first, first)); 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] [Fact]
public void RadarNeverCachesAnUnboundBootstrapSnapshot() public void RadarNeverCachesAnUnboundBootstrapSnapshot()
{ {
@ -216,7 +255,9 @@ public sealed class InteractionUiRuntimeSourcesTests
IGameRuntimeCommands, IGameRuntimeCommands,
IRuntimeInventoryStateCommands, IRuntimeInventoryStateCommands,
IRuntimeSpellbookCommands, IRuntimeSpellbookCommands,
IRuntimeCharacterCommands IRuntimeCharacterCommands,
IRuntimeCharacterSelectionView,
IRuntimeCharacterSelectionCommands
{ {
public RuntimeTarget(RuntimeGenerationToken generation) public RuntimeTarget(RuntimeGenerationToken generation)
{ {
@ -234,6 +275,7 @@ public sealed class InteractionUiRuntimeSourcesTests
public IRuntimeCharacterView Character => null!; public IRuntimeCharacterView Character => null!;
public IRuntimeSocialView Social => null!; public IRuntimeSocialView Social => null!;
public IRuntimeChatView Chat => null!; public IRuntimeChatView Chat => null!;
public IRuntimeCharacterSelectionView CharacterSelection => this;
public IRuntimeFellowshipView Fellowship => null!; public IRuntimeFellowshipView Fellowship => null!;
public IRuntimeAllegianceView Allegiance => null!; public IRuntimeAllegianceView Allegiance => null!;
public IRuntimeActionView Actions => null!; public IRuntimeActionView Actions => null!;
@ -242,6 +284,7 @@ public sealed class InteractionUiRuntimeSourcesTests
null!; null!;
public IRuntimePortalView Portal => null!; public IRuntimePortalView Portal => null!;
public IRuntimeSessionCommands Session => null!; public IRuntimeSessionCommands Session => null!;
IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection => this;
public IRuntimeSelectionCommands Selection => null!; public IRuntimeSelectionCommands Selection => null!;
public IRuntimeCombatCommands Combat => null!; public IRuntimeCombatCommands Combat => null!;
public IRuntimeMagicCommands Magic => null!; public IRuntimeMagicCommands Magic => null!;
@ -257,6 +300,67 @@ public sealed class InteractionUiRuntimeSourcesTests
public RuntimeStateCheckpoint CaptureCheckpoint() => default; 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( public RuntimeCommandResult AddShortcut(
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
in RuntimeShortcutCommand command) => 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 private sealed class EmptyRadarSource : ILiveEntityRadarSource
{ {
public static EmptyRadarSource Instance { get; } = new(); public static EmptyRadarSource Instance { get; } = new();

View file

@ -137,12 +137,19 @@ public sealed class SessionPlayerCompositionTests
[Fact] [Fact]
public void GraphicalCompositionPausesOnlyWhenCharacterSelectorIsAbsent() public void GraphicalCompositionPausesOnlyWhenCharacterSelectorIsAbsent()
{ {
string root = FindRepoRoot();
string phase = File.ReadAllText(Path.Combine( string phase = File.ReadAllText(Path.Combine(
FindRepoRoot(), root,
"src", "src",
"AcDream.App", "AcDream.App",
"Composition", "Composition",
"SessionPlayerComposition.cs")); "SessionPlayerComposition.cs"));
string retainedUi = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"InteractionRetainedUiComposition.cs"));
Assert.Contains( Assert.Contains(
"AwaitCharacterSelection:", "AwaitCharacterSelection:",
@ -156,6 +163,18 @@ public sealed class SessionPlayerCompositionTests
"CharacterList.TrySelectFirstAvailable", "CharacterList.TrySelectFirstAvailable",
phase, phase,
StringComparison.Ordinal); 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( private sealed class RetryBinding(

View file

@ -0,0 +1,164 @@
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
{
[Fact]
public void EnumTable5_ResolvesAndImportsTheExactRetailScreenAndDialogs()
{
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
return;
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"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;
}
}

View file

@ -0,0 +1,716 @@
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.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 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 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 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 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()
{
uint id = View.Snapshot.HighlightedCharacterId;
Update(snapshot => snapshot with
{
LastRestoreRequestedCharacterId = id,
Operation = RuntimeCharacterSelectionOperation.RestoreRequested,
Buttons = new RuntimeCharacterSelectionButtons(
false,
false,
false,
false,
true),
});
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] [Theory]
[InlineData(0x13u)] // ConfirmationDialog (catalog root 0x15) [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 [InlineData(0x19u)] // WaitDialog (catalog root 0x31 — OP8 #396's live
// crash: unmapped type built a plain UiDatElement and // crash: unmapped type built a plain UiDatElement and
// RetailWaitDialogView's ctor threw out of OnClick) // RetailWaitDialogView's ctor threw out of OnClick)

View file

@ -378,6 +378,68 @@ public sealed class RetailDialogFactoryTests
Assert.Equal("text", data.GetString(RetailDialogProperty.Message)); 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);
}
private static RetailDialogFactory CreateFactory( private static RetailDialogFactory CreateFactory(
UiRoot root, UiRoot root,
List<ImportedLayout> layouts) List<ImportedLayout> layouts)
@ -401,4 +463,91 @@ public sealed class RetailDialogFactoryTests
private static string Message(ImportedLayout layout) private static string Message(ImportedLayout layout)
=> string.Join(" ", Assert.IsType<UiText>(layout.FindElement( => string.Join(" ", Assert.IsType<UiText>(layout.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)); RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text));
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); 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] [Fact]
public void PointerDownAndUp_InvokeDistinctTransitionHandlers() public void PointerDownAndUp_InvokeDistinctTransitionHandlers()
{ {