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;
}
}