acdream/src/AcDream.App/UI/RetailUiRuntime.cs
Erik 1aa7709988 fix(chat): CH6a/b rework — grip media, retail window-id model, floaty fixture
Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:

- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
  draws its own authored DirectState media (a synthetic parameterless
  grip still draws nothing, preserving existing resize-drag tests).
  DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
  live grips on the main chat window now resolve a non-zero sprite,
  restoring the visible borders/corners CH6a silently dropped.

- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
  distinct from every real window id (0-4), fixing the bug where the
  main window's explicit-addressing branch coincided with the broadcast
  check (both were literal 0). SetFilter's main-window no-op is dropped
  — the main window's filter is now genuinely settable. ChatWindowController
  .Bind takes a ChatWindowState (the same canonical instance the floating
  windows already share) and GetTranscriptLines builds a real accept
  predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
  never reaches ChatLog (AddText routes it to the SpewBox and returns),
  so nothing observable regresses.

- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
  indicator buttons (DAT property 0x0B=true, no retail click handler)
  from flipping their own Selected mirror on a stray click.

- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
  the real installed dats; added the permanent RetailLayoutFixtureGenerator
  entry. All three flagged FloatingChatWindowController assumptions
  (input field, title bar, close button) are confirmed correct against
  real data — no controller code changes needed. New finding: unlike the
  main window, ALL EIGHT floaty border/corner elements are live Type-9
  grips (the floaty's own title bar is its move handle), so a floaty
  window resizes from every edge and corner.

- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
  200-line-tail vs retail's per-window 10,000-line scrollback depth gap.

- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
  asymmetry and the reconnect-preserves-filters intent; corrected the
  research doc's modifier-mask mislabel and the "ONLY function" false
  superlative; moved WrapText off ChatWindowController onto
  ChatTranscriptRenderer, closing the circular dependency.

Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:09:32 +02:00

2399 lines
94 KiB
C#

using System.Collections.Generic;
using AcDream.App.Plugins;
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Spells;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
using AcDream.Content;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Panels.Vitals;
using AcDream.UI.Abstractions.Input;
using DatReaderWriter;
using Silk.NET.Input;
namespace AcDream.App.UI;
public sealed record RetailUiAssets(
IDatReaderWriter 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);
/// <param name="Windows">
/// Runtime's canonical per-window filter/open state
/// (<see cref="AcDream.Runtime.Gameplay.RuntimeCommunicationState.ChatWindows"/>).
/// Campaign CH slice CH6b: the four floating chat windows read/write this
/// exact instance — never a presentation-owned copy.
/// </param>
/// <param name="Store">
/// Optional local settings store used to persist the four floating
/// windows' text-type filters (open/visible + geometry already persist for
/// free through the generic <see cref="RetailWindowLayoutPersistence"/>
/// path once each window is registered under a distinct
/// <see cref="WindowNames"/> entry). Null in hosts that don't have a
/// settings store wired (e.g. some tests) — filters then simply keep their
/// retail <c>PostInit</c> defaults for the session. The wire format
/// (retail's <c>0x1000008B</c>/<c>0x1000008C</c> gameplay-options blob) is
/// explicitly deferred — see
/// <c>docs/plans/2026-08-09-chat-parity-campaign.md</c>'s CH6f row.
/// </param>
public sealed record ChatRuntimeBindings(
ChatVM ViewModel,
Func<ICommandBus> CommandBus,
ChatWindowState Windows,
SettingsStore? Store = null);
public sealed record RadarRuntimeBindings(
Func<UiRadarSnapshot> Snapshot,
SelectionState Selection,
Action<bool> SetUiLocked);
public sealed record CombatRuntimeBindings(
CombatState State,
RuntimeCombatAttackState Attacks,
Func<GameplaySettings> Gameplay,
Action<GameplaySettings> SetGameplay);
public sealed record MagicRuntimeBindings(
Spellbook Spellbook,
RuntimeSpellCastState Casting,
ClientObjectTable Objects,
Func<uint> PlayerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> Components,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
Func<uint, uint> ResolveSpellIcon,
Func<uint, uint> ResolveComponentIcon,
SelectionState Selection,
Func<uint, int> SpellLevel,
Func<uint, IReadOnlyList<SpellExamineComponent>> SpellComponents,
Func<MagicSchool, uint> MagicSkill,
Action<uint> SelectObject,
Action<uint> UseItem,
Action<int, int, uint> AddFavorite,
Action<int, uint> RemoveFavorite,
Action<uint> SendSpellbookFilter,
Action<uint> RemoveSpell,
Action<uint, uint> SetDesiredComponent,
Func<double> ServerTime);
public sealed record JumpPowerbarRuntimeBindings(Func<JumpChargeSnapshot> Snapshot);
public sealed record FpsRuntimeBindings(
Func<double> FramesPerSecond,
Func<double> DegradeMultiplier,
Func<bool> IsVisible);
public sealed record IndicatorRuntimeBindings(
Spellbook Spellbook,
ClientObjectTable Objects,
Func<uint> PlayerGuid,
Func<int?> Strength,
Func<LinkStatusSnapshot> LinkStatus,
Func<double> CurrentTime,
Action RequestLinkStatusPing,
Action EndCharacterSession);
public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects,
ShortcutStore Shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
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,
// Slice 6.2: composed at InteractionRetainedUiComposition from
// d.Inventory.Vendor + d.Inventory.Objects — SelectedObjectController's
// vendor-owned split-exempt-seed predicate (research doc §C.1).
Func<uint, bool> IsVendorSplitExempt);
public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
Func<uint> PlayerGuid,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
Func<int?> Strength,
Spellbook Spellbook,
Action<uint>? SendUse,
Action<uint, uint, int>? SendPutItemInContainer,
Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
Action<uint, uint, uint>? SendStackableMerge,
ItemInteractionController ItemInteraction,
SelectionState Selection);
public sealed record ExternalContainerRuntimeBindings(
ExternalContainerState State,
ClientObjectTable Objects,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
ItemInteractionController ItemInteraction,
SelectionState Selection,
Action<uint> SendUse,
Action<uint, uint, int> SendPutItemInContainer,
Action<uint, uint, uint, uint> SendStackableSplitToContainer,
Func<uint, bool> IsWithinUseRange);
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,
Func<InputAction, bool> PressInput,
Func<InputAction, bool, bool> SetInputHeld,
Testing.IRetailUiAutomationRuntime? Runtime = null);
public sealed record RetailUiCursorBindings(
CursorFeedbackController Feedback,
RetailCursorManager Manager);
public sealed record ConfirmationRuntimeBindings(
Action<uint, uint, bool> SendResponse);
public sealed record AppraisalRuntimeBindings(
Func<string> PlayerName,
Action<uint, string> SendSetInscription,
Action<string> DisplaySystemMessage);
public sealed record VendorRuntimeBindings(
VendorState State,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
// Slice 6.1: shared examine routing — VendorUiController.ExamineItem
// calls ItemInteraction.ExamineSelectedOrEnterMode the same way
// ExternalContainerRuntimeBindings.ItemInteraction's consumer does.
ItemInteractionController ItemInteraction,
// Slice 6.2: the canonical selection authority — every sibling binding
// record (Radar/Magic/Toolbar/Inventory/ExternalContainer) already
// carries this; Vendor was the one outlier (research doc §C.1).
SelectionState Selection,
// Slice 6b/6c: InqAcceptability's rejection strings, delivered on the
// SAME notice-0x1a system-message channel AppraisalRuntimeBindings'
// own DisplaySystemMessage already uses.
Action<string>? DisplaySystemMessage = null);
public sealed record RetailUiRuntimeBindings(
UiHost Host,
RetailUiAssets Assets,
VitalsRuntimeBindings Vitals,
ChatRuntimeBindings Chat,
RadarRuntimeBindings Radar,
CombatRuntimeBindings Combat,
MagicRuntimeBindings Magic,
JumpPowerbarRuntimeBindings JumpPowerbar,
FpsRuntimeBindings Fps,
VividTargetRuntimeBindings VividTarget,
IndicatorRuntimeBindings Indicators,
ToolbarRuntimeBindings Toolbar,
CharacterRuntimeBindings Character,
InventoryRuntimeBindings Inventory,
ExternalContainerRuntimeBindings ExternalContainer,
VendorRuntimeBindings Vendor,
RetailUiCursorBindings Cursor,
ConfirmationRuntimeBindings Confirmations,
AppraisalRuntimeBindings Appraisal,
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 RetailWindowLayoutPersistence? _persistence;
private RetailUiAutomationScriptRunner? _automation;
private readonly RetailPanelUiController _panelUi;
private ChatWindowController? _chatWindowController;
private readonly FloatingChatWindowController?[] _floatingChatControllers = new FloatingChatWindowController?[4];
private GameplayConfirmationController? _gameplayConfirmationController;
private RetailItemConfirmationController? _itemConfirmationController;
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
private ItemCooldownUiController? _itemCooldownController;
private VividTargetIndicatorController? _vividTargetIndicator;
private IDisposable? _characterSheetSubscription;
private ResourceShutdownTransaction? _shutdown;
private bool _disposed;
internal bool IsDisposalComplete => _disposed;
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
{
_bindings = bindings;
_panelUi = new RetailPanelUiController(
bindings.Host.IsWindowVisible,
bindings.Host.ShowWindow,
bindings.Host.HideWindow);
}
internal static RetailUiRuntime CreateUninitialized(
RetailUiRuntimeBindings bindings)
{
ArgumentNullException.ThrowIfNull(bindings);
return new RetailUiRuntime(bindings);
}
internal void InitializeForLease() => Initialize();
private void Initialize()
{
RetailUiRuntimeBindings bindings = _bindings;
MountFpsDisplay();
MountVividTargetIndicator();
MountVitals();
MountRadar();
MountChat();
MountFloatingChatWindows();
MountToolbar();
MountCombat();
MountSpellbook();
MountAppraisal();
MountEffects();
MountIndicatorDetailPanels();
MountIndicators();
MountJumpPowerbar();
MountDialogFactory();
MountCharacter();
MountPlugins();
MountInventory();
MountExternalContainer();
MountVendor();
MountItemCooldowns();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
SyncToolbarWindowButtons();
if (bindings.Persistence is { } persistence)
{
_persistence = new RetailWindowLayoutPersistence(
Host.WindowManager,
persistence.Store,
persistence.CharacterKey,
persistence.ScreenSize,
stateManagedVisibilityWindows:
[
WindowNames.Combat,
WindowNames.JumpPowerbar,
WindowNames.ExternalContainer,
WindowNames.Vendor,
]);
}
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,
text => ChatCommandRouter.Submit(
text,
bindings.Chat.ViewModel,
bindings.Chat.CommandBus(),
ChatChannelKind.Say),
bindings.Probe.PressInput,
bindings.Probe.SetInputHeld,
bindings.Probe.Runtime);
}
}
public UiHost Host => _bindings.Host;
/// <summary>
/// Shared dat/sprite/font resolvers this runtime was built with.
/// Campaign CH user-gate round 3: lets a controller built OUTSIDE this
/// runtime's own <c>Mount*</c> graph (e.g. <c>SpewBoxController</c>,
/// composed directly in <c>LivePresentationComposition</c>) resolve the
/// SAME memoized <see cref="UiDatFont"/> instances instead of loading
/// its own copy.
/// </summary>
public RetailUiAssets Assets => _bindings.Assets;
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 CombatUiController? CombatUiController { get; private set; }
public SpellcastingUiController? SpellcastingUiController { get; private set; }
public SpellbookWindowController? SpellbookWindowController { get; private set; }
public AppraisalUiController? AppraisalController { get; private set; }
public UiViewport? CreatureAppraisalViewportWidget { get; private set; }
public UiElement? ExaminationFrame { get; private set; }
public EffectsUiController? PositiveEffectsController { get; private set; }
public EffectsUiController? NegativeEffectsController { get; private set; }
public LinkStatusUiController? LinkStatusUiController { get; private set; }
public VitaeUiController? VitaeUiController { get; private set; }
public MiniGameUiController? MiniGameUiController { get; private set; }
public IndicatorBarController? IndicatorBarController { get; private set; }
public JumpPowerbarController? JumpPowerbarController { get; private set; }
public RetailFpsController? FpsController { get; private set; }
public SelectedObjectController? SelectedObjectController { get; private set; }
public UiViewport? PaperdollViewportWidget { get; private set; }
public UiNineSlicePanel? InventoryFrame { get; private set; }
public InventoryController? InventoryPanelController { get; private set; }
public RetailDialogFactory? DialogFactory { get; private set; }
public ExternalContainerController? ExternalContainerController { get; private set; }
public VendorUiController? VendorController { get; private set; }
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
ArgumentNullException.ThrowIfNull(bindings);
var runtime = CreateUninitialized(bindings);
try
{
runtime.Initialize();
return runtime;
}
catch (Exception initializationFailure)
{
try
{
runtime.Dispose();
}
catch (Exception cleanupFailure)
{
throw new AggregateException(
"Retail UI initialization failed and its partially constructed ownership could not be fully released.",
initializationFailure,
cleanupFailure);
}
throw;
}
}
public void Tick(double deltaSeconds)
{
FpsController?.Tick();
_vividTargetIndicator?.Tick();
SpellbookWindowController?.Tick();
AppraisalController?.Tick(deltaSeconds);
SpellcastingUiController?.Tick();
PositiveEffectsController?.Tick();
NegativeEffectsController?.Tick();
LinkStatusUiController?.Tick();
IndicatorBarController?.Tick();
JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick();
_itemCooldownController?.Tick();
DialogFactory?.Tick();
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)
{
if (SpellcastingUiController?.Handle(action) == true)
return true;
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel)
{
OpenSpellbook(SpellbookWindowPage.Spells);
return true;
}
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel)
{
OpenSpellbook(SpellbookWindowPage.Components);
return true;
}
return ToolbarInputController?.Handle(action) == true;
}
private void OpenSpellbook(SpellbookWindowPage page)
{
bool visible = Host.IsWindowVisible(WindowNames.Spellbook);
if (visible && SpellbookWindowController?.CurrentPage == page)
CloseWindow(WindowNames.Spellbook);
else
{
SpellbookWindowController?.ShowPage(page);
_panelUi.SetPanelVisibility(RetailPanelCatalog.Magic, visible: true);
}
}
public bool HandleConfirmationRequest(GameEvents.CharacterConfirmationRequest request)
=> _gameplayConfirmationController?.HandleRequest(request) == true;
public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done)
=> _gameplayConfirmationController?.HandleDone(done) == true;
public bool HandleAppraisal(AppraiseInfoParser.Parsed appraisal)
=> AppraisalController is { } controller
? controller.Apply(appraisal)
: ItemInteraction.AcceptAppraisalResponse(appraisal.Guid).Accepted;
public uint ShowConfirmation(string message, Action<bool> completed)
{
ArgumentNullException.ThrowIfNull(message);
ArgumentNullException.ThrowIfNull(completed);
return DialogFactory?.MakeConfirmation(
message,
data => completed(data.GetBoolean(RetailDialogProperty.ConfirmationResult))) ?? 0u;
}
/// <summary>
/// Close character-session dialogs through retail's normal callback and
/// close-notice path, then forget the gameplay confirmation tuple.
/// <c>gmGamePlayUI::~gmGamePlayUI @ 0x004EA2A0</c> closes its retained
/// contexts before <c>DialogFactory::Reset @ 0x00477950</c> drains the
/// remaining factory entries.
/// </summary>
public void ResetSessionDialogs()
{
try
{
DialogFactory?.Reset();
}
finally
{
_gameplayConfirmationController?.ResetSession();
}
}
public void ResetSessionTransientUi()
{
ResetSessionDialogs();
AppraisalController?.ResetSession();
Host.HideWindow(WindowNames.Examination);
}
public void UpdateCursor(IEnumerable<IMouse> mice)
{
CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root);
_bindings.Cursor.Manager.Apply(mice, feedback);
}
/// <summary>
/// #348: opts cursor application into the process-lifetime native
/// GLFW cursor cache. Called once by the graphical host after the
/// native window exists; zero handle keeps the Silk fallback.
/// </summary>
public void AttachNativeCursorWindow(nint glfwWindowHandle)
{
_bindings.Cursor.Manager.AttachNativeWindow(glfwWindowHandle);
}
public void RestoreLayout() => _persistence?.RestoreAll();
public void SaveLayout()
{
_persistence?.SaveAll();
SaveChatWindowFilters();
}
public void SaveNamedLayout(string profileName) => _persistence?.SaveNamed(profileName);
public void RestoreNamedLayout(string profileName) => _persistence?.RestoreNamed(profileName);
public bool ToggleWindow(string name)
=> RetailPanelCatalog.TryGetPanelId(name, out uint panelId)
? _panelUi.TogglePanel(panelId)
: Host.ToggleWindow(name);
/// <summary>
/// The four floating chat window controllers, indexed <c>[windowId - 1]</c>
/// (null entries are windows whose required elements were missing —
/// see <see cref="MountFloatingChatWindows"/>).
/// </summary>
public IReadOnlyList<FloatingChatWindowController?> FloatingChatWindows => _floatingChatControllers;
/// <summary>
/// Toggle floating chat window <paramref name="windowId"/> (1-4) — the
/// <c>ToggleFloatingChatWindow1..4</c> keybind's landing point
/// (research doc §1.3: the keybind mechanism, not a button click, is
/// what drives this in retail). Routes through the generic
/// <see cref="UiHost.ToggleWindow"/>, whose visibility-change event
/// drives both the persisted open state
/// (<see cref="OnWindowVisibilityChanged"/> syncing
/// <see cref="ChatWindowState"/>) and the main window's indicator-button
/// mirror — one chokepoint for every trigger.
/// </summary>
public bool ToggleFloatingChatWindow(int windowId)
=> Host.ToggleWindow(FloatingChatWindowName(windowId));
private static string FloatingChatWindowName(int windowId) => windowId switch
{
1 => WindowNames.ChatWindow1,
2 => WindowNames.ChatWindow2,
3 => WindowNames.ChatWindow3,
4 => WindowNames.ChatWindow4,
_ => throw new ArgumentOutOfRangeException(nameof(windowId), windowId, "floating chat window id must be 1-4."),
};
/// <summary>
/// Capture the four floating windows' current text-type filters into
/// the local settings store (research doc §4.4/§6.1 — local-only until
/// CH6f's wire format). No-op when no store was wired.
///
/// <para>
/// CH6a/b REJECT-review NIT 1: unlike window geometry/visibility (which
/// <see cref="_persistence"/> auto-saves on every change), a filter
/// change is only captured here — i.e. only when <see cref="SaveLayout"/>
/// runs, which today is exclusively the explicit <c>/saveautoui</c>
/// command. Harmless in practice (nothing currently mutates a window's
/// filter live — no options UI exists yet), but worth tightening to
/// auto-save-on-change once CH6e/CH6f gives filters a live settings
/// surface a user can actually edit mid-session.
/// </para>
/// </summary>
private void SaveChatWindowFilters()
{
if (_bindings.Chat.Store is not { } store) return;
ChatWindowState windows = _bindings.Chat.Windows;
ChatSettings current = store.LoadChat();
store.SaveChat(current with
{
ChatWindow1Filter = windows.GetFilter(1),
ChatWindow2Filter = windows.GetFilter(2),
ChatWindow3Filter = windows.GetFilter(3),
ChatWindow4Filter = windows.GetFilter(4),
});
}
public void CloseWindow(string name)
{
if (RetailPanelCatalog.TryGetPanelId(name, out uint panelId))
_panelUi.SetPanelVisibility(panelId, visible: false);
else
Host.HideWindow(name);
}
public void SyncToolbarWindowButtons()
{
if (ToolbarController is null) return;
foreach (var (panelId, windowName) in RetailPanelCatalog.ToolbarPanels)
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)
{
_panelUi.ObserveWindowVisibility(windowName, visible);
if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId))
ToolbarController?.SetPanelOpen(panelId, visible);
if (TryGetFloatingChatWindowId(windowName, out int chatWindowId))
{
// Keep Runtime's canonical open flag in sync regardless of what
// triggered the visibility change (keybind, restored layout,
// or the window's own close button) — one write path, matching
// gmFloatyChatUI::SetVisible's own single call site
// (persistence research doc §4.2).
_bindings.Chat.Windows.SetOpen(chatWindowId, visible);
_chatWindowController?.SetIndicatorOpen(chatWindowId, visible);
}
}
private static bool TryGetFloatingChatWindowId(string windowName, out int windowId)
{
windowId = windowName switch
{
WindowNames.ChatWindow1 => 1,
WindowNames.ChatWindow2 => 2,
WindowNames.ChatWindow3 => 3,
WindowNames.ChatWindow4 => 4,
_ => 0,
};
return windowId != 0;
}
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 ImportedLayout? Import(uint layoutId, uint rootElementId)
{
lock (_bindings.Assets.DatLock)
return LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
rootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
private void MountFpsDisplay()
{
ImportedLayout? layout = Import(
RetailFpsController.LayoutId,
RetailFpsController.DisplayElementId);
if (layout is null)
{
Console.WriteLine("[D.2b] FPS display: SmartBox element 0x10000047 not found.");
return;
}
FpsRuntimeBindings b = _bindings.Fps;
FpsController = RetailFpsController.Bind(
layout,
b.FramesPerSecond,
b.DegradeMultiplier,
b.IsVisible);
if (FpsController is null)
{
Console.WriteLine("[D.2b] FPS display: SmartBox element is not UIElement_Text.");
return;
}
// SmartBox HUD content is mounted before movable windows, matching retail's
// screen-layer ordering and ensuring ordinary panels can cover the readout.
Host.Root.AddChild(layout.Root);
Console.WriteLine("[D.2b] retail FPS display from SmartBox LayoutDesc 0x2100000F.");
}
private void MountVividTargetIndicator()
{
_vividTargetIndicator = VividTargetIndicatorController.Mount(
Host.Root,
_bindings.Assets,
_bindings.VividTarget);
Console.WriteLine(_vividTargetIndicator is null
? "[D.2b] vivid target indicator DAT surfaces unavailable."
: "[D.2b] vivid target indicator mounted from client-enum category 0x10000009.");
}
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);
var strings = new DatStringResolver(_bindings.Assets.Dats);
layout = info is null ? null : LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve);
}
if (info is null || layout is null)
{
Console.WriteLine("[D.2b] chat: LayoutDesc 0x2100006F not found.");
return;
}
ChatWindowController? controller = ChatWindowController.Bind(
info,
layout,
_bindings.Chat.ViewModel,
_bindings.Chat.CommandBus,
_bindings.Chat.Windows,
_bindings.Assets.DefaultFont,
_bindings.Assets.DebugFont,
_bindings.Assets.ResolveSprite);
if (controller is null)
{
Console.WriteLine("[D.2b] chat: required role elements missing in 0x2100006F.");
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,
// 0x2100006F's root already IS the complete retail window — its own
// 8 border/corner elements (the live Resizebar/Dragbar grips, or their
// _Locked cosmetic twins) are its chrome, at its authored 410x100
// extent. No universal nine-slice wrapper or content crop needed
// (Campaign CH slice CH6a; the wrapper + 490px crop were compensations
// for importing the wrong 0x21000006 layout).
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 440f,
DatConstraintSource = controller.DatWindowInfo,
// Bumping this resets any locally-persisted width/height captured
// under the old, wrong-layout mount (490px-cropped content, patched
// 9px height) to the newly authored 410x100 default on next load.
AuthoredGeometryRevision = 1,
ResizeX = true,
ResizeY = true,
// All 4 edges + 4 corners resize via the authored Resizebar grips
// (UiRoot gives a directly-hit grip's own edges priority over this
// blanket mask); the default already covers every edge, and the
// plain top-STRIP correctly remains a move-only affordance because
// its own element is a Type-2 Dragbar, not a grip (research doc §2.1,
// §2.3 — the top edge authors NO Resizebar of its own; only its two
// corners do).
// Opacity: retail's whole-window alpha (ChatInterface::SetOpacity
// @0x004F3120) is CH6c's scope — UiRenderContext.AlphaMod currently has
// no draw-path consumer, so any value here is presentation-inert; leave
// the frame at the default 1f rather than assert a value with no effect.
Controller = controller,
StateController = controller,
});
controller.AttachWindow(handle);
Host.Root.DefaultTextInput = controller.Input;
_chatWindowController = controller;
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x2100006F).");
}
/// <summary>
/// Mount the four retail floating chat windows — always-resident
/// authored children per <c>gmGamePlayUI::SetupChildren @0x004E9EC0</c>
/// (research doc §1.1), all four sharing LayoutDesc <c>0x2100005B</c>
/// but each with its own widget tree, window-registry name, and
/// screen position; every one starts hidden
/// (<c>gmFloatyChatUI</c> instances are born closed and toggled by
/// keybind/indicator button — research doc §1.3-§1.4). Must run after
/// <see cref="MountChat"/> so <see cref="_chatWindowController"/> is
/// available for the indicator-button mirror wired in
/// <see cref="OnWindowVisibilityChanged"/>.
/// </summary>
private void MountFloatingChatWindows()
{
ElementInfo? info;
lock (_bindings.Assets.DatLock)
{
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, FloatingChatWindowController.LayoutId);
}
if (info is null)
{
Console.WriteLine("[D.2b] floating chat: LayoutDesc 0x2100005B not found.");
return;
}
ChatWindowState windowFilters = _bindings.Chat.Windows;
if (_bindings.Chat.Store is { } store)
{
// Local-only persistence (research doc §4.4/§6.1 — the retail
// 0x1000008B/0x1000008C gameplay-options wire is explicitly
// deferred to CH6f). Open/visible + geometry persist for free
// through the generic RetailWindowLayoutPersistence path once
// each window registers below; only the filter mask needs its
// own load/save leg.
ChatSettings chat = store.LoadChat();
windowFilters.SetFilter(1, chat.ChatWindow1Filter);
windowFilters.SetFilter(2, chat.ChatWindow2Filter);
windowFilters.SetFilter(3, chat.ChatWindow3Filter);
windowFilters.SetFilter(4, chat.ChatWindow4Filter);
}
// acdream-chosen default screen positions — retail's own authored
// rect (0,80 in the shared LayoutDesc's local space) is identical
// for all four windows, so a literal port would stack every window
// at the same spot on first open; stagger them the same way the
// main chat window's own (10,440) default is an acdream placement
// choice, not a retail-authored screen position (MountChat above).
(string windowName, float left, float top)[] slots =
{
(WindowNames.ChatWindow1, 440f, 40f),
(WindowNames.ChatWindow2, 440f, 170f),
(WindowNames.ChatWindow3, 440f, 300f),
(WindowNames.ChatWindow4, 440f, 430f),
};
var strings = new DatStringResolver(_bindings.Assets.Dats);
for (int windowId = 1; windowId <= 4; windowId++)
{
ImportedLayout layout;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve);
}
FloatingChatWindowController? controller = FloatingChatWindowController.Bind(
windowId,
info,
layout,
_bindings.Chat.ViewModel,
_bindings.Chat.CommandBus,
windowFilters,
_bindings.Assets.DefaultFont,
_bindings.Assets.DebugFont,
_bindings.Assets.ResolveSprite);
if (controller is null)
{
Console.WriteLine($"[D.2b] floating chat window {windowId}: required role elements missing in 0x2100005B.");
continue;
}
controller.Transcript.Keyboard = Host.Keyboard;
controller.Input.Keyboard = Host.Keyboard;
(string windowName, float left, float top) = slots[windowId - 1];
UiElement root = controller.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = windowName,
// Same reasoning as MountChat: 0x2100005B's root is its
// own complete chrome (title bar, close button, and
// whichever mix of Resizebar/Dragbar grips the DAT
// authors for this layout — CH6a's importer/UiRoot
// priority logic is fully data-driven and needs no
// per-layout grip-count knowledge here).
Chrome = RetailWindowChrome.Imported,
Left = left,
Top = top,
DatConstraintSource = controller.DatWindowInfo,
AuthoredGeometryRevision = 1,
ResizeX = true,
ResizeY = true,
// Born hidden (research doc §1.1) — opened only via
// ToggleFloatingChatWindow (keybind) or a saved
// "visible" layout entry restored by
// RetailWindowLayoutPersistence.
Visible = false,
Controller = controller,
});
controller.AttachWindow(handle);
_floatingChatControllers[windowId - 1] = controller;
}
Console.WriteLine("[D.2b] retail floating chat windows 1-4 from LayoutDesc importer (0x2100005B).");
}
private void MountToolbar()
{
ImportedLayout? layout = Import(0x21000016u);
if (layout is null)
{
Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found.");
return;
}
UiShortcutDigitGraphics shortcutDigits = LoadShortcutDigitGraphics();
ToolbarRuntimeBindings b = _bindings.Toolbar;
ToolbarController = Layout.ToolbarController.Bind(
layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat,
shortcutDigits.RegularDigits, shortcutDigits.GhostedDigits,
shortcutDigits.EmptyDigits, b.ItemInteraction,
b.SendAddShortcut, b.SendRemoveShortcut,
toggleCombat: b.ToggleCombat,
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u,
selection: b.Selection,
playerGuid: b.PlayerGuid,
sendPutItemInContainer: b.SendPutItemInContainer,
ammoFont: _bindings.Assets.DefaultFont,
dragIconIds: b.ResolveDragIcon);
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,
b.IsVendorSplitExempt);
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 void MountCombat()
{
ElementInfo? info;
ImportedLayout? layout;
CombatUiLabels? labels;
uint favoriteEmptySprite;
lock (_bindings.Assets.DatLock)
{
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, CombatUiController.LayoutId);
favoriteEmptySprite = info is null
? 0u
: ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
info,
SpellcastingUiController.FavoriteListId);
var strings = new DatStringResolver(_bindings.Assets.Dats);
layout = info is null ? null : LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve);
labels = info is null ? null : CombatUiLabels.Resolve(info, strings);
}
if (info is null || layout is null || labels is null)
{
Console.WriteLine("[M2] combat: LayoutDesc 0x21000073 not found.");
return;
}
float combatWidth = RetailCombatLayout.FitFavoriteSlots(layout);
CombatUiController? controller = Layout.CombatUiController.Bind(
layout,
_bindings.Combat.State,
_bindings.Combat.Attacks,
_bindings.Combat.Gameplay,
_bindings.Combat.SetGameplay,
labels,
visible =>
{
if (visible) Host.ShowWindow(WindowNames.Combat);
else Host.HideWindow(WindowNames.Combat);
});
if (controller is null)
{
Console.WriteLine("[M2] combat: required controls missing in LayoutDesc 0x21000073.");
return;
}
SpellcastingUiController? spellcasting = Layout.SpellcastingUiController.Bind(
layout,
_bindings.Magic.Spellbook,
_bindings.Magic.Casting,
_bindings.Magic.Objects,
_bindings.Magic.PlayerGuid,
_bindings.Magic.ResolveSpellIcon,
item => _bindings.Magic.ResolveDragIcon(
item.Type, item.IconId, item.IconUnderlayId,
item.IconOverlayId, item.Effects),
_bindings.Magic.UseItem,
_bindings.Magic.Selection,
_bindings.Magic.AddFavorite,
_bindings.Magic.RemoveFavorite,
LoadShortcutDigitGraphics(),
favoriteEmptySprite,
examineSpell: spellId => AppraisalController?.ExamineSpell(spellId));
if (spellcasting is null)
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
CombatUiController = controller;
SpellcastingUiController = spellcasting;
IRetainedPanelController controllerOwner = spellcasting is null
? controller
: new RetainedPanelControllerGroup(controller, spellcasting);
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Combat,
Chrome = RetailWindowChrome.Imported,
Left = 0f,
Top = Math.Max(0f, Host.Root.Height - root.Height - 10f),
ContentWidth = combatWidth,
DatConstraintSource = info,
OuterAnchors = AnchorEdges.Left | AnchorEdges.Bottom,
Visible = false,
Draggable = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controllerOwner,
});
controller.SyncVisibility();
Console.WriteLine(spellcasting is null
? "[M2] retail combat from LayoutDesc 0x21000073; magic binding unavailable."
: $"[M3] retail combat + spell bar from LayoutDesc 0x21000073; " +
$"favorite empty=0x{favoriteEmptySprite:X8}.");
}
private void MountSpellbook()
{
ImportedLayout? layout;
SpellbookRowStyle? rowStyle;
ComponentBookTemplateFactory? componentTemplates;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
SpellbookWindowController.LayoutId,
SpellbookWindowController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
rowStyle = SpellbookRowStyle.TryLoad(_bindings.Assets.Dats);
componentTemplates = ComponentBookTemplateFactory.TryLoad(
_bindings.Assets.Dats,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null || rowStyle is null || componentTemplates is null)
{
Console.WriteLine(
"[M3] spellbook: layout, spell row, or component list templates not found.");
return;
}
UiDatFont? rowFont = rowStyle.Value.FontDid == 0u
? _bindings.Assets.DefaultFont
: _bindings.Assets.ResolveFont(rowStyle.Value.FontDid)
?? _bindings.Assets.DefaultFont;
SpellbookWindowController? controller = Layout.SpellbookWindowController.Bind(
layout,
_bindings.Magic.Spellbook,
_bindings.Magic.Objects,
_bindings.Magic.PlayerGuid,
_bindings.Magic.Components,
_bindings.Magic.Selection,
_bindings.Magic.ResolveSpellIcon,
_bindings.Magic.ResolveComponentIcon,
_bindings.Magic.SpellLevel,
_bindings.Magic.SelectObject,
spellId => SpellcastingUiController?.AddFavorite(spellId),
_bindings.Magic.SendSpellbookFilter,
_bindings.Magic.RemoveSpell,
(message, completed) => ShowConfirmation(message, completed),
_bindings.Magic.SetDesiredComponent,
() => CloseWindow(WindowNames.Spellbook),
componentTemplates,
rowStyle.Value,
rowFont);
if (controller is null)
{
Console.WriteLine("[M3] spellbook: required controls missing in LayoutDesc 0x21000034.");
return;
}
SpellbookWindowController = controller;
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Spellbook,
// LayoutDesc 0x21000034 is the 300x600 content surface. Like the
// Attributes/Skills window, retail supplies the outer gold frame
// through the shared window chrome rather than this root element.
Chrome = RetailWindowChrome.NineSlice,
Left = 18f,
Top = 18f,
Visible = false,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
ContentClickThrough = false,
Controller = controller,
});
_panelUi.RegisterMainPanel(
RetailPanelCatalog.Magic,
WindowNames.Spellbook,
handle);
Console.WriteLine("[M3] retail spellbook/component book from LayoutDesc 0x21000034.");
}
private void MountAppraisal()
{
ImportedLayout? layout;
CreatureAppraisalRowTemplateFactory? creatureRows;
SpellExamineComponentTemplateFactory? spellComponentTemplates;
CreatureDisplayNameResolver? creatureNames;
RetailAppraisalNameResolver? itemNames;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
AppraisalUiController.LayoutId,
AppraisalUiController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
creatureRows = CreatureAppraisalRowTemplateFactory.TryLoad(
_bindings.Assets.Dats,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
spellComponentTemplates =
SpellExamineComponentTemplateFactory.TryLoad(
_bindings.Assets.Dats,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
creatureNames = CreatureDisplayNameResolver.Load(
_bindings.Assets.Dats);
itemNames = RetailAppraisalNameResolver.Load(
_bindings.Assets.Dats,
creatureNames);
}
if (layout is null)
{
Console.WriteLine(
"[M4] examination: LayoutDesc 0x2100006B root 0x100005F2 not found.");
return;
}
AppraisalUiController? controller = AppraisalUiController.Bind(
layout,
_bindings.Inventory.Objects,
_bindings.Inventory.ItemInteraction,
_bindings.Inventory.Selection,
_bindings.Combat.State,
_bindings.Magic.Spellbook,
_bindings.Appraisal.PlayerName,
_bindings.Appraisal.SendSetInscription,
_bindings.Appraisal.DisplaySystemMessage,
show: () => Host.ShowWindow(WindowNames.Examination),
close: () => CloseWindow(WindowNames.Examination),
creatureRowTemplates: creatureRows,
creatureNames: creatureNames,
itemNames: itemNames,
resolveSpellIcon: _bindings.Magic.ResolveSpellIcon,
resolveComponentIcon: _bindings.Magic.ResolveComponentIcon,
spellComponents: _bindings.Magic.SpellComponents,
magicSkill: _bindings.Magic.MagicSkill,
spellComponentTemplates: spellComponentTemplates);
if (controller is null)
{
Console.WriteLine(
"[M4] examination: required authored controls are missing.");
return;
}
AppraisalController = controller;
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Examination,
Chrome = RetailWindowChrome.Imported,
Left = root.Left,
Top = root.Top,
ContentWidth = root.Width,
ContentHeight = root.Height,
AuthoredGeometryRevision = 1,
Visible = false,
ResizeX = true,
ResizeY = true,
MinWidth = 310f,
MinHeight = 400f,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentClickThrough = false,
Controller = controller,
});
CreatureAppraisalViewportWidget =
layout.FindElement(AppraisalUiController.CreatureViewportId)
as UiViewport;
ExaminationFrame = handle.OuterFrame;
Console.WriteLine(
"[M4] retail examination window from LayoutDesc 0x2100006B.");
}
private void MountEffects()
{
MountEffectsInstance(positive: true);
MountEffectsInstance(positive: false);
}
private void MountEffectsInstance(bool positive)
{
uint rootId = positive ? EffectsUiController.PositiveRootId : EffectsUiController.NegativeRootId;
ElementInfo? rootInfo;
ImportedLayout? layout;
EffectRowTemplateFactory? rowTemplates;
string selectPrompt;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
EffectsUiController.LayoutId,
rootId);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
new DatStringResolver(_bindings.Assets.Dats).Resolve);
rowTemplates = EffectRowTemplateFactory.TryLoad(
_bindings.Assets.Dats,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
var strings = new DatStringResolver(_bindings.Assets.Dats);
selectPrompt = strings.Resolve(
0x23000001u,
DatStringResolver.ComputeHash("ID_Effects_Info_SelectASpell"))
?? "SELECT A SPELL";
}
if (rootInfo is null || layout is null || rowTemplates is null)
{
Console.WriteLine($"[M3] effects: root or row template for 0x{rootId:X8} not found.");
return;
}
EffectsUiController? controller = Layout.EffectsUiController.Bind(
layout,
_bindings.Magic.Spellbook,
positive,
_bindings.Magic.ServerTime,
_bindings.Assets.ResolveSprite,
_bindings.Magic.ResolveSpellIcon,
rowTemplates,
selectPrompt,
close: () => CloseWindow(
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects));
if (controller is null)
{
Console.WriteLine($"[M3] effects: list missing under root 0x{rootId:X8}.");
return;
}
if (positive) PositiveEffectsController = controller;
else NegativeEffectsController = controller;
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
Chrome = RetailWindowChrome.NineSlice,
Left = Math.Max(0f, Host.Root.Width - root.Width - 12f),
Top = 18f,
Visible = false,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
ContentClickThrough = false,
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
Controller = controller,
});
_panelUi.RegisterMainPanel(
positive ? RetailPanelCatalog.PositiveEffects : RetailPanelCatalog.NegativeEffects,
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
handle,
rootInfo.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
}
private void MountIndicatorDetailPanels()
{
MountLinkStatusPanel();
MountVitaePanel();
MountMiniGamePanel();
MountCharacterInformationPanel();
}
private void MountCharacterInformationPanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
CharacterInfoStrings strings;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
CharacterController.LayoutId,
CharacterController.RootId);
var resolver = new DatStringResolver(_bindings.Assets.Dats);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
resolver.Resolve);
strings = CharacterInfoStrings.FromDat(key => resolver.ResolveAll(
0x23000001u, DatStringResolver.ComputeHash(key)));
}
if (rootInfo is null || layout is null) return;
CharacterInformationUiController controller = CharacterController.Bind(
layout,
_bindings.Character.Provider.BuildSheet,
_bindings.Assets.DefaultFont,
() => CloseWindow(WindowNames.CharacterInformation),
strings,
_bindings.Character.Provider.SubscribeChanged);
RegisterIndicatorDetailPanel(
RetailPanelCatalog.CharacterInformation,
WindowNames.CharacterInformation,
rootInfo,
layout.Root,
controller);
}
private void MountLinkStatusPanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
LinkStatusStrings strings;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
LinkStatusUiController.LayoutId,
LinkStatusUiController.RootId);
var resolver = new DatStringResolver(_bindings.Assets.Dats);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
resolver.Resolve);
LinkStatusStrings fallback = LinkStatusStrings.English;
strings = new LinkStatusStrings(
resolver.Resolve(0x23000001u, 0x0D632F7Fu) ?? fallback.Description,
resolver.Resolve(0x23000001u, 0x038FABF3u) ?? fallback.Legend,
resolver.Resolve(0x23000001u, 0x05481084u) ?? fallback.DisconnectWarning,
resolver.Resolve(0x23000001u, 0x0CADBA93u) ?? fallback.PacketLossPrefix,
resolver.Resolve(0x23000001u, 0x0D6485F7u) ?? fallback.PingPrefix);
}
if (rootInfo is null || layout is null) return;
LinkStatusUiController? controller = Layout.LinkStatusUiController.Bind(
layout,
_bindings.Indicators.LinkStatus,
_bindings.Indicators.CurrentTime,
_bindings.Indicators.RequestLinkStatusPing,
strings,
() => CloseWindow(WindowNames.LinkStatus));
if (controller is null) return;
LinkStatusUiController = controller;
RegisterIndicatorDetailPanel(
RetailPanelCatalog.LinkStatus,
WindowNames.LinkStatus,
rootInfo,
layout.Root,
controller);
}
private void MountVitaePanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
VitaeStrings strings;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
VitaeUiController.LayoutId,
VitaeUiController.RootId);
var resolver = new DatStringResolver(_bindings.Assets.Dats);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
resolver.Resolve);
VitaeStrings fallback = VitaeStrings.English;
strings = new VitaeStrings(
Resolve("ID_Vitae_Text_Full", 0, fallback.FullStrength),
Resolve("ID_Vitae_Text_Vitae", 0, fallback.LostPrefix),
Resolve("ID_Vitae_Text_Vitae", 1, fallback.LostSuffix),
Resolve("ID_Vitae_Text_Skills", 0, fallback.SkillsPrefix),
Resolve("ID_Vitae_Text_Skills", 1, fallback.SkillsSuffix),
Resolve("ID_Vitae_Text_Experience", 0, fallback.RecoveryPrefix),
Resolve("ID_Vitae_Text_Experience", 1, fallback.RecoverySuffix));
string Resolve(string key, int token, string fallbackValue)
=> resolver.Resolve(
0x23000001u,
DatStringResolver.ComputeHash(key),
token) ?? fallbackValue;
}
if (rootInfo is null || layout is null) return;
VitaeUiController? controller = Layout.VitaeUiController.Bind(
layout,
_bindings.Indicators.Spellbook,
_bindings.Indicators.Objects,
_bindings.Indicators.PlayerGuid,
strings,
() => CloseWindow(WindowNames.Vitae));
if (controller is null) return;
VitaeUiController = controller;
RegisterIndicatorDetailPanel(
RetailPanelCatalog.Vitae,
WindowNames.Vitae,
rootInfo,
layout.Root,
controller);
}
private void MountMiniGamePanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
MiniGameUiController.LayoutId,
MiniGameUiController.RootId);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
new DatStringResolver(_bindings.Assets.Dats).Resolve);
}
if (rootInfo is null || layout is null) return;
MiniGameUiController? controller = Layout.MiniGameUiController.Bind(
layout,
() => CloseWindow(WindowNames.MiniGame));
if (controller is null) return;
MiniGameUiController = controller;
RegisterIndicatorDetailPanel(
RetailPanelCatalog.MiniGame,
WindowNames.MiniGame,
rootInfo,
layout.Root,
controller);
}
private void RegisterIndicatorDetailPanel(
uint panelId,
string windowName,
ElementInfo rootInfo,
UiElement root,
IRetainedPanelController? controller)
{
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = windowName,
Chrome = RetailWindowChrome.NineSlice,
Left = 18f,
Top = 18f,
Visible = false,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
ContentClickThrough = false,
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
Controller = controller,
});
_panelUi.RegisterMainPanel(
panelId,
windowName,
handle,
rootInfo.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
}
/// <summary>
/// Retail's Vitae and Character Information children carry their own
/// 0x06004CC2 body surface below the 25px title row. The standalone shared
/// window contributes the border only in that case; painting the same
/// translucent center twice makes the panel look opaque.
/// </summary>
internal static bool AuthorsFullPanelCenter(ElementInfo rootInfo)
{
const float RetailTitleHeight = 25f;
return Visit(rootInfo);
bool Visit(ElementInfo info)
{
bool coversBody = info.Width >= rootInfo.Width
&& info.Height >= rootInfo.Height - RetailTitleHeight;
if (coversBody && info.StateMedia.Values.Any(
media => media.File == RetailChromeSprites.CenterFill))
return true;
return info.Children.Any(Visit);
}
}
private void MountIndicators()
{
ImportedLayout? layout = Import(IndicatorBarController.LayoutId);
if (layout is null)
{
Console.WriteLine("[UI] indicator bar: LayoutDesc 0x21000071 not found.");
return;
}
IndicatorRuntimeBindings b = _bindings.Indicators;
IndicatorBarController? controller = Layout.IndicatorBarController.Bind(
layout,
new IndicatorBarBindings(
b.Spellbook,
b.Objects,
b.PlayerGuid,
b.Strength,
b.LinkStatus,
b.CurrentTime,
panelId => _panelUi.TogglePanel(panelId),
RequestEndCharacterSession));
if (controller is null)
{
Console.WriteLine("[UI] indicator bar: one or more authored controls are missing.");
return;
}
IndicatorBarController = controller;
RetailWindowFrame.Mount(
Host.Root,
layout.Root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Indicators,
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 96f,
Visible = true,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
Console.WriteLine("[UI] retail seven-control indicator bar from LayoutDesc 0x21000071.");
}
private void RequestEndCharacterSession()
{
const string fallback = "Are you sure you want to end this character session?";
string message;
lock (_bindings.Assets.DatLock)
{
message = new DatStringResolver(_bindings.Assets.Dats).Resolve(
0x23000001u,
DatStringResolver.ComputeHash("ID_Client_EndCharacterSessionConfirm"))
?? fallback;
}
ShowConfirmation(message, accepted =>
{
if (accepted)
_bindings.Indicators.EndCharacterSession();
});
}
private void MountJumpPowerbar()
{
ElementInfo? info;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
info = LayoutImporter.ImportInfos(
_bindings.Assets.Dats, JumpPowerbarController.LayoutId);
layout = info is null ? null : LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine("[D.6] jump powerbar: LayoutDesc 0x21000072 not found.");
return;
}
JumpPowerbarController? controller = Layout.JumpPowerbarController.Bind(
layout,
_bindings.JumpPowerbar.Snapshot,
visible =>
{
if (visible) Host.ShowWindow(WindowNames.JumpPowerbar);
else Host.HideWindow(WindowNames.JumpPowerbar);
});
if (controller is null)
{
Console.WriteLine("[D.6] jump powerbar: required JumpMode meter missing in LayoutDesc 0x21000072.");
return;
}
JumpPowerbarController = controller;
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.JumpPowerbar,
Chrome = RetailWindowChrome.Imported,
Left = Math.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
Top = Math.Max(0f, Host.Root.Height - root.Height - 105f),
Visible = false,
DatConstraintSource = info,
Resizable = true,
ResizeX = true,
ResizeY = true,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
controller.SyncVisibility();
Console.WriteLine("[D.6] retail jump bar from gmFloatyPowerBarUI LayoutDesc 0x21000072.");
}
private void MountDialogFactory()
{
uint layoutId;
lock (_bindings.Assets.DatLock)
{
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
// creates a fresh type-specific root from that catalog.
layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u);
}
if (layoutId == 0u)
{
Console.WriteLine("[UI] retail dialog catalog could not be resolved.");
return;
}
ImportedLayout? CreateLayout(RetailDialogType type)
{
uint rootElementId = RetailDialogFactory.RootElementId(type);
if (rootElementId == 0u)
return null;
lock (_bindings.Assets.DatLock)
{
return LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
rootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
}
DialogFactory = new RetailDialogFactory(Host.Root, CreateLayout);
_gameplayConfirmationController = new GameplayConfirmationController(
DialogFactory,
_bindings.Confirmations.SendResponse);
_itemConfirmationController = new RetailItemConfirmationController(
DialogFactory,
ItemInteraction);
_skillTrainingConfirmationController =
new RetailSkillTrainingConfirmationController(DialogFactory);
Console.WriteLine(
$"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15.");
}
private UiShortcutDigitGraphics LoadShortcutDigitGraphics()
{
if (_shortcutDigitGraphics is not null)
return _shortcutDigitGraphics;
uint[]? regular = null, ghosted = 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)
{
regular = ReadDataIds(props, 0x10000042u);
ghosted = 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);
}
regular ??=
[
0x0600109Eu, 0x0600109Fu, 0x060010A0u, 0x060010A1u, 0x060010A2u,
0x060010A3u, 0x060010A4u, 0x060010A5u, 0x060010A6u,
];
ghosted ??=
[
0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u,
0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u,
];
_shortcutDigitGraphics = new UiShortcutDigitGraphics(
regular, ghosted, empty);
Console.WriteLine(
$"[D.5.1] shared shortcut digits ready: regular={regular.Length}, " +
$"ghosted={ghosted.Length}, empty={empty?.Length ?? 0}.");
return _shortcutDigitGraphics;
}
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;
CharacterSheet currentSheet = provider.BuildSheet();
_characterSheetSubscription = provider.SubscribeChanged(
() => currentSheet = provider.BuildSheet());
CharacterStatController.Bind(
layout,
() => currentSheet,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont(0x40000001u) ?? _bindings.Assets.DefaultFont,
_bindings.Assets.ResolveSprite,
(request, completed) => HandleCharacterRaise(provider, request, completed),
() => CloseWindow(WindowNames.Character));
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
layout.Root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Character,
Chrome = RetailWindowChrome.NineSlice,
Left = 540f,
Top = 18f,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ConstrainResizeToParent = true,
Visible = false,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
ContentClickThrough = false,
});
_panelUi.RegisterMainPanel(
RetailPanelCatalog.Character,
WindowNames.Character,
handle);
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
}
private void HandleCharacterRaise(
CharacterSheetProvider provider,
CharacterStatController.RaiseRequest request,
Action completed)
{
if (request.Kind != CharacterStatController.RaiseTargetKind.TrainSkill)
{
provider.HandleRaiseRequest(request);
completed();
return;
}
RetailSkillTrainingConfirmationController confirmations =
_skillTrainingConfirmationController
?? throw new InvalidOperationException(
"The retail dialog factory must be mounted before the character panel.");
confirmations.Request(
request,
provider.BuildSheet(),
provider.HandleRaiseRequest,
completed);
}
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,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
});
_panelUi.RegisterMainPanel(
RetailPanelCatalog.Inventory,
WindowNames.Inventory,
handle);
uint contents, sideBag, mainPack;
IReadOnlyDictionary<uint, uint> paperdollEmptySprites;
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);
paperdollEmptySprites = PaperdollSlotBackgrounds.ResolveEmptySprites(_bindings.Assets.Dats);
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.SendPutItemInContainer, b.SendStackableSplitToContainer, b.SendStackableMerge,
notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory),
StackSplitQuantity,
b.ResolveDragIcon,
b.Spellbook);
InventoryPanelController = inventory;
PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.ItemInteraction,
contents, _bindings.Assets.DefaultFont, paperdollClickMap,
b.ResolveDragIcon, paperdollEmptySprites);
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).");
}
private void MountExternalContainer()
{
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
ExternalContainerController.LayoutId,
ExternalContainerController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine("[D.2b] external container: LayoutDesc 0x21000008 not found.");
return;
}
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
ExternalContainerController.CreateWindowOptions(root));
uint contentsEmpty;
uint containerEmpty;
lock (_bindings.Assets.DatLock)
{
contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
ExternalContainerController.LayoutId,
ExternalContainerController.ContentsListId);
containerEmpty = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
ExternalContainerController.LayoutId,
ExternalContainerController.ContainerListId);
}
ExternalContainerRuntimeBindings b = _bindings.ExternalContainer;
ExternalContainerController = ExternalContainerController.Bind(
layout,
b.State,
b.Objects,
b.Selection,
b.ItemInteraction,
StackSplitQuantity,
b.ResolveIcon,
b.ResolveDragIcon,
b.SendUse,
b.SendPutItemInContainer,
b.SendStackableSplitToContainer,
b.IsWithinUseRange,
handle,
contentsEmpty,
containerEmpty);
Host.WindowManager.AttachController(
WindowNames.ExternalContainer,
ExternalContainerController);
Console.WriteLine(
"[D.2b] retail external-container strip mounted from LayoutDesc 0x21000008.");
}
/// <summary>
/// Slice 5.4: the retail vendor "Items" browse tab (LayoutDesc
/// 0x21000012, root 0x100000B7 — see
/// docs/research/2026-08-08-slice5-vendor-browse-research.md §B.3/§B.4
/// for the discovery + D0 category-filter read). Two-step mount
/// (Mount then Bind then AttachController) matches
/// <see cref="MountExternalContainer"/>: the controller needs the
/// <see cref="RetailWindowHandle"/> to Show/Hide itself as
/// <see cref="VendorState"/> opens/closes, so the handle must exist
/// before the controller is constructed.
/// </summary>
private void MountVendor()
{
ImportedLayout? layout;
uint emptySlotSprite;
uint buyingEmptySlotSprite;
uint sellingEmptySlotSprite;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
VendorUiController.LayoutId,
VendorUiController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
// F7b (Slice 5.4 review): the authored empty-slot background for
// the item strip, same resolution path ExternalContainerController
// uses for its own lists.
emptySlotSprite = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
VendorUiController.LayoutId,
VendorUiController.ItemListId);
// G2 (vendor gate finding): the Buying/Selling pages author
// their OWN item strips (same cell-template prototype as the
// Items list per the fixture, but resolved independently here
// rather than assumed identical).
buyingEmptySlotSprite = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
VendorUiController.LayoutId,
VendorUiController.BuyingListId);
sellingEmptySlotSprite = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
VendorUiController.LayoutId,
VendorUiController.SellingListId);
}
if (layout is null)
{
Console.WriteLine("[M4] vendor: LayoutDesc 0x21000012 root 0x100000B7 not found.");
return;
}
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Vendor,
// Root 0x100000B7 authors only the tiled center surface
// (backdrop element 0x1000008D provides its own fill); the
// common floating-window bevel surrounds it — same shape as
// ExternalContainerController's 0x21000008/0x10000063.
Chrome = RetailWindowChrome.NineSlice,
Left = root.Left,
Top = root.Top,
ContentWidth = root.Width,
ContentHeight = root.Height,
MinWidth = root.Width,
MinHeight = root.Height,
Visible = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
DrawChromeCenter = false,
});
VendorRuntimeBindings b = _bindings.Vendor;
// F1/F2/F3/F7b (Slice 5.4 review): the category dropdown needs
// fonts/sprites to draw at all; the price text needs the local
// player's coin total, sourced from the SAME ClientObjectTable/
// PlayerGuid pair InventoryRuntimeBindings already exposes (no new
// binding record needed — this mirrors how MountExternalContainer
// reads its own sibling binding).
VendorController = VendorUiController.Bind(
layout,
b.State,
handle,
b.ResolveIcon,
_bindings.Inventory.Objects,
_bindings.Inventory.PlayerGuid,
b.ItemInteraction,
b.Selection,
StackSplitQuantity,
_bindings.Assets.DefaultFont,
_bindings.Assets.DebugFont,
_bindings.Assets.ResolveSprite,
emptySlotSprite,
buyingEmptySlotSprite,
sellingEmptySlotSprite,
// Slice 6b/6c: the X-close staging confirmation and
// InqAcceptability rejection strings.
DialogFactory,
b.DisplaySystemMessage);
if (VendorController is null)
{
Console.WriteLine("[M4] vendor: required authored controls are missing.");
return;
}
Host.WindowManager.AttachController(WindowNames.Vendor, VendorController);
Console.WriteLine("[M4] retail vendor browse panel mounted from LayoutDesc 0x21000012.");
}
private void MountItemCooldowns()
{
ItemCooldownAssets? assets;
lock (_bindings.Assets.DatLock)
assets = ItemCooldownAssets.TryLoad(_bindings.Assets.Dats);
if (assets is null)
{
Console.WriteLine(
"[UI] shared UIItem cooldown overlays missing from LayoutDesc 0x21000037.");
return;
}
_itemCooldownController = ItemCooldownUiController.Bind(
Host.Root,
_bindings.Magic.Spellbook,
_bindings.Magic.Objects,
_bindings.Magic.ServerTime,
assets.Value);
Console.WriteLine(
"[UI] retail shared item cooldown overlays ready (10 DAT-authored steps).");
}
public void Dispose()
{
if (_disposed)
return;
_shutdown ??= CreateShutdownTransaction(
() => _automation?.Dispose(),
() => _persistence?.Dispose(),
() =>
{
_characterSheetSubscription?.Dispose();
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
},
() => _itemConfirmationController?.Dispose(),
() => _gameplayConfirmationController?.Dispose(),
() => DialogFactory?.Dispose(),
_panelUi.Dispose,
Host.Dispose);
_shutdown.CompleteOrThrow();
_disposed = _shutdown.IsComplete;
}
internal static ResourceShutdownTransaction CreateShutdownTransaction(
Action disposeAutomation,
Action disposePersistence,
Action unsubscribeWindowVisibility,
Action disposeItemConfirmation,
Action disposeGameplayConfirmation,
Action disposeDialogFactory,
Action disposePanelController,
Action disposeHost)
{
ArgumentNullException.ThrowIfNull(disposeAutomation);
ArgumentNullException.ThrowIfNull(disposePersistence);
ArgumentNullException.ThrowIfNull(unsubscribeWindowVisibility);
ArgumentNullException.ThrowIfNull(disposeItemConfirmation);
ArgumentNullException.ThrowIfNull(disposeGameplayConfirmation);
ArgumentNullException.ThrowIfNull(disposeDialogFactory);
ArgumentNullException.ThrowIfNull(disposePanelController);
ArgumentNullException.ThrowIfNull(disposeHost);
return new ResourceShutdownTransaction(
new ResourceShutdownStage("retail UI observers",
[
new("UI automation", disposeAutomation),
new("window persistence", disposePersistence),
new("window visibility", unsubscribeWindowVisibility),
]),
new ResourceShutdownStage("retail UI semantic controllers",
[
new("item confirmation", disposeItemConfirmation),
new("gameplay confirmation", disposeGameplayConfirmation),
]),
new ResourceShutdownStage("retail UI panel composition",
[
new("dialog factory", disposeDialogFactory),
new("panel controller", disposePanelController),
]),
new ResourceShutdownStage("retained UI host",
[
new("host", disposeHost),
]));
}
}