acdream/src/AcDream.App/UI/Layout/OptionsPanelController.cs
Erik a31fd631ad fix #381: Options-panel footer needs an opaque backing field
Root cause: a live-DAT probe found retail authors NO backing element
behind the Character/Chat/Config tabs' Apply/Reset/Defaults footer —
each page root has exactly five children (the row ListBox, its
scrollbar, and the three buttons) with zero direct-state media on the
root itself. Scrolled row content therefore bled through visibly
between/behind the three buttons; the bleed-through is a rendering gap
in our own composition, not a missing import.

Fix: new minimal widget UiSolidSpriteFill tiles
RetailChromeSprites.CenterFill (the SAME panel-background sprite the
Options window's own chrome already draws behind everything, not an
invented color) across the footer strip's rect, derived from the three
buttons' own resolved Top/Height and z-ordered strictly behind every
other child so it can never intercept input or occlude the buttons.
Register row AP-205 records the synthesis. Regressed by
OptionsPanelControllerTests.
Bind_SynthesizesOneOpaqueFooterBacking_PerPageWithApplyResetDefaults,
which pins exactly one backing field per page, sized from the live
button rects, z-ordered behind every sibling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 23:01:43 +02:00

409 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
// 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;
/// <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,
// Campaign OP slice OP8: opens the Configure Keyboard screen — retires
// OP3's INERT contract for this button (0x10000204). Null leaves the
// button inert (e.g. a test harness with no keyboard screen wired).
Action? OpenConfigureKeyboard = 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;
// 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;
}
/// <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>
/// <param name="resolveSprite">#381: RenderSurface id → (GL tex handle,
/// pixel width, pixel height) — wired onto each page's synthesized
/// footer backing field (<see cref="UiSolidSpriteFill"/>). Null leaves
/// the field present but drawing nothing (matching every other
/// null-safe sprite resolver in this codebase) — the fixture/
/// conformance callers that never exercise the visual layer.</param>
/// <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,
Func<uint, (uint tex, int w, int h)>? resolveSprite = null)
{
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: Campaign OP slice OP8 wires the real Configure
// Keyboard screen — the OP3 INERT contract is retired.
BindButton(layout, ConfigureKeyboardId, callbacks.OpenConfigureKeyboard);
// 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);
UiButton? defaults = BindPageButton(pageRoot, DefaultsButtonId, page.Defaults);
// #381 (2026-08-11, gate 4): the opaque backing field behind the
// footer strip — see UiSolidSpriteFill's own doc for the
// live-DAT probe proving retail authors no such element (the
// page root has exactly 5 children, zero direct-state media).
// Sized from the THREE BUTTONS' OWN resolved rects (not a
// hardcoded literal) so it tracks whatever the authored layout
// actually placed them at.
AddFooterBacking(pageRoot, apply, reset, defaults, resolveSprite);
// 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;
}
/// <summary>Draws behind everything else already added to
/// <paramref name="pageRoot"/> — see <see cref="UiSolidSpriteFill"/>'s
/// own doc for why this element exists at all (AP-205, synthesized,
/// no authored counterpart).</summary>
private const int FooterBackingZOrder = int.MinValue / 2;
/// <summary>
/// #381: inserts the opaque, borderless backing field behind
/// Apply/Reset/Defaults, deriving its Y/height from whichever of the
/// three physical buttons resolved (all three share the SAME authored
/// Y=564/Height=32, research doc §3.1 — any one is sufficient). Skipped
/// (not an error — logged) only when NONE resolved; a page missing all
/// three already logged its own per-button warnings above.
/// </summary>
private static void AddFooterBacking(
UiElement pageRoot,
UiButton? apply,
UiButton? reset,
UiButton? defaults,
Func<uint, (uint tex, int w, int h)>? resolveSprite)
{
UiButton? first = apply ?? reset ?? defaults;
if (first is null)
{
Console.WriteLine(
$"[D.2b] OptionsPanelController: page 0x{pageRoot.DatElementId:X8} has no "
+ "resolved Apply/Reset/Defaults buttons — footer backing field skipped.");
return;
}
// Span the FULL authored page width (not just the button union) —
// retail's own three buttons don't reach the page's right edge, but
// the reported bleed-through is content scrolling behind the WHOLE
// footer strip, not just the button rects themselves.
var backing = new UiSolidSpriteFill
{
SpriteId = RetailChromeSprites.CenterFill,
SpriteResolve = resolveSprite,
Left = 0f,
Top = first.Top,
Width = pageRoot.Width,
Height = first.Height,
ZOrder = FooterBackingZOrder,
};
pageRoot.AddChild(backing);
}
/// <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 = () =>
{
// #372 gate evidence: the user's first connected gate reported
// most Gameplay buttons "did nothing"; two of the seven are
// contract-inert and the other four have dialog/chat effects
// that can go unnoticed. One event-scale line per click makes
// the next gate's log a definitive fired/not-fired record.
Console.WriteLine($"[options] gameplay button 0x{elementId:X8} clicked — handler invoked");
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();
}
/// <summary>OP4 re-review R2: a fresh PlayerDescription seed landed —
/// re-read the ACTIVE page's rows from live state (flush-free; see
/// <see cref="OptionPage.ReloadFromLive"/>). Hidden pages re-read on
/// their next <see cref="OptionPage.OnShown"/> as always.</summary>
public void OnServerOptionsSeeded()
{
if (_pages.TryGetValue(_tabPanel.ActivePageElementId, out OptionPage? page))
page.ReloadFromLive();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_tabPanel.ActivePageChanged -= OnActivePageChanged;
}
}