feat(ui): Campaign OP slice OP3 — Options panel shell, open paths, Gameplay tab

Mounts retail's Options panel (LayoutDesc 0x2100002B resolved through host
0x2100006E slot 0x1000018D, gmPanelUI key 10) via the same catalog-import
pattern CharacterController already validates, registered through
RetailPanelUiController so it shares retail's "one active gmPanelUI child"
mutual exclusion with every other sibling panel for free. F11 and the
toolbar's options button (0x1000019B, already authoring panel id 10) both
now open it; the close button fires the same ToggleOptionsPanel action.

OptionPageModel (OptionPage/BoolOptionRow) ports retail's exact
Apply/Reset/Defaults/visibility semantics from
UIOption_Checkbox/PlayerOptionPage — LED clicks apply live immediately,
Apply commits every row unconditionally + flushes the batched blob, Reset
reverts only Changed rows, Defaults restores without committing, and
tab-switch/window-hide revert uncommitted edits. Wired for all four tabs;
this slice registers real rows on none of them (Gameplay authentically has
none — a pure button list). UiTabPanel gains an ActivePageChanged event so
the page model can hook every tab transition, including the initial
default-tab activation.

The seven Gameplay-tab buttons: Exit Game reuses the existing graceful
window-close path; Exit to Character Selection gets retail's confirmation
dialog and byte-verified mid-air refusal but still behaves as Exit Game
(AD-74 — no pre-world character-select flow exists); Configure Keyboard
and In-Game Help Files are inert this slice (AD-76 for Help — the
plugin retail depends on doesn't exist); Urgent Assistance/Report Abuse
short-circuit to their own byte-verified failure text through the
interface-text seam instead of ShellExecute against a dead URL (AD-75);
Use Mouse Turning Settings runs the pure MouseTurningSettingsMacro port,
persisting five new CameraTurningSettings preferences and sending
PlayerOption.UseMouseTurning — TS-74 records that acdream has no
persistent mouse-turning camera mode for the bit to drive yet.

Full Release suite: 12,918 passed / 4 skipped / 0 failed (baseline
12,871/4/0 — only new tests added).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 02:14:40 +02:00
parent 5242de9f15
commit 9d26ecc623
27 changed files with 25696 additions and 8 deletions

View file

@ -0,0 +1,235 @@
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;
/// <summary>
/// Mounts retail's Options panel — LayoutDesc <c>0x2100002B</c> (the tab host,
/// class <c>gmFloatyPanelUI</c>'s slot content) resolved through host
/// <c>0x2100006E</c> at slot <c>0x1000018D</c> (stack key
/// <see cref="RetailPanelCatalog.Options"/> = 10) — the SAME catalog-import
/// mechanism <see cref="CharacterController"/> already uses for its own
/// <c>0x2100006E</c> slot (<c>0x10000183</c>), byte-verified empirically: a
/// throwaway probe against the live installed DATs confirmed
/// <c>LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x1000018Du)</c> 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 <c>gmPanelUI</c> panels sharing
/// that host is needed. <c>RetailPanelUiController</c>
/// (<c>RetailUiRuntime.MountOptionsPanel</c>'s <c>RegisterMainPanel</c> call)
/// is what gives Options the SAME retail
/// "one active <c>gmPanelUI</c> 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 <see cref="OptionPage"/> model, the close button,
/// and the seven Gameplay-tab buttons.
///
/// <para>
/// Research anchors: <c>docs/research/2026-08-10-options-panel-structure.md</c>
/// §1.3 (tab table), §1.4 (host/slot), §3.6 (visibility semantics), §10.1
/// (structural inventory); <c>docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md</c>
/// §1-4 (the seven buttons, byte-verified anchors).
/// </para>
/// </summary>
public sealed class OptionsPanelController : IRetainedPanelController
{
/// <summary>The floating host LayoutDesc the tab panel is resolved through.</summary>
public const uint HostLayoutId = 0x2100006Eu;
/// <summary>The Options panel's slot within <see cref="HostLayoutId"/>'s
/// shared <c>gmPanelUI</c> page stack — also its
/// <see cref="RetailPanelCatalog.Options"/> panel id's element identity.</summary>
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;
/// <summary>The tab host's close (X) button — fires the SAME
/// <c>ToggleOptionsPanel</c> action as F11 and the toolbar button
/// (research doc §2.3).</summary>
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;
/// <summary>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.</summary>
public sealed record Callbacks(
Action Toggle,
Action RequestExitToCharacterSelection,
Action ExitGame,
Action UseMouseTurningSettings,
Action<string> DisplaySystemMessage,
Action? AfterApply = null)
{
/// <summary>Urgent Assistance's own byte-verified retail failure text.</summary>
public string UrgentAssistanceMessage { get; init; } =
OptionsPanelText.UrgentAssistanceUnavailable;
/// <summary>Report Abuse's own byte-verified retail failure text.</summary>
public string ReportAbuseMessage { get; init; } =
OptionsPanelText.ReportAbuseUnavailable;
}
private readonly UiTabPanel _tabPanel;
private readonly Dictionary<uint, OptionPage> _pages = new();
private bool _disposed;
/// <summary>Root element of the imported panel (the tab host itself —
/// this widget IS a <see cref="UiTabPanel"/>).</summary>
public UiElement Root => _tabPanel;
/// <summary>The underlying tab-control widget, for callers that need
/// direct tab-switch access (e.g. tests).</summary>
public UiTabPanel TabPanel => _tabPanel;
/// <summary>Per-tab option-page models, keyed by page slot element id.
/// Every entry exists from construction (Campaign OP slice OP3) even
/// though only <see cref="GameplayPageId"/>'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.</summary>
public IReadOnlyDictionary<uint, OptionPage> Pages => _pages;
/// <summary>The Gameplay tab's page model — always empty (research doc
/// §6: a pure button list, no <c>UIOption</c> rows, no Apply/Reset/
/// Defaults). Exposed by name for tests exercising the empty-page case.</summary>
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;
foreach (uint pageId in new[] { GameplayPageId, CharacterPageId, ChatPageId, ConfigPageId })
{
var page = new OptionPage { AfterApply = afterApply };
_pages.Add(pageId, page);
}
_tabPanel.ActivePageChanged += OnActivePageChanged;
}
/// <summary>
/// Bind an imported <see cref="HostLayoutId"/>/<see cref="SlotElementId"/>
/// layout to live behavior. <paramref name="layout"/>'s root MUST be the
/// built <see cref="UiTabPanel"/> — the caller imports via
/// <c>LayoutImporter.ImportInfos(dats, HostLayoutId, SlotElementId)</c>
/// then <c>LayoutImporter.Build</c>, exactly like every other catalog-style
/// import in this codebase (<see cref="CharacterController"/>,
/// <see cref="RetailDialogFactory"/>'s dialog catalog).
/// </summary>
/// <returns>Null if <paramref name="layout"/>'s root did not build as a
/// <see cref="UiTabPanel"/> (a missing/malformed LayoutDesc).</returns>
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;
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));
return controller;
}
/// <summary>
/// Activates the tab-switching behavior (idempotent — safe even if
/// already active). Must run AFTER <see cref="Bind"/> so this
/// controller's <see cref="OnActivePageChanged"/> subscription is in
/// place before the default-entry switch fires (Gameplay's
/// <see cref="OptionPage.OnShown"/> for the initial tab).
/// </summary>
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();
}
/// <summary>Retail's whole-window close also hides whichever page slot
/// is currently visible — same revert as a tab switch away.</summary>
public void OnHidden()
{
if (_pages.TryGetValue(_tabPanel.ActivePageElementId, out OptionPage? page))
page.OnHidden();
}
/// <summary>Re-opening the window re-shows the last-active page — same
/// apply+commit as a tab switch in.</summary>
public void OnShown()
{
if (_pages.TryGetValue(_tabPanel.ActivePageElementId, out OptionPage? page))
page.OnShown();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_tabPanel.ActivePageChanged -= OnActivePageChanged;
}
}