Both OP4 reviews converged on one headline bug (Character-tab rows never re-read live server truth after their pre-login constructor-word seed) plus overlapping MUST-FIXes. All ten converged/consolidated findings land here: MUST-FIX: - BoolOptionRow.SaveCurrentValue now re-reads its live binding (retail's GetValue()-into-SaveCurrentValue) on every OnShown — panel open, tab switch in, initial activation — instead of trusting the pre-login constructor word it was built with. Reset/tab-switch can now only restore values that were actually live at the last show. LockUI's host.Root.UiLocked one-shot mount seed now also converges on every PlayerDescription via the existing OnCharacterOptionsChanged hook. - Apply/Reset are wired to OptionPage.OnOptionChanged in production (Ghosted when nothing changed, Normal when dirty, run once at bind so both start disabled per retail's PostInit); Defaults stays ungated. - The Combat panel's three LEDs (Repeat Attacks/Auto Target/Keep in View) now read/write the same RuntimeCharacterOptionsState seam the Character tab uses instead of a disconnected client-local GameplaySettings copy — closes the "two writable copies" divergence. The three now-orphaned GameplaySettings fields and RuntimeSettingsController's mirror properties/SetCombatGameplay are deleted outright; the headless host's hardcoded AutoRepeatAttack/AutoTarget now read the live option bit. - RuntimeSettingsController.SetUiLocked's convergence guard now compares against the last value actually applied to the runtime target instead of the persisted GameplaySettings.LockUI snapshot, which could already match a server-derived request without ever having been pushed. SHOULD-FIX: - DisplayTimeStamps now prefixes every chat producer (ChatLog.Append is the one seam all of them funnel through), not just AddText's own callers — heard speech, emotes, Turbine channels, and combat text were previously missed. The prefix format escapes its colons and forces InvariantCulture instead of the culture-dependent TimeSeparator placeholder. - sky.frag now honors uFogParams.w (fog mode) like the mesh/terrain shaders, so Disable Distance Fog stops the sky dome's horizon band from blending toward fog color too. - Corrected the "byte-verified" overclaim on the timestamp format string doc comment (BN-sourced, wire doc U6) and the AP-194 anchor-column class-name typo; the RunAsDefaultMovement doc comments now cite retail's actual acclient.h enumerator name. - Added: DispatcherMovementInputSource's option x modifier truth table (incl. || AutoRunActive with the option off), the per-page Apply/Reset enable-gate tests, a real checkbox.OnClick/ToggleBehavior-driven click test, and hash-pins for the six header string keys. - Gate script step 8 corrected for the logout-flush false-failure (closing the panel before relogging is load-bearing); a new step documents the enable-gate sequence and the Combat-panel/Character-tab cross-check. Register: AP-196 (the Group-C default-source change + GameplaySettings retirement) and AP-197 (the ignored per-character timestamp format override) filed in this commit. Full Release suite: 13,044 passed / 4 skipped / 0 failed (was 13,008/4/0; net +36 tests from new coverage and legitimate assertion updates from the GameplaySettings retirement). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
319 lines
15 KiB
C#
319 lines
15 KiB
C#
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)
|
||
{
|
||
/// <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>
|
||
/// <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;
|
||
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;
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
}
|