Harden retail character selection recovery

This commit is contained in:
Erik 2026-08-14 20:59:10 +02:00
parent 6cfab727f1
commit aeac874dab
8 changed files with 736 additions and 81 deletions

View file

@ -45,6 +45,7 @@ internal sealed class CharacterManagementUiController : IDisposable
private uint _enterWaitContext;
private uint _errorDialogContext;
private bool _active;
private bool _restoreCommandInFlight;
private bool _suppressDialogCallbacks;
private bool _disposed;
@ -197,7 +198,7 @@ internal sealed class CharacterManagementUiController : IDisposable
if (TryCaptureRoster(view, snapshot, out RuntimeCharacterSelectionEntry[] roster))
{
bool rowsReady;
if (RowsMatchRoster(roster))
if (RowsMatchRoster(roster, snapshot.SlotCount))
{
ApplyHighlight(snapshot.HighlightedCharacterId);
rowsReady = true;
@ -206,6 +207,7 @@ internal sealed class CharacterManagementUiController : IDisposable
{
rowsReady = RebuildRows(
roster,
snapshot.SlotCount,
snapshot.HighlightedCharacterId);
}
@ -280,11 +282,16 @@ internal sealed class CharacterManagementUiController : IDisposable
}
private bool RowsMatchRoster(
IReadOnlyList<RuntimeCharacterSelectionEntry> roster)
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
int allowedSlotCount)
{
if (_rows.Count != roster.Count)
return false;
int rowHeight = ComputeRowHeight(
_list.Height,
roster.Count,
allowedSlotCount);
for (int i = 0; i < roster.Count; i++)
{
UiButton row = _rows[i];
@ -292,6 +299,7 @@ internal sealed class CharacterManagementUiController : IDisposable
if (!_rowIds.TryGetValue(row, out uint characterId)
|| characterId != character.CharacterId
|| !string.Equals(row.Label, character.Name, StringComparison.Ordinal)
|| (int)row.Height != rowHeight
|| row.LabelColor != (character.IsPendingDelete
? new Vector4(1f, 0f, 0f, 1f)
: Vector4.One))
@ -305,6 +313,7 @@ internal sealed class CharacterManagementUiController : IDisposable
private bool RebuildRows(
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
int allowedSlotCount,
uint highlightedCharacterId)
{
foreach (UiButton row in _rows)
@ -316,15 +325,34 @@ internal sealed class CharacterManagementUiController : IDisposable
_rowIds.Clear();
_list.Flush();
bool complete = true;
int rowHeight = ComputeRowHeight(
_list.Height,
roster.Count,
allowedSlotCount);
_list.LineHeight = rowHeight;
bool complete = _list.Templates.Count > 0
&& _list.TemplateResolver is not null;
foreach (RuntimeCharacterSelectionEntry character in roster)
{
if (_list.AddItemFromTemplateList(0) is not UiButton row)
if (!complete)
break;
UiTemplateListEntry template = _list.Templates[0];
if (_list.TemplateResolver!(
template.TemplateLayoutId,
template.TemplateElementId) is not UiButton row)
{
complete = false;
break;
}
// AddItemFromTemplateList creates the same template, but its
// retained viewport stacks at the template's authored 16px
// height. Retail establishes the computed size on every row; our
// list fixes Top during insertion, so build and resize first to
// make every subsequent Top exact.
row.Height = rowHeight;
_list.AddPrebuiltRow(row);
uint characterId = character.CharacterId;
row.Label = character.Name;
row.LabelColor = character.IsPendingDelete
@ -354,6 +382,21 @@ internal sealed class CharacterManagementUiController : IDisposable
return false;
}
internal static int ComputeRowHeight(
float listHeight,
int rosterCount,
int allowedSlotCount)
{
// RebuildCharacterList @ 0x004EC3A0 uses integer UIRegion height and
// signed integer division for both terms. The 0x66666667 multiply/
// shift sequence is compiler output for height / 10.
int height = (int)MathF.Truncate(listHeight);
int denominator = Math.Max(rosterCount, allowedSlotCount);
if (denominator <= 0)
return height / 10;
return Math.Max(height / denominator, height / 10);
}
private void ApplyHighlight(uint highlightedCharacterId)
{
foreach (UiButton row in _rows)
@ -407,9 +450,39 @@ internal sealed class CharacterManagementUiController : IDisposable
{
if (_disposed)
return;
RuntimeCommandResult result = _bindings.Restore();
if (result.Accepted)
EnsureOperationWait();
// ListenToElementMessage @ 0x004ED5A0 opens Please Wait before it
// calls CPlayerSystem::RestoreCharacter. Keep it modal even if a
// synchronous command callback re-enters Tick before Runtime has
// returned its accepted projection.
EnsureOperationWait();
RuntimeCommandResult result = default;
Exception? failure = null;
_restoreCommandInFlight = true;
try
{
result = _bindings.Restore();
}
catch (Exception error)
{
failure = error;
}
finally
{
_restoreCommandInFlight = false;
}
if (failure is not null)
{
Console.WriteLine(
$"[UI] character restore command failed: {failure.Message}");
CloseContext(ref _operationWaitContext, suppressCallback: true);
InvalidateAndTick();
return;
}
if (!result.Accepted)
CloseContext(ref _operationWaitContext, suppressCallback: true);
InvalidateAndTick();
}
@ -446,7 +519,8 @@ internal sealed class CharacterManagementUiController : IDisposable
CloseContext(ref _deleteDialogContext, suppressCallback: true);
}
if (snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested
if (_restoreCommandInFlight
|| snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
or RuntimeCharacterSelectionOperation.RestoreRequested)
{

View file

@ -0,0 +1,87 @@
namespace AcDream.App.UI.Layout;
internal sealed record CharacterManagementUiMountResources(
uint LayoutId,
ImportedLayout Layout,
Func<uint, uint, UiElement?> TemplateResolver,
CharacterManagementUiController.DialogStrings Strings);
/// <summary>
/// Retryable, idempotent composition edge for the pre-world character screen.
/// DATs can become readable after the graphical runtime starts (installer copy,
/// mapped-file replacement, or a transient catalog miss), so an unavailable
/// dialog catalog, root, template, or string must not permanently suppress the
/// screen. Once bound, later ticks are no-ops and cannot duplicate the root or
/// controller lifetime.
/// </summary>
internal sealed class CharacterManagementUiMountCoordinator : IDisposable
{
private readonly UiRoot _host;
private readonly CharacterSelectionRuntimeBindings _bindings;
private readonly Func<RetailDialogFactory?> _ensureDialogs;
private readonly Func<CharacterManagementUiMountResources?> _loadResources;
private bool _disposed;
public CharacterManagementUiMountCoordinator(
UiRoot host,
CharacterSelectionRuntimeBindings bindings,
Func<RetailDialogFactory?> ensureDialogs,
Func<CharacterManagementUiMountResources?> loadResources)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_ensureDialogs = ensureDialogs
?? throw new ArgumentNullException(nameof(ensureDialogs));
_loadResources = loadResources
?? throw new ArgumentNullException(nameof(loadResources));
}
public CharacterManagementUiController? Controller { get; private set; }
public void Tick()
{
if (_disposed || Controller is not null)
return;
try
{
RetailDialogFactory? dialogs = _ensureDialogs();
if (dialogs is null)
return;
CharacterManagementUiMountResources? resources = _loadResources();
if (resources is null)
return;
Controller = CharacterManagementUiController.Bind(
_host,
resources.Layout,
resources.TemplateResolver,
dialogs,
_bindings,
resources.Strings);
if (Controller is not null)
{
Console.WriteLine(
$"[UI] retail character management from enum table 5 "
+ $"(0x10000005 -> 0x{resources.LayoutId:X8}, "
+ "root 0x1000039A; flat list, no viewport).");
}
}
catch (Exception error)
{
Console.WriteLine(
"[UI] character management mount will retry after resource "
+ $"recovery: {error.Message}");
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Controller?.Dispose();
Controller = null;
}
}

View file

@ -24,6 +24,7 @@ public sealed class RetailDialogFactory : IDisposable
private readonly Dictionary<uint, DialogInfo> _activeQueued = new();
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
private readonly LinkedList<DialogInfo> _retryable = new();
private readonly List<DialogInfo> _openOrder = new();
private uint _globalContext;
private bool _resetting;
@ -51,6 +52,8 @@ public sealed class RetailDialogFactory : IDisposable
public int PendingCount => _pending.Values.Sum(static queue => queue.Count);
internal int RetryCount => _retryable.Count;
/// <summary>Exact root-element switch from <c>CreateDialog_ @ 0x00477AD0</c>.</summary>
public static uint RootElementId(RetailDialogType type)
=> type switch
@ -93,14 +96,28 @@ public sealed class RetailDialogFactory : IDisposable
if (queueKey == NonQueuedKey)
{
_activeNonQueued.Add(context, info);
CreateDialog(info);
if (!TryCreateDialog(info))
{
_activeNonQueued.Remove(context);
QueueRetry(info);
}
return context;
}
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
{
if (HasRetry(queueKey))
{
PendingQueue(queueKey).AddLast(info);
return context;
}
_activeQueued.Add(queueKey, info);
CreateDialog(info);
if (!TryCreateDialog(info))
{
_activeQueued.Remove(queueKey);
QueueRetry(info);
}
return context;
}
@ -118,7 +135,15 @@ public sealed class RetailDialogFactory : IDisposable
Suspend(current);
queue.AddFirst(current);
_activeQueued[queueKey] = info;
CreateDialog(info);
if (!TryCreateDialog(info))
{
_activeQueued.Remove(queueKey);
queue.Remove(current);
if (queue.Count == 0)
_pending.Remove(queueKey);
OpenSpecificDialog(current);
QueueRetry(info);
}
return context;
}
@ -211,11 +236,25 @@ public sealed class RetailDialogFactory : IDisposable
return true;
}
LinkedListNode<DialogInfo>? retry = _retryable.First;
while (retry is not null && retry.Value.Context != context)
retry = retry.Next;
if (retry is not null)
{
DialogInfo failed = retry.Value;
_retryable.Remove(retry);
DialogDone(failed);
if (failed.QueueKey != NonQueuedKey)
OpenNextDialog(failed.QueueKey);
return true;
}
return false;
}
public void Tick()
{
RetryFailedDialogs();
foreach (DialogInfo info in _openOrder.ToArray())
info.View?.Tick();
}
@ -238,6 +277,7 @@ public sealed class RetailDialogFactory : IDisposable
DialogInfo[] infos = _activeNonQueued.Values
.Concat(_activeQueued.Values)
.Concat(_pending.Values.SelectMany(static queue => queue))
.Concat(_retryable)
.Distinct()
.ToArray();
if (infos.Length == 0)
@ -249,6 +289,7 @@ public sealed class RetailDialogFactory : IDisposable
_activeNonQueued.Clear();
_activeQueued.Clear();
_pending.Clear();
_retryable.Clear();
foreach (DialogInfo info in infos)
{
try { DialogDone(info); }
@ -292,41 +333,57 @@ public sealed class RetailDialogFactory : IDisposable
return queue;
}
private void CreateDialog(DialogInfo info)
private bool TryCreateDialog(DialogInfo info)
{
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
if (type is not (RetailDialogType.Confirmation
or RetailDialogType.Wait
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 type = (RetailDialogType)info.Data.GetUInt32(
RetailDialogProperty.Type);
try
{
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(
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)),
_ => 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);
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)
@ -380,6 +437,9 @@ public sealed class RetailDialogFactory : IDisposable
if (_activeQueued.ContainsKey(queueKey))
return;
if (TryActivateRetry(queueKey))
return;
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|| queue.First is null)
return;
@ -389,7 +449,73 @@ public sealed class RetailDialogFactory : IDisposable
if (queue.Count == 0)
_pending.Remove(queueKey);
_activeQueued.Add(queueKey, next);
CreateDialog(next);
if (!TryCreateDialog(next))
{
_activeQueued.Remove(queueKey);
QueueRetry(next);
}
}
private void OpenSpecificDialog(DialogInfo info)
{
_activeQueued.Add(info.QueueKey, info);
if (!TryCreateDialog(info))
{
_activeQueued.Remove(info.QueueKey);
QueueRetry(info);
}
}
private void RetryFailedDialogs()
{
foreach (DialogInfo info in _retryable.ToArray())
{
if (info.QueueKey == NonQueuedKey)
{
_activeNonQueued.Add(info.Context, info);
if (TryCreateDialog(info))
_retryable.Remove(info);
else
_activeNonQueued.Remove(info.Context);
continue;
}
if (!_activeQueued.ContainsKey(info.QueueKey)
&& ReferenceEquals(FirstRetry(info.QueueKey), info))
{
TryActivateRetry(info.QueueKey);
}
}
}
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 void QueueRetry(DialogInfo info)
{
if (!_retryable.Contains(info))
_retryable.AddLast(info);
}
private void UpdatePendingDialogDisplays()

View file

@ -434,6 +434,7 @@ public sealed class RetailUiRuntime : IDisposable
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
private ItemCooldownUiController? _itemCooldownController;
private VividTargetIndicatorController? _vividTargetIndicator;
private CharacterManagementUiMountCoordinator? _characterManagementMount;
private IDisposable? _characterSheetSubscription;
private ResourceShutdownTransaction? _shutdown;
private bool _disposed;
@ -500,7 +501,8 @@ public sealed class RetailUiRuntime : IDisposable
MountVendor();
MountSecureTrade();
MountItemCooldowns();
MountCharacterManagement();
ConfigureCharacterManagement();
_characterManagementMount?.Tick();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
SyncToolbarWindowButtons();
@ -595,7 +597,8 @@ public sealed class RetailUiRuntime : IDisposable
public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller;
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
@ -641,6 +644,7 @@ public sealed class RetailUiRuntime : IDisposable
ExternalContainerController?.Tick();
SocialPanelController?.Tick();
_itemCooldownController?.Tick();
_characterManagementMount?.Tick();
CharacterManagementController?.Tick();
DialogFactory?.Tick();
Host.Tick(deltaSeconds);
@ -3051,13 +3055,29 @@ public sealed class RetailUiRuntime : IDisposable
private void MountDialogFactory()
{
if (DialogFactory is not null)
return;
uint layoutId;
lock (_bindings.Assets.DatLock)
try
{
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
// creates a fresh type-specific root from that catalog.
layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u);
lock (_bindings.Assets.DatLock)
{
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
// creates a fresh type-specific root from that catalog.
layoutId = RetailDataIdResolver.Resolve(
_bindings.Assets.Dats,
2u,
5u);
}
}
catch (Exception error)
{
Console.WriteLine(
"[UI] retail dialog catalog will retry after resource "
+ $"recovery: {error.Message}");
return;
}
if (layoutId == 0u)
@ -3690,19 +3710,28 @@ public sealed class RetailUiRuntime : IDisposable
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
}
private void MountCharacterManagement()
private void ConfigureCharacterManagement()
{
CharacterSelectionRuntimeBindings? bindings =
_bindings.CharacterSelection;
if (bindings is null)
if (bindings is null || _characterManagementMount is not null)
return;
if (DialogFactory is null)
{
Console.WriteLine(
"[UI] character management: retail DialogFactory is unavailable.");
return;
}
_characterManagementMount = new CharacterManagementUiMountCoordinator(
Host.Root,
bindings,
EnsureDialogFactory,
LoadCharacterManagementResources);
}
private RetailDialogFactory? EnsureDialogFactory()
{
MountDialogFactory();
return DialogFactory;
}
private CharacterManagementUiMountResources? LoadCharacterManagementResources()
{
const uint stringTableId = 0x23000002u;
uint layoutId;
ImportedLayout? layout;
@ -3730,7 +3759,7 @@ public sealed class RetailUiRuntime : IDisposable
{
Console.WriteLine(
"[UI] character management: enum-table-5 root could not be imported.");
return;
return null;
}
string? deleteResponse;
@ -3767,7 +3796,7 @@ public sealed class RetailUiRuntime : IDisposable
{
Console.WriteLine(
"[UI] character management: required retail strings are unavailable.");
return;
return null;
}
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
@ -3798,23 +3827,15 @@ public sealed class RetailUiRuntime : IDisposable
}
}
CharacterManagementController = CharacterManagementUiController.Bind(
Host.Root,
return new CharacterManagementUiMountResources(
layoutId,
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(
@ -3873,7 +3894,7 @@ public sealed class RetailUiRuntime : IDisposable
() => _itemConfirmationController?.Dispose(),
() =>
{
CharacterManagementController?.Dispose();
_characterManagementMount?.Dispose();
_gameplayConfirmationController?.Dispose();
},
() => DialogFactory?.Dispose(),