namespace AcDream.App.UI.Layout; /// /// Retained-mode port of retail DialogFactory @ 0x004773C0..0x00478470. /// It owns dialog contexts, independent FIFO queue groups, nonqueued dialogs, /// priority preemption, callback delivery, close notices, and fresh catalog roots. /// 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 required ulong Sequence { get; init; } public Action? Callback { get; init; } public IRetailDialogView? View { get; set; } } private readonly UiRoot _host; private readonly Func _createLayout; private readonly Dictionary _activeQueued = new(); private readonly Dictionary _activeNonQueued = new(); private readonly Dictionary> _pending = new(); private readonly LinkedList _retryable = new(); private readonly List _openOrder = new(); private uint _globalContext; private ulong _globalSequence; private bool _resetting; private bool _disposed; public RetailDialogFactory( UiRoot host, Func createLayout) { _host = host ?? throw new ArgumentNullException(nameof(host)); _createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout)); } /// /// Retail's global close-dialog notice, raised after the per-context callback and /// before the live root is removed from its parent. /// public event Action? DialogClosed; public event Action? 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); internal int RetryCount => _retryable.Count; /// Exact root-element switch from CreateDialog_ @ 0x00477AD0. 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); /// /// Retail MakeCallbackDialogInCurrentUI @ 0x00478430. /// public uint MakeDialog(RetailDialogData data, Action? 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, Sequence = NextSequence(), Callback = callback, }; if (queueKey == NonQueuedKey) { _activeNonQueued.Add(context, info); if (!TryCreateDialog(info)) { _activeNonQueued.Remove(context); QueueRetry(info); } return context; } if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current)) { if (HasRetry(queueKey) && !IsPriority(info)) { PendingQueue(queueKey).AddLast(info); return context; } _activeQueued.Add(queueKey, info); if (!TryCreateDialog(info)) { _activeQueued.Remove(queueKey); QueueRetry(info); } return context; } LinkedList queue = PendingQueue(queueKey); if (!IsPriority(info)) { 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; if (!TryCreateDialog(info)) { _activeQueued.Remove(queueKey); queue.Remove(current); if (queue.Count == 0) _pending.Remove(queueKey); OpenSpecificDialog(current); QueueRetry(info); } return context; } public uint MakeConfirmation( string message, Action? 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); } /// /// Text-only wait dialog (type 2), closed by the caller via /// . Property shape mirrors /// UIOption_ActionKeyMap::OpenMapWarnDialog @ 0x00488A00: type 2, /// caller-chosen queue key, element attribute 0x40 set, message text. /// public uint MakeWait(string message, uint queueKey = DefaultQueueKey) { RetailDialogData data = RetailDialogData.Wait(message) .Set(RetailDialogProperty.QueueKey, queueKey); return MakeDialog(data, callback: null); } public uint MakeMessage( string message, Action? callback = null, uint queueKey = DefaultQueueKey) { RetailDialogData data = RetailDialogData.Message(message) .Set(RetailDialogProperty.QueueKey, queueKey); return MakeDialog(data, callback); } public uint MakeConfirmationTextInput( string message, Action? callback = null, uint queueKey = DefaultQueueKey) { RetailDialogData data = RetailDialogData.ConfirmationTextInput(message) .Set(RetailDialogProperty.QueueKey, queueKey); return MakeDialog(data, callback); } /// /// Retail CloseDialog @ 0x00478160. The context can identify an active /// nonqueued dialog, an active queued dialog, or an item still pending in a queue. /// 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 queue) in _pending.ToArray()) { LinkedListNode? 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; } LinkedListNode? retry = _retryable.First; while (retry is not null && retry.Value.Context != context) retry = retry.Next; if (retry is not null) { DialogInfo failed = retry.Value; _retryable.Remove(retry); DialogDone(failed); if (failed.QueueKey != NonQueuedKey) OpenNextDialog(failed.QueueKey); return true; } return false; } /// /// GF-15 fix (Campaign CC gate round 1, Batch A, 2026-08-16). Live-repro- /// confirmed root cause: CharacterCreationUiController.Tick and /// CharacterManagementUiController.Tick both call /// UiRoot.BringToFront(Root) UNCONDITIONALLY on every frame while /// their screen is open — a per-tick "stay on top of my sibling screen" /// assertion (needed so chargen never bleeds input to the occluded /// char-management screen underneath it, register AP-229). A dialog this /// factory opens is ALSO a direct sibling of those screen roots under /// the same UiRoot (_host.AddChild(view.Root) in /// ), competing for the SAME z-order slot. /// is a simple "highest /// ZOrder among _root's direct children + 1" — whichever sibling's /// own BringToFront call runs LAST in a frame wins the top slot. /// Before this fix, this method never re-asserted a dialog's own /// z-order after the one-time raise in , so /// the VERY NEXT frame's screen Tick() (which always runs before /// this factory's own Tick() in /// RetailUiRuntime.Tick(double)'s per-frame sequence) silently /// buried the dialog behind the screen's opaque backdrop — while the /// dialog remained the registered and kept /// EXCLUSIVE input priority (OnMouseDown's Modal-vs-bounds gate is /// independent of render/z-order). The user-visible symptom: press /// Finish empty → the NoName dialog is created successfully /// (visible=true, correct geometry, live-DAT-probe-confirmed) but /// renders NOTHING, and every subsequent click across the WHOLE canvas /// resolves to the invisible dialog root instead of the name field or /// Finish button underneath — both GF-15 symptoms from one mechanism. /// Retail's real dialogs are always-on-top overlays by construction (a /// separate presentation layer, not a z-ordered sibling of the game UI); /// re-asserting every open dialog's z-order here, every tick, in /// order (so the MOST RECENTLY opened dialog — /// the same one already treats as /// authoritative — ends up on top) reproduces that invariant without /// touching either screen controller's own already-verified raise. /// public void Tick() { RetryFailedDialogs(); foreach (DialogInfo info in _openOrder.ToArray()) { if (info.View is { } view) { _host.BringToFront(view.Root); view.Tick(); } } } /// /// Retail DialogFactory::Reset @ 0x00477950 completes active and pending /// infos before clearing the factory. /// public void Reset() { if (_resetting) return; _resetting = true; List? failures = null; try { while (true) { DialogInfo[] infos = _activeNonQueued.Values .Concat(_activeQueued.Values) .Concat(_pending.Values.SelectMany(static queue => queue)) .Concat(_retryable) .Distinct() .ToArray(); if (infos.Length == 0) break; // Callbacks may synchronously create another dialog. Retire // this exact snapshot, then loop until those reentrant infos // have also completed through DialogDone. _activeNonQueued.Clear(); _activeQueued.Clear(); _pending.Clear(); _retryable.Clear(); foreach (DialogInfo info in infos) { try { DialogDone(info); } catch (Exception error) { (failures ??= []).Add(error); } } } } finally { _resetting = false; RefreshModal(); } if (failures is not null) throw new AggregateException( "One or more dialogs failed while the factory reset.", failures); } public void Dispose() { if (_disposed) return; _disposed = true; Reset(); } private uint NextContext() { _globalContext++; if (_globalContext == 0u) _globalContext++; return _globalContext; } private ulong NextSequence() { _globalSequence++; if (_globalSequence == 0uL) _globalSequence++; return _globalSequence; } private LinkedList PendingQueue(uint queueKey) { if (_pending.TryGetValue(queueKey, out LinkedList? queue)) return queue; queue = new LinkedList(); _pending.Add(queueKey, queue); return queue; } private bool TryCreateDialog(DialogInfo info) { RetailDialogType type = (RetailDialogType)info.Data.GetUInt32( RetailDialogProperty.Type); try { if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait or RetailDialogType.Message or RetailDialogType.ConfirmationTextInput)) { throw new NotSupportedException( $"Retail dialog type {(uint)type} does not have a ported presenter yet."); } ImportedLayout layout = _createLayout(type) ?? throw new InvalidOperationException( $"Retail dialog catalog could not create type {(uint)type}."); IRetailDialogView view = type switch { RetailDialogType.Wait => new RetailWaitDialogView( _host, layout, info.Data), RetailDialogType.Message => new RetailMessageDialogView( _host, layout, info.Data, info.Context, context => CloseDialog(context)), RetailDialogType.ConfirmationTextInput => new RetailConfirmationTextInputDialogView( _host, layout, info.Data, info.Context, context => CloseDialog(context)), _ => new RetailConfirmationDialogView( _host, layout, info.Data, info.Context, context => CloseDialog(context)), }; info.View = view; _host.AddChild(view.Root); _host.BringToFront(view.Root); _openOrder.Add(info); _host.Modal = view.Root; view.Tick(); UpdatePendingDialogDisplays(); DialogOpened?.Invoke(info.Context); return true; } catch (Exception error) { RemoveView(info); Console.WriteLine( $"[UI] retail dialog type {(uint)type} context {info.Context} " + $"will retry after catalog recovery: {error.Message}"); return false; } } private void Suspend(DialogInfo info) { if (info.View is null) return; IRetailDialogView 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) { view.DetachHandlers(); _host.RemoveChild(view.Root); info.View = null; _openOrder.Remove(info); } RefreshModal(); } private void OpenNextDialog(uint queueKey) { // A callback invoked from DialogDone (above, in CloseDialog) may // synchronously make a new dialog under this same queue key before // control returns here — MakeDialog's queued branch will have // already re-occupied _activeQueued[queueKey]. Retail's HashTable::add // tolerates the duplicate; Dictionary.Add does not. Bail out: the // re-entrant dialog's own eventual CloseDialog will drain the // pending queue via its own OpenNextDialog call. if (_activeQueued.ContainsKey(queueKey)) return; if (TryActivateRetry(queueKey)) return; if (!_pending.TryGetValue(queueKey, out LinkedList? queue) || queue.First is null) return; DialogInfo next = queue.First.Value; queue.RemoveFirst(); if (queue.Count == 0) _pending.Remove(queueKey); _activeQueued.Add(queueKey, next); if (!TryCreateDialog(next)) { _activeQueued.Remove(queueKey); QueueRetry(next); } } private void OpenSpecificDialog(DialogInfo info) { _activeQueued.Add(info.QueueKey, info); if (!TryCreateDialog(info)) { _activeQueued.Remove(info.QueueKey); QueueRetry(info); } } private void RetryFailedDialogs() { foreach (DialogInfo info in _retryable.ToArray()) { if (info.QueueKey == NonQueuedKey) { _activeNonQueued.Add(info.Context, info); if (TryCreateDialog(info)) _retryable.Remove(info); else _activeNonQueued.Remove(info.Context); continue; } if (!ReferenceEquals(FirstRetry(info.QueueKey), info)) continue; if (!_activeQueued.TryGetValue( info.QueueKey, out DialogInfo? active)) TryActivateRetry(info.QueueKey); else if (IsPriority(info) && (!IsPriority(active) || info.Sequence > active.Sequence)) TryPreemptWithRetry(info, active); } } private void TryPreemptWithRetry(DialogInfo priority, DialogInfo current) { LinkedList queue = PendingQueue(priority.QueueKey); Suspend(current); queue.AddFirst(current); _activeQueued[priority.QueueKey] = priority; _retryable.Remove(priority); if (TryCreateDialog(priority)) return; _activeQueued.Remove(priority.QueueKey); queue.Remove(current); if (queue.Count == 0) _pending.Remove(priority.QueueKey); OpenSpecificDialog(current); QueueRetry(priority); } private bool TryActivateRetry(uint queueKey) { DialogInfo? info = FirstRetry(queueKey); if (info is null) return false; _activeQueued.Add(queueKey, info); if (TryCreateDialog(info)) _retryable.Remove(info); else _activeQueued.Remove(queueKey); return true; } private DialogInfo? FirstRetry(uint queueKey) { foreach (DialogInfo info in _retryable) if (info.QueueKey == queueKey) return info; return null; } private bool HasRetry(uint queueKey) => FirstRetry(queueKey) is not null; private static bool IsPriority(DialogInfo info) => info.Data.GetBoolean(RetailDialogProperty.Priority); private void QueueRetry(DialogInfo info) { if (_retryable.Contains(info)) return; if (!IsPriority(info)) { _retryable.AddLast(info); return; } LinkedListNode? existing = _retryable.First; while (existing is not null && existing.Value.QueueKey != info.QueueKey) { existing = existing.Next; } if (existing is null) _retryable.AddLast(info); else _retryable.AddBefore(existing, info); } private void UpdatePendingDialogDisplays() { foreach ((uint queueKey, DialogInfo active) in _activeQueued) { int count = _pending.TryGetValue(queueKey, out LinkedList? queue) ? queue.Count : 0; active.View?.SetPendingCount(count); } } private void RefreshModal() { _host.Modal = _openOrder.Count == 0 ? null : _openOrder[^1].View?.Root; } }