acdream/src/AcDream.App/UI/RetailUiRuntime.cs
Erik 22b86b9ff4 feat(ui): Campaign OP slice OP4 — the Character tab
Binds LayoutDesc 0x21000028 (gmCharacterSettingsUI) through OP2's
template-list mechanism and OP3's OptionPage model: 6 authored group
headers + 50 toggle rows (49 from the 2013 build + D3's "Listen to PK
death messages", AP-193) in research doc §2's authored order, each row
resolved by PlayerOption id through CharacterOptionTable, seeded from
live RuntimeCharacterOptionsState, defaulted from CharacterOptionTable.
ClientDefault (byte-verified against UIOption_Checkbox::SetPlayerOption
@0x00486e80's own GetDefaultOptionValue call — AP-194 updated to confirm
the directive was followed), labels/tooltips resolved by name from
string table 0x23000003 (never hard-coded English), and registered with
OptionsPanelController.CharacterPage. Apply/Reset/Defaults
(0x100001FC/FD/FE) are now wired per-page via a scoped subtree search
(UiElement.FindDescendant, promoted from UiTabPanel) since Character/
Chat/Config each author their own physical instance under the SAME
element ids.

Consumers: Group A (29 ids) wire+store only via the existing
SetSingleCharacterOptionRuntimeCmd/TrySetOption seam. Group B: Display
Timestamps prefixes new transcript lines (RuntimeCommunicationState.
DisplayTimestampsSource); Disable Distance Fog forces FogMode.Off
(WeatherSystem.DisableDistanceFogSource, retiring half of TS-73); Run as
Default Movement inverts the walk-mode modifier's default
(RuntimeLocalPlayerMovementState.RunAsDefaultMovementSource). Group C
re-points AutoTarget/AutoRepeatAttack/ViewCombatTarget
(CharacterOptionCombatSettingsSource), VividTargetingIndicator/
CoordinatesOnRadar/LockUI/AcceptLootPermits from the client-local
GameplaySettings record to the canonical server bit — closing two
previously-unfiled divergences where AutoRepeatAttack and
AcceptCorpseLootingPermissions never reached the wire despite being
retail auto-save ids. TS-73 narrowed to its two still-open cases;
TS-75..TS-80 file the genuine gaps (no day/night force, no weather-
particle/profanity-filter/salvage/housing/pickup-preference subsystem,
fellowship-create's unaudited client-sourced field) rather than
inventing stand-ins.

Conformance: CharacterOptionsPageControllerTests pins all 50 rows
against CharacterOptionTable in both directions (an invented or dropped
row fails the build), the authored group/order row-by-row, and the
build/seed/Apply/Reset/Defaults/wire-publish behavior end-to-end against
the committed fixture. 52 new tests; full solution suite 13,008 passed /
4 skipped / 0 failed (was 12,956/4/0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 04:31:34 +02:00

2707 lines
110 KiB
C#

using System.Collections.Generic;
using AcDream.App.Plugins;
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Net;
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);
/// <summary>
/// Campaign OP slice OP3 (2026-08-11): bindings the retail Options panel
/// needs beyond what the generic <see cref="RetailPanelUiController"/> mount
/// already supplies (retained-window registration, geometry, opacity — all
/// shared with every other <c>gmPanelUI</c> sibling through
/// <see cref="RetailUiRuntimeBindings"/> itself).
/// </summary>
/// <param name="CommandBus">Late-resolved live command surface — the SAME
/// <c>Func&lt;ICommandBus&gt;</c> provider pattern <see cref="ChatRuntimeBindings.CommandBus"/>
/// already uses, since the live session's bus is not yet constructed at the
/// point this binding record is built (composition order).</param>
/// <param name="IsGrounded">Retail's mid-air logout-refusal check
/// (<c>transient_state &amp; 1</c>), tri-state per
/// <c>gmGamePlayUI::UseTime @0x004EA3A0</c>'s exact branch structure:
/// <c>else if (smartbox-&gt;player) { if (!grounded) refuse; else proceed; }</c>
/// — the airborne test is reached ONLY when a player object exists at all.
/// <c>null</c> means "no player" (acdream: <c>!d.PlayerMode.IsPlayerMode ||
/// d.PlayerController.Controller is null</c>), which retail's own structure
/// reaches neither branch for — no refusal, no proceed (mechanism review S3
/// / blast review SHOULD-FIX 1, 2026-08-11).</param>
/// <param name="IsUseMouseTurningEnabled">Live read of
/// <c>PlayerOption.UseMouseTurning</c> (<c>CharacterOptions2</c> bit
/// <c>0x00400000</c>) from the canonical <c>RuntimeCharacterOptionsState</c> —
/// the mouse-turning macro's sixth, server-synced target.</param>
/// <param name="DisplaySystemMessage">The interface-text seam
/// (<c>RetailLogTextType.ClientLocal</c>, SpewBox-only) — the SAME
/// <c>text =&gt; d.Communication.AddText(text, RetailLogTextType.ClientLocal)</c>
/// delegate shape <see cref="VendorRuntimeBindings.DisplaySystemMessage"/>/
/// <see cref="AppraisalRuntimeBindings"/> already use. Used for the mid-air
/// refusal (byte-verified <c>ClientLocal 0x1A</c>) and the Urgent
/// Assistance/Report Abuse short-circuit (AD-75) — NOT the mouse-turning
/// macro's own six lines, which use <see cref="DisplayMouseTurningMacroLine"/>
/// instead.</param>
/// <param name="DisplayMouseTurningMacroLine">The mouse-turning macro's own
/// six retail chat lines — routed at <c>RetailLogTextType.Magic (0x07)</c>,
/// BYTE-VERIFIED at all six <c>ClientSystem::AddTextToScroll</c> call sites
/// inside <c>gmConfigUI::SetMouseTurningDefaults</c> (raw bytes: <c>6a 07</c>
/// immediately before the text-pointer push and the call, at every one of
/// <c>0x0049E972</c>/<c>0x0049E9E2</c>/<c>0x0049EA52</c>/<c>0x0049EAA4</c>/
/// <c>0x0049EAF6</c>/<c>0x0049EB48</c>, byte-verified against the PDB-paired
/// binary 2026-08-11 — the OP3 review-fix round). Magic routes through
/// <c>RuntimeCommunicationState.AddText</c> to the scrolling chat transcript
/// (light blue, timestamped, logged), NOT the 4-slot SpewBox
/// <see cref="DisplaySystemMessage"/>'s <c>ClientLocal</c> uses.</param>
public sealed record OptionsRuntimeBindings(
Func<ICommandBus> CommandBus,
Func<bool?> IsGrounded,
Func<bool> IsUseMouseTurningEnabled,
Action<string> DisplaySystemMessage,
Action<string> DisplayMouseTurningMacroLine,
Func<CameraTurningSettings> LoadCameraTurning,
Action<CameraTurningSettings> SaveCameraTurning,
// Campaign OP slice OP4 (2026-08-11): reads a live character-option
// bit by its linear id (RuntimeCharacterOptionsState.GetOptionBit) —
// the Character-tab panel's row-seed source.
Func<uint, bool> CurrentCharacterOption);
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,
OptionsRuntimeBindings Options,
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);
// Constructed here (before Initialize's Mount* calls run) so its
// RetailWindowManager.WindowRegistered subscription is live from the very
// first window mount — every window this runtime ever registers picks up
// the live focus-driven opacity fade, matching retail's GLOBAL option scope
// (Campaign CH slice CH6c; register row AP-190). Seeded from the persisted
// Chat settings (defaults to ChatSettings.Default's 0.5/1.0 — retail's
// ChatInterface base-constructor values, 0x004F4550 — when no store is
// wired or nothing has been saved yet).
ChatSettings chatSettings = bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default;
WindowOpacity = new RetailWindowOpacityController(
bindings.Host.Root.WindowManager,
chatSettings.DefaultOpacity,
chatSettings.ActiveOpacity);
}
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();
MountOptionsPanel();
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>
/// The live focus-driven window-opacity mechanism (Campaign CH slice CH6c).
/// <c>AcDream.App.Settings.RuntimeSettingsTargets</c> calls into this from the
/// Chat tab's Save button so the transparency sliders apply with no restart.
/// </summary>
public RetailWindowOpacityController WindowOpacity { get; }
/// <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 OptionsPanelController? OptionsPanelController { 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: leave Options.Opacity at its 1f default — WindowOpacity
// (constructed in the ctor, before this Mount call runs) overwrites it
// the instant this window registers, via RetailWindowManager.WindowRegistered
// (Campaign CH slice CH6c: retail's whole-window alpha,
// ChatInterface::SetOpacity @0x004F3120).
Controller = controller,
StateController = controller,
});
controller.AttachWindow(handle);
Host.Root.DefaultTextInput = controller.Input;
_chatWindowController = controller;
// Round 4 (2026-08-10): the indicator buttons' CLICK half — drives the
// same ToggleFloatingChatWindow chokepoint the Alt+1..4 keybinds use.
// See ChatWindowController.SetIndicatorOpen's doc for the full retail
// mechanism reconciliation (user-directed; the generic UIElement_Button
// action-dispatch system is real and armed on the button side, but the
// floating-window fixture authors no matching listener registration).
controller.BindIndicatorClicks(ToggleFloatingChatWindow);
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.");
}
/// <summary>
/// Retail <c>ID_Client_EndCharacterSessionConfirm</c>
/// (<c>CM_UI::SendNotice_EndCharacterSession</c>'s confirmation dialog
/// text — research doc <c>2026-08-10-keyboard-config-and-gameplay-tab.md</c>
/// §2.1), shared by both the indicator bar's own end-session control
/// (<see cref="RequestEndCharacterSession"/>) and the Options panel's
/// Exit to Character Selection button
/// (<see cref="RequestExitToCharacterSelection"/>).
/// </summary>
private string ResolveEndCharacterSessionConfirmMessage()
{
const string fallback = "Are you sure you want to end this character session?";
lock (_bindings.Assets.DatLock)
{
return new DatStringResolver(_bindings.Assets.Dats).Resolve(
0x23000001u,
DatStringResolver.ComputeHash("ID_Client_EndCharacterSessionConfirm"))
?? fallback;
}
}
private void RequestEndCharacterSession()
{
ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted =>
{
if (accepted)
_bindings.Indicators.EndCharacterSession();
});
}
/// <summary>
/// Campaign OP slice OP3: the Options panel's Exit to Character
/// Selection button (element <c>0x10000203</c>) — D6's "behaves as Exit
/// Game" adaptation (register row, same commit), PLUS retail's
/// confirmation dialog and mid-air refusal, which DO port exactly.
/// <c>gmGamePlayUI::UseTime @0x004EA3A0</c>'s drain, its EXACT three-way
/// branch (review-fix round, 2026-08-11 — the original port collapsed
/// this to a two-way `if/else` that fired the refusal outside player
/// mode too):
/// <code>
/// if (m_bLogoutConfirmed) {
/// if (m_shouldQuitOnLogout) { QueueUIMode(0x10000009); }
/// else if (smartbox-&gt;player) {
/// if (!grounded) refuse "Cannot log off while in mid-air.";
/// else LogOffCharacter();
/// }
/// // no player at all: neither branch runs — SILENT no-op.
/// }
/// </code>
/// </summary>
private void RequestExitToCharacterSelection()
{
ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted =>
{
if (!accepted) return;
switch (_bindings.Options.IsGrounded())
{
case true:
_bindings.Indicators.EndCharacterSession();
break;
case false:
_bindings.Options.DisplaySystemMessage(ClientTextRefusals.CantLogOffMidAir);
break;
case null:
// Retail's `else if (smartbox->player)` gate: outside
// player mode (or with no live controller) there is no
// player object for UseTime to test at all, so neither
// the airborne refusal nor the logoff itself ever runs.
break;
}
});
}
/// <summary>
/// Campaign OP slice OP3: the Options panel's Use Mouse Turning Settings
/// button (element <c>0x100005CC</c>) — the pure
/// <see cref="MouseTurningSettingsMacro"/> orchestrated against live
/// bindings: load current values, compute the macro, persist + apply
/// live, send the wire bit if it changed, emit every retail chat line
/// (byte-verified <c>Magic 0x07</c>, review-fix round 2026-08-11 — see
/// <see cref="OptionsRuntimeBindings.DisplayMouseTurningMacroLine"/>),
/// then commit the (currently empty) Config page exactly like retail's
/// own trailing <c>SaveCurrentValues()</c>
/// (<c>gmConfigUI::SetMouseTurningDefaults @0x0049EB57</c>).
/// </summary>
private void ApplyMouseTurningSettingsMacro()
{
CameraTurningSettings current = _bindings.Options.LoadCameraTurning();
bool useMouseTurningCurrent = _bindings.Options.IsUseMouseTurningEnabled();
MouseTurningSettingsMacro.Result result =
MouseTurningSettingsMacro.Compute(current, useMouseTurningCurrent);
_bindings.Options.SaveCameraTurning(result.Updated);
if (result.UseMouseTurningChanged)
{
_bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd(
(uint)CharacterOptionId.UseMouseTurning, true));
}
foreach (string line in result.ChatLines)
_bindings.Options.DisplayMouseTurningMacroLine(line);
OptionsPanelController?.ConfigPage.Apply();
}
private void MountOptionsPanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
Layout.OptionsPanelController.HostLayoutId,
Layout.OptionsPanelController.SlotElementId);
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);
}
if (rootInfo is null || layout is null)
{
Console.WriteLine("[UI] options panel: LayoutDesc 0x2100006E slot 0x1000018D not found.");
return;
}
var callbacks = new Layout.OptionsPanelController.Callbacks(
Toggle: () => ToggleWindow(WindowNames.Options),
RequestExitToCharacterSelection: RequestExitToCharacterSelection,
ExitGame: _bindings.Indicators.EndCharacterSession,
UseMouseTurningSettings: ApplyMouseTurningSettingsMacro,
DisplaySystemMessage: _bindings.Options.DisplaySystemMessage,
AfterApply: () => _bindings.Options.CommandBus().Publish(
new SaveCharacterOptionsRuntimeCmd()));
Layout.OptionsPanelController? controller =
Layout.OptionsPanelController.Bind(layout, callbacks);
if (controller is null)
{
Console.WriteLine("[UI] options panel: required root did not build as UiTabPanel.");
return;
}
OptionsPanelController = controller;
// Campaign OP slice OP4 (2026-08-11): the Character tab's 6
// headers + 50 rows. Runs BEFORE ActivateTabs (below) so the
// Character page's model is fully populated before the default
// tab's OnShown fires. Needs a SECOND dat-lock scope — resolving
// each row's template (property 0x64 on the ListBox) is a live
// DAT read per row, unlike the already-built tree the block above
// only wires callbacks into.
lock (_bindings.Assets.DatLock)
{
var strings = new DatStringResolver(_bindings.Assets.Dats);
bool bound = Layout.CharacterOptionsPageController.Bind(
layout,
controller.CharacterPage,
templateResolver: (templateLayoutId, templateElementId) =>
{
ElementInfo? info = LayoutImporter.ImportInfos(
_bindings.Assets.Dats, templateLayoutId, templateElementId);
return info is null
? null
: LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
},
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
new Layout.CharacterOptionsPageController.Bindings(
CurrentValue: id => _bindings.Options.CurrentCharacterOption((uint)id),
SetOption: (id, value) => _bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd((uint)id, value))));
if (!bound)
Console.WriteLine("[UI] options panel: Character tab rows did not bind.");
}
controller.ActivateTabs();
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
controller.Root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Options,
Chrome = RetailWindowChrome.NineSlice,
Left = 150f,
Top = 80f,
Visible = false,
// Blast review SHOULD-FIX 2 (2026-08-11): match every OTHER
// gmPanelUI sibling sharing RetailPanelUiController's ONE
// main-panel geometry rectangle (Character/Inventory/
// Spellbook/Effects/the four indicator-detail panels) —
// ResizeX=false, bottom-edge-only resize, no invented
// Min/MaxWidth/Height. Options previously authored the ONLY
// divergent policy in that shared group (ResizeX=true,
// all-four-edge resize, literal 300x200 minimums); since
// every sibling forces `width = frame.Width` on the shared
// rect (RetailWindowManager.ResizeTo), a user-widened
// Options window silently reverted to 300 the next time ANY
// sibling was shown — this makes the whole cohort consistent
// again instead of one outlier with its own rules.
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(
RetailPanelCatalog.Options,
WindowNames.Options,
handle,
rootInfo.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
Console.WriteLine("[UI] retail Options panel from LayoutDesc importer (0x2100006E slot 0x1000018D).");
}
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;
WindowOpacity.Dispose();
},
() => _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),
]));
}
}