feat(ui): port retail dialog factory lifecycle

Replace the single mutable confirmation service with retail's property-backed DialogFactory model: fresh DAT roots, context ids, queue groups, priority preemption, callback/close-notice ordering, and context cancellation. Route /die, server confirmation aborts, and guarded item use through focused semantic owners.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-13 16:30:05 +02:00
parent 43f7c7807c
commit 66bdae7a83
22 changed files with 1442 additions and 220 deletions

View file

@ -1,122 +0,0 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained implementation of retail <c>DialogFactory</c>'s confirmation queue.
/// The visual tree is element <c>0x15</c> from the shared dialog catalog resolved by
/// <c>GetDIDByEnum(2, 5)</c>; callbacks mirror property <c>0x92</c> from
/// <c>ConfirmationDialog::ListenToElementMessage @ 0x00476670</c>.
/// </summary>
public sealed class RetailConfirmationDialogService
{
public const uint RootElementId = 0x15u;
public const uint PopupElementId = 0x3Du;
public const uint MessageElementId = 0x3Eu;
public const uint AcceptButtonId = 0x17u;
public const uint RejectButtonId = 0x19u;
private sealed record Request(string Message, Action<bool> Completed);
private readonly UiRoot _host;
private readonly UiDialogRoot _dialog;
private readonly UiElement _popup;
private readonly UiText _message;
private readonly UiButton _accept;
private readonly UiButton _reject;
private readonly Queue<Request> _queue = new();
private readonly float _basePopupHeight;
private readonly float _baseMessageHeight;
private Request? _current;
public RetailConfirmationDialogService(UiRoot host, ImportedLayout layout)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
ArgumentNullException.ThrowIfNull(layout);
_dialog = layout.Root as UiDialogRoot
?? throw new ArgumentException("Confirmation layout root is not a UiDialogRoot.", nameof(layout));
_popup = layout.FindElement(PopupElementId)
?? throw new ArgumentException("Confirmation layout is missing popup element 0x3D.", nameof(layout));
_message = layout.FindElement(MessageElementId) as UiText
?? throw new ArgumentException("Confirmation layout is missing text element 0x3E.", nameof(layout));
_accept = layout.FindElement(AcceptButtonId) as UiButton
?? throw new ArgumentException("Confirmation layout is missing accept button 0x17.", nameof(layout));
_reject = layout.FindElement(RejectButtonId) as UiButton
?? throw new ArgumentException("Confirmation layout is missing reject button 0x19.", 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;
_dialog.Cancel = () => Complete(false);
_accept.OnClick = () => Complete(true);
_reject.OnClick = () => Complete(false);
}
public bool IsOpen => _current is not null;
public int PendingCount => _queue.Count;
public void Show(string message, Action<bool> completed)
{
ArgumentNullException.ThrowIfNull(message);
ArgumentNullException.ThrowIfNull(completed);
_queue.Enqueue(new Request(message, completed));
if (_current is null)
ShowNext();
}
public void Tick()
{
if (_current is null) return;
SizeAndCenter();
}
private void ShowNext()
{
if (_queue.Count == 0) return;
_current = _queue.Dequeue();
float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
Func<string, float> measure = _message.DatFont is { } font
? font.MeasureWidth
: static text => text.Length * 8f;
IReadOnlyList<string> wrapped = UiText.WrapWords(
_current.Message,
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);
SizeAndCenter();
_host.AddChild(_dialog);
_host.Modal = _dialog;
}
private void SizeAndCenter()
{
_dialog.Left = 0f;
_dialog.Top = 0f;
_dialog.Width = _host.Width;
_dialog.Height = _host.Height;
_popup.Left = MathF.Round((_dialog.Width - _popup.Width) * 0.5f);
_popup.Top = MathF.Round((_dialog.Height - _popup.Height) * 0.5f);
}
private void Complete(bool accepted)
{
Request? completed = _current;
if (completed is null) return;
_current = null;
_host.RemoveChild(_dialog);
completed.Completed(accepted);
ShowNext();
}
}

View file

@ -0,0 +1,139 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// One live type-1 retail confirmation root. Instances are intentionally short-lived:
/// <c>DialogFactory::CreateDialog_ @ 0x00477AD0</c> creates a fresh catalog root for
/// every displayed <c>DialogInfo</c>.
/// </summary>
internal sealed class RetailConfirmationDialogView
{
public const uint RootElementId = 0x15u;
public const uint AcceptButtonId = 0x17u;
public const uint RejectButtonId = 0x19u;
public const uint PendingDisplayId = 0x33u;
public const uint PendingDisplayTextId = 0x34u;
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 UiButton _accept;
private readonly UiButton _reject;
private readonly UiElement? _pendingDisplay;
private readonly float _basePopupHeight;
private readonly float _baseMessageHeight;
public RetailConfirmationDialogView(
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 layout root is not a UiDialogRoot.", nameof(layout));
_popup = layout.FindElement(PopupElementId)
?? throw new ArgumentException("Confirmation layout is missing popup element 0x3D.", nameof(layout));
_message = layout.FindElement(MessageElementId) as UiText
?? throw new ArgumentException("Confirmation layout is missing text element 0x3E.", nameof(layout));
_accept = layout.FindElement(AcceptButtonId) as UiButton
?? throw new ArgumentException("Confirmation layout is missing accept button 0x17.", nameof(layout));
_reject = layout.FindElement(RejectButtonId) as UiButton
?? throw new ArgumentException("Confirmation layout is missing reject button 0x19.", nameof(layout));
_pendingDisplay = layout.FindElement(PendingDisplayId);
_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;
if (_data.GetString(RetailDialogProperty.AcceptLabel) is { } acceptLabel)
_accept.Label = acceptLabel;
if (_data.GetString(RetailDialogProperty.RejectLabel) is { } rejectLabel)
_reject.Label = rejectLabel;
Root.Cancel = Reject;
_accept.OnClick = Accept;
_reject.OnClick = Reject;
SetMessage(_data.GetString(RetailDialogProperty.Message) ?? string.Empty);
SizeAndCenter();
}
public UiDialogRoot Root { get; }
public RetailDialogData Data => _data;
public void Tick() => SizeAndCenter();
public void SetPendingCount(int count)
{
// Dialog::UpdatePendingDialogDisplay @ 0x00476930 resolves children
// 0x33/0x34 and calls SetState(0x19) for an empty queue or SetState(0x18)
// for a non-empty queue. Do not synthesize count text: retail does not.
if (_pendingDisplay is IUiDatStateful stateful)
stateful.TrySetRetailState(count == 0 ? 0x19u : 0x18u);
}
public void DetachHandlers()
{
Root.Cancel = null;
_accept.OnClick = null;
_reject.OnClick = null;
}
private void Accept()
{
// ConfirmationDialog::ListenToElementMessage @ 0x00476670.
_data.Set(RetailDialogProperty.ConfirmationResult, true);
_closeDialog(_context);
}
private void Reject()
{
// ConfirmationDialog::CancelDialog @ 0x00476770 uses the same false
// result for the reject button and Escape/cancel.
_data.Set(RetailDialogProperty.ConfirmationResult, false);
_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

@ -0,0 +1,113 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Property identifiers consumed by retail's <c>Dialog</c> and
/// <c>DialogFactory</c> implementations.
/// </summary>
public static class RetailDialogProperty
{
public const uint Priority = 0x8Du;
public const uint Type = 0x8Eu;
public const uint AcceptLabel = 0x90u;
public const uint RejectLabel = 0x91u;
public const uint ConfirmationResult = 0x92u;
/// <summary>
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
/// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable.
/// </summary>
public const uint ElementAttribute40 = 0xACu;
public const uint QueueKey = 0xC3u;
public const uint Message = 0xC5u;
public const uint UsageObjectId = 0x1000003Du;
}
/// <summary>
/// Retail dialog types selected by property <c>0x8E</c> in
/// <c>DialogFactory::CreateDialog_ @ 0x00477AD0</c>.
/// </summary>
public enum RetailDialogType : uint
{
Confirmation = 1,
Wait = 2,
Message = 3,
TextInput = 4,
ConfirmationTextInput = 5,
Menu = 6,
ConfirmationMenu = 7,
}
/// <summary>
/// Modern typed carrier for retail's <c>PropertyCollection</c> dialog contract.
/// The raw numeric keys remain visible because type-specific presenters and semantic
/// callbacks both extend the same collection in retail.
/// </summary>
public sealed class RetailDialogData
{
private readonly Dictionary<uint, object> _values = new();
public IReadOnlyDictionary<uint, object> Values => _values;
public RetailDialogData Set<T>(uint propertyId, T value) where T : notnull
{
_values[propertyId] = value;
return this;
}
public bool Contains(uint propertyId) => _values.ContainsKey(propertyId);
public bool TryGet<T>(uint propertyId, out T value)
{
if (_values.TryGetValue(propertyId, out object? raw) && raw is T typed)
{
value = typed;
return true;
}
value = default!;
return false;
}
public bool GetBoolean(uint propertyId, bool defaultValue = false)
=> _values.TryGetValue(propertyId, out object? raw)
? raw switch
{
bool value => value,
byte value => value != 0,
int value => value != 0,
uint value => value != 0,
_ => defaultValue,
}
: defaultValue;
public uint GetUInt32(uint propertyId, uint defaultValue = 0u)
=> _values.TryGetValue(propertyId, out object? raw)
? raw switch
{
byte value => value,
ushort value => value,
int value when value >= 0 => (uint)value,
uint value => value,
Enum value => Convert.ToUInt32(value),
_ => defaultValue,
}
: defaultValue;
public string? GetString(uint propertyId)
=> _values.TryGetValue(propertyId, out object? raw) ? raw as string : null;
public RetailDialogData Clone()
{
var clone = new RetailDialogData();
foreach ((uint propertyId, object value) in _values)
clone._values.Add(propertyId, value);
return clone;
}
public static RetailDialogData Confirmation(string message)
{
ArgumentNullException.ThrowIfNull(message);
return new RetailDialogData()
.Set(RetailDialogProperty.Type, RetailDialogType.Confirmation)
.Set(RetailDialogProperty.Message, message);
}
}

View file

@ -0,0 +1,323 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained-mode port of retail <c>DialogFactory @ 0x004773C0..0x00478470</c>.
/// It owns dialog contexts, independent FIFO queue groups, nonqueued dialogs,
/// priority preemption, callback delivery, close notices, and fresh catalog roots.
/// </summary>
public sealed class RetailDialogFactory : IDisposable
{
public const uint DefaultQueueKey = 2u;
public const uint NonQueuedKey = 1u;
private sealed class DialogInfo
{
public required RetailDialogData Data { get; init; }
public required uint Context { get; init; }
public required uint QueueKey { get; init; }
public Action<RetailDialogData>? Callback { get; init; }
public RetailConfirmationDialogView? View { get; set; }
}
private readonly UiRoot _host;
private readonly Func<RetailDialogType, ImportedLayout?> _createLayout;
private readonly Dictionary<uint, DialogInfo> _activeQueued = new();
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
private readonly List<DialogInfo> _openOrder = new();
private uint _globalContext;
private bool _disposed;
public RetailDialogFactory(
UiRoot host,
Func<RetailDialogType, ImportedLayout?> createLayout)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout));
}
/// <summary>
/// Retail's global close-dialog notice, raised after the per-context callback and
/// before the live root is removed from its parent.
/// </summary>
public event Action<uint, RetailDialogData>? DialogClosed;
public event Action<uint>? DialogOpened;
public bool IsOpen => _activeQueued.Count != 0 || _activeNonQueued.Count != 0;
public int ActiveCount => _activeQueued.Count + _activeNonQueued.Count;
public int PendingCount => _pending.Values.Sum(static queue => queue.Count);
/// <summary>Exact root-element switch from <c>CreateDialog_ @ 0x00477AD0</c>.</summary>
public static uint RootElementId(RetailDialogType type)
=> type switch
{
RetailDialogType.Confirmation => 0x15u,
RetailDialogType.Wait => 0x31u,
RetailDialogType.Message => 0x24u,
RetailDialogType.TextInput => 0x28u,
RetailDialogType.ConfirmationTextInput => 0x2Cu,
RetailDialogType.Menu => 0x1Bu,
RetailDialogType.ConfirmationMenu => 0x1Fu,
_ => 0u,
};
public uint MakeDialog(RetailDialogData data)
=> MakeDialog(data, callback: null);
/// <summary>
/// Retail <c>MakeCallbackDialogInCurrentUI @ 0x00478430</c>.
/// </summary>
public uint MakeDialog(RetailDialogData data, Action<RetailDialogData>? callback)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(data);
uint context = NextContext();
RetailDialogData ownedData = data.Clone();
uint queueKey = ownedData.GetUInt32(RetailDialogProperty.QueueKey, DefaultQueueKey);
if (queueKey == 0u)
queueKey = DefaultQueueKey;
var info = new DialogInfo
{
Data = ownedData,
Context = context,
QueueKey = queueKey,
Callback = callback,
};
if (queueKey == NonQueuedKey)
{
_activeNonQueued.Add(context, info);
CreateDialog(info);
return context;
}
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
{
_activeQueued.Add(queueKey, info);
CreateDialog(info);
return context;
}
LinkedList<DialogInfo> queue = PendingQueue(queueKey);
if (!ownedData.GetBoolean(RetailDialogProperty.Priority))
{
queue.AddLast(info);
UpdatePendingDialogDisplays();
return context;
}
// MakeDialog's 0x8D branch removes the active root without completing the
// DialogInfo, inserts that current info at the front, then displays the
// priority request in its place.
Suspend(current);
queue.AddFirst(current);
_activeQueued[queueKey] = info;
CreateDialog(info);
return context;
}
public uint MakeConfirmation(
string message,
Action<RetailDialogData>? callback = null,
uint queueKey = DefaultQueueKey,
bool priority = false)
{
RetailDialogData data = RetailDialogData.Confirmation(message)
.Set(RetailDialogProperty.QueueKey, queueKey);
if (priority)
data.Set(RetailDialogProperty.Priority, true);
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.
/// </summary>
public bool CloseDialog(uint context)
{
if (context == 0u)
return false;
if (_activeNonQueued.Remove(context, out DialogInfo? nonQueued))
{
DialogDone(nonQueued);
return true;
}
foreach ((uint queueKey, DialogInfo active) in _activeQueued.ToArray())
{
if (active.Context != context)
continue;
_activeQueued.Remove(queueKey);
DialogDone(active);
OpenNextDialog(queueKey);
return true;
}
foreach ((uint queueKey, LinkedList<DialogInfo> queue) in _pending.ToArray())
{
LinkedListNode<DialogInfo>? node = queue.First;
while (node is not null && node.Value.Context != context)
node = node.Next;
if (node is null)
continue;
DialogInfo pending = node.Value;
queue.Remove(node);
if (queue.Count == 0)
_pending.Remove(queueKey);
DialogDone(pending);
UpdatePendingDialogDisplays();
return true;
}
return false;
}
public void Tick()
{
foreach (DialogInfo info in _openOrder.ToArray())
info.View?.Tick();
}
/// <summary>
/// Retail <c>DialogFactory::Reset @ 0x00477950</c> completes active and pending
/// infos before clearing the factory.
/// </summary>
public void Reset()
{
DialogInfo[] infos = _activeNonQueued.Values
.Concat(_activeQueued.Values)
.Concat(_pending.Values.SelectMany(static queue => queue))
.Distinct()
.ToArray();
_activeNonQueued.Clear();
_activeQueued.Clear();
_pending.Clear();
foreach (DialogInfo info in infos)
DialogDone(info);
RefreshModal();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Reset();
}
private uint NextContext()
{
_globalContext++;
if (_globalContext == 0u)
_globalContext++;
return _globalContext;
}
private LinkedList<DialogInfo> PendingQueue(uint queueKey)
{
if (_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue))
return queue;
queue = new LinkedList<DialogInfo>();
_pending.Add(queueKey, queue);
return queue;
}
private void CreateDialog(DialogInfo info)
{
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
if (type != RetailDialogType.Confirmation)
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}.");
var view = 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);
}
private void Suspend(DialogInfo info)
{
if (info.View is null)
return;
RetailConfirmationDialogView view = info.View;
info.View = null;
view.DetachHandlers();
_openOrder.Remove(info);
_host.RemoveChild(view.Root);
RefreshModal();
}
private void DialogDone(DialogInfo info)
{
// DialogDone @ 0x004773C0 delivers the registered callback first, then
// broadcasts the CloseDialog notice, and only then removes the root.
try
{
info.Callback?.Invoke(info.Data);
DialogClosed?.Invoke(info.Context, info.Data);
}
finally
{
RemoveView(info);
}
}
private void RemoveView(DialogInfo info)
{
if (info.View is { } view)
{
info.View = null;
view.DetachHandlers();
_openOrder.Remove(info);
_host.RemoveChild(view.Root);
}
RefreshModal();
}
private void OpenNextDialog(uint queueKey)
{
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|| queue.First is null)
return;
DialogInfo next = queue.First.Value;
queue.RemoveFirst();
if (queue.Count == 0)
_pending.Remove(queueKey);
_activeQueued.Add(queueKey, next);
CreateDialog(next);
}
private void UpdatePendingDialogDisplays()
{
foreach ((uint queueKey, DialogInfo active) in _activeQueued)
{
int count = _pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
? queue.Count
: 0;
active.View?.SetPendingCount(count);
}
}
private void RefreshModal()
{
_host.Modal = _openOrder.Count == 0 ? null : _openOrder[^1].View?.Root;
}
}