The automation probe fed element-center CANVAS coordinates straight into
UiRoot.OnMouseMove/Down/Up, which take WINDOW coordinates and map
window->canvas internally (UiRoot.MapWindowToCanvas). The two spaces are
identical on every screen without UiRoot.FixedCanvasSize — every prior
probe gate passed — but the character-select/chargen screens stretch an
authored 800x600 canvas across the window, so every synthetic click and
hover landed at canvas*(canvas/window): nowhere near the target. The
enter-world connected gate's 'click element 0x100003A2' silently did
nothing for two full rounds. Element-derived pointer paths (ClickAt,
DragAt, HoverElement) now convert canvas->window via UiRoot.CanvasScale;
raw 'mousemove x y' stays a passthrough.
CharacterManagementUiController.EnterSelected also logs a once-per-click
outcome line ('[UI] character enter accepted/rejected status=...') — a
refused Enter was previously indistinguishable from a click that never
dispatched (both silent), which cost a connected-gate round to tell
apart.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
835 lines
28 KiB
C#
835 lines
28 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 WorldTextElementId = 0x1000039Bu;
|
||
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;
|
||
/// <summary>
|
||
/// gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0's element-id
|
||
/// switch is keyed off <c>idElement - 0x1000039d</c> (the listbox base);
|
||
/// offset 6 -> QueueUIMode(0x10000005), the mode gmCreditsUI registers
|
||
/// (Register@0x0047a69e) — out of scope this round (finding 1 note).
|
||
/// </summary>
|
||
internal const uint CreditsElementId = 0x100003A3u;
|
||
/// <summary>Offset 7 from the listbox base -> MakeConfirmExitDialog@0x004ed250.</summary>
|
||
internal const uint ExitElementId = 0x100003A4u;
|
||
|
||
internal sealed record DialogStrings(
|
||
Func<string, string> DeleteConfirmation,
|
||
string DeleteResponse,
|
||
string PleaseWait,
|
||
string EnteringWorld,
|
||
/// <summary>
|
||
/// Retail <c>ID_CharacterManagement_ConfirmExit</c> (table
|
||
/// <c>0x23000002</c>) — "Are you sure you want to leave?", the text
|
||
/// <c>MakeConfirmExitDialog@0x004ed250</c> resolves via
|
||
/// <c>StringInfo::SetStringIDandTableEnum(compute_str_hash(
|
||
/// "ID_CharacterManagement_ConfirmExit"), 0x10000002)</c>.
|
||
/// </summary>
|
||
string ConfirmExit);
|
||
|
||
private readonly UiRoot _host;
|
||
private readonly ImportedLayout _layout;
|
||
private readonly UiText _worldText;
|
||
private readonly UiTemplateListBox _list;
|
||
private readonly UiButton _create;
|
||
private readonly UiButton _enter;
|
||
private readonly UiButton _delete;
|
||
private readonly UiButton _restore;
|
||
private readonly UiButton _credits;
|
||
private readonly UiButton _exit;
|
||
private readonly RetailDialogFactory _dialogs;
|
||
private readonly CharacterSelectionRuntimeBindings _bindings;
|
||
private readonly DialogStrings _strings;
|
||
private readonly List<UiButton> _rows = [];
|
||
private readonly Dictionary<UiButton, uint> _rowIds = [];
|
||
|
||
private Vector2 _authoredCanvas;
|
||
private RuntimeGenerationToken _lastGeneration;
|
||
private long _lastRevision = long.MinValue;
|
||
private string _lastWorldName = string.Empty;
|
||
private uint _deleteDialogContext;
|
||
private uint _operationWaitContext;
|
||
private uint _enterWaitContext;
|
||
private uint _errorDialogContext;
|
||
private uint _confirmExitDialogContext;
|
||
private bool _active;
|
||
private bool _restoreCommandInFlight;
|
||
private bool _suppressDialogCallbacks;
|
||
private bool _disposed;
|
||
|
||
private CharacterManagementUiController(
|
||
UiRoot host,
|
||
ImportedLayout layout,
|
||
UiText worldText,
|
||
UiTemplateListBox list,
|
||
UiButton create,
|
||
UiButton enter,
|
||
UiButton delete,
|
||
UiButton restore,
|
||
UiButton credits,
|
||
UiButton exit,
|
||
RetailDialogFactory dialogs,
|
||
CharacterSelectionRuntimeBindings bindings,
|
||
DialogStrings strings)
|
||
{
|
||
_host = host;
|
||
_layout = layout;
|
||
_worldText = worldText;
|
||
_list = list;
|
||
_create = create;
|
||
_enter = enter;
|
||
_delete = delete;
|
||
_restore = restore;
|
||
_credits = credits;
|
||
_exit = exit;
|
||
_dialogs = dialogs;
|
||
_bindings = bindings;
|
||
_strings = strings;
|
||
|
||
Root.Left = 0f;
|
||
Root.Top = 0f;
|
||
Root.ClickThrough = false;
|
||
Root.Visible = false;
|
||
|
||
// Campaign LA gate round 2 (register AD-98): the root KEEPS its authored
|
||
// 800×600 extent — retail never resizes it (zero edge anchors, verified
|
||
// against the installed DAT) and its blitter has no stretch mode; the
|
||
// whole composed screen stretches once at presentation. Our equivalent:
|
||
// while this screen is active, the host stretches the ENTIRE canvas —
|
||
// widgets, glyphs, and the painted background (which carries the
|
||
// "World"/"Characters" captions as art) — as one unit via
|
||
// UiRoot.FixedCanvasSize, declared/revoked through the owner-scoped
|
||
// arbiter (review fix round R1, 2026-08-15) rather than written
|
||
// directly — character-creation can be simultaneously active on top
|
||
// of this screen, and a raw write from either controller is a last-
|
||
// writer-wins race with no owner. Resizing the root here instead of
|
||
// using the canvas is exactly the half-substitution that misaligned
|
||
// the widgets against the stretched art at the 2026-08-15 user gate.
|
||
_authoredCanvas = new Vector2(
|
||
Root.Width > 0f ? Root.Width : 800f,
|
||
Root.Height > 0f ? Root.Height : 600f);
|
||
|
||
// Campaign CC slice CC7: gmCharacterManagementUI::ListenToElementMessage
|
||
// @ 0x004ed5a0 case 3 dispatches Create unconditionally on click
|
||
// (QueueUIMode(0x1000000b) — no gate at click time); the gate lives
|
||
// entirely in UpdateButtons @ 0x004ec240's own Enabled/ghosted state
|
||
// (see ApplyButtons below), so the click handler is wired once here
|
||
// and Enabled tracks the borrowed snapshot every tick. Starts
|
||
// disabled/ghosted until the first real snapshot arrives.
|
||
_create.Visible = true;
|
||
_create.Enabled = false;
|
||
_create.OnClick = RequestCreate;
|
||
_enter.OnClick = EnterSelected;
|
||
_delete.OnClick = RequestDelete;
|
||
_restore.OnClick = RestoreSelected;
|
||
|
||
// Credits (retail QueueUIMode(0x10000005) -> gmCreditsUI) is out of
|
||
// scope this round (finding 1 note) — same "future campaign, visibly
|
||
// ghosted, no invented action" treatment as Create above. Filed as
|
||
// issue #400.
|
||
_credits.Visible = true;
|
||
_credits.Enabled = false;
|
||
_credits.OnClick = null;
|
||
_exit.OnClick = RequestExit;
|
||
|
||
// World name (retail UpdateWorldName@0x004ec120 /
|
||
// RecvNotice_WorldName@0x004ec360 both just push
|
||
// Client::GetWorldName() onto this element). LinesProvider reads the
|
||
// live field Tick() updates each time Runtime's snapshot changes.
|
||
_worldText.LinesProvider =
|
||
() => [new UiText.Line(_lastWorldName, _worldText.DefaultColor)];
|
||
}
|
||
|
||
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 uint ConfirmExitDialogContext => _confirmExitDialogContext;
|
||
|
||
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)
|
||
{
|
||
CharacterManagementUiController? controller = CreateDetached(
|
||
host,
|
||
layout,
|
||
templateResolver,
|
||
dialogs,
|
||
bindings,
|
||
strings);
|
||
if (controller is null)
|
||
return null;
|
||
|
||
try
|
||
{
|
||
controller.AttachAndTick();
|
||
return controller;
|
||
}
|
||
catch
|
||
{
|
||
controller.Dispose();
|
||
throw;
|
||
}
|
||
}
|
||
|
||
internal static CharacterManagementUiController? CreateDetached(
|
||
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(WorldTextElementId) is not UiText worldText
|
||
|| 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
|
||
|| layout.FindElement(CreditsElementId) is not UiButton credits
|
||
|| layout.FindElement(ExitElementId) is not UiButton exit)
|
||
{
|
||
Console.WriteLine(
|
||
"[UI] character management: the authored root/list/button contract is incomplete.");
|
||
return null;
|
||
}
|
||
|
||
list.TemplateResolver = templateResolver;
|
||
try
|
||
{
|
||
return new CharacterManagementUiController(
|
||
host,
|
||
layout,
|
||
worldText,
|
||
list,
|
||
create,
|
||
enter,
|
||
delete,
|
||
restore,
|
||
credits,
|
||
exit,
|
||
dialogs,
|
||
bindings,
|
||
strings);
|
||
}
|
||
catch
|
||
{
|
||
list.TemplateResolver = null;
|
||
create.OnClick = null;
|
||
enter.OnClick = null;
|
||
delete.OnClick = null;
|
||
restore.OnClick = null;
|
||
exit.OnClick = null;
|
||
throw;
|
||
}
|
||
}
|
||
|
||
internal void AttachAndTick()
|
||
{
|
||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||
if (Root.Parent is null)
|
||
_host.AddChild(Root);
|
||
Tick();
|
||
}
|
||
|
||
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.DeclareFixedCanvas(this, _authoredCanvas);
|
||
_host.BringToFront(Root);
|
||
}
|
||
|
||
// World name rides independently of the roster revision gate below —
|
||
// ServerName can arrive slightly before or after CharacterList (see
|
||
// RuntimeCharacterSelectionState.ApplyWorldName).
|
||
_lastWorldName = snapshot.WorldName;
|
||
|
||
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;
|
||
try
|
||
{
|
||
CloseAllDialogs(suppressCallbacks: true);
|
||
}
|
||
finally
|
||
{
|
||
_host.RevokeFixedCanvas(this);
|
||
_enter.OnClick = null;
|
||
_delete.OnClick = null;
|
||
_restore.OnClick = null;
|
||
_exit.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 = buttons.CanCreate;
|
||
_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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign CC slice CC7: <c>ListenToElementMessage</c> case 3 ->
|
||
/// <c>QueueUIMode(0x1000000b)</c>. Purely presentational — no Runtime
|
||
/// command, no roster/state change here; <see cref="_bindings"/>'
|
||
/// <c>RequestCreate</c> is resolved per-call (never captured) so it
|
||
/// reflects whatever <see cref="RetailUiRuntime"/> wired at the time of
|
||
/// the click, matching every other late-bound seam in this bindings
|
||
/// record.
|
||
/// </summary>
|
||
private void RequestCreate()
|
||
{
|
||
if (_disposed)
|
||
return;
|
||
_bindings.RequestCreate?.Invoke();
|
||
}
|
||
|
||
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();
|
||
// Enter-world round (2026-08-17): once-per-click outcome line — a
|
||
// refused Enter was previously indistinguishable from a click that
|
||
// never dispatched (both silent), which cost a full connected-gate
|
||
// round to tell apart.
|
||
Console.WriteLine(result.Accepted
|
||
? "[UI] character enter accepted"
|
||
: $"[UI] character enter rejected status={result.Status}");
|
||
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 RequestExit()
|
||
{
|
||
if (_disposed)
|
||
return;
|
||
|
||
// MakeConfirmExitDialog @ 0x004ed250's own guard: a second Exit
|
||
// click while the confirmation is already open is a no-op.
|
||
if (_confirmExitDialogContext != 0u)
|
||
return;
|
||
|
||
_confirmExitDialogContext = _dialogs.MakeConfirmation(
|
||
_strings.ConfirmExit,
|
||
data =>
|
||
{
|
||
_confirmExitDialogContext = 0u;
|
||
if (_disposed || _suppressDialogCallbacks)
|
||
return;
|
||
|
||
// RecvNotice_CloseDialog @ 0x004ed760 case 1: only a
|
||
// confirmed (OK) close proceeds through the SAME graceful
|
||
// shutdown path window-close uses; Cancel leaves the screen
|
||
// exactly as it was.
|
||
if (data.GetBoolean(RetailDialogProperty.ConfirmationResult))
|
||
_bindings.RequestExit();
|
||
});
|
||
}
|
||
|
||
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;
|
||
_host.RevokeFixedCanvas(this);
|
||
}
|
||
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);
|
||
CloseContext(ref _confirmExitDialogContext, 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;
|
||
}
|
||
}
|
||
}
|