refactor(ui): extract retained runtime composition

This commit is contained in:
Erik 2026-07-11 00:03:45 +02:00
parent 5d9e98c118
commit ff06aa3107
7 changed files with 727 additions and 605 deletions

View file

@ -750,29 +750,13 @@ public sealed class GameWindow : IDisposable
// when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md
private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost;
private AcDream.App.UI.Testing.RetailUiAutomationScriptRunner? _uiAutomationRunner;
private AcDream.App.UI.CursorFeedbackController? _cursorFeedbackController;
private RetailCursorManager? _retailCursorManager;
// Phase D.5.1 — toolbar controller (kept for lifetime clarity; mirrors _chatWindowController pattern).
private AcDream.App.UI.Layout.ToolbarController? _toolbarController;
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
// Phase D.5.3a — selected-object strip controller (name, overlay state, health meter).
private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController;
// Phase D.6 — retail gmRadarUI (LayoutDesc 0x21000074). Behavior lives in
// Core + the retained controller; GameWindow supplies live state only.
private AcDream.App.UI.Layout.RadarController? _radarController;
private AcDream.App.UI.Layout.RadarSnapshotProvider? _radarSnapshotProvider;
// Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
private AcDream.App.UI.Layout.InventoryController? _inventoryController;
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
private AcDream.App.UI.Layout.PaperdollController? _paperdollController;
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer;
private AcDream.App.UI.UiViewport? _paperdollViewportWidget;
private AcDream.App.UI.UiNineSlicePanel? _inventoryFrame;
private bool _paperdollDollDirty = true;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
@ -2016,13 +2000,13 @@ public sealed class GameWindow : IDisposable
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
sendDrop: item => _liveSession?.SendDropItem(item),
toast: text => _debugVm?.AddToast(text));
_cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
_itemInteractionController,
// Retail UpdateCursorState (0x00564630) keys target-mode
// valid/invalid off the SmartBox found object — the world
// entity under the cursor, self included.
worldTargetProvider: () => PickWorldGuidAtCursor(includeSelf: true) ?? 0u);
_retailCursorManager = new RetailCursorManager(_dats!, _datLock);
var retailCursorManager = new RetailCursorManager(_dats!, _datLock);
_characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider(
Objects, LocalPlayer,
playerGuid: () => _playerServerGuid,
@ -2080,555 +2064,87 @@ public sealed class GameWindow : IDisposable
? "[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay."
: "[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font.");
// Phase D.2b — the vitals window is data-driven from the dat LayoutDesc
// (0x2100006C) via the LayoutImporter. The former hand-authored vitals.xml
// markup path was retired after the importer proved pixel-identical at the
// 2026-06-15 A/B gate. MarkupDocument stays for plugin/custom panels.
AcDream.App.UI.Layout.ImportedLayout? imported;
lock (_datLock)
imported = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, 0x2100006Cu, ResolveChrome, vitalsDatFont, ResolveDatFont);
if (imported is not null)
{
AcDream.App.UI.Layout.VitalsController.Bind(imported,
healthPct: () => _vitalsVm!.HealthPercent,
staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f,
manaPct: () => _vitalsVm!.ManaPercent ?? 0f,
healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "",
staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "",
manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : "");
var vitalsRoot = imported.Root;
AcDream.App.UI.Layout.RetailWindowFrame.Mount(
_uiHost.Root, vitalsRoot, ResolveChrome,
new AcDream.App.UI.Layout.RetailWindowFrame.Options
{
WindowName = AcDream.App.UI.WindowNames.Vitals,
Chrome = AcDream.App.UI.Layout.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).");
}
else
{
Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable.");
}
_uiHost.Root.Width = _window!.Size.X;
_uiHost.Root.Height = _window.Size.Y;
// Phase D.6 — retail gmRadarUI. The importer owns the exact static
// LayoutDesc 0x21000074 shell; RadarController owns retained behavior;
// RadarSnapshotProvider is the thin live-world adapter.
AcDream.App.UI.Layout.ImportedLayout? radarLayout;
lock (_datLock)
radarLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, AcDream.App.UI.Layout.RadarController.LayoutId,
ResolveChrome, vitalsDatFont, ResolveDatFont);
if (radarLayout is not null && radarLayout.Root is AcDream.App.UI.UiRadar radarRoot)
{
_radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
_entitiesByServerGuid,
_lastSpawnByGuid,
playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selectedGuid,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI);
_radarController = AcDream.App.UI.Layout.RadarController.Bind(
radarLayout,
snapshotProvider: _radarSnapshotProvider.BuildSnapshot,
selectObject: guid => SelectedGuid = guid,
setUiLocked: SetRetailUiLocked,
datFont: vitalsDatFont);
AcDream.App.UI.Layout.RetailWindowFrame.Mount(
_uiHost.Root, radarRoot, ResolveChrome,
new AcDream.App.UI.Layout.RetailWindowFrame.Options
{
WindowName = AcDream.App.UI.WindowNames.Radar,
Chrome = AcDream.App.UI.Layout.RetailWindowChrome.Imported,
Left = System.Math.Max(0f, _window!.Size.X - radarRoot.Width - 10f),
Top = 10f,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = _radarController,
});
Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074.");
}
else
{
Console.WriteLine("[D.6] radar: LayoutDesc 0x21000074 not found or root class mismatch.");
}
// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI),
// the same importer path as vitals. ChatWindowController binds the transcript,
// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter.
var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
_entitiesByServerGuid,
_lastSpawnByGuid,
playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selectedGuid,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI);
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
AcDream.App.UI.Layout.ElementInfo? chatRootInfo;
AcDream.App.UI.Layout.ImportedLayout? chatLayout;
lock (_datLock)
{
chatRootInfo = AcDream.App.UI.Layout.LayoutImporter.ImportInfos(
_dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId);
chatLayout = chatRootInfo is null ? null
: AcDream.App.UI.Layout.LayoutImporter.Build(chatRootInfo, ResolveChrome, vitalsDatFont, ResolveDatFont);
}
if (chatRootInfo is not null && chatLayout is not null)
{
var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind(
chatRootInfo, chatLayout, retailChatVm,
() => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)AcDream.UI.Abstractions.NullCommandBus.Instance,
vitalsDatFont, _debugFont, ResolveChrome);
if (chatController is not null)
{
// Ctrl+C / Ctrl+A on the transcript + Ctrl+C/X/V/A on the input need the
// keyboard for clipboard + modifier (Ctrl/Shift) state. _uiHost.Keyboard
// is set by WireKeyboard above — it is non-null here.
chatController.Transcript.Keyboard = _uiHost.Keyboard;
chatController.Input.Keyboard = _uiHost.Keyboard;
// Wrap the dat content in the universal 8-piece beveled window chrome —
// the SAME UiNineSlicePanel the vitals window uses. The chat's own dat
// layout only carries flat background sprites, so without this the window
// has no retail-style border (the user asked for the vitals border). The
// nine-slice IS the movable/resizable window; the dat content fills its
// interior, inset by the border. The gmMainChatUI content is authored 490
// wide (its transcript/input panels) — KEEP that width + the dat-authored
// HEIGHT so the content's child anchors (input-bar-at-bottom, transcript-
// fills) capture correct margins on first layout; resizing the frame reflows
// them correctly from there.
var chatRoot = chatController.Root;
var chatHandle = AcDream.App.UI.Layout.RetailWindowFrame.Mount(
_uiHost.Root, chatRoot, ResolveChrome,
new AcDream.App.UI.Layout.RetailWindowFrame.Options
{
WindowName = AcDream.App.UI.WindowNames.Chat,
Chrome = AcDream.App.UI.Layout.RetailWindowChrome.NineSlice,
Left = 10f,
Top = 440f,
ContentWidth = 490f,
ContentHeight = chatRoot.Height,
RebaseContentLayout = true,
DatConstraintSource = chatController.DatWindowInfo,
MinWidth = 200f,
ResizeX = true,
ResizeY = true,
Opacity = 0.75f,
Controller = chatController,
StateController = chatController,
});
chatController.AttachWindow(chatHandle);
// The LayoutDesc root is 800px wide because it shares the display
// coordinate space with auxiliary/floaty chat elements. The docked
// gmMainChatUI content is the left 490px. Rebase its immediate children
// after that intentional crop so subsequent frame resizing grows the
// transcript/input across the whole mounted interior.
// Tab / Enter enters "write mode" by focusing this input (retail's chat
// activation); a focused input suppresses character movement (see the
// WantsKeyboard gate in the movement poll).
_uiHost.Root.DefaultTextInput = chatController.Input;
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006).");
}
else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006.");
}
else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found.");
// Phase D.5.1 — toolbar window, data-driven from LayoutDesc 0x21000016
// (gmToolbarUI). Mirrors the vitals/chat import+bind+mount pattern above.
// Read the shortcut-slot digit sprite DID arrays from LayoutDesc 0x21000037
// (the UIItem cell template): element 0x1000034A under composite 0x10000346.
// Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465);
// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621) re-stamps per stance.
// Occupancy branch (decomp 229481):
// occupied → StateDesc.Properties[0x10000042] (peace) / [0x10000043] (war)
// empty → StateDesc.Properties[0x1000005e] (background digit, stance-independent)
uint[]? toolbarPeaceDigits = null;
uint[]? toolbarWarDigits = null;
uint[]? toolbarEmptyDigits = null;
lock (_datLock)
{
var uiItemLd = _dats!.Get<DatReaderWriter.DBObjs.LayoutDesc>(0x21000037u);
if (uiItemLd is not null
&& uiItemLd.Elements.TryGetValue(0x10000346u, out var composite)
&& composite.Children.TryGetValue(0x1000034Au, out var shortcutNumElem)
&& shortcutNumElem.StateDesc is { } sd
&& sd.Properties is { } props)
{
// Mirror LayoutImporter.ReadState: Properties[key] is ArrayBaseProperty
// containing DataIdBaseProperty entries. Each DataIdBaseProperty.Value is
// the RenderSurface DID for that digit.
// Peace: property 0x10000042; War: property 0x10000043.
if (props.TryGetValue(0x10000042u, out var rawPeace)
&& rawPeace is DatReaderWriter.Types.ArrayBaseProperty arrPeace)
{
toolbarPeaceDigits = new uint[arrPeace.Value.Count];
for (int i = 0; i < arrPeace.Value.Count; i++)
if (arrPeace.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d)
toolbarPeaceDigits[i] = d.Value;
}
if (props.TryGetValue(0x10000043u, out var rawWar)
&& rawWar is DatReaderWriter.Types.ArrayBaseProperty arrWar)
{
toolbarWarDigits = new uint[arrWar.Value.Count];
for (int i = 0; i < arrWar.Value.Count; i++)
if (arrWar.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d)
toolbarWarDigits[i] = d.Value;
}
}
else
{
Console.WriteLine("[D.5.1] digit arrays: element 0x1000034A/0x10000346 not found in LayoutDesc 0x21000037 — falling back to cited constants.");
}
// Empty-slot BACKGROUND digit lives under a DIFFERENT cell composite:
// composite 0x10000341 (the UIElement_UIItem-typed variant) carries property
// 0x1000005e (plainer digits 0x060010FA..0x06001102 for 1-9, 0x060074CF for the
// bottom row); composite 0x10000346 (peace/war, read above) does NOT carry it.
// Confirmed by a live dat property dump. Retail: UIElement_UIItem::SetShortcutNum
// (decomp 229481/229493) — empty branch (m_elem_Icon->m_state == 0x1000001c) reads
// 0x1000005e, stance-independent. No fallback constants (safe: no digit if absent).
if (uiItemLd is not null
&& uiItemLd.Elements.TryGetValue(0x10000341u, out var emptyComposite)
&& emptyComposite.Children.TryGetValue(0x1000034Au, out var emptyScn)
&& emptyScn.StateDesc is { } emptySd
&& emptySd.Properties is { } emptyProps
&& emptyProps.TryGetValue(0x1000005Eu, out var rawEmpty)
&& rawEmpty is DatReaderWriter.Types.ArrayBaseProperty arrEmpty)
{
toolbarEmptyDigits = new uint[arrEmpty.Value.Count];
for (int i = 0; i < arrEmpty.Value.Count; i++)
if (arrEmpty.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d)
toolbarEmptyDigits[i] = d.Value;
}
Console.WriteLine($"[D.5.1] empty digit array (0x10000341/0x1000005e): {toolbarEmptyDigits?.Length ?? 0} entries.");
}
// Cited-constant fallback (UIElement_UIItem::SetShortcutNum, decomp 229465 + dat probe).
// Used when the dat navigation above fails (e.g. missing LayoutDesc in older dat).
if (toolbarPeaceDigits is null)
toolbarPeaceDigits = new uint[]
{ 0x0600109Eu, 0x0600109Fu, 0x060010A0u, 0x060010A1u, 0x060010A2u,
0x060010A3u, 0x060010A4u, 0x060010A5u, 0x060010A6u };
if (toolbarWarDigits is null)
toolbarWarDigits = new uint[]
{ 0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u,
0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u };
// Report the arrays actually used (after any fallback substitution).
Console.WriteLine($"[D.5.1] toolbar digit arrays ready: peace={toolbarPeaceDigits.Length}, war={toolbarWarDigits.Length}, empty={toolbarEmptyDigits?.Length ?? 0} entries.");
AcDream.App.UI.Layout.ImportedLayout? toolbarLayout;
lock (_datLock)
toolbarLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, 0x21000016u, ResolveChrome, vitalsDatFont, ResolveDatFont);
if (toolbarLayout is not null)
{
_toolbarController = AcDream.App.UI.Layout.ToolbarController.Bind(
toolbarLayout, Objects,
() => Shortcuts,
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
useItem: guid => UseItemByGuid(guid),
combatState: Combat,
peaceDigits: toolbarPeaceDigits,
warDigits: toolbarWarDigits,
emptyDigits: toolbarEmptyDigits,
itemInteraction: _itemInteractionController,
sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g),
sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i));
// Phase D.5.3a — selected-object strip (name, overlay state, health meter).
// Analogue of retail gmToolbarUI::HandleSelectionChanged
// (acclient_2013_pseudo_c.txt:198635).
_selectedObjectController = AcDream.App.UI.Layout.SelectedObjectController.Bind(
toolbarLayout,
subscribeSelectionChanged: h => SelectionChanged += h,
unsubscribeSelectionChanged: h => SelectionChanged -= h,
subscribeHealthChanged: h => Combat.HealthChanged += h,
unsubscribeHealthChanged: h => Combat.HealthChanged -= h,
isHealthTarget: IsHealthBarTarget,
name: g => Objects.Get(g)?.Name,
healthPercent: g => Combat.GetHealthPercent(g),
hasHealth: g => Combat.HasHealth(g),
stackSize: g => (uint)(Objects.Get(g)?.StackSize ?? 0),
sendQueryHealth: g => _liveSession?.SendQueryHealth(g),
datFont: vitalsDatFont);
var toolbarRoot = toolbarLayout.Root;
// Wrap the dat content in the universal 8-piece beveled window chrome —
// the SAME UiNineSlicePanel used by the vitals and chat windows. The
// toolbar LayoutDesc (0x21000016) is 300×122; the frame adds one border
// thickness on every side, giving an outer window of 310×132.
const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border;
float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height;
var toolbarHandle = AcDream.App.UI.Layout.RetailWindowFrame.Mount(
_uiHost.Root, toolbarRoot, ResolveChrome,
new AcDream.App.UI.Layout.RetailWindowFrame.Options
{
WindowName = AcDream.App.UI.WindowNames.Toolbar,
Chrome = AcDream.App.UI.Layout.RetailWindowChrome.CollapsibleNineSlice,
Left = 10f,
Top = 300f,
ContentWidth = toolbarContentW,
ContentHeight = toolbarContentH,
Resizable = false,
ResizeX = false,
ResizeY = true,
ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
ContentAnchors = AcDream.App.UI.AnchorEdges.Left
| AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Right,
ContentClickThrough = true,
Controller = new AcDream.App.UI.RetainedPanelControllerGroup(
_toolbarController,
_selectedObjectController),
});
var toolbarFrame = (AcDream.App.UI.UiCollapsibleFrame)toolbarHandle.OuterFrame;
// Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden)
// and two-row height. CollapsedHeight is computed from the layout (just above row 2),
// so there's no magic constant. Bottom-edge only; default expanded.
// The full row-2 band (all at content-y 90, toolbar dump 0x21000016): a 6px left
// edge-piece (0x100006B6) + the 9 slots (0x100006B7..BF) + an 8px right edge-piece
// (0x100006C0). Hide ALL 11 when collapsed — hiding only the 9 slots leaves the two
// edge-pieces drawing as black pillars below the bar.
uint[] row2Ids =
{
0x100006B6u,
0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu,
0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu,
0x100006C0u,
};
var toolbarRow2 = new System.Collections.Generic.List<AcDream.App.UI.UiElement>();
float minRow2Top = float.MaxValue;
foreach (var id in row2Ids)
if (toolbarLayout.FindElement(id) is { } e2)
{
toolbarRow2.Add(e2);
if (e2.Top < minRow2Top) minRow2Top = e2.Top;
}
if (toolbarRow2.Count > 0)
{
float expandedH = toolbarContentH + 2 * toolbarBorder; // today's full height
float collapsedH = minRow2Top + 2 * toolbarBorder; // just above row 2
toolbarFrame.CollapsedHeight = collapsedH;
toolbarFrame.ExpandedHeight = expandedH;
toolbarFrame.SecondRow = toolbarRow2;
toolbarFrame.Resizable = true;
toolbarFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom; // bottom edge only
toolbarFrame.MinHeight = collapsedH;
toolbarFrame.MaxHeight = expandedH;
// Height stays expandedH (already set at construction) = default expanded.
Console.WriteLine($"[D.2b] toolbar collapse: collapsed={collapsedH:0} expanded={expandedH:0} (row2Top={minRow2Top:0}).");
}
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
}
else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found.");
// Phase D.2b-C: live Character window (Attributes / Skills / Titles) from
// LayoutDesc 0x2100002E. Starts hidden and is toggled by the status-bar
// character button; stats use the same retail-bound sheet shape as the studio
// until the live character stat feed is wired.
AcDream.App.UI.Layout.ImportedLayout? characterLayout;
lock (_datLock)
characterLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, 0x2100002Eu, ResolveChrome, vitalsDatFont, ResolveDatFont);
if (characterLayout is not null)
{
AcDream.App.UI.Layout.CharacterStatController.Bind(
characterLayout,
data: _characterSheetProvider!.BuildSheet,
datFont: vitalsDatFont,
rowDatFont: ResolveDatFont(0x40000001u) ?? vitalsDatFont,
spriteResolve: ResolveChrome,
onRaiseRequest: _characterSheetProvider.HandleRaiseRequest,
onClose: () => CloseRetailWindow(AcDream.App.UI.WindowNames.Character));
// Retail-authored content is 380px tall but the skill list wants to
// grow: bottom-edge-only resize up to 760px, X locked to the dat width.
AcDream.App.UI.Layout.RetailWindowFrame.Mount(
_uiHost.Root, characterLayout.Root, ResolveChrome,
new AcDream.App.UI.Layout.RetailWindowFrame.Options
{
WindowName = AcDream.App.UI.WindowNames.Character,
Chrome = AcDream.App.UI.Layout.RetailWindowChrome.NineSlice,
Left = 540f,
Top = 18f,
MaxHeight = 760f,
ResizeX = false,
ResizeY = true,
ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
Visible = false,
ContentAnchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Bottom,
ContentClickThrough = false,
});
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
}
else Console.WriteLine("[D.2b-C] character: LayoutDesc 0x2100002E not found.");
// Drain plugin-registered markup panels (buffered before the GL
// window opened) into the same UiRoot tree. A faulty plugin markup
// file is isolated — logged + skipped, never crashes the client.
if (_uiRegistry is not null)
{
foreach (var p in _uiRegistry.Drain())
{
try
{
string pluginXml = System.IO.File.ReadAllText(p.MarkupPath);
var pluginPanel = AcDream.App.UI.MarkupDocument.Build(
pluginXml, p.Binding, ResolveChrome, controls);
_uiHost.Root.AddChild(pluginPanel);
Console.WriteLine($"[D.2b] plugin UI panel loaded: {p.MarkupPath}");
}
catch (Exception ex)
{
Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}");
}
}
}
// Phase D.2b-B — the real inventory window from LayoutDesc 0x21000023 (gmInventoryUI),
// via the LayoutImporter. The sub-window mount pulls in the nested paperdoll/backpack/
// 3D-items panels (Type-0 leaves inheriting BaseLayoutId 0x21000024/22/21). Starts
// HIDDEN; F12 toggles it via the window manager. Position is the dat's own (X=500,Y=138).
AcDream.App.UI.Layout.ImportedLayout? invLayout;
lock (_datLock)
invLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, 0x21000023u, ResolveChrome, vitalsDatFont, ResolveDatFont);
if (invLayout is not null)
{
var inventoryRoot = invLayout.Root;
// Wrap the dat content in the universal 8-piece beveled window chrome — the
// SAME UiNineSlicePanel the vitals/chat/toolbar windows use. The gmInventoryUI
// LayoutDesc (0x21000023) is 300×362; the frame adds one border thickness on
// every side. The frame is the draggable window; the dat content sits inside it.
float invContentW = inventoryRoot.Width, invContentH = inventoryRoot.Height;
var inventoryHandle = AcDream.App.UI.Layout.RetailWindowFrame.Mount(
_uiHost.Root, inventoryRoot, ResolveChrome,
new AcDream.App.UI.Layout.RetailWindowFrame.Options
{
WindowName = AcDream.App.UI.WindowNames.Inventory,
Chrome = AcDream.App.UI.Layout.RetailWindowChrome.NineSlice,
Left = inventoryRoot.Left,
Top = inventoryRoot.Top,
ContentWidth = invContentW,
ContentHeight = invContentH,
Visible = false,
ResizeX = false,
ResizeY = true,
ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
MaxHeight = 560f,
ContentAnchors = AcDream.App.UI.AnchorEdges.Left
| AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Bottom,
});
var inventoryFrame = (AcDream.App.UI.UiNineSlicePanel)inventoryHandle.OuterFrame;
// InventoryController owns descendant resize/reflow policy and captures
// its hidden-window baselines when it binds below.
// Phase D.2b-B — populate the inventory from ClientObjectTable: the
// "Contents of Backpack" grid + the pack-selector strip + the burden meter +
// captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit.
// Resolve each inventory list's empty-slot art from the dat cell template
// (retail UIElement_ItemList::InternalCreateItem 0x004e3570: attr 0x1000000e ->
// catalog 0x21000037 prototype's ItemSlot_Empty). The contents grid lives in
// gm3DItemsUI (0x21000021); the side-bag + main-pack in gmBackpackUI (0x21000022).
uint contentsEmpty, sideBagEmpty, mainPackEmpty;
lock (_datLock)
{
contentsEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000021u, 0x100001C6u);
sideBagEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001CAu);
mainPackEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001C9u);
}
Console.WriteLine($"[D.2b empty-slot] contents=0x{contentsEmpty:X8} sideBag=0x{sideBagEmpty:X8} mainPack=0x{mainPackEmpty:X8}");
_inventoryController = AcDream.App.UI.Layout.InventoryController.Bind(
invLayout, Objects,
playerGuid: () => _playerServerGuid,
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
strength: () => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } sa
? (int?)sa.Current : null,
datFont: vitalsDatFont,
ownerName: _characterSheetProvider!.CharacterName,
contentsEmptySprite: contentsEmpty,
sideBagEmptySprite: sideBagEmpty,
mainPackEmptySprite: mainPackEmpty,
sendUse: g => _liveSession?.SendUse(g),
sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g),
sendPutItemInContainer: (item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement),
itemInteraction: _itemInteractionController,
onClose: () => CloseRetailWindow(AcDream.App.UI.WindowNames.Inventory));
// Slice 1: bind the paperdoll equip slots (same imported subtree) — show equipped gear +
// wield-on-drop. Unwield is handled by InventoryController (drag an equipped item to the grid).
_paperdollController = AcDream.App.UI.Layout.PaperdollController.Bind(
invLayout, Objects,
playerGuid: () => _playerServerGuid,
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
// Empty equip slots show a visible frame (same square as the inventory grid) so every
// slot position is seen + usable; the live 3D character (the doll) is Slice 2.
emptySlotSprite: contentsEmpty,
datFont: vitalsDatFont,
itemInteraction: _itemInteractionController); // Slice 2: caption the "Slots" toggle button
_uiHost.WindowManager.AttachController(
AcDream.App.UI.WindowNames.Inventory,
new AcDream.App.UI.RetainedPanelControllerGroup(
_inventoryController,
_paperdollController));
// Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory
// frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only
// when the window is open and in doll-view. The renderer itself is built after the
// WbDrawDispatcher exists (further down this method).
_paperdollViewportWidget = invLayout.FindElement(0x100001D5u) as AcDream.App.UI.UiViewport;
_inventoryFrame = inventoryFrame;
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
}
else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found.");
_toolbarController?.BindWindowToggles(
toggleInventory: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory),
toggleCharacter: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Character));
SyncToolbarWindowButtons();
if (_settingsStore is not null)
{
_windowLayoutPersistence = new AcDream.App.UI.RetailWindowLayoutPersistence(
_uiHost.WindowManager,
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
? null
: new AcDream.App.UI.RetailUiPersistenceBindings(
_settingsStore,
characterKey: () => _activeToonKey,
screenSize: () => (_window!.Size.X, _window.Size.Y));
}
CharacterKey: () => _activeToonKey,
ScreenSize: () => (_window.Size.X, _window.Size.Y));
void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(
new AcDream.App.UI.RetailUiRuntimeBindings(
Host: _uiHost,
Assets: new AcDream.App.UI.RetailUiAssets(
_dats!, _datLock, ResolveChrome, ResolveDatFont,
vitalsDatFont, _debugFont, controls, iconComposer),
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
Chat: new AcDream.App.UI.ChatRuntimeBindings(
retailChatVm,
() => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)
AcDream.UI.Abstractions.NullCommandBus.Instance),
Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot,
guid => SelectedGuid = guid,
SetRetailUiLocked),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects,
() => Shortcuts,
(type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
UseItemByGuid,
Combat,
_itemInteractionController,
(index, guid) => _liveSession?.SendAddShortcut(index, guid),
index => _liveSession?.SendRemoveShortcut(index),
handler => SelectionChanged += handler,
handler => SelectionChanged -= handler,
handler => Combat.HealthChanged += handler,
handler => Combat.HealthChanged -= handler,
IsHealthBarTarget,
guid => Objects.Get(guid)?.Name,
Combat.GetHealthPercent,
Combat.HasHealth,
guid => (uint)(Objects.Get(guid)?.StackSize ?? 0),
guid => _liveSession?.SendQueryHealth(guid)),
Character: new AcDream.App.UI.CharacterRuntimeBindings(_characterSheetProvider),
Inventory: new AcDream.App.UI.InventoryRuntimeBindings(
Objects,
() => _playerServerGuid,
(type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
() => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
guid => _liveSession?.SendUse(guid),
guid => _liveSession?.SendNoLongerViewingContents(guid),
(item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement),
(item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
_itemInteractionController),
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
cursorFeedbackController,
retailCursorManager),
Plugins: _uiRegistry,
Persistence: persistence,
Probe: new AcDream.App.UI.RetailUiProbeBindings(
_options.UiProbeEnabled,
_options.UiProbeScript,
_options.UiProbeDump,
UiProbeLog)));
if (_options.UiProbeEnabled)
{
void ProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
var probe = new AcDream.App.UI.Testing.RetailUiAutomationProbe(
_uiHost.Root,
Objects,
ProbeLog);
_uiAutomationRunner = new AcDream.App.UI.Testing.RetailUiAutomationScriptRunner(
probe,
_options.UiProbeScript,
_options.UiProbeDump,
ProbeLog);
}
}
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
@ -2738,11 +2254,12 @@ public sealed class GameWindow : IDisposable
// Slice 2: now that the dispatcher exists, build the paperdoll doll RTT renderer and hand it to
// the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only
// blits the texture; the pre-UI hook drives the 3-D pass (see OnRender).
if (_paperdollViewportWidget is not null && _sceneLightingUbo is not null)
if (_retailUiRuntime?.PaperdollViewportWidget is { } paperdollViewport
&& _sceneLightingUbo is not null)
{
_paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer(
_gl, _wbDrawDispatcher, _sceneLightingUbo);
_paperdollViewportWidget.Renderer = _paperdollViewportRenderer;
paperdollViewport.Renderer = _paperdollViewportRenderer;
}
// Phase A8: EnvCellRenderer init. Shares _meshShader (mesh_modern.{vert,frag})
@ -2910,7 +2427,7 @@ public sealed class GameWindow : IDisposable
_liveSession.EnterWorld(user, characterIndex: 0);
_activeToonKey = chosen.Name;
_windowLayoutPersistence?.RestoreAll();
_retailUiRuntime?.RestoreLayout();
SyncToolbarWindowButtons();
if (_settingsStore is not null && _settingsVm is not null)
{
@ -8670,7 +8187,7 @@ public sealed class GameWindow : IDisposable
// Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse
// on selection, then revert). No-op when nothing is flashing.
_selectedObjectController?.Tick(dt);
// Retained panel-local timers advance through RetailUiRuntime.Tick on the draw seam.
// Phase K.2 — re-evaluate WantCaptureMouse for the MMB
// mouse-look state machine. Detect rising/falling edges so the
@ -10242,8 +9759,8 @@ public sealed class GameWindow : IDisposable
// is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle).
// The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state.
if (_options.RetailUi && _paperdollViewportRenderer is not null
&& _paperdollViewportWidget is { Visible: true } dollWidget
&& _inventoryFrame is { Visible: true })
&& _retailUiRuntime?.PaperdollViewportWidget is { Visible: true } dollWidget
&& _retailUiRuntime.InventoryFrame is { Visible: true })
{
if (_paperdollDollDirty && RefreshPaperdollDoll())
_paperdollDollDirty = false;
@ -10255,12 +9772,12 @@ public sealed class GameWindow : IDisposable
// Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own
// blend/depth state and restores. Drawn before ImGui so the devtools
// overlay composites on top during development.
if (_options.RetailUi && _uiHost is not null)
if (_options.RetailUi && _retailUiRuntime is not null)
{
_uiHost.Tick(deltaSeconds);
_uiAutomationRunner?.Tick(deltaSeconds);
UpdateRetailCursorFeedback();
_uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
_retailUiRuntime.Tick(deltaSeconds);
if (_input is not null)
_retailUiRuntime.UpdateCursor(_input.Mice);
_retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
}
// Teleport fade cover (retail-teleport spec C). Drawn AFTER the world + retail UI so
@ -11800,27 +11317,16 @@ public sealed class GameWindow : IDisposable
// "default" and gets swapped to the actual character name on the
// first EnterWorld.
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
private AcDream.App.UI.RetailWindowLayoutPersistence? _windowLayoutPersistence;
private string _activeToonKey = "default";
private bool ToggleRetailWindow(string name)
{
bool visible = _uiHost?.ToggleWindow(name) ?? false;
SyncToolbarWindowButtons();
return visible;
}
private void CloseRetailWindow(string name)
{
_uiHost?.HideWindow(name);
SyncToolbarWindowButtons();
return _retailUiRuntime?.ToggleWindow(name) ?? false;
}
private void SyncToolbarWindowButtons()
{
if (_toolbarController is null || _uiHost is null) return;
_toolbarController.SetInventoryOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Inventory));
_toolbarController.SetCharacterOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Character));
_retailUiRuntime?.SyncToolbarWindowButtons();
}
private void SetRetailUiLocked(bool locked)
@ -11844,15 +11350,6 @@ public sealed class GameWindow : IDisposable
}
}
private void UpdateRetailCursorFeedback()
{
if (_uiHost is null || _cursorFeedbackController is null || _retailCursorManager is null || _input is null)
return;
var feedback = _cursorFeedbackController.Update(_uiHost.Root);
_retailCursorManager.Apply(_input.Mice, feedback);
}
private void OnUiDragReleasedOutside(object payload, int x, int y)
{
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
@ -14040,9 +13537,8 @@ public sealed class GameWindow : IDisposable
// Retained controllers subscribe to session-owned object/selection/combat sources.
// Tear their window-manager ownership down first, while every source and GL-backed
// UI resource is still alive. UiHost disposes controllers before its renderer.
_windowLayoutPersistence?.Dispose();
_windowLayoutPersistence = null;
_uiHost?.Dispose();
_retailUiRuntime?.Dispose();
_retailUiRuntime = null;
_uiHost = null;
// Phase A.1: join the streamer worker thread before tearing down GL