acdream/src/AcDream.App/UI/RetailUiRuntime.cs
Erik 2719782dc0 fix(ui): #409 tooltip review fix round — F1-F11
Opus review of a377b9bf returned architectural PASS-with-findings /
retail-fidelity FAIL with F1-F12 (F12 info-only). All eleven fixed,
each re-derived against docs/research/named-retail/acclient_2013_pseudo_c.txt:

- F1 PositionAtMouse: retail offsets BOTH axes +32px before the clamp
  (StartTooltip @0x00459700, @0x00459739/@0x00459747) — was landing
  flush at the cursor.
- F2 UiRoot: the dwell timer now anchors to mouse-IDLE like retail's
  m_lastMouseMoveTime (MouseMoveHandler @0x0045E710), resetting on
  every move within the same widget while !_tooltipFired, not just on
  hover-enter.
- F3 register TS-85 rewritten: the "dynamic InqProperty(0x49) override"
  framing was false — UIElement::InqProperty @0x004638D0's base impl
  reads the same authored bags this port already reads. The real
  second text source (m_TTText/SetTooltip, headed by the P0xD0
  truncated-text auto-tooltip @0x00466F80) needs a per-line-position
  truncation model UiText doesn't have — sized disproportionate for
  this round and left honestly deferred rather than stubbed.
- F4 OnTooltipShow: null LayoutPolicy + Anchors=None on the popup root
  and text child before resizing, mirroring RetailMessageDialogView's
  sibling shape.
- F5 OnTooltipShow: return without mounting when the P0x4A text child
  doesn't resolve to a UiText (retail's DynamicCast gate,
  StartTooltip @0x0045DE90 @0x0045df65/@0x0045df6f) — was mounting an
  empty 30x30 bevel artifact.
- F6 UiRoot.Tick: the dwell-arm branch now requires Captured is null
  (CheckTooltip @0x0045B6E0 @0x0045b715) — a widget hovered before a
  drag/resize/capture began must not pop mid-gesture.
- F7 UiRoot.ReleaseCapture: no longer resets _tooltipFired
  (ReleaseMouseCapture @0x0045D2B0 touches only the idle timestamp) —
  a mouse-up while a tooltip is shown no longer tears it down and
  silently re-fires it 250ms later.
- F8 ApplyTooltipText: applies ResizeTo's own max/min width/height
  clamps (P0x3C/0x3D/0x3E/0x3F, @0x00463C30) before assigning the
  grown size; zeroes text.Padding to keep the measured size margin-
  comparable. New ElementInfo/UiElement plumbing for the four
  properties, same shape as the existing tooltip fields.
- F9 doc precision: sweep counts corrected 434->430 / 191->187 (live-
  DAT re-measured), the "243 showable" claim now measured exactly
  (not assumed) via a new Showable column in the sweep test, and the
  MiscSettings citation split into its two real mechanisms
  (RegisterPreference in Init vs. AttachPreference/SetPreferenceRange
  elsewhere).
- F10 register AD-106: the topmost guarantee is versus dialogs/screens
  only (the overlay popup layer and drag ghost still paint above
  regardless), and the per-tick BringToFront ratchet has four rungs,
  not three.
- F11 RetailUiRuntime.ResetSessionDialogs: now also calls the new
  UiRoot.ResetTooltipTracking() so a post-reset hover re-shows
  immediately instead of waiting out the stale fired-latch.

New pinning tests (RetailTooltipPresenterTests: F1/F2/F5/F6/F7/F8) each
verified to fail against the pre-fix behavior via a temporary revert-
and-rerun before being confirmed against the restored fix.
PortalProjectionTests.ProjectToClipLease_ReusesPooledWorkWithoutResultArrays
recurrence logged on issue #346 (already the tracking issue for this
load-sensitive flake) — hit twice under load this review, standalone
26/26, unrelated to #409.

Gates: Release build 0 errors; App suite (live-DAT env) 5416/5413
passed/3 skips (was 5410/5407/3, +6 new tests); Runtime 1735/0;
UI.Abstractions 926/0; full solution (no env, 69 skips expected)
14,623/14,554 passed/69 skipped/0 failed (was 14,617/14,548, +6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:54:15 +02:00

4307 lines
188 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;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.Content;
using AcDream.Core.Input;
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);
// MUST-FIX 3 (OP4 review-fix round, 2026-08-11, blast M2): the
// GameplaySettings read/write pair was removed — MountCombat wires the
// three LEDs through the same OptionsRuntimeBindings server-bit seam the
// Character tab's rows use (CombatUiController.Bindings), not a
// client-local settings mirror.
public sealed record CombatRuntimeBindings(
CombatState State,
RuntimeCombatAttackState Attacks);
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,
// Campaign OP slice OP6 (2026-08-11): the Config tab's Display/Audio-
// backed rows. RuntimeSettingsController is the SOLE writer of these
// two sections (unlike Chat, which OP5 already routes through a raw
// SettingsStore side channel — Config's Chat Font Face/Size rows reuse
// THAT same store directly at the composition site instead of these
// two delegates, to avoid a stale-cache clobber of CH6's filter/opacity
// writes; see RetailUiRuntime.MountOptionsPanel).
Func<DisplaySettings> LoadDisplay,
Action<DisplaySettings> SaveDisplay,
Func<AudioSettings> LoadAudio,
Action<AudioSettings> SaveAudio);
/// <summary>
/// Campaign FA slice FA3: the social panel's (Friends/Allegiance/
/// Fellowship/Squelch) own bindings — the FA2 owners' typed snapshot
/// readers plus the two J4.1 social-list owners, direct instances (not the
/// <see cref="AcDream.Runtime.IRuntimeSocialView"/> view seam bots use — that
/// view has no full-collection enumeration, only per-id lookup + counts;
/// the panel's read-only rows need the actual entries, same access shape
/// <see cref="AcDream.App.Composition.InteractionRetainedUiDependencies.Communication"/>
/// already exposes for every other Communication-owned consumer).
///
/// <para>
/// Campaign FA slice FA4 adds the Fellowship page's write surface:
/// <see cref="FellowshipMembers"/> (the roster <see cref="IRuntimeFellowshipView"/>
/// alone cannot enumerate — see that interface's own FA4 addendum) and the
/// seven generation-gated commands, all routed through
/// <see cref="AcDream.App.Composition.DeferredGameRuntimeStateCommands"/>
/// (the SAME late-bound, race-safe seam <c>AddShortcut</c>/<c>Advance</c>
/// already use) rather than a raw <c>WorldSession.SendXxx</c> call —
/// <c>FellowshipQuit</c> in particular MUST go through
/// <c>IRuntimeFellowshipCommands.Quit</c> because the retail leader
/// hand-off rule (<c>0x0290</c> before <c>0x00A3</c>) lives in
/// <c>RuntimeFellowshipState.RequiresLeaderHandoffBeforeQuit</c> +
/// <c>DirectGameRuntimeCommandAdapter.Quit</c>/
/// <c>CurrentGameRuntimeCommandAdapter.Quit</c>, not in
/// <c>WorldSession</c> itself — bypassing the command layer would silently
/// drop that behavior. <see cref="Selection"/> is the Recruit target source
/// (retail's <c>ACCWeenieObject::selectedID</c> — lane B §2.3, the
/// currently-selected WORLD object, not a panel-local selection).
/// </para>
///
/// <para>
/// Campaign FA slice FA5 adds the Allegiance page's write/read surface,
/// mirroring FA4's Fellowship shape: <see cref="AllegianceMonarch"/>/
/// <see cref="AllegiancePatron"/>/<see cref="AllegianceMember"/>/
/// <see cref="AllegianceVassals"/> project
/// <see cref="AcDream.Runtime.IRuntimeAllegianceView"/>'s <c>out</c>-param
/// accessors into plain nullable-returning delegates (the shape
/// <see cref="SocialAllegiancePageController.Bindings"/> wants), and
/// <see cref="AllegianceSwear"/>/<see cref="AllegianceBreak"/>/
/// <see cref="AllegianceKick"/>/<see cref="AllegianceSetUpdateSubscription"/>
/// route through <see cref="AcDream.App.Composition.DeferredGameRuntimeStateCommands"/>
/// exactly like the fellowship commands above.
/// </para>
/// </summary>
public sealed record SocialRuntimeBindings(
Func<AcDream.Runtime.RuntimeFellowshipSnapshot> FellowshipSnapshot,
Func<AcDream.Runtime.RuntimeAllegianceSnapshot> AllegianceSnapshot,
AcDream.Core.Social.FriendsState Friends,
AcDream.Core.Social.SquelchState Squelch,
Func<IEnumerable<AcDream.Runtime.RuntimeFellowMemberSnapshot>> FellowshipMembers,
Func<string, bool, RuntimeCommandResult> FellowshipCreate,
Func<uint, RuntimeCommandResult> FellowshipRecruit,
Func<uint, RuntimeCommandResult> FellowshipDismiss,
Func<bool, RuntimeCommandResult> FellowshipQuit,
Func<uint, RuntimeCommandResult> FellowshipAssignLeader,
Func<bool, RuntimeCommandResult> FellowshipSetOpen,
Func<bool, RuntimeCommandResult> FellowshipSetPanelOpen,
SelectionState Selection,
Func<uint> LocalPlayerGuid,
Func<AcDream.Runtime.RuntimeAllegianceMemberSnapshot?> AllegianceMonarch,
Func<uint, AcDream.Runtime.RuntimeAllegianceMemberSnapshot?> AllegiancePatron,
Func<uint, AcDream.Runtime.RuntimeAllegianceMemberSnapshot?> AllegianceMember,
Func<uint, IEnumerable<AcDream.Runtime.RuntimeAllegianceMemberSnapshot>> AllegianceVassals,
Func<uint, RuntimeCommandResult> AllegianceSwear,
Func<uint, RuntimeCommandResult> AllegianceBreak,
Func<uint, RuntimeCommandResult> AllegianceKick,
Func<bool, RuntimeCommandResult> AllegianceSetUpdateSubscription,
// Secure trade (2026-08-14): the third sibling J-owner's borrowed view.
// Trailing/optional per the established compatibility convention.
AcDream.Runtime.Gameplay.IRuntimeTradeView? Trade = null);
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);
/// <summary>
/// Campaign OP slice OP8: the Configure Keyboard screen's live read/write seam —
/// the ONE live <see cref="InputDispatcher"/> (Bindings for reads,
/// SetBindings+BeginCapture for writes/capture) plus the portable
/// <c>keybinds.json</c> path (D4 — no <c>.keymap</c> file interchange). Null
/// <see cref="Dispatcher"/> (headless/no-window hosts, or before the graphical
/// input stack finishes constructing) degrades to "Configure Keyboard has no
/// live effect" exactly like every other null-dependency Options-panel seam.
/// </summary>
public sealed record KeyboardRuntimeBindings(
InputDispatcher? Dispatcher,
string KeyBindingsFilePath);
/// <summary>
/// Borrowed LA7b character-selection projection and its generation-capturing
/// typed command routes. App owns no roster, selection, operation, or error
/// mirror; an absent view means the current adapter has not bound (or has
/// already been released).
/// </summary>
/// <param name="RequestExit">
/// Campaign LA gate round 2 finding 1: retail's Exit button
/// (<c>gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0</c>,
/// element offset 7 from the listbox base — id <c>0x100003A4</c>) opens
/// <c>MakeConfirmExitDialog@0x004ed250</c>; on confirm
/// (<c>RecvNotice_CloseDialog@0x004ed760</c> case 1) retail queues UI mode
/// <c>0x10000009</c> (<c>gmEpilogueUI</c>) rather than exiting immediately —
/// out of scope here. This is a plain host action, not a generation-gated
/// Runtime command: it is the SAME window-close path
/// <c>GameplayWindowCommands</c>/<c>IGameplayWindowCommands.Close</c> already
/// use for the in-world Escape fallback (<c>d.Window.Close</c> at
/// composition), so status events <c>disconnected</c>/<c>exited</c> still
/// fire through <c>GameWindow.OnClosing</c> → <c>CompleteShutdown</c>.
/// </param>
public sealed record CharacterSelectionRuntimeBindings(
Func<IRuntimeCharacterSelectionView?> View,
Func<uint, RuntimeCommandResult> Highlight,
Func<RuntimeCommandResult> Enter,
Func<RuntimeCommandResult> RequestDelete,
Func<RuntimeCommandResult> ConfirmDelete,
Func<RuntimeCommandResult> Restore,
Func<RuntimeCommandResult> Cancel,
Action RequestExit,
/// <summary>
/// Campaign CC slice CC7: retail's Create button
/// (<c>gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0</c>
/// case 3 -&gt; <c>UIFramework::QueueUIMode(this, 0x1000000b)</c>, the
/// <c>gmCharGenMainUI</c> mode). Wired by <see cref="RetailUiRuntime"/>
/// itself (it alone holds both the character-management and
/// character-creation controllers) to
/// <c>CharacterCreationController?.Open()</c> — resolved lazily so
/// mount order between the two screens does not matter.
/// <see langword="null"/> when no chargen screen is mounted (e.g. a
/// headless bot's <c>LiveCharacterSelector</c> path, where
/// <see cref="RetailUiRuntimeBindings.CharacterCreation"/> is also
/// null) — the button then behaves as a no-op click while its own
/// <c>Enabled</c> gate (<see cref="RuntimeCharacterSelectionButtons.CanCreate"/>)
/// still reflects the real roster-vs-slot state.
/// </summary>
Action? RequestCreate = 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,
SocialRuntimeBindings Social,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
RetailUiProbeBindings Probe,
KeyboardRuntimeBindings? Keyboard = null,
CharacterSelectionRuntimeBindings? CharacterSelection = null,
// Campaign CC slice CC4: sibling of CharacterSelection above.
CharacterCreationRuntimeBindings? CharacterCreation = null);
/// <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 CharacterManagementUiMountCoordinator? _characterManagementMount;
private CharacterCreationUiMountCoordinator? _characterCreationMount;
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();
MountKeyboardConfig();
MountIndicators();
MountJumpPowerbar();
MountDialogFactory();
MountTooltipPresenter();
MountSocialPanel();
MountCharacter();
MountPlugins();
MountInventory();
MountExternalContainer();
MountVendor();
MountSecureTrade();
MountItemCooldowns();
ConfigureCharacterManagement();
_characterManagementMount?.Tick();
ConfigureCharacterCreation();
_characterCreationMount?.Tick();
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,
// Trade gate round 2 (2026-08-14): the trade window is
// transient — visibility belongs to RuntimeTradeState's
// open/closed lifecycle, never to the layout file (a
// session that ended mid-trade was restoring an empty
// open window at every launch).
WindowNames.SecureTrade,
]);
}
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; }
/// <summary>#409: client-wide retail hover-tooltip presenter. Constructed
/// alongside <see cref="DialogFactory"/> — see <c>MountTooltipPresenter</c>.</summary>
public RetailTooltipPresenter? TooltipPresenter { get; private set; }
public ExternalContainerController? ExternalContainerController { get; private set; }
public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller;
internal CharacterCreationUiController? CharacterCreationController =>
_characterCreationMount?.Controller;
/// <summary>Campaign CC slice CC6b-MOUNT: the Appearance page's authored
/// viewport (<c>0x100003bb</c>) — null until the screen has mounted.
///
/// <para>
/// Fix round F8 correction: this is NOT the same shape as
/// <see cref="PaperdollViewportWidget"/> — that one is a plain
/// <c>{ get; private set; }</c> auto-property assigned exactly once,
/// eagerly and non-retryably, inside <c>MountInventory()</c> (itself
/// called synchronously from <c>Initialize()</c>; if it fails the whole
/// <see cref="Mount"/> call throws and the WHOLE UI runtime fails to
/// construct — there is no partial-failure case where
/// <see cref="PaperdollViewportWidget"/> stays null while the rest of
/// the runtime comes up). THIS property is computed-through specifically
/// BECAUSE its underlying mount, <c>_characterCreationMount</c>
/// (<see cref="CharacterCreationUiMountCoordinator"/>), is explicitly
/// retryable/idempotent — ticked once per frame via <see cref="Tick"/>
/// until it succeeds, tolerating a DAT/resource read that isn't ready
/// yet without failing the rest of the UI. <see cref="AcDream.App.Composition.LivePresentationComposition"/>
/// reads this property EXACTLY ONCE, during the single synchronous
/// startup composition pass (<c>GameWindow.OnLoad</c>) — unlike the
/// coordinator's own per-frame <c>Tick</c>, that one-shot GPU-resource
/// composition pass is NOT retried, matching every other private
/// viewport binding in that same method (paperdoll, creature appraisal)
/// — see that call site's own comment for the full disposition.
/// </para>
/// </summary>
internal UiViewport? ChargenPreviewViewportWidget =>
CharacterCreationController?.AppearanceViewport;
/// <summary>CC6b-MOUNT: the late-bound zoom/rotate control surface the
/// composition root assigns once the graphics backend exists.</summary>
internal AcDream.App.Rendering.IChargenPreviewControl? ChargenPreviewControl
{
get => CharacterCreationController?.AppearancePreviewControl;
set
{
if (CharacterCreationController is { } controller)
controller.AppearancePreviewControl = value;
}
}
/// <summary>Campaign CC gate round 1 closeout (Group 1, R2-5): the same
/// late-bound pattern as <see cref="ChargenPreviewControl"/> above, for
/// the real color-wheel/swatch-color mechanism's three DAT-backed seams
/// — see <see cref="AcDream.App.UI.Layout.CharacterCreationAppearancePage.PalSetSource"/>'s
/// own doc comment.</summary>
internal AcDream.Core.CharGen.IChargenPalSetSource? ChargenPalSetSource
{
get => CharacterCreationController?.AppearancePalSetSource;
set
{
if (CharacterCreationController is { } controller)
controller.AppearancePalSetSource = value;
}
}
internal AcDream.Core.CharGen.IChargenClothingTableSource? ChargenClothingTableSource
{
get => CharacterCreationController?.AppearanceClothingTableSource;
set
{
if (CharacterCreationController is { } controller)
controller.AppearanceClothingTableSource = value;
}
}
internal AcDream.Core.CharGen.IChargenPaletteColorSource? ChargenPaletteColorSource
{
get => CharacterCreationController?.AppearancePaletteColorSource;
set
{
if (CharacterCreationController is { } controller)
controller.AppearancePaletteColorSource = value;
}
}
/// <summary>R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth
/// late-bound seam, same pattern as the three above — see
/// <see cref="AcDream.App.UI.Layout.CharacterCreationAppearancePage.SwatchTextureSource"/>'s
/// own doc comment.</summary>
internal AcDream.App.UI.Layout.IChargenSwatchTextureSource? ChargenSwatchTextureSource
{
get => CharacterCreationController?.AppearanceSwatchTextureSource;
set
{
if (CharacterCreationController is { } controller)
controller.AppearanceSwatchTextureSource = value;
}
}
/// <summary>CC6b-MOUNT: whether the Appearance page (specifically) is
/// the one currently showing — false, safely, before the screen mounts.
/// </summary>
internal bool IsChargenPreviewPageVisible =>
CharacterCreationController?.IsAppearancePageVisible ?? false;
/// <summary>Campaign CC slice CC5: the Summary page's OWN authored
/// viewport (<c>0x10000406</c>) — same one-shot GPU-composition
/// disposition as <see cref="ChargenPreviewViewportWidget"/> (see that
/// property's own doc comment; AP-221 covers both).</summary>
internal UiViewport? SummaryPreviewViewportWidget =>
CharacterCreationController?.SummaryViewport;
/// <summary>Campaign CC slice CC5: the Summary preview's late-bound
/// control surface. No zoom/rotate buttons bind against it (retail's
/// Summary page has none) — the composition root assigns it purely so
/// <see cref="AcDream.App.Rendering.ChargenPreviewController.Rebuild"/>
/// gets driven per-selection-change the same way the Appearance
/// preview's is.</summary>
internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl
{
get => CharacterCreationController?.SummaryPreviewControl;
set
{
if (CharacterCreationController is { } controller)
controller.SummaryPreviewControl = value;
}
}
/// <summary>Campaign CC slice CC5: whether the Summary page
/// (specifically) is the one currently showing.</summary>
internal bool IsSummaryPreviewPageVisible =>
CharacterCreationController?.IsSummaryPageVisible ?? false;
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();
SecureTradeController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick();
SocialPanelController?.Tick();
_itemCooldownController?.Tick();
_characterManagementMount?.Tick();
CharacterManagementController?.Tick();
_characterCreationMount?.Tick();
CharacterCreationController?.Tick();
DialogFactory?.Tick();
Host.Tick(deltaSeconds);
// #409: MUST tick after both DialogFactory and Host so the tooltip's
// own BringToFront re-raise always lands on top of whatever z-order
// those two just asserted this frame — see RetailTooltipPresenter.Tick's
// own doc comment and the register row filed alongside this class.
TooltipPresenter?.Tick();
_automation?.Tick(deltaSeconds);
}
private System.Numerics.Vector2 _lastScreenSize;
private bool _screenSizeSettling;
public void Draw(System.Numerics.Vector2 screenSize)
{
// #390: retail's display-change UI cascade, ported as a two-step edge
// detector on the per-frame screen size:
// - the frame a change is seen: re-clamp every floating window into
// the new bounds (retail's unconditional clamping MoveTo cascade,
// UIElementManager::RefreshEvent @0x0045C530) — cheap, no I/O,
// keeps windows reachable through a live drag-resize;
// - the first frame the size REPEATS after a change: one reload of
// the per-resolution saved layout (retail's post-change global
// message 0xE → per-resolution auto-layout reload), without the
// login path's lazy save-back (retail saves only via @saveui).
// The first-ever frame seeds the size silently — the login restore
// owns initial placement.
if (_lastScreenSize == default)
{
_lastScreenSize = screenSize;
}
else if (screenSize != _lastScreenSize)
{
_persistence?.ClampAllToScreen();
_lastScreenSize = screenSize;
_screenSizeSettling = true;
}
else if (_screenSizeSettling)
{
_screenSizeSettling = false;
_persistence?.RestoreAll(saveBack: false);
}
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;
}
// Campaign FA slice FA3: F3/F4 — keyboard-only open paths (lane A
// §6.1: neither action authors a toolbar button). Both share the
// one social panel (RetailPanelCatalog.SocialPanel) and switch to
// their own tab; the panel participates in the SAME gmPanelUI
// one-active-panel exclusivity every sibling panel gets from
// RetailPanelUiController.RegisterMainPanel.
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel)
{
OpenSocialPanel(showAllegiance: true);
return true;
}
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel)
{
OpenSocialPanel(showAllegiance: false);
return true;
}
return ToolbarInputController?.Handle(action) == true;
}
/// <summary>Shared F3/F4 handler — same "toggle closes on a repeat press
/// of the SAME tab, otherwise show + switch" shape as <see cref="OpenSpellbook"/>.</summary>
private void OpenSocialPanel(bool showAllegiance)
{
bool visible = Host.IsWindowVisible(WindowNames.SocialPanel);
bool onTargetTab = showAllegiance
? SocialPanelController?.IsShowingAllegiance == true
: SocialPanelController?.IsShowingFellowship == true;
if (visible && onTargetTab)
{
CloseWindow(WindowNames.SocialPanel);
return;
}
if (showAllegiance)
SocialPanelController?.ShowAllegiance();
else
SocialPanelController?.ShowFellowship();
_panelUi.SetPanelVisibility(RetailPanelCatalog.SocialPanel, visible: 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);
}
}
/// <summary>
/// MUST-FIX 2 (FA4 fix round, 2026-08-12): every server confirmation
/// request — including a fellowship invite
/// (<c>ConfirmationType.Fellowship</c>, 4) — reaches the generic
/// <see cref="GameplayConfirmationController"/> dialog unconditionally,
/// exactly like retail. This slice originally shipped a client-side
/// interceptor here (D6) that auto-declined/auto-accepted based on
/// <c>IgnoreFellowshipRequests</c>/<c>FellowshipAutoAcceptRequests</c>
/// before the dialog. Byte-verified across
/// <c>Handle_Character__ConfirmationRequest @0x005640A0</c> (a bare
/// 7-way jump table; case 4 is one call, no option read),
/// <c>RecvNotice_FellowshipRequest @0x00490880</c> (copies the string,
/// tail-calls the dialog maker, no option read), and
/// <c>MakeFellowRequestDialog @0x00490620</c> (its ONLY guard is
/// <c>m_fellowRequestContext</c>) — plus a whole-file sweep of both
/// accessors — retail's client reads NEITHER option bit on ANY
/// confirmation path. ACE filters both bits SERVER-SIDE
/// (<c>Player_Fellowship.cs:98-102</c> refuses the recruiter outright;
/// <c>Fellowship.cs:121</c> auto-joins without ever sending a type-4
/// request) — so the interceptor was either dead code (against a
/// correct ACE, which never sends the request the interceptor would
/// act on) or actively harmful (against a drifting/misconfigured
/// server, where <c>IgnoreFellowshipRequests</c>'s client default of
/// <c>true</c> would silently swallow every real invite with no dialog
/// and no chat line). Deleted outright — see
/// docs/plans/2026-08-11-fellowship-allegiance-campaign.md D6's
/// correction and register row AD-78's fix-round addendum.
/// </summary>
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
{
CharacterManagementController?.ResetSession();
DialogFactory?.Reset();
TooltipPresenter?.HideCurrent();
// #409 F11: HideCurrent only tears down the presenter's own
// popup element — it leaves UiRoot's own hover-dwell/tooltip-
// shown latch untouched. Without this, a reconnect while the
// mouse never left the hovered widget leaves _tooltipFired
// latched true and the tooltip cannot re-arm until either the
// 10 s auto-hide timeout or a hover-target change.
Host.Root.ResetTooltipTracking();
}
finally
{
_gameplayConfirmationController?.ResetSession();
}
}
/// <summary>
/// MUST-FIX 3 (FA4 fix round): also re-arms the fellowship page's
/// <c>0x00A6</c> panel-open declaration latch
/// (<see cref="Layout.SocialPanelController.ResetSessionDeclaration"/>).
/// <see cref="SocialPanelController"/> is process-lifetime, but its
/// edge-trigger latch is not — without this, a reconnect while the
/// Fellowship page is left open never re-sends <c>0x00A6</c> on the new
/// session, and every fellow's vitals freeze for the rest of the
/// session (lane B §4.5: ACE streams <c>0x02C0</c> only to fellows with
/// <c>FellowshipPanelOpen</c> set, and that flag is set only by
/// <c>0x00A6</c>).
/// </summary>
public void ResetSessionTransientUi()
{
ResetSessionDialogs();
AppraisalController?.ResetSession();
Host.HideWindow(WindowNames.Examination);
SocialPanelController?.ResetSessionDeclaration();
}
/// <summary>MUST-FIX 3 re-fix (FA4 re-review REOPEN, 2026-08-12): wired to
/// the LiveSession <c>EnteredWorld</c> seam so a still-open Fellowship
/// page re-declares its <c>0x00A6</c> panel-open state AFTER a (re)connect
/// is in world — <see cref="ResetSessionTransientUi"/> runs pre-world and
/// only cleared the latch, and <c>SetPanelOpen</c> is world-gated.</summary>
public void RedeclareSocialPanelAfterWorldEntry() =>
SocialPanelController?.RedeclareAfterWorldEntry();
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 main window's and four floating windows' current text-type
/// filters into the local settings store (research doc §4.4/§6.1 — local-only
/// until a future retail <c>0x1000008C</c> wire format). No-op when no store was
/// wired.
///
/// <para>
/// CH6a/b REJECT-review NIT 1 — CLOSED by Campaign OP slice OP5: filters are now
/// ALSO captured on every live edit through the Chat tab's five filter blocks
/// (each block's <c>BitfieldOptionRow.apply</c> closure calls this directly), not
/// only when <see cref="SaveLayout"/> runs via the explicit <c>/saveautoui</c>
/// command — OP5 is the "live settings surface a user can actually edit
/// mid-session" this NIT was waiting on.
/// </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
{
ChatWindowMainFilter = windows.GetFilter(ChatWindowState.MainWindowId),
ChatWindow1Filter = windows.GetFilter(1),
ChatWindow2Filter = windows.GetFilter(2),
ChatWindow3Filter = windows.GetFilter(3),
ChatWindow4Filter = windows.GetFilter(4),
});
}
/// <summary>
/// Campaign OP slice OP5: persists the Chat tab's two opacity-slider values —
/// the "worth tightening to auto-save-on-change" gap
/// <see cref="SaveChatWindowFilters"/>'s own doc flagged before a live options UI
/// existed to edit them.
/// <para>
/// OP5 review fix S1 (2026-08-11): this is now the BATCHED settle point, not a
/// per-tick write. <see cref="Layout.ChatOptionsPageController.Bindings.SetDefaultOpacity"/>/
/// <c>SetActiveOpacity</c> below apply the value LIVE only; this method is wired as
/// <c>FlushOpacity</c> and is called by the controller exactly once per discrete
/// edit — a Reset/Defaults click, or a drag's own <c>MouseUp</c> — never once per
/// <c>MouseMove</c> tick. Previously this ran a full <c>settings.json</c> load +
/// rewrite on every drag tick (dozens-to-hundreds of synchronous whole-file round
/// trips on the UI thread for one drag); retail's own structure batches the
/// equivalent blob behind a dirty timer (research doc §3.5).
/// </para>
/// </summary>
private void SaveChatOpacity()
{
if (_bindings.Chat.Store is not { } store) return;
ChatSettings current = store.LoadChat();
store.SaveChat(current with
{
DefaultOpacity = WindowOpacity.DefaultOpacity,
ActiveOpacity = WindowOpacity.ActiveOpacity,
});
}
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;
}
// Campaign OP slice OP5: the main window's own filter (id 0) is now
// user-settable through the Chat tab's main-window filter block
// (research doc §5.2) — seed it from the local store here, the same
// "local-only persistence" leg MountFloatingChatWindows already runs
// for windows 1-4 (research doc §4.4/§6.1 — the retail 0x1000008C
// gameplay-options wire remains explicitly deferred).
if (_bindings.Chat.Store is { } chatStore)
_bindings.Chat.Windows.SetFilter(
ChatWindowState.MainWindowId, chatStore.LoadChat().ChatWindowMainFilter);
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);
// MUST-FIX 3 (OP4 review-fix round, 2026-08-11, blast M2): the SAME
// server-bit seam MountOptionsPanel wires the Character tab's rows
// through — see CharacterOptionsPageController.Bindings below.
CombatUiController? controller = Layout.CombatUiController.Bind(
layout,
_bindings.Combat.State,
_bindings.Combat.Attacks,
new Layout.CombatUiController.Bindings(
CurrentValue: id => _bindings.Options.CurrentCharacterOption((uint)id),
SetOption: (id, value) => _bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd((uint)id, value))),
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()),
OpenConfigureKeyboard: () => ToggleWindow(WindowNames.KeyboardConfig));
Layout.OptionsPanelController? controller =
Layout.OptionsPanelController.Bind(layout, callbacks, _bindings.Assets.ResolveSprite);
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.");
}
// Campaign OP slice OP5 (2026-08-11): the Chat tab's 6 headers, 2 linked
// opacity sliders, and 5 per-window filter blocks. Same "runs before
// ActivateTabs, own dat-lock scope" shape as the Character-tab block above.
lock (_bindings.Assets.DatLock)
{
var strings = new DatStringResolver(_bindings.Assets.Dats);
// U1's Chat-tab-specific consumer (this class's own doc trace): the
// two sliders' Defaults values come from the DAT DBProperties
// collection, resolved ONCE — the installed DAT does not change
// within a session.
if (!Layout.ChatOptionsDatDefaults.TryRead(
_bindings.Assets.Dats, out float datDefaultOpacity, out float datActiveOpacity))
{
Console.WriteLine(
"[UI] options panel: Chat tab opacity DAT defaults did not resolve "
+ "(DID 0x78000001) — falling back to retail's ChatInterface base-"
+ "constructor values (0.5/1.0).");
}
// #380: the two sliders' row CAPTIONS ("Inactive Opacity" /
// "Active Opacity") + tooltips — a SECOND, sibling DAT catalog
// lookup (DID 0x78000000, not the 0x78000001 defaults catalog
// above) — see ChatOptionsDatCaptions' own doc for the byte
// trace. Resolved once, same shape as the defaults read above.
if (!Layout.ChatOptionsDatCaptions.TryRead(
_bindings.Assets.Dats, strings,
out Layout.ChatOptionsDatCaptions.Caption defaultOpacityCaption,
out Layout.ChatOptionsDatCaptions.Caption activeOpacityCaption))
{
Console.WriteLine(
"[UI] options panel: Chat tab opacity slider captions did not resolve "
+ "(DID 0x78000000) — rows render with no caption rather than invented "
+ "English.");
}
bool chatBound = Layout.ChatOptionsPageController.Bind(
layout,
controller.ChatPage,
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.ChatOptionsPageController.Bindings(
CurrentDefaultOpacity: () => WindowOpacity.DefaultOpacity,
CurrentActiveOpacity: () => WindowOpacity.ActiveOpacity,
// S1 fix: live apply ONLY — no disk write per call. The controller
// calls FlushOpacity (below) once per discrete edit, not once per tick.
SetDefaultOpacity: WindowOpacity.SetDefaultOpacity,
SetActiveOpacity: WindowOpacity.SetActiveOpacity,
FlushOpacity: SaveChatOpacity,
DefaultOpacityDatDefault: datDefaultOpacity,
ActiveOpacityDatDefault: datActiveOpacity,
DefaultOpacityCaption: defaultOpacityCaption,
ActiveOpacityCaption: activeOpacityCaption,
CurrentFilter: windowId => _bindings.Chat.Windows.GetFilter(windowId),
SetFilter: (windowId, value) =>
{
_bindings.Chat.Windows.SetFilter(windowId, value);
SaveChatWindowFilters();
}));
if (!chatBound)
Console.WriteLine("[UI] options panel: Chat tab rows did not bind.");
}
// Campaign OP slice OP6 (2026-08-11): the Config tab's 6 headers +
// 27 rows. Same "runs before ActivateTabs, own dat-lock scope"
// shape as the Character/Chat blocks above.
lock (_bindings.Assets.DatLock)
{
var strings = new DatStringResolver(_bindings.Assets.Dats);
bool configBound = Layout.ConfigOptionsPageController.Bind(
layout,
controller.ConfigPage,
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.ConfigOptionsPageController.Bindings(
LoadDisplay: _bindings.Options.LoadDisplay,
SaveDisplay: _bindings.Options.SaveDisplay,
LoadAudio: _bindings.Options.LoadAudio,
SaveAudio: _bindings.Options.SaveAudio,
LoadCameraTurning: _bindings.Options.LoadCameraTurning,
SaveCameraTurning: _bindings.Options.SaveCameraTurning,
// Chat Font Face/Size route through the SAME raw
// SettingsStore side channel SaveChatWindowFilters/
// SaveChatOpacity already use — RuntimeSettingsController's
// cached ChatSettings is NOT refreshed by those direct
// writes, so reading/writing through it here would
// silently clobber CH6's filter/opacity edits with a
// stale snapshot the next time either surface saves.
LoadChat: () => _bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default,
SaveChat: chat => _bindings.Chat.Store?.SaveChat(chat)),
// #378: the eight Config-tab dropdown menus need the SAME
// sprite/font resolvers every other retail-menu consumer
// (ChatWindowController's channel menu, VendorUiController's
// category dropdown) already gets — see
// ConfigOptionsPageController.MenuChromeSprites' own doc.
resolveSprite: _bindings.Assets.ResolveSprite,
datFont: _bindings.Assets.DefaultFont,
debugFont: _bindings.Assets.DebugFont,
// #391: the monitor-derived curated list + desktop-mode
// default, installed at startup by the graphical host;
// fixture/headless mounts leave the catalog empty and the
// controller falls back to the static preset ladder.
// #407: the dropdown offers the WINDOWED union (hardware
// modes + static-ladder sizes that fit the desktop) — a
// windowed Size write needs no video mode, and remote/RDP
// displays advertise almost none. The fullscreen APPLY
// still validates against the hardware list only.
availableResolutions: Rendering.DisplayModeCatalog.WindowedResolutions,
resolutionDefault: Rendering.DisplayModeCatalog.DesktopResolution);
if (!configBound)
Console.WriteLine("[UI] options panel: Config 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).");
}
/// <summary>
/// Campaign OP slice OP8: retail's Configure Keyboard screen
/// (<c>gmKeyboardUI</c>, LayoutDesc <c>0x21000009</c>) — its own separate
/// full-screen window, distinct from the four-tab Options panel's
/// <c>gmPanelUI</c> mutual-exclusion group (research doc structure lane
/// §8). Skips cleanly (no window, INERT button stays inert) when
/// <see cref="RetailUiRuntimeBindings.Keyboard"/> is null — a no-window
/// host or an App composition that hasn't wired the input dispatcher yet.
/// </summary>
private void MountKeyboardConfig()
{
KeyboardRuntimeBindings? keyboard = _bindings.Keyboard;
if (keyboard is null || keyboard.Dispatcher is null)
{
Console.WriteLine(
"[UI] keyboard config: no InputDispatcher wired — Configure Keyboard "
+ "screen will not open (button stays inert).");
return;
}
InputDispatcher dispatcher = keyboard.Dispatcher;
ElementInfo? info;
ImportedLayout? layout;
RetailActionMapSnapshot? snapshot;
DatStringResolver strings;
lock (_bindings.Assets.DatLock)
{
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, Layout.KeyboardConfigController.LayoutId);
strings = new DatStringResolver(_bindings.Assets.Dats);
layout = info is null
? null
: LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
// #375: the main screen build was the ONE mount in this class
// missing the string resolver — every AUTHORED caption
// (OK/Cancel/Defaults/Revert/Load/Save, the six ActionClass
// tab labels, the Command/Mapping column headers) rendered
// empty at the gate, while the controller's own resolveString
// lookups (row captions, headers) worked. Keep in step with
// every sibling Mount* Build call above.
strings.Resolve);
snapshot = RetailActionMapReader.Read(_bindings.Assets.Dats);
}
if (layout is null || snapshot is null)
{
Console.WriteLine(
"[UI] keyboard config: LayoutDesc 0x21000009 or the DAT ActionMap "
+ "singleton (0x26000000) not found — Configure Keyboard will not open.");
return;
}
string unmappedPath = UnmappedKeyBindingsPath(keyboard.KeyBindingsFilePath);
var unmapped = RetailUnmappedKeyBindings.LoadOrEmpty(unmappedPath);
// ID_KeyMapCantOverwriteReadOnlyKeymap_Label — table 0x23000004, byte-
// verified 2026-08-11 (live probe): "Could not overwrite ". Falls back
// to silence (no invented English) if the DAT string is ever missing.
string? refusalText = strings.Resolve(
0x23000004u, DatStringResolver.ComputeHash("ID_KeyMapCantOverwriteReadOnlyKeymap_Label"));
Layout.KeyboardConfigController? controller = Layout.KeyboardConfigController.Bind(
layout,
snapshot,
templateResolver: (templateLayoutId, templateElementId) =>
{
lock (_bindings.Assets.DatLock)
{
ElementInfo? templateInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats, templateLayoutId, templateElementId);
return templateInfo is null
? null
: LayoutImporter.Build(
templateInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
}
},
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
new Layout.KeyboardConfigController.Bindings(
// M1 (2026-08-11 review): read/write the FULL live Binding
// (chord + activation + scope), not a bare chord — SetForAction
// used to reconstruct every Binding with the constructor's bare
// defaults (Press/Game), collapsing walk-mode's Hold and every
// combat-scoped binding's scope the instant a row wrote back.
CurrentForAction: action => dispatcher.Bindings.ForAction(action).ToArray(),
SetForAction: (action, newBindings) =>
{
KeyBindings updated = CloneWithout(dispatcher.Bindings, action);
foreach (Binding b in newBindings)
updated.Add(b);
dispatcher.SetBindings(updated);
},
CurrentForUnmapped: key => unmapped.Get(key.InputMapId, key.ActionId),
SetForUnmapped: (key, chords) => unmapped.Set(key.InputMapId, key.ActionId, chords),
BeginCapture: onResult => dispatcher.BeginCapture(
chord => onResult(chord == default ? null : chord)),
Save: () =>
{
// S3 (2026-08-11 review): match the existing keybinds.json
// writer's own discipline (RuntimeKeyBindingTarget.Apply) —
// an IO failure is reported, not thrown out of UiButton.OnClick
// into the input/render loop, and does not roll back the
// already-accepted live binding.
try
{
dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath);
unmapped.SaveToFile(unmappedPath);
}
catch (Exception failure)
{
Console.WriteLine($"keyboard config: save failed: {failure.Message}");
}
},
Toggle: () => ToggleWindow(WindowNames.KeyboardConfig),
DisplaySystemMessage: text =>
{
if (!string.IsNullOrEmpty(text)) _bindings.Options.DisplaySystemMessage(text);
},
NonBindableRefusalText: refusalText ?? string.Empty,
// M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog —
// confirm through the SAME RetailDialogFactory/MakeConfirmation
// seam GameplayConfirmationController already uses, before
// reassigning. DialogFactory is mounted AFTER MountKeyboardConfig
// in Initialize()'s order, so this reads the property lazily
// (Initialize() has always finished by the time a user can
// actually open this screen and trigger a capture).
ConfirmOverwrite: (message, onResult) =>
{
if (DialogFactory is null) { onResult(false); return; }
DialogFactory.MakeConfirmation(
message,
data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult)));
},
// OP8 re-gate (2026-08-14): retail's capture-instruction dialog
// (InitiateBinding @ 0x004899D0 → OpenMapWarnDialog @ 0x00488A00):
// a type-2 wait dialog on retail's own MapWarn queue key
// 0x10000001, text = ID_ActionKeyMap_MapInstructions (table
// 0x23000004) with the row's action label as its one ACTION
// variable. Same lazy DialogFactory read as ConfirmOverwrite.
OpenCaptureInstructions: actionLabel =>
{
if (DialogFactory is null) return 0u;
string? text = strings.ResolveTemplate(
0x23000004u,
"ID_ActionKeyMap_MapInstructions",
new Dictionary<uint, string>
{
[DatStringResolver.ComputeHash("ACTION")] = actionLabel,
});
if (text is null) return 0u; // no invented English
// The authored text stores its blank line as a literal
// "\n\n" two-character escape (live-probed) — same
// convention DatWidgetFactory/IndicatorDetailText already
// unescape for other DAT-authored strings.
text = text.Replace("\\n", "\n", StringComparison.Ordinal);
try
{
return DialogFactory.MakeWait(text, queueKey: 0x10000001u);
}
catch (Exception failure)
{
// Retail's OpenMapWarnDialog returns 0 on failure and
// InitiateBinding then refuses to arm capture — a
// malformed catalog must land on that contracted path,
// not escape UiButton.OnClick into the render loop
// (the 2026-08-14 live crash: wait root type 0x19 was
// unmapped and the view ctor threw).
Console.WriteLine(
"[UI] keyboard config: capture-instruction dialog failed to "
+ $"build — capture refused. {failure.Message}");
return 0u;
}
},
CloseCaptureInstructions: context =>
DialogFactory?.CloseDialog(context)),
resolveTemplateFont: (templateLayoutId, templateElementId) =>
{
lock (_bindings.Assets.DatLock)
{
ElementInfo? templateInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats, templateLayoutId, templateElementId);
return templateInfo is null || templateInfo.FontDid == 0u
? null
: _bindings.Assets.ResolveFont(templateInfo.FontDid);
}
});
if (controller is null)
{
Console.WriteLine("[UI] keyboard config: required window root did not build.");
return;
}
KeyboardConfigController = controller;
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.KeyboardConfig,
Chrome = RetailWindowChrome.Imported,
Left = Math.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
Top = Math.Max(0f, (Host.Root.Height - root.Height) * 0.5f),
Visible = false,
DatConstraintSource = info,
ContentClickThrough = false,
});
Console.WriteLine("[UI] retail Configure Keyboard screen from gmKeyboardUI LayoutDesc 0x21000009.");
}
private static string UnmappedKeyBindingsPath(string keyBindingsFilePath)
{
string? dir = System.IO.Path.GetDirectoryName(keyBindingsFilePath);
string name = System.IO.Path.GetFileNameWithoutExtension(keyBindingsFilePath);
string ext = System.IO.Path.GetExtension(keyBindingsFilePath);
string sibling = $"{name}-unmapped{ext}";
return string.IsNullOrEmpty(dir) ? sibling : System.IO.Path.Combine(dir, sibling);
}
private static KeyBindings CloneWithout(KeyBindings source, InputAction action)
{
var result = new KeyBindings();
foreach (Binding b in source.All)
if (b.Action != action) result.Add(b);
return result;
}
/// <summary>The mounted Configure Keyboard screen's controller — null until
/// <see cref="MountKeyboardConfig"/> runs (or if it degraded — see that
/// method's null-dependency guards).</summary>
public Layout.KeyboardConfigController? KeyboardConfigController { get; private set; }
private void MountJumpPowerbar()
{
ElementInfo? info;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
info = LayoutImporter.ImportInfos(
_bindings.Assets.Dats, JumpPowerbarController.LayoutId);
// The string resolver feeds the meter's absorbed per-mode caption
// (0x10000035: JumpMode 'Height' — 2026-08-14 gate).
var powerbarStrings = new DatStringResolver(_bindings.Assets.Dats);
layout = info is null ? null : LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
powerbarStrings.Resolve);
}
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.");
}
/// <summary>
/// Campaign FA slice FA3: retail's four-tab social panel — Friends /
/// Allegiance / Fellowship / Squelch, host <c>0x2100006E</c> slot
/// <c>0x1000018F</c>, <see cref="RetailPanelCatalog.SocialPanel"/> (12).
/// Same five-step shape as <see cref="MountOptionsPanel"/>: Import →
/// build callbacks → <see cref="Layout.SocialPanelController.Bind"/> →
/// <see cref="Layout.SocialPanelController.ActivateTabs"/> →
/// <see cref="RetailWindowFrame.Mount"/> → <see cref="RetailPanelUiController.RegisterMainPanel"/>.
/// </summary>
private void MountSocialPanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
Layout.SocialPanelController.HostLayoutId,
Layout.SocialPanelController.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] social panel: LayoutDesc 0x2100006E slot 0x1000018F not found.");
return;
}
// The Friends/Squelch row templates (0x2100005D/0x21000060) differ
// from the Character/Chat/Config tab row templates: those build a
// FIXED row set exactly once at Bind (MountOptionsPanel, above) and
// never revisit their resolver again. Friends/Squelch instead poll
// a live roster whose COUNT can change all session (a friend logs
// in/out), so this resolver is called on every rebuild.
//
// Fix-round blast SF-2: the ORIGINAL shape re-ran
// LayoutImporter.ImportInfos — a full DAT tree walk — under the
// shared DatLock on EVERY row, every revision, even while the
// social panel was closed (SocialPanelController.Tick had no
// visibility gate). The fix has two parts: this resolver now caches
// each template's ElementInfo the FIRST time it is resolved (i.e.
// once, effectively at Bind — the page controllers' own Bind()
// calls Refresh() once unconditionally) and never re-Imports for
// that template id again; SocialPanelController separately gates
// Friends/Squelch's Tick-driven rebuild on the panel's own
// visibility (see that class's OnShown/OnHidden), so no rebuild —
// cached or not — runs while the panel is closed. Build() itself
// still runs (and still needs the DatLock: its sprite/font/string
// resolvers read the shared, non-thread-safe DatCollection) because
// each row needs its OWN UiElement instance — but the expensive
// per-row DAT tree walk is gone after the first resolve.
// FA4 carry-forward 2: the caching logic itself now lives in the
// hermetically-testable Layout.RowTemplateResolver (see its own
// class doc) — this closure supplies only the two DAT-touching
// primitives (import, build) and keeps the shared lock at the
// production call site. Feeds Friends/Squelch's row families (FA3)
// AND Fellowship's (FA4, 0x21000030/0x10000281) through the SAME
// instance/cache — all three are UiTemplateListBox row families on
// the one social panel.
var rowTemplates = new Layout.RowTemplateResolver(
(layoutId, elementId) => LayoutImporter.ImportInfos(
_bindings.Assets.Dats, layoutId, elementId),
info =>
{
var strings = new DatStringResolver(_bindings.Assets.Dats);
return LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
});
UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
return rowTemplates.Resolve(templateLayoutId, templateElementId);
}
// Shared across every bind-time ResolveString call below (2
// captions + 4x2 checkbox label/tooltip) so its internal
// per-table-id cache actually helps rather than re-reading the same
// StringTable object ten times.
var fellowshipStrings = new DatStringResolver(_bindings.Assets.Dats);
var callbacks = new Layout.SocialPanelController.Callbacks(
Toggle: () => ToggleWindow(WindowNames.SocialPanel),
Fellowship: new Layout.SocialFellowshipPageController.Bindings(
Snapshot: _bindings.Social.FellowshipSnapshot,
Members: _bindings.Social.FellowshipMembers,
TemplateResolver: TemplateResolver,
Create: _bindings.Social.FellowshipCreate,
Recruit: _bindings.Social.FellowshipRecruit,
Dismiss: _bindings.Social.FellowshipDismiss,
Quit: _bindings.Social.FellowshipQuit,
AssignLeader: _bindings.Social.FellowshipAssignLeader,
SetOpen: _bindings.Social.FellowshipSetOpen,
SetPanelOpen: _bindings.Social.FellowshipSetPanelOpen,
Selection: _bindings.Social.Selection,
LocalPlayerGuid: _bindings.Social.LocalPlayerGuid,
CurrentCharacterOption: id => _bindings.Options.CurrentCharacterOption((uint)id),
SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd((uint)id, value)),
ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId)),
Allegiance: new Layout.SocialAllegiancePageController.Bindings(
Snapshot: _bindings.Social.AllegianceSnapshot,
Monarch: _bindings.Social.AllegianceMonarch,
Patron: _bindings.Social.AllegiancePatron,
Member: _bindings.Social.AllegianceMember,
Vassals: _bindings.Social.AllegianceVassals,
Swear: _bindings.Social.AllegianceSwear,
Break: _bindings.Social.AllegianceBreak,
Kick: _bindings.Social.AllegianceKick,
SetUpdateSubscription: _bindings.Social.AllegianceSetUpdateSubscription,
Selection: _bindings.Social.Selection,
LocalPlayerGuid: _bindings.Social.LocalPlayerGuid,
CurrentCharacterOption: id => _bindings.Options.CurrentCharacterOption((uint)id),
SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd((uint)id, value)),
TemplateResolver: TemplateResolver,
ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId),
// ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) — the same
// ClientObjectTable resolver ToolbarRuntimeBindings.ResolveName
// already wires (Swear's target is a WORLD selection, not yet
// an allegiance member, so the allegiance-profile accessors
// above cannot name them).
ResolveWorldObjectName: guid => _bindings.Inventory.Objects.Get(guid)?.GetAppropriateName(),
ShowConfirmation: (message, completed) => ShowConfirmation(message, completed),
// Swear/Break/Kick confirmation sentences: the 0x23000001
// templates' PLAYER slot filled at click time (2026-08-13
// confirm/weenie-error research §1.4 — the AD-85 dangling
// "Do you wish to swear to " fix).
ResolvePlayerTemplate: (key, playerName) =>
{
lock (_bindings.Assets.DatLock)
{
return fellowshipStrings.ResolveTemplate(
0x23000001u,
key,
new Dictionary<uint, string>
{
[Layout.DatStringResolver.PlayerVariable] = playerName,
});
}
}),
Friends: _bindings.Social.Friends,
Squelch: _bindings.Social.Squelch,
TemplateResolver: TemplateResolver,
// 2026-08-13 (AD-79 retirement): the wire beneath every one of
// these commands shipped with J4.1/FA1 — builders, WorldSession
// sends, router registrations, inbound parsers (see
// docs/research/2026-08-13-social-wire-completion.md §4); the
// panel publishes onto the SAME bus every other surface uses.
FriendsActions: new Layout.SocialFriendsPageController.Actions(
AddFriend: name => _bindings.Options.CommandBus().Publish(
new AddFriendRuntimeCmd(name)),
RemoveFriend: guid => _bindings.Options.CommandBus().Publish(
new RemoveFriendRuntimeCmd(guid)),
CurrentAppearOffline: () => _bindings.Options.CurrentCharacterOption(
(uint)CharacterOptionId.AppearOffline),
SetAppearOffline: value => _bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd(
(uint)CharacterOptionId.AppearOffline, value))),
SquelchActions: new Layout.SocialSquelchPageController.Actions(
// Character scope, guid 0 (ACE name-looks-up), AllChannels —
// research §1.5's exact retail body.
SquelchCharacter: name => _bindings.Options.CommandBus().Publish(
new ModifyCharacterSquelchRuntimeCmd(true, 0u, name, 1u)),
SquelchAccount: name => _bindings.Options.CommandBus().Publish(
new ModifyAccountSquelchRuntimeCmd(true, name)),
RemoveCharacterSquelch: (guid, name) => _bindings.Options.CommandBus().Publish(
new ModifyCharacterSquelchRuntimeCmd(false, guid, name, 1u)),
RemoveAccountSquelch: name => _bindings.Options.CommandBus().Publish(
new ModifyAccountSquelchRuntimeCmd(false, name))));
// A second DatLock scope (MountOptionsPanel's own precedent, above):
// SocialFellowshipPageController.Bind resolves the Open/Close
// caption pair and all four checkbox label/tooltip strings at BIND
// TIME (never per-tick — see that class's own doc on why), and
// Friends/Squelch/Fellowship's row-building all reach the SAME
// DatLock through TemplateResolver above (re-entrant on this
// thread — Monitor.Enter is safe to re-acquire recursively).
Layout.SocialPanelController? controller;
lock (_bindings.Assets.DatLock)
controller = Layout.SocialPanelController.Bind(layout, callbacks);
if (controller is null)
{
Console.WriteLine("[UI] social panel: required root did not build as UiTabPanel.");
return;
}
controller.ActivateTabs();
SocialPanelController = controller;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
controller.Root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.SocialPanel,
Chrome = RetailWindowChrome.NineSlice,
Left = 200f,
Top = 140f,
Visible = false,
// Same shared main-panel geometry policy every gmPanelUI
// sibling uses (Options' own MUST-FIX 2 correction, above).
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.SocialPanel,
WindowNames.SocialPanel,
handle,
rootInfo.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F).");
}
private void MountDialogFactory()
{
if (DialogFactory is not null)
return;
uint layoutId;
try
{
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);
}
}
catch (Exception error)
{
Console.WriteLine(
"[UI] retail dialog catalog will retry after resource "
+ $"recovery: {error.Message}");
return;
}
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);
// Types 1/4 carry ACE's bare player name; retail's client wraps it
// via the local StringTable templates (single PLAYER variable,
// token-free — 2026-08-13 confirm/weenie-error research §1).
var confirmationStrings = new DatStringResolver(_bindings.Assets.Dats);
string? ComposeConfirmation(uint type, string bareName)
{
string? key = type switch
{
// gmAllegianceUI::RecvNotice_SwearAllegiance
1u => "ID_Allegiance_AcceptSwearConfirmation",
// gmFellowshipUI's incoming FellowshipRequest
4u => "ID_Fellowship_FellowshipRequest",
_ => null,
};
if (key is null)
return null;
lock (_bindings.Assets.DatLock)
{
return confirmationStrings.ResolveTemplate(
0x23000001u,
key,
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = bareName,
});
}
}
_gameplayConfirmationController = new GameplayConfirmationController(
DialogFactory,
_bindings.Confirmations.SendResponse,
ComposeConfirmation);
_itemConfirmationController = new RetailItemConfirmationController(
DialogFactory,
ItemInteraction);
_skillTrainingConfirmationController =
new RetailSkillTrainingConfirmationController(DialogFactory);
Console.WriteLine(
$"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15.");
}
/// <summary>
/// #409: mounts the client-wide retail tooltip presenter. Unlike
/// <see cref="MountDialogFactory"/>'s catalog resolve, the tooltip popup
/// layout/root are entirely data-driven off each hovered widget's OWN
/// authored <c>0x47</c>/<c>0x48</c> properties (<see cref="RetailTooltipPresenter"/>'s
/// own class doc) — this method only wires the generic layout-by-DID
/// resolver and seeds the client-local <c>Misc.TooltipEnable</c>/
/// <c>Misc.TooltipDelay</c> preferences from the shared
/// <see cref="SettingsStore"/> (the same instance
/// <c>_bindings.Chat.Store</c> already threads to every other client-local
/// preference reader in this file).
/// </summary>
private void MountTooltipPresenter()
{
if (TooltipPresenter is not null)
return;
ImportedLayout? CreateTooltipLayout(uint layoutDid, uint rootElementId)
{
lock (_bindings.Assets.DatLock)
{
return LayoutImporter.Import(
_bindings.Assets.Dats,
layoutDid,
rootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
}
TooltipPresenter = new RetailTooltipPresenter(Host.Root, CreateTooltipLayout);
if (_bindings.Chat.Store is { } store)
{
MiscSettings misc = store.LoadMisc();
TooltipPresenter.Enabled = misc.TooltipEnable;
Host.Root.TooltipDelayMs = (int)(misc.TooltipDelaySeconds * 1000f);
}
}
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);
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
}
catch (Exception ex)
{
_bindings.Plugins.FailMount(panel);
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.");
}
/// <summary>The mounted secure-trade window's controller — null until
/// <see cref="MountSecureTrade"/> runs (or when the trade view/layout is
/// unavailable).</summary>
public Layout.SecureTradeUiController? SecureTradeController { get; private set; }
private void MountSecureTrade()
{
if (_bindings.Social.Trade is not { } tradeView)
{
Console.WriteLine("[M4] secure trade: no runtime trade view bound.");
return;
}
ImportedLayout? layout;
uint selfEmptySlotSprite;
uint partnerEmptySlotSprite;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
Layout.SecureTradeUiController.LayoutId,
Layout.SecureTradeUiController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
// The authored empty-slot art for each grid (cell-template attr
// 0x1000000E → 0x1000033A) — same resolution the vendor uses.
selfEmptySlotSprite = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
Layout.SecureTradeUiController.LayoutId,
Layout.SecureTradeUiController.SelfListId);
partnerEmptySlotSprite = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
Layout.SecureTradeUiController.LayoutId,
Layout.SecureTradeUiController.PartnerListId);
}
if (layout is null)
{
Console.WriteLine(
"[M4] secure trade: LayoutDesc 0x2100000D root 0x1000007A not found.");
return;
}
// Gate round 3 root cause: CommandBus is a Func for a REASON — the
// live route attaches at session start, AFTER this mount. Capturing
// the surface once here published every trade command into the
// pre-session null route, silently (the exact reason the social
// mount resolves the Func inside each lambda). Resolve per call.
Layout.SecureTradeUiController? controller =
Layout.SecureTradeUiController.Bind(
layout,
new Layout.SecureTradeUiController.Bindings(
Trade: tradeView,
Objects: _bindings.Inventory.Objects,
ResolveIcon: _bindings.Inventory.ResolveIcon,
OpenTrade: partner => _bindings.Options.CommandBus().Publish(
new OpenTradeNegotiationsRuntimeCmd(partner)),
CloseTrade: () => _bindings.Options.CommandBus().Publish(
new CloseTradeNegotiationsRuntimeCmd()),
AddToTrade: item => _bindings.Options.CommandBus().Publish(
new AddToTradeRuntimeCmd(item)),
AcceptTrade: (selfAccepted, partnerAccepted, partner) =>
_bindings.Options.CommandBus().Publish(new AcceptTradeRuntimeCmd(
partner, selfAccepted, partnerAccepted)),
DeclineTrade: () => _bindings.Options.CommandBus().Publish(
new DeclineTradeRuntimeCmd()),
ResetTrade: () => _bindings.Options.CommandBus().Publish(
new ResetTradeRuntimeCmd()),
SetWindowVisible: visible =>
{
if (visible) Host.ShowWindow(WindowNames.SecureTrade);
else Host.HideWindow(WindowNames.SecureTrade);
},
SelfEmptySlotSprite: selfEmptySlotSprite,
PartnerEmptySlotSprite: partnerEmptySlotSprite,
// ID_SecureTrade_TotalItemsLabel (probe-verified
// token-free; the ITEMS variable) — retires AD-95.
FormatTotalItems: count =>
{
lock (_bindings.Assets.DatLock)
{
var strings = new DatStringResolver(_bindings.Assets.Dats);
return strings.ResolveTemplate(
0x23000001u,
"ID_SecureTrade_TotalItemsLabel",
new Dictionary<uint, string>
{
[DatStringResolver.ComputeHash("ITEMS")] =
count.ToString(),
}) ?? count.ToString();
}
}));
if (controller is null)
{
Console.WriteLine("[M4] secure trade: required authored grids are missing.");
return;
}
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.SecureTrade,
// Root 0x1000007A authors content only (the in-screen copy's
// bevel lives in LayoutDesc 0x21000005, not here) — the
// shared nine-slice frame surrounds it, same as the vendor.
Chrome = RetailWindowChrome.NineSlice,
Left = MathF.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
Top = MathF.Max(0f, (Host.Root.Height - root.Height) * 0.5f),
ContentWidth = root.Width,
ContentHeight = root.Height,
MinWidth = root.Width,
MinHeight = root.Height,
Visible = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
});
SecureTradeController = controller;
Host.WindowManager.AttachController(WindowNames.SecureTrade, controller);
// Both retail open paths (Use-on-player, drag-item-on-player) —
// raised by ItemInteractionController's policy execution arms.
_bindings.Inventory.ItemInteraction.SecureTradeRequested +=
controller.RequestSecureTrade;
Console.WriteLine(
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
}
private void ConfigureCharacterManagement()
{
CharacterSelectionRuntimeBindings? bindings =
_bindings.CharacterSelection;
if (bindings is null || _characterManagementMount is not null)
return;
// Campaign CC slice CC7: RetailUiRuntime is the one object holding
// BOTH controllers, so it supplies the cross-screen seam locally
// rather than routing it through the externally-composed bindings
// record (which is built before this runtime exists — see
// CharacterSelectionRuntimeBindings.RequestCreate's own doc
// comment). The lambda closes over `this` and reads
// CharacterCreationController per call, so it is safe even though
// ConfigureCharacterCreation() has not run yet at this point (see
// its call site immediately below this method's own caller).
_characterManagementMount = new CharacterManagementUiMountCoordinator(
Host.Root,
bindings with { RequestCreate = () => CharacterCreationController?.Open() },
EnsureDialogFactory,
LoadCharacterManagementResources);
}
private RetailDialogFactory? EnsureDialogFactory()
{
MountDialogFactory();
return DialogFactory;
}
private CharacterManagementUiMountResources? LoadCharacterManagementResources()
{
const uint stringTableId = 0x23000002u;
uint layoutId;
ImportedLayout? layout;
var strings = new DatStringResolver(_bindings.Assets.Dats);
lock (_bindings.Assets.DatLock)
{
// gmCharacterManagementUI's framework call passes enum
// 0x10000005 and category/table 5, then selects root 0x1000039A.
layoutId = RetailDataIdResolver.Resolve(
_bindings.Assets.Dats,
CharacterManagementUiController.RootEnum,
5u);
layout = layoutId == 0u
? null
: LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
CharacterManagementUiController.RootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine(
"[UI] character management: enum-table-5 root could not be imported.");
return null;
}
string? deleteResponse;
string? deleteConfirmationProbe;
string? pleaseWait;
string? enteringWorld;
string? confirmExit;
lock (_bindings.Assets.DatLock)
{
deleteConfirmationProbe = strings.ResolveTemplate(
stringTableId,
"ID_CharacterManagement_DeleteCharacterConfirmation",
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = string.Empty,
});
deleteResponse = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharacterManagement_DeleteCharacterResponse");
pleaseWait = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharacterManagement_PleaseWait");
enteringWorld = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_Character_EnteringWorld");
// Finding 1: MakeConfirmExitDialog@0x004ed250 resolves this via
// compute_str_hash("ID_CharacterManagement_ConfirmExit") against
// the same table-enum-0x10000002 -> 0x23000002 the other
// character-management dialogs already use.
confirmExit = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharacterManagement_ConfirmExit");
}
if (deleteConfirmationProbe is null
|| deleteResponse is null
|| pleaseWait is null
|| enteringWorld is null
|| confirmExit is null)
{
Console.WriteLine(
"[UI] character management: required retail strings are unavailable.");
return null;
}
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
{
return LayoutImporter.Import(
_bindings.Assets.Dats,
templateLayoutId,
templateElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont)?.Root;
}
}
string ComposeDeleteConfirmation(string characterName)
{
lock (_bindings.Assets.DatLock)
{
return NormalizeRetailNewlines(strings.ResolveTemplate(
stringTableId,
"ID_CharacterManagement_DeleteCharacterConfirmation",
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = characterName,
})!);
}
}
return new CharacterManagementUiMountResources(
layoutId,
layout,
ResolveTemplate,
new CharacterManagementUiController.DialogStrings(
ComposeDeleteConfirmation,
deleteResponse,
pleaseWait,
enteringWorld,
confirmExit));
}
private static string? ResolveCharacterManagementString(
DatStringResolver strings,
uint tableId,
string key) =>
strings.Resolve(tableId, DatStringResolver.ComputeHash(key)) is { } value
? NormalizeRetailNewlines(value)
: null;
private static string NormalizeRetailNewlines(string value) =>
value.Replace("\\n", "\n", StringComparison.Ordinal);
private void ConfigureCharacterCreation()
{
CharacterCreationRuntimeBindings? bindings = _bindings.CharacterCreation;
if (bindings is null || _characterCreationMount is not null)
return;
_characterCreationMount = new CharacterCreationUiMountCoordinator(
Host.Root,
bindings,
EnsureDialogFactory,
LoadCharacterCreationResources);
}
private CharacterCreationUiMountResources? LoadCharacterCreationResources()
{
const uint stringTableId = 0x23000002u;
uint layoutId;
ImportedLayout? layout;
var strings = new DatStringResolver(_bindings.Assets.Dats);
lock (_bindings.Assets.DatLock)
{
// gmCharGenMainUI's framework registration passes enum
// 0x10000039 and category/table 5, then selects root 0x100003CC.
layoutId = RetailDataIdResolver.Resolve(
_bindings.Assets.Dats,
CharacterCreationUiController.RootEnum,
5u);
layout = layoutId == 0u
? null
: LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
CharacterCreationUiController.RootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine(
"[UI] character creation: enum-table-5 root could not be imported.");
return null;
}
string? exitWarning;
string? noNameWarning;
string? creditWarning;
string? randomizeWarning;
string? nameTooLong;
lock (_bindings.Assets.DatLock)
{
exitWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_ExitWarning");
noNameWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_NoNameWarning");
creditWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_CreditWarning");
randomizeWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_RandomizeWarning");
nameTooLong = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_NameTooLong");
}
if (exitWarning is null
|| noNameWarning is null
|| creditWarning is null
|| randomizeWarning is null
|| nameTooLong is null)
{
Console.WriteLine(
"[UI] character creation: required retail strings are unavailable.");
return null;
}
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
{
return LayoutImporter.Import(
_bindings.Assets.Dats,
templateLayoutId,
templateElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont)?.Root;
}
}
return new CharacterCreationUiMountResources(
layoutId,
layout,
ResolveTemplate,
new CharacterCreationUiController.DialogStrings(
exitWarning, noNameWarning, creditWarning, randomizeWarning, nameTooLong));
}
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();
if (SecureTradeController is { } trade)
{
_bindings.Inventory.ItemInteraction.SecureTradeRequested -=
trade.RequestSecureTrade;
}
},
() => _itemConfirmationController?.Dispose(),
() =>
{
_characterManagementMount?.Dispose();
_characterCreationMount?.Dispose();
_gameplayConfirmationController?.Dispose();
},
() =>
{
DialogFactory?.Dispose();
TooltipPresenter?.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),
]));
}
}