acdream/src/AcDream.App/UI/RetailUiRuntime.cs
Erik d3d1c895a0 feat(ui): finish retail toolbar controls
Discover all seven panel launchers from their DAT panel-id attributes, route mounted panels through event-driven retained window state, and ghost unavailable panels. Port Use and Examine selection/target behavior with exact Appraise dispatch and retail cursor modes.

Co-Authored-By: Codex <codex@openai.com>
2026-07-11 12:11:53 +02:00

653 lines
25 KiB
C#

using System.Collections.Generic;
using AcDream.App.Plugins;
using AcDream.App.Rendering;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Panels.Vitals;
using DatReaderWriter;
using Silk.NET.Input;
namespace AcDream.App.UI;
public sealed record RetailUiAssets(
DatCollection Dats,
object DatLock,
Func<uint, (uint Texture, int Width, int Height)> ResolveSprite,
Func<uint, UiDatFont?> ResolveFont,
UiDatFont? DefaultFont,
BitmapFont? DebugFont,
ControlsIni Controls,
IconComposer Icons);
public sealed record VitalsRuntimeBindings(VitalsVM ViewModel);
public sealed record ChatRuntimeBindings(ChatVM ViewModel, Func<ICommandBus> CommandBus);
public sealed record RadarRuntimeBindings(
Func<UiRadarSnapshot> Snapshot,
SelectionState Selection,
Action<bool> SetUiLocked);
public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects,
Func<IReadOnlyList<ShortcutEntry>> Shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Action<uint> UseItem,
CombatState Combat,
ItemManaState ItemMana,
Action ToggleCombat,
ItemInteractionController ItemInteraction,
Action<ShortcutEntry>? SendAddShortcut,
Action<uint>? SendRemoveShortcut,
SelectionState Selection,
Action<Action<uint, float>> SubscribeHealthChanged,
Action<Action<uint, float>> UnsubscribeHealthChanged,
Func<uint, bool> IsHealthTarget,
Func<uint, string?> ResolveName,
Func<uint, float> HealthPercent,
Func<uint, bool> HasHealth,
Func<uint, uint> StackSize,
Action<uint> SendQueryHealth,
Action<uint> SendQueryItemMana,
Func<uint> PlayerGuid,
Action<uint, uint, int>? SendPutItemInContainer);
public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
Func<uint> PlayerGuid,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<int?> Strength,
Action<uint>? SendUse,
Action<uint>? SendNoLongerViewing,
Action<uint, uint, int>? SendPutItemInContainer,
Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
Action<uint, uint, uint>? SendStackableMerge,
Action<uint, uint>? SendWield,
ItemInteractionController ItemInteraction,
SelectionState Selection);
public sealed record RetailUiPersistenceBindings(
SettingsStore Store,
Func<string> CharacterKey,
Func<(int Width, int Height)> ScreenSize);
public sealed record RetailUiProbeBindings(
bool Enabled,
string? ScriptPath,
bool DumpOnStart,
Action<string> Log);
public sealed record RetailUiCursorBindings(
CursorFeedbackController Feedback,
RetailCursorManager Manager);
public sealed record RetailUiRuntimeBindings(
UiHost Host,
RetailUiAssets Assets,
VitalsRuntimeBindings Vitals,
ChatRuntimeBindings Chat,
RadarRuntimeBindings Radar,
ToolbarRuntimeBindings Toolbar,
CharacterRuntimeBindings Character,
InventoryRuntimeBindings Inventory,
RetailUiCursorBindings Cursor,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
RetailUiProbeBindings Probe);
/// <summary>
/// Composition owner for the production retained gameplay UI. GameWindow supplies
/// state/action delegates and GL/DAT resolvers; this runtime imports layouts, binds
/// controllers, mounts windows, owns persistence, and presents one tick/draw/dispose seam.
/// </summary>
public sealed class RetailUiRuntime : IDisposable
{
private readonly RetailUiRuntimeBindings _bindings;
private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity;
private readonly RetailWindowLayoutPersistence? _persistence;
private readonly RetailUiAutomationScriptRunner? _automation;
private bool _disposed;
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
{
_bindings = bindings;
MountVitals();
MountRadar();
MountChat();
MountToolbar();
MountCharacter();
MountPlugins();
MountInventory();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
SyncToolbarWindowButtons();
if (bindings.Persistence is { } persistence)
{
_persistence = new RetailWindowLayoutPersistence(
Host.WindowManager,
persistence.Store,
persistence.CharacterKey,
persistence.ScreenSize);
}
if (bindings.Probe.Enabled)
{
var probe = new RetailUiAutomationProbe(
Host.Root,
bindings.Inventory.Objects,
bindings.Probe.Log);
_automation = new RetailUiAutomationScriptRunner(
probe,
bindings.Probe.ScriptPath,
bindings.Probe.DumpOnStart,
bindings.Probe.Log);
}
}
public UiHost Host => _bindings.Host;
public ItemInteractionController ItemInteraction => _bindings.Inventory.ItemInteraction;
public CharacterSheetProvider CharacterSheetProvider => _bindings.Character.Provider;
public ToolbarController? ToolbarController { get; private set; }
public ToolbarInputController? ToolbarInputController { get; private set; }
public SelectedObjectController? SelectedObjectController { get; private set; }
public UiViewport? PaperdollViewportWidget { get; private set; }
public UiNineSlicePanel? InventoryFrame { get; private set; }
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
ArgumentNullException.ThrowIfNull(bindings);
try
{
return new RetailUiRuntime(bindings);
}
catch
{
bindings.Host.Dispose();
throw;
}
}
public void Tick(double deltaSeconds)
{
SelectedObjectController?.Tick(deltaSeconds);
Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds);
}
public void Draw(System.Numerics.Vector2 screenSize) => Host.Draw(screenSize);
public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action)
=> ToolbarInputController?.Handle(action) == true;
public void UpdateCursor(IEnumerable<IMouse> mice)
{
CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root);
_bindings.Cursor.Manager.Apply(mice, feedback);
}
public void RestoreLayout() => _persistence?.RestoreAll();
public bool ToggleWindow(string name)
=> Host.ToggleWindow(name);
public void CloseWindow(string name)
=> Host.HideWindow(name);
public void SyncToolbarWindowButtons()
{
if (ToolbarController is null) return;
foreach (var (panelId, windowName) in RetailPanelCatalog.MountedPanels)
ToolbarController.SetPanelOpen(panelId, Host.IsWindowVisible(windowName));
}
private void BindToolbarPanelButtons()
{
ToolbarController?.BindPanelButtons(
panelId => RetailPanelCatalog.TryGetWindowName(panelId, out string name)
&& Host.WindowManager.TryGet(name, out _),
panelId =>
{
if (RetailPanelCatalog.TryGetWindowName(panelId, out string name))
ToggleWindow(name);
});
}
private void OnWindowVisibilityChanged(string windowName, bool visible)
{
if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId))
ToolbarController?.SetPanelOpen(panelId, visible);
}
private ImportedLayout? Import(uint layoutId)
{
lock (_bindings.Assets.DatLock)
return LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
private void MountVitals()
{
ImportedLayout? layout = Import(0x2100006Cu);
if (layout is null)
{
Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable.");
return;
}
VitalsVM vm = _bindings.Vitals.ViewModel;
VitalsController.Bind(layout,
() => vm.HealthPercent,
() => vm.StaminaPercent ?? 0f,
() => vm.ManaPercent ?? 0f,
() => (vm.HealthCurrent, vm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "",
() => (vm.StaminaCurrent, vm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "",
() => (vm.ManaCurrent, vm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : "");
RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Vitals,
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 30f,
ResizeX = true,
ResizeY = false,
MinWidth = 40f,
ContentClickThrough = false,
});
Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C).");
}
private void MountRadar()
{
ImportedLayout? layout = Import(RadarController.LayoutId);
if (layout is null || layout.Root is not UiRadar radarRoot)
{
Console.WriteLine("[D.6] radar: LayoutDesc 0x21000074 not found or root class mismatch.");
return;
}
RadarController controller = RadarController.Bind(
layout,
_bindings.Radar.Snapshot,
guid =>
{
if (_bindings.Toolbar.ItemInteraction.OfferPrimaryClick(guid)
!= ItemPrimaryClickResult.NotActive)
return;
_bindings.Radar.Selection.Select(guid, SelectionChangeSource.Radar);
},
setUiLocked: _bindings.Radar.SetUiLocked,
datFont: _bindings.Assets.DefaultFont);
RetailWindowFrame.Mount(Host.Root, radarRoot, _bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Radar,
Chrome = RetailWindowChrome.Imported,
Left = Math.Max(0f, Host.Root.Width - radarRoot.Width - 10f),
Top = 10f,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074.");
}
private void MountChat()
{
ElementInfo? info;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, ChatWindowController.LayoutId);
layout = info is null ? null : LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (info is null || layout is null)
{
Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found.");
return;
}
ChatWindowController? controller = ChatWindowController.Bind(
info,
layout,
_bindings.Chat.ViewModel,
_bindings.Chat.CommandBus,
_bindings.Assets.DefaultFont,
_bindings.Assets.DebugFont,
_bindings.Assets.ResolveSprite);
if (controller is null)
{
Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006.");
return;
}
controller.Transcript.Keyboard = Host.Keyboard;
controller.Input.Keyboard = Host.Keyboard;
UiElement root = controller.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.NineSlice,
Left = 10f,
Top = 440f,
ContentWidth = 490f,
ContentHeight = root.Height,
RebaseContentLayout = true,
DatConstraintSource = controller.DatWindowInfo,
MinWidth = 200f,
ResizeX = true,
ResizeY = true,
Opacity = 0.75f,
Controller = controller,
StateController = controller,
});
controller.AttachWindow(handle);
Host.Root.DefaultTextInput = controller.Input;
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006).");
}
private void MountToolbar()
{
ImportedLayout? layout = Import(0x21000016u);
if (layout is null)
{
Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found.");
return;
}
var (peace, war, empty) = LoadToolbarDigits();
ToolbarRuntimeBindings b = _bindings.Toolbar;
ToolbarController = Layout.ToolbarController.Bind(
layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat,
peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut,
toggleCombat: b.ToggleCombat,
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u,
playerGuid: b.PlayerGuid,
sendPutItemInContainer: b.SendPutItemInContainer);
ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection);
SelectedObjectController = Layout.SelectedObjectController.Bind(
layout,
b.Selection,
b.SubscribeHealthChanged,
b.UnsubscribeHealthChanged,
handler => b.ItemMana.ItemManaChanged += handler,
handler => b.ItemMana.ItemManaChanged -= handler,
b.IsHealthTarget,
b.ItemInteraction.IsOwnedByPlayer,
b.ResolveName,
b.HealthPercent,
b.HasHealth,
b.StackSize,
b.SendQueryHealth,
b.ItemMana.GetManaPercent,
b.SendQueryItemMana,
_bindings.Assets.DefaultFont,
StackSplitQuantity,
handler => b.Objects.ObjectUpdated += handler,
handler => b.Objects.ObjectUpdated -= handler);
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root, root, _bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Toolbar,
Chrome = RetailWindowChrome.CollapsibleNineSlice,
Left = 10f,
Top = 300f,
ContentWidth = 300f,
ContentHeight = root.Height,
Resizable = false,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
ContentClickThrough = true,
Controller = new RetainedPanelControllerGroup(
ToolbarController,
SelectedObjectController),
});
ConfigureToolbarCollapse(layout, (UiCollapsibleFrame)handle.OuterFrame, root.Height);
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
}
private (uint[] Peace, uint[] War, uint[]? Empty) LoadToolbarDigits()
{
uint[]? peace = null, war = null, empty = null;
lock (_bindings.Assets.DatLock)
{
var layout = _bindings.Assets.Dats.Get<DatReaderWriter.DBObjs.LayoutDesc>(0x21000037u);
if (layout is not null
&& layout.Elements.TryGetValue(0x10000346u, out var composite)
&& composite.Children.TryGetValue(0x1000034Au, out var number)
&& number.StateDesc?.Properties is { } props)
{
peace = ReadDataIds(props, 0x10000042u);
war = ReadDataIds(props, 0x10000043u);
}
if (layout is not null
&& layout.Elements.TryGetValue(0x10000341u, out var emptyComposite)
&& emptyComposite.Children.TryGetValue(0x1000034Au, out var emptyNumber)
&& emptyNumber.StateDesc?.Properties is { } emptyProps)
empty = ReadDataIds(emptyProps, 0x1000005Eu);
}
peace ??=
[
0x0600109Eu, 0x0600109Fu, 0x060010A0u, 0x060010A1u, 0x060010A2u,
0x060010A3u, 0x060010A4u, 0x060010A5u, 0x060010A6u,
];
war ??=
[
0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u,
0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u,
];
Console.WriteLine($"[D.5.1] toolbar digit arrays ready: peace={peace.Length}, war={war.Length}, empty={empty?.Length ?? 0} entries.");
return (peace, war, empty);
}
private static uint[]? ReadDataIds(
IReadOnlyDictionary<uint, DatReaderWriter.Types.BaseProperty> properties,
uint key)
{
if (!properties.TryGetValue(key, out var raw)
|| raw is not DatReaderWriter.Types.ArrayBaseProperty array)
return null;
var result = new uint[array.Value.Count];
for (int i = 0; i < array.Value.Count; i++)
if (array.Value[i] is DatReaderWriter.Types.DataIdBaseProperty dataId)
result[i] = dataId.Value;
return result;
}
private static void ConfigureToolbarCollapse(
ImportedLayout layout,
UiCollapsibleFrame frame,
float contentHeight)
{
uint[] ids =
[
0x100006B6u, 0x100006B7u, 0x100006B8u, 0x100006B9u,
0x100006BAu, 0x100006BBu, 0x100006BCu, 0x100006BDu,
0x100006BEu, 0x100006BFu, 0x100006C0u,
];
var row = new List<UiElement>();
float top = float.MaxValue;
foreach (uint id in ids)
if (layout.FindElement(id) is { } element)
{
row.Add(element);
top = Math.Min(top, element.Top);
}
if (row.Count == 0) return;
const int border = RetailChromeSprites.Border;
frame.CollapsedHeight = top + 2 * border;
frame.ExpandedHeight = contentHeight + 2 * border;
frame.SecondRow = row;
frame.Resizable = true;
frame.ResizableEdges = ResizeEdges.Bottom;
frame.MinHeight = frame.CollapsedHeight;
frame.MaxHeight = frame.ExpandedHeight;
}
private void MountCharacter()
{
ImportedLayout? layout = Import(0x2100002Eu);
if (layout is null)
{
Console.WriteLine("[D.2b-C] character: LayoutDesc 0x2100002E not found.");
return;
}
CharacterSheetProvider provider = _bindings.Character.Provider;
CharacterStatController.Bind(
layout,
provider.BuildSheet,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont(0x40000001u) ?? _bindings.Assets.DefaultFont,
_bindings.Assets.ResolveSprite,
provider.HandleRaiseRequest,
() => CloseWindow(WindowNames.Character));
RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Character,
Chrome = RetailWindowChrome.NineSlice,
Left = 540f,
Top = 18f,
MaxHeight = 760f,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
Visible = false,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
ContentClickThrough = false,
});
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
}
private void MountPlugins()
{
if (_bindings.Plugins is null) return;
foreach (var panel in _bindings.Plugins.Drain())
{
try
{
string xml = File.ReadAllText(panel.MarkupPath);
UiElement element = MarkupDocument.Build(
xml,
panel.Binding,
_bindings.Assets.ResolveSprite,
_bindings.Assets.Controls);
Host.Root.AddChild(element);
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
}
catch (Exception ex)
{
Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}");
}
}
}
private void MountInventory()
{
ImportedLayout? layout = Import(0x21000023u);
if (layout is null)
{
Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found.");
return;
}
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root, root, _bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Inventory,
Chrome = RetailWindowChrome.NineSlice,
Left = root.Left,
Top = root.Top,
ContentWidth = root.Width,
ContentHeight = root.Height,
Visible = false,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
// The old 560px cap left a previously expanded window permanently
// parked at its maximum. Let it grow to the available screen height.
MaxHeight = Math.Max(
root.Height + 2f * RetailChromeSprites.Border,
Host.Root.Height - root.Top),
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
});
uint contents, sideBag, mainPack;
PaperdollClickMap? paperdollClickMap;
lock (_bindings.Assets.DatLock)
{
contents = ItemListCellTemplate.ResolveEmptySprite(_bindings.Assets.Dats, 0x21000021u, 0x100001C6u);
sideBag = ItemListCellTemplate.ResolveEmptySprite(_bindings.Assets.Dats, 0x21000022u, 0x100001CAu);
mainPack = ItemListCellTemplate.ResolveEmptySprite(_bindings.Assets.Dats, 0x21000022u, 0x100001C9u);
paperdollClickMap = PaperdollClickMap.Load(_bindings.Assets.Dats);
}
InventoryRuntimeBindings b = _bindings.Inventory;
Action<uint, uint>? notifyMergeAttempt = ToolbarController is null
? null
: ToolbarController.ReplaceFullyMergedShortcut;
InventoryController inventory = InventoryController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
b.SendPutItemInContainer, b.SendStackableSplitToContainer, b.SendStackableMerge,
notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory),
StackSplitQuantity);
PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,
contents, _bindings.Assets.DefaultFont, b.ItemInteraction, paperdollClickMap);
Host.WindowManager.AttachController(
WindowNames.Inventory,
new RetainedPanelControllerGroup(inventory, paperdoll));
PaperdollViewportWidget = layout.FindElement(0x100001D5u) as UiViewport;
InventoryFrame = (UiNineSlicePanel)handle.OuterFrame;
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_persistence?.Dispose();
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
Host.Dispose();
}
}