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