using System; using System.Collections.Generic; using AcDream.App.UI; using AcDream.UI.Abstractions.Panels.Settings; using AcDream.Core.Chat; namespace AcDream.App.UI.Layout; /// /// Mounts retail's Options panel — LayoutDesc 0x2100002B (the tab host, /// class gmFloatyPanelUI's slot content) resolved through host /// 0x2100006E at slot 0x1000018D (stack key /// = 10) — the SAME catalog-import /// mechanism already uses for its own /// 0x2100006E slot (0x10000183), byte-verified empirically: a /// throwaway probe against the live installed DATs confirmed /// LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x1000018Du) resolves /// directly to the fully base-merged tab-host content (buttons, page slots, /// each page's own children) at the slot's authored 300×362 extent — no /// separate import of the OTHER ~15 sibling gmPanelUI panels sharing /// that host is needed. RetailPanelUiController /// (RetailUiRuntime.MountOptionsPanel's RegisterMainPanel call) /// is what gives Options the SAME retail /// "one active gmPanelUI child, opening one hides the others" mutual /// exclusion every sibling panel (Character Info, Vitae, Inventory, ...) /// already has — this controller owns only the panel's OWN content: tab /// activation, the per-tab model, the close button, /// and the seven Gameplay-tab buttons. /// /// /// Research anchors: docs/research/2026-08-10-options-panel-structure.md /// §1.3 (tab table), §1.4 (host/slot), §3.6 (visibility semantics), §10.1 /// (structural inventory); docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md /// §1-4 (the seven buttons, byte-verified anchors). /// /// public sealed class OptionsPanelController : IRetainedPanelController { /// The floating host LayoutDesc the tab panel is resolved through. public const uint HostLayoutId = 0x2100006Eu; /// The Options panel's slot within 's /// shared gmPanelUI page stack — also its /// panel id's element identity. public const uint SlotElementId = 0x1000018Du; // Tab page slot ids (research doc §10.1 — the four page slots mounted // inside the tab host 0x10000208, each already base-merged with its own // page LayoutDesc content by the time this element resolves). private const uint GameplayPageId = 0x10000212u; private const uint CharacterPageId = 0x10000211u; private const uint ChatPageId = 0x1000050Cu; private const uint ConfigPageId = 0x10000213u; /// The tab host's close (X) button — fires the SAME /// ToggleOptionsPanel action as F11 and the toolbar button /// (research doc §2.3). private const uint CloseButtonId = 0x10000210u; // Gameplay tab (0x2100002A) button ids — research doc §6, byte-verified // against the committed options_gameplay_2100002A.json fixture. private const uint ExitToCharacterSelectionId = 0x10000203u; private const uint ConfigureKeyboardId = 0x10000204u; private const uint InGameHelpFilesId = 0x10000205u; private const uint UrgentAssistanceId = 0x10000206u; private const uint ReportAbuseId = 0x10000207u; private const uint UseMouseTurningSettingsId = 0x100005CCu; private const uint ExitGameId = 0x10000617u; // Apply/Reset/Defaults — research doc §3.1/§10.1: identical geometry // AND identical element ids on every page that has them (Character, // Chat, Config — NOT Gameplay). Each page's own LayoutDesc authors its // OWN physical button instances under these SAME numeric ids, so a // flat layout.FindElement lookup cannot reliably pick one page's // instance — Campaign OP OP4 wires each page's copy from a SCOPED // search rooted at that page's own slot (UiElement.FindDescendant). private const uint ApplyButtonId = 0x100001FCu; private const uint ResetButtonId = 0x100001FDu; private const uint DefaultsButtonId = 0x100001FEu; /// Callback delegates this controller wires the seven Gameplay /// buttons and the close button to. Every field maps to exactly one /// button; a null field leaves that button INERT (authored, clickable, /// no handler) — the shape D5's In-Game Help Files and OP8's still- /// unimplemented Configure Keyboard both need. public sealed record Callbacks( Action Toggle, Action RequestExitToCharacterSelection, Action ExitGame, Action UseMouseTurningSettings, Action DisplaySystemMessage, Action? AfterApply = null) { /// Urgent Assistance's own byte-verified retail failure text. public string UrgentAssistanceMessage { get; init; } = OptionsPanelText.UrgentAssistanceUnavailable; /// Report Abuse's own byte-verified retail failure text. public string ReportAbuseMessage { get; init; } = OptionsPanelText.ReportAbuseUnavailable; } private readonly UiTabPanel _tabPanel; private readonly Dictionary _pages = new(); private bool _disposed; /// Root element of the imported panel (the tab host itself — /// this widget IS a ). public UiElement Root => _tabPanel; /// The underlying tab-control widget, for callers that need /// direct tab-switch access (e.g. tests). public UiTabPanel TabPanel => _tabPanel; /// Per-tab option-page models, keyed by page slot element id. /// Every entry exists from construction (Campaign OP slice OP3) even /// though only 's stays permanently empty — /// Character/Chat/Config slices (OP4-6) register their rows into these /// SAME instances rather than re-deriving the page-tracking dictionary. public IReadOnlyDictionary Pages => _pages; /// The Gameplay tab's page model — always empty (research doc /// §6: a pure button list, no UIOption rows, no Apply/Reset/ /// Defaults). Exposed by name for tests exercising the empty-page case. public OptionPage GameplayPage => _pages[GameplayPageId]; public OptionPage CharacterPage => _pages[CharacterPageId]; public OptionPage ChatPage => _pages[ChatPageId]; public OptionPage ConfigPage => _pages[ConfigPageId]; private OptionsPanelController(UiTabPanel tabPanel, Action? afterApply) { _tabPanel = tabPanel; // Mechanism review S1 (2026-08-11 fix round): gmGameplayOptionsUI // (acclient.h:55857) derives from UIElement_Field, NOT // OptionPage/PlayerOptionPage at all — unlike the other three tabs // (gmCharacterSettingsUI/gmChatOptionsUI/gmConfigUI, all // : PlayerOptionPage). Retail never calls SaveCurrentValues for the // Gameplay page, so it never flushes the batched blob on show/hide. // The model still needs an (empty) page instance so // OnActivePageChanged's TryGetValue lookup and OnHidden/OnShown // (Reset/Apply over zero rows, both harmless no-ops) keep working // uniformly across all four tabs — only AfterApply is deliberately // left null here, so entering/leaving Gameplay never publishes // SaveCharacterOptionsRuntimeCmd. _pages.Add(GameplayPageId, new OptionPage { AfterApply = null }); foreach (uint pageId in new[] { CharacterPageId, ChatPageId, ConfigPageId }) { var page = new OptionPage { AfterApply = afterApply }; _pages.Add(pageId, page); } _tabPanel.ActivePageChanged += OnActivePageChanged; } /// /// Bind an imported / /// layout to live behavior. 's root MUST be the /// built — the caller imports via /// LayoutImporter.ImportInfos(dats, HostLayoutId, SlotElementId) /// then LayoutImporter.Build, exactly like every other catalog-style /// import in this codebase (, /// 's dialog catalog). /// /// Null if 's root did not build as a /// (a missing/malformed LayoutDesc). public static OptionsPanelController? Bind(ImportedLayout layout, Callbacks callbacks) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(callbacks); if (layout.Root is not UiTabPanel tabPanel) { Console.WriteLine( "[D.2b] OptionsPanelController.Bind: root did not build as UiTabPanel " + $"(actual type {layout.Root.GetType().Name}) — Options panel will not open."); return null; } var controller = new OptionsPanelController(tabPanel, callbacks.AfterApply); if (layout.FindElement(CloseButtonId) is UiButton close) close.OnClick = callbacks.Toggle; else Console.WriteLine( $"[D.2b] OptionsPanelController: close button 0x{CloseButtonId:X8} " + "not found in the built layout — its handler was not wired."); BindButton(layout, ExitToCharacterSelectionId, callbacks.RequestExitToCharacterSelection); // ConfigureKeyboardId: INERT this slice — authored, clickable, no // handler. OP8 wires the real Configure Keyboard screen; the campaign // cannot close with this button still inert (plan §4 OP3). // InGameHelpFilesId: INERT — retail's own KeyStone::OpenHelp fails // without the missing plugins\ACHelpPlugin.dll (D5, register row). BindButton(layout, UseMouseTurningSettingsId, callbacks.UseMouseTurningSettings); BindButton(layout, ExitGameId, callbacks.ExitGame); BindButton(layout, UrgentAssistanceId, () => callbacks.DisplaySystemMessage(callbacks.UrgentAssistanceMessage)); BindButton(layout, ReportAbuseId, () => callbacks.DisplaySystemMessage(callbacks.ReportAbuseMessage)); // Apply/Reset/Defaults — retail's gmCharacterSettingsUI / // gmChatOptionsUI / gmConfigUI ::ListenToElementMessage // @0x0049E3A0 (COMDAT-folded — literally the SAME handler body on // all three pages, structure doc §3.1): idElement == 0x100001FC -> // SaveCurrentValues (Apply); 0x100001FD -> RestoreSavedValues // (Reset); 0x100001FE -> RestoreDefaultValues (Defaults). Each // page's OWN OptionPage model owns the actual semantics // (OptionPageModel.cs); this loop only wires each page's physical // button instances to its own model. foreach (uint pageId in new[] { CharacterPageId, ChatPageId, ConfigPageId }) { OptionPage page = controller._pages[pageId]; UiElement? pageRoot = UiElement.FindDescendant(tabPanel, pageId); if (pageRoot is null) continue; UiButton? apply = BindPageButton(pageRoot, ApplyButtonId, page.Apply); UiButton? reset = BindPageButton(pageRoot, ResetButtonId, page.Reset); BindPageButton(pageRoot, DefaultsButtonId, page.Defaults); // MUST-FIX 2 (OP4 review-fix round, 2026-08-11): retail // PlayerOptionPage::OnOptionChanged @0x004F27D0 — Apply/Reset // Ghosted (disabled) when the page has nothing to commit/ // revert, Normal (enabled) otherwise; Defaults is NEVER gated // (retail's override never fetches its child id at all). if (apply is not null && reset is not null) { page.OnOptionChanged = () => { uint state = page.Changed ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted; apply.TrySetRetailState(state); reset.TrySetRetailState(state); }; // Retail's PostInit calls InitOptions() then // OnOptionChanged(0) so the pair starts disabled — run the // gate once now, at bind time, for the same effect. page.OnOptionChanged(); } } return controller; } private static UiButton? BindPageButton(UiElement pageRoot, uint elementId, Action onClick) { if (UiElement.FindDescendant(pageRoot, elementId) is UiButton button) { button.OnClick = onClick; return button; } Console.WriteLine( $"[D.2b] OptionsPanelController: page 0x{pageRoot.DatElementId:X8}'s button " + $"0x{elementId:X8} not found — its handler was not wired."); return null; } /// /// Activates the tab-switching behavior (idempotent — safe even if /// already active). Must run AFTER so this /// controller's subscription is in /// place before the default-entry switch fires (Gameplay's /// for the initial tab). /// public void ActivateTabs() => _tabPanel.ActivateTabBehavior(); private static void BindButton(ImportedLayout layout, uint elementId, Action? onClick) { if (onClick is null) return; if (layout.FindElement(elementId) is UiButton button) button.OnClick = onClick; else Console.WriteLine( $"[D.2b] OptionsPanelController: Gameplay-tab button 0x{elementId:X8} " + "not found in the built layout — its handler was not wired."); } private void OnActivePageChanged(uint previousPageElementId, uint newPageElementId) { // Retail PlayerOptionPage::OnVisibilityChanged(false) -> RestoreSavedValues: // leaving a page reverts its uncommitted edits. if (previousPageElementId != 0 && _pages.TryGetValue(previousPageElementId, out OptionPage? previous)) previous.OnHidden(); // OnVisibilityChanged(true) -> SaveCurrentValues: entering a page // (including the initial default-tab activation, previous == 0) // applies + commits. if (_pages.TryGetValue(newPageElementId, out OptionPage? next)) next.OnShown(); } /// Retail's whole-window close also hides whichever page slot /// is currently visible — same revert as a tab switch away. public void OnHidden() { if (_pages.TryGetValue(_tabPanel.ActivePageElementId, out OptionPage? page)) page.OnHidden(); } /// Re-opening the window re-shows the last-active page — same /// apply+commit as a tab switch in. public void OnShown() { if (_pages.TryGetValue(_tabPanel.ActivePageElementId, out OptionPage? page)) page.OnShown(); } public void Dispose() { if (_disposed) return; _disposed = true; _tabPanel.ActivePageChanged -= OnActivePageChanged; } }