Consolidated fix round for the two OP3 dual-lens reviews
(docs/research/2026-08-11-op3-review-{mechanism,blast}.md), both
APPROVE-WITH-FIXES.
MUST-FIX:
- The six "Use Mouse Turning Settings" chat lines were typed
RetailLogTextType.ClientLocal (0x1A); retail types them 0x07 (Magic).
BYTE-VERIFIED against the PDB-paired binary at all six
gmConfigUI::SetMouseTurningDefaults call sites (0x0049E972/E9E2/EA52/
EAA4/EAF6/EB48): every site pushes `6a 07` (type=7) immediately before
the text-pointer push and the AddTextToScroll call. Added a dedicated
OptionsRuntimeBindings.DisplayMouseTurningMacroLine seam routed at
Magic (scrolling chat transcript, light blue, timestamped) instead of
the 4-slot SpewBox ClientLocal uses; the mid-air refusal and UA/RA
keep ClientLocal (both independently confirmed correct).
- Filed AD-77: the client-wide floating-only gmPanelUI host divergence
(retail also exposes a docked 0x21000017 host) the plan §5 delegated
to this review, scoped to every main panel, not just Options.
SHOULD-FIX:
- gmGameplayOptionsUI is not an OptionPage in retail (acclient.h:55857,
UIElement_Field). OptionsPanelController now constructs the Gameplay
slot's OptionPage with AfterApply deliberately null, so entering/
leaving that tab never publishes SaveCharacterOptionsRuntimeCmd.
Corrected OptionPageModel's doc comment and rewrote the two tests
that pinned the wrong (Gameplay-flushes) shape.
- Added the OptionPage.OnOptionChanged seam (PlayerOptionPage::
OnOptionChanged @0x004F27D0) — fires as the last step of Apply/
Reset/Defaults, plus once per live LED edit via a new
IOptionRow.AttachPageNotify hook (BoolOptionRow wires it into
SetCurrentValue only, matching retail's Apply(1)-only
HandleDialogAndNotices path). OP4-6 will bind Apply/Reset enable
state to this.
- Exit to Character Selection's mid-air refusal is now tri-state
(Func<bool?> IsGrounded): retail's UseTime only reaches the airborne
test inside `else if (smartbox->player)`, so outside player mode (or
with no live controller) the button is a SILENT no-op, not a
refusal. Fixed the inverted comment at both call sites.
- Options panel geometry now matches its nine gmPanelUI siblings
sharing RetailPanelUiController's one main-panel rectangle
(ResizeX=false, bottom-edge-only resize, no invented Min/MaxWidth/
Height) instead of being the only all-four-edge/horizontal-resize
outlier whose width silently reverted whenever a sibling was shown.
- Added the three missing test pins: Options/Character mutual
exclusion through a REAL RetailPanelUiController registration,
RetailDialogFactory.MakeConfirmation's omitted-queueKey overload
sharing DefaultQueueKey, and UiTabPanel.ActivePageChanged never
firing on a dormant (non-activated) host.
- TS-74's What/Where now names the five store-only CameraTurning
preferences explicitly instead of only mentioning them in Risk.
- Test script gains the toolbar-button ghosted->enabled+highlight
check, UseMouseTurning-survives-relogin and the five prefs-survive-
relaunch steps, a UA/RA legibility eye-item, and the corrected
bottom-edge-only geometry description for step 5.
One-liners fixed in files already touched: symmetric close-button
resolve-failure logging in OptionsPanelController.Bind (blast NOTE 8).
Full Release suite: 12,947 passed / 4 skipped / 0 failed (baseline
12,935/4/0 post-OP7 — 12 net new tests; the two OptionPageModelTests
"wrong-shape" tests were renamed/rewritten in place, not removed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
262 lines
12 KiB
C#
262 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// One retail <c>OptionPage</c>/<c>PlayerOptionPage</c> row: a
|
|
/// <c>(m_current, m_saved, m_default)</c> triple plus the three verbs a row
|
|
/// leaf implements (<c>UIOption_Checkbox</c> is the canonical case — Campaign
|
|
/// OP slices OP4-6 add slider/menu/bitfield leaves behind the same shape).
|
|
/// Retail anchors: <c>docs/research/2026-08-10-options-panel-structure.md</c>
|
|
/// §3.3 (<c>UIOption_Checkbox::Changed @0x004868C0</c>,
|
|
/// <c>SaveCurrentValue @0x004868E0</c>, <c>RestoreSavedValue @0x00486900</c>,
|
|
/// <c>RestoreDefaultValue @0x00486930</c>, <c>SetCurrentValue @0x00486970</c>).
|
|
/// </summary>
|
|
public interface IOptionRow
|
|
{
|
|
/// <summary><c>UIOption_Checkbox::Changed</c>: <c>m_saved != m_current</c>.</summary>
|
|
bool Changed { get; }
|
|
|
|
/// <summary><c>SaveCurrentValue</c>: <c>m_saved = m_current</c>. No live
|
|
/// side effect — the value is already live (every mutator below applies
|
|
/// immediately).</summary>
|
|
void SaveCurrentValue();
|
|
|
|
/// <summary><c>RestoreSavedValue</c>: <c>m_current = m_saved</c>, then
|
|
/// applies the reverted value live.</summary>
|
|
void RestoreSavedValue();
|
|
|
|
/// <summary><c>RestoreDefaultValue</c>: <c>m_current = m_default</c>,
|
|
/// then applies the default live.</summary>
|
|
void RestoreDefaultValue();
|
|
|
|
/// <summary>
|
|
/// Wires this row's owning-page notify hook — retail's
|
|
/// <c>UIOption::m_pOCH</c> (option-change-handler) pointer, invoked by
|
|
/// <c>UIOption::HandleDialogAndNotices @0x004EFB90</c>'s
|
|
/// <c>m_pOCH->OnOptionChanged(this)</c> call after a LIVE user edit
|
|
/// (a widget's own <c>SetCurrentValue</c>/<c>Apply(1)</c> path ONLY —
|
|
/// <c>RestoreSavedValue</c>/<c>RestoreDefaultValue</c> use retail's
|
|
/// <c>Apply(0)</c>, which skips this per-row notify because the OWNING
|
|
/// VERB (<see cref="OptionPage.Reset"/>/<see cref="OptionPage.Defaults"/>)
|
|
/// already calls <see cref="OptionPage.OnOptionChanged"/> once itself,
|
|
/// at its own tail). Called once by <see cref="OptionPage.Register"/>;
|
|
/// mechanism review S2, 2026-08-11 fix round.
|
|
/// </summary>
|
|
void AttachPageNotify(Action notify);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The canonical retail leaf — <c>UIOption_Checkbox</c>'s current/saved/default
|
|
/// triple over a <see cref="bool"/>. <see cref="SetCurrentValue"/> is what a
|
|
/// user's LED click runs: it writes <c>m_current</c> and applies it live
|
|
/// IMMEDIATELY (retail's <c>SetCurrentValue @0x00486970</c> calls
|
|
/// <c>Apply(1)</c> synchronously) — Apply/Reset/Defaults never gate this; they
|
|
/// only move the <c>m_saved</c>/<c>m_default</c> baselines and re-apply.
|
|
/// </summary>
|
|
public sealed class BoolOptionRow : IOptionRow
|
|
{
|
|
private readonly Action<bool>? _apply;
|
|
private Action? _notifyPageOptionChanged;
|
|
private bool _current;
|
|
private bool _saved;
|
|
private bool _default;
|
|
|
|
public BoolOptionRow(bool initial, bool defaultValue, Action<bool>? apply = null)
|
|
{
|
|
_current = initial;
|
|
_saved = initial;
|
|
_default = defaultValue;
|
|
_apply = apply;
|
|
}
|
|
|
|
/// <summary>The live value — what the LED currently shows.</summary>
|
|
public bool Current => _current;
|
|
|
|
/// <summary>The committed baseline Reset reverts to.</summary>
|
|
public bool Saved => _saved;
|
|
|
|
/// <summary>The value Defaults restores. Mutable via
|
|
/// <see cref="SetDefaultValue"/> — retail's <c>SetDefaultValue</c> is
|
|
/// authored per-row in each page's <c>InitOptions</c>, sometimes from a
|
|
/// DAT-resolved default rather than a compile-time literal (OP4's
|
|
/// Character-tab U1 closure).</summary>
|
|
public bool DefaultValue => _default;
|
|
|
|
public bool Changed => _saved != _current;
|
|
|
|
/// <summary>Retail <c>UIOption_Checkbox::SetDefaultValue @0x00486960</c>.</summary>
|
|
public void SetDefaultValue(bool value) => _default = value;
|
|
|
|
/// <summary>
|
|
/// Retail <c>SetCurrentValue @0x00486970</c> — the LED-click entry point.
|
|
/// Writes <c>m_current</c> and applies it live immediately; does NOT
|
|
/// touch <see cref="Saved"/> (Apply is the only verb that commits). Also
|
|
/// notifies the owning page (<see cref="AttachPageNotify"/>) — retail's
|
|
/// <c>Apply(1)</c>-only <c>HandleDialogAndNotices</c> path.
|
|
/// </summary>
|
|
public void SetCurrentValue(bool value)
|
|
{
|
|
_current = value;
|
|
_apply?.Invoke(value);
|
|
_notifyPageOptionChanged?.Invoke();
|
|
}
|
|
|
|
public void AttachPageNotify(Action notify) => _notifyPageOptionChanged = notify;
|
|
|
|
public void SaveCurrentValue() => _saved = _current;
|
|
|
|
public void RestoreSavedValue()
|
|
{
|
|
_current = _saved;
|
|
_apply?.Invoke(_current);
|
|
}
|
|
|
|
public void RestoreDefaultValue()
|
|
{
|
|
_current = _default;
|
|
_apply?.Invoke(_current);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>OptionPage</c>/<c>PlayerOptionPage</c>: a page's registered-option
|
|
/// array plus the four verbs (Apply/Reset/Defaults/visibility) with retail's
|
|
/// EXACT semantics — <c>docs/research/2026-08-10-options-panel-structure.md</c>
|
|
/// §3 and §10.4:
|
|
///
|
|
/// <list type="number">
|
|
/// <item><description>Clicking an LED applies immediately
|
|
/// (<see cref="BoolOptionRow.SetCurrentValue"/> → <c>Apply(1)</c>). Apply and
|
|
/// Reset operate on an undo baseline, not a staging buffer.</description></item>
|
|
/// <item><description><see cref="Apply"/> commits EVERY row's baseline
|
|
/// unconditionally (<c>OptionPage::SaveCurrentValues @0x004F2C60</c> — no
|
|
/// <c>Changed</c> gate), then flushes the batched character-options blob via
|
|
/// <see cref="AfterApply"/> (<c>PlayerOptionPage::SaveCurrentValues
|
|
/// @0x004F2710</c>'s <c>CPlayerModule::SaveToServer(0)</c> tailcall).</description></item>
|
|
/// <item><description><see cref="Reset"/> reverts only rows whose
|
|
/// <see cref="IOptionRow.Changed"/> is true
|
|
/// (<c>OptionPage::RestoreSavedValues @0x004F2D00</c>).</description></item>
|
|
/// <item><description><see cref="Defaults"/> restores every row unconditionally,
|
|
/// live, WITHOUT committing — <see cref="IOptionRow.Changed"/> can go true
|
|
/// afterward, re-enabling Apply/Reset
|
|
/// (<c>OptionPage::RestoreDefaultValues @0x004F2CB0</c>).</description></item>
|
|
/// <item><description><see cref="OnHidden"/> (page becomes invisible — a tab
|
|
/// switch away, or the window closing) reverts uncommitted edits exactly like
|
|
/// Reset (<c>PlayerOptionPage::OnVisibilityChanged(false) @0x004F26E0</c> →
|
|
/// <c>RestoreSavedValues</c>).</description></item>
|
|
/// <item><description><see cref="OnShown"/> (page becomes visible — the
|
|
/// initial default tab, a tab switch in, or the window (re)opening) applies +
|
|
/// commits exactly like Apply (<c>OnVisibilityChanged(true)</c> →
|
|
/// <c>SaveCurrentValues</c>, which ALSO flushes the blob via
|
|
/// <see cref="AfterApply"/>).</description></item>
|
|
/// </list>
|
|
///
|
|
/// <para>
|
|
/// An empty page (zero registered rows) makes every verb a no-op and
|
|
/// <see cref="Changed"/> permanently false; when <see cref="AfterApply"/> IS
|
|
/// wired, it still fires on <see cref="Apply"/>/<see cref="OnShown"/> even
|
|
/// with zero rows (retail's <c>SaveCurrentValues</c> flushes the blob
|
|
/// regardless of whether THIS page's own rows changed anything — the
|
|
/// module's dirty flag is global, not per-page). <b>This is a property of
|
|
/// the generic empty-page shape, not a description of the Gameplay tab.</b>
|
|
/// <c>gmGameplayOptionsUI</c> (<c>acclient.h:55857</c>) derives from
|
|
/// <c>UIElement_Field</c>, not <c>OptionPage</c>/<c>PlayerOptionPage</c> at
|
|
/// all, so retail never calls <c>SaveCurrentValues</c> for it in the first
|
|
/// place — <see cref="OptionsPanelController"/> deliberately
|
|
/// constructs the Gameplay slot's <see cref="OptionPage"/> instance with
|
|
/// <see cref="AfterApply"/> left <c>null</c> so entering/leaving that tab
|
|
/// never flushes (mechanism review S1, 2026-08-11 fix round).
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class OptionPage
|
|
{
|
|
private readonly List<IOptionRow> _rows = new();
|
|
|
|
public IReadOnlyList<IOptionRow> Rows => _rows;
|
|
|
|
/// <summary>
|
|
/// Invoked after every <see cref="Apply"/> (button click OR
|
|
/// <see cref="OnShown"/>) — the seam a controller wires to the batched
|
|
/// <c>SaveOptions</c>/blob-flush command. Never invoked by
|
|
/// <see cref="Reset"/> or <see cref="Defaults"/> (retail's Reset/Defaults
|
|
/// call <c>Apply(0)</c> per-row and reach <see cref="OnOptionChanged"/>
|
|
/// directly — they never reach <c>PlayerOptionPage::SaveCurrentValues</c>,
|
|
/// so they never flush).
|
|
/// </summary>
|
|
public Action? AfterApply { get; set; }
|
|
|
|
/// <summary>
|
|
/// Retail <c>OptionPage::OnOptionChanged(0)</c> — the ONLY thing that
|
|
/// enable-gates Apply/Reset (<c>PlayerOptionPage::OnOptionChanged
|
|
/// @0x004F27D0</c>: disabled when <see cref="Changed"/> is false, enabled
|
|
/// otherwise; Defaults is NEVER gated — retail's override never fetches
|
|
/// its child id at all). Retail runs this as the LAST statement of all
|
|
/// three verbs (<c>0x004F2C95</c> Apply, <c>0x004F2CE5</c> Defaults,
|
|
/// <c>0x004F2D4A</c> Reset); a live user edit
|
|
/// (<see cref="IOptionRow.AttachPageNotify"/>'s
|
|
/// <c>SetCurrentValue</c>-only path) also reaches it directly via
|
|
/// <c>UIOption::HandleDialogAndNotices @0x004EFB90</c>. OP4-6 bind
|
|
/// buttons to this seam; mechanism review S2, 2026-08-11 fix round.
|
|
/// </summary>
|
|
public Action? OnOptionChanged { get; set; }
|
|
|
|
/// <summary>Registers one row. Retail's <c>OptionPage::RegisterOption
|
|
/// @0x004F2E90</c>, called from each page's <c>InitOptions</c>.</summary>
|
|
public void Register(IOptionRow row)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(row);
|
|
row.AttachPageNotify(() => OnOptionChanged?.Invoke());
|
|
_rows.Add(row);
|
|
}
|
|
|
|
/// <summary><c>OptionPage::Changed @0x004F2D60</c>: true if ANY
|
|
/// registered row's own <see cref="IOptionRow.Changed"/> is true.</summary>
|
|
public bool Changed => _rows.Any(static row => row.Changed);
|
|
|
|
/// <summary><c>OptionPage::SaveCurrentValues @0x004F2C60</c> — Apply:
|
|
/// commits every row's baseline unconditionally, flushes via
|
|
/// <see cref="AfterApply"/>, then re-evaluates <see cref="OnOptionChanged"/>.</summary>
|
|
public void Apply()
|
|
{
|
|
foreach (IOptionRow row in _rows)
|
|
row.SaveCurrentValue();
|
|
AfterApply?.Invoke();
|
|
OnOptionChanged?.Invoke();
|
|
}
|
|
|
|
/// <summary><c>OptionPage::RestoreSavedValues @0x004F2D00</c> — Reset:
|
|
/// reverts only the rows that are currently <see cref="IOptionRow.Changed"/>,
|
|
/// then re-evaluates <see cref="OnOptionChanged"/>.
|
|
/// Snapshotted before iterating so a row's own revert (which flips
|
|
/// <see cref="IOptionRow.Changed"/> back to false) cannot skip a later
|
|
/// row.</summary>
|
|
public void Reset()
|
|
{
|
|
foreach (IOptionRow row in _rows.Where(static row => row.Changed).ToArray())
|
|
row.RestoreSavedValue();
|
|
OnOptionChanged?.Invoke();
|
|
}
|
|
|
|
/// <summary><c>OptionPage::RestoreDefaultValues @0x004F2CB0</c> —
|
|
/// Defaults: restores every row unconditionally, live, without
|
|
/// committing, then re-evaluates <see cref="OnOptionChanged"/>.</summary>
|
|
public void Defaults()
|
|
{
|
|
foreach (IOptionRow row in _rows)
|
|
row.RestoreDefaultValue();
|
|
OnOptionChanged?.Invoke();
|
|
}
|
|
|
|
/// <summary><c>PlayerOptionPage::OnVisibilityChanged(true)</c> — the page
|
|
/// became visible (initial default tab, a tab switch in, or the window
|
|
/// (re)opening): applies + commits, same as <see cref="Apply"/>.</summary>
|
|
public void OnShown() => Apply();
|
|
|
|
/// <summary><c>PlayerOptionPage::OnVisibilityChanged(false)</c> — the page
|
|
/// became hidden (a tab switch away, or the window closing): reverts
|
|
/// uncommitted edits, same as <see cref="Reset"/>.</summary>
|
|
public void OnHidden() => Reset();
|
|
}
|