652 lines
20 KiB
C#
652 lines
20 KiB
C#
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 _restoreCommandInFlight;
|
|
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, snapshot.SlotCount))
|
|
{
|
|
ApplyHighlight(snapshot.HighlightedCharacterId);
|
|
rowsReady = true;
|
|
}
|
|
else
|
|
{
|
|
rowsReady = RebuildRows(
|
|
roster,
|
|
snapshot.SlotCount,
|
|
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,
|
|
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];
|
|
RuntimeCharacterSelectionEntry character = roster[i];
|
|
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))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private bool RebuildRows(
|
|
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
|
|
int allowedSlotCount,
|
|
uint highlightedCharacterId)
|
|
{
|
|
foreach (UiButton row in _rows)
|
|
{
|
|
row.OnClick = null;
|
|
row.OnDoubleClick = null;
|
|
}
|
|
_rows.Clear();
|
|
_rowIds.Clear();
|
|
_list.Flush();
|
|
|
|
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 (!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
|
|
? 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;
|
|
}
|
|
|
|
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)
|
|
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;
|
|
|
|
// 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();
|
|
}
|
|
|
|
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 (_restoreCommandInFlight
|
|
|| 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;
|
|
}
|
|
}
|
|
}
|