Fills TS-82's Summary placeholder with a faithful port of gmCGSummaryPage (name field with NameInputFilter + the retail commit-on-focus-lost/submit dispatch + the >32-char ID_CharGen_NameTooLong reject-and-revert path, the REAL three-row-template listbox confirmed against the installed EoR dat before writing any page code, and Summary's own independent gmCG3DView preview instance wired through a second ChargenPreviewController pair mirroring the Appearance page's exact composition shape). Ports CharGenState::RandomizeCharacter and its six sub-primitives into RuntimeCharacterCreationState — not approximated: the RandInt/RollDice semantics are independently confirmed from both the decompiled RNG bodies and the CharGenStateVtbl union struct in acclient.h. Three consumers: the chargen screen's open-roll (retiring AP-214's honest-blank deviation and reproducing the Appearance page's gender-flip-on-init quirk), the Summary page's Random button (behind the retail randomize-warning confirm), and the Appearance page's Random button (narrowing AP-212 to just Heritage/Profession/Town's still-approximated rolls and Skills' still-unported RandomizeSkills). Wires the Finish button (previously ghosted) with retail's NoName/ CreditWarning dialog pair, adds the F12 amendment's HeritageOrGenderUnset local refusal to TryBeginFinish (register AP-223) as a defensive backstop now that the screen-open roll normally makes it unreachable, and wires the four ID_Character_Err_* rejection dialogs for the 0xF643 response codes CC3 already parsed but nothing displayed. Register: TS-82 retired, AP-214 retired, AP-212 narrowed, AP-223/224/225 filed (heritage/gender Finish refusal, Summary's two-bucket skill-list narrowing, the 32-vs-33 name-length threshold reconciliation). Runtime 1722/0 (was 1713), App 5240/3 skips (was 5223/3), Headless 166/0 unchanged, full solution Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4165 lines
182 KiB
C#
4165 lines
182 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<ICommandBus></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 & 1</c>), tri-state per
|
|
/// <c>gmGamePlayUI::UseTime @0x004EA3A0</c>'s exact branch structure:
|
|
/// <c>else if (smartbox->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 => 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);
|
|
|
|
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();
|
|
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; }
|
|
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>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);
|
|
_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();
|
|
}
|
|
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->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.
|
|
availableResolutions: Rendering.DisplayModeCatalog.Resolutions,
|
|
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.");
|
|
}
|
|
|
|
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;
|
|
|
|
_characterManagementMount = new CharacterManagementUiMountCoordinator(
|
|
Host.Root,
|
|
bindings,
|
|
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(),
|
|
_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),
|
|
]));
|
|
}
|
|
}
|