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:
parent
5242de9f15
commit
9d26ecc623
27 changed files with 25696 additions and 8 deletions
214
src/AcDream.App/UI/Layout/OptionPageModel.cs
Normal file
214
src/AcDream.App/UI/Layout/OptionPageModel.cs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
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>
|
||||
/// 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 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).
|
||||
/// </summary>
|
||||
public void SetCurrentValue(bool value)
|
||||
{
|
||||
_current = value;
|
||||
_apply?.Invoke(value);
|
||||
}
|
||||
|
||||
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 — the Gameplay tab's own model, which
|
||||
/// has no <c>UIOption</c> rows at all per research doc §6) makes every verb a
|
||||
/// no-op and <see cref="Changed"/> permanently false; <see cref="AfterApply"/>
|
||||
/// still fires on <see cref="Apply"/>/<see cref="OnShown"/> (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).
|
||||
/// </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 re-run <c>OnOptionChanged(0)</c>
|
||||
/// directly — they never reach <c>PlayerOptionPage::SaveCurrentValues</c>,
|
||||
/// so they never flush).
|
||||
/// </summary>
|
||||
public Action? AfterApply { 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);
|
||||
_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, then flushes via
|
||||
/// <see cref="AfterApply"/>.</summary>
|
||||
public void Apply()
|
||||
{
|
||||
foreach (IOptionRow row in _rows)
|
||||
row.SaveCurrentValue();
|
||||
AfterApply?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary><c>OptionPage::RestoreSavedValues @0x004F2D00</c> — Reset:
|
||||
/// reverts only the rows that are currently <see cref="IOptionRow.Changed"/>.
|
||||
/// 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();
|
||||
}
|
||||
|
||||
/// <summary><c>OptionPage::RestoreDefaultValues @0x004F2CB0</c> —
|
||||
/// Defaults: restores every row unconditionally, live, without
|
||||
/// committing.</summary>
|
||||
public void Defaults()
|
||||
{
|
||||
foreach (IOptionRow row in _rows)
|
||||
row.RestoreDefaultValue();
|
||||
}
|
||||
|
||||
/// <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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue