using System;
using System.Collections.Generic;
using System.Linq;
namespace AcDream.App.UI.Layout;
///
/// One retail OptionPage/PlayerOptionPage row: a
/// (m_current, m_saved, m_default) triple plus the three verbs a row
/// leaf implements (UIOption_Checkbox is the canonical case — Campaign
/// OP slices OP4-6 add slider/menu/bitfield leaves behind the same shape).
/// Retail anchors: docs/research/2026-08-10-options-panel-structure.md
/// §3.3 (UIOption_Checkbox::Changed @0x004868C0,
/// SaveCurrentValue @0x004868E0, RestoreSavedValue @0x00486900,
/// RestoreDefaultValue @0x00486930, SetCurrentValue @0x00486970).
///
public interface IOptionRow
{
/// UIOption_Checkbox::Changed: m_saved != m_current.
bool Changed { get; }
/// SaveCurrentValue: m_saved = m_current. No live
/// side effect — the value is already live (every mutator below applies
/// immediately).
void SaveCurrentValue();
/// RestoreSavedValue: m_current = m_saved, then
/// applies the reverted value live.
void RestoreSavedValue();
/// RestoreDefaultValue: m_current = m_default,
/// then applies the default live.
void RestoreDefaultValue();
///
/// Wires this row's owning-page notify hook — retail's
/// UIOption::m_pOCH (option-change-handler) pointer, invoked by
/// UIOption::HandleDialogAndNotices @0x004EFB90's
/// m_pOCH->OnOptionChanged(this) call after a LIVE user edit
/// (a widget's own SetCurrentValue/Apply(1) path ONLY —
/// RestoreSavedValue/RestoreDefaultValue use retail's
/// Apply(0), which skips this per-row notify because the OWNING
/// VERB (/)
/// already calls once itself,
/// at its own tail). Called once by ;
/// mechanism review S2, 2026-08-11 fix round.
///
void AttachPageNotify(Action notify);
}
///
/// The canonical retail leaf — UIOption_Checkbox's current/saved/default
/// triple over a . is what a
/// user's LED click runs: it writes m_current and applies it live
/// IMMEDIATELY (retail's SetCurrentValue @0x00486970 calls
/// Apply(1) synchronously) — Apply/Reset/Defaults never gate this; they
/// only move the m_saved/m_default baselines and re-apply.
///
public sealed class BoolOptionRow : IOptionRow
{
private readonly Action? _apply;
private Action? _notifyPageOptionChanged;
private bool _current;
private bool _saved;
private bool _default;
public BoolOptionRow(bool initial, bool defaultValue, Action? apply = null)
{
_current = initial;
_saved = initial;
_default = defaultValue;
_apply = apply;
}
/// The live value — what the LED currently shows.
public bool Current => _current;
/// The committed baseline Reset reverts to.
public bool Saved => _saved;
/// The value Defaults restores. Mutable via
/// — retail's SetDefaultValue is
/// authored per-row in each page's InitOptions, sometimes from a
/// DAT-resolved default rather than a compile-time literal (OP4's
/// Character-tab U1 closure).
public bool DefaultValue => _default;
public bool Changed => _saved != _current;
/// Retail UIOption_Checkbox::SetDefaultValue @0x00486960.
public void SetDefaultValue(bool value) => _default = value;
///
/// Retail SetCurrentValue @0x00486970 — the LED-click entry point.
/// Writes m_current and applies it live immediately; does NOT
/// touch (Apply is the only verb that commits). Also
/// notifies the owning page () — retail's
/// Apply(1)-only HandleDialogAndNotices path.
///
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);
}
}
///
/// Retail OptionPage/PlayerOptionPage: a page's registered-option
/// array plus the four verbs (Apply/Reset/Defaults/visibility) with retail's
/// EXACT semantics — docs/research/2026-08-10-options-panel-structure.md
/// §3 and §10.4:
///
///
/// - Clicking an LED applies immediately
/// ( → Apply(1)). Apply and
/// Reset operate on an undo baseline, not a staging buffer.
/// - commits EVERY row's baseline
/// unconditionally (OptionPage::SaveCurrentValues @0x004F2C60 — no
/// Changed gate), then flushes the batched character-options blob via
/// (PlayerOptionPage::SaveCurrentValues
/// @0x004F2710's CPlayerModule::SaveToServer(0) tailcall).
/// - reverts only rows whose
/// is true
/// (OptionPage::RestoreSavedValues @0x004F2D00).
/// - restores every row unconditionally,
/// live, WITHOUT committing — can go true
/// afterward, re-enabling Apply/Reset
/// (OptionPage::RestoreDefaultValues @0x004F2CB0).
/// - (page becomes invisible — a tab
/// switch away, or the window closing) reverts uncommitted edits exactly like
/// Reset (PlayerOptionPage::OnVisibilityChanged(false) @0x004F26E0 →
/// RestoreSavedValues).
/// - (page becomes visible — the
/// initial default tab, a tab switch in, or the window (re)opening) applies +
/// commits exactly like Apply (OnVisibilityChanged(true) →
/// SaveCurrentValues, which ALSO flushes the blob via
/// ).
///
///
///
/// An empty page (zero registered rows) makes every verb a no-op and
/// permanently false; when IS
/// wired, it still fires on / even
/// with zero rows (retail's SaveCurrentValues flushes the blob
/// regardless of whether THIS page's own rows changed anything — the
/// module's dirty flag is global, not per-page). This is a property of
/// the generic empty-page shape, not a description of the Gameplay tab.
/// gmGameplayOptionsUI (acclient.h:55857) derives from
/// UIElement_Field, not OptionPage/PlayerOptionPage at
/// all, so retail never calls SaveCurrentValues for it in the first
/// place — deliberately
/// constructs the Gameplay slot's instance with
/// left null so entering/leaving that tab
/// never flushes (mechanism review S1, 2026-08-11 fix round).
///
///
public sealed class OptionPage
{
private readonly List _rows = new();
public IReadOnlyList Rows => _rows;
///
/// Invoked after every (button click OR
/// ) — the seam a controller wires to the batched
/// SaveOptions/blob-flush command. Never invoked by
/// or (retail's Reset/Defaults
/// call Apply(0) per-row and reach
/// directly — they never reach PlayerOptionPage::SaveCurrentValues,
/// so they never flush).
///
public Action? AfterApply { get; set; }
///
/// Retail OptionPage::OnOptionChanged(0) — the ONLY thing that
/// enable-gates Apply/Reset (PlayerOptionPage::OnOptionChanged
/// @0x004F27D0: disabled when 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 (0x004F2C95 Apply, 0x004F2CE5 Defaults,
/// 0x004F2D4A Reset); a live user edit
/// ('s
/// SetCurrentValue-only path) also reaches it directly via
/// UIOption::HandleDialogAndNotices @0x004EFB90. OP4-6 bind
/// buttons to this seam; mechanism review S2, 2026-08-11 fix round.
///
public Action? OnOptionChanged { get; set; }
/// Registers one row. Retail's OptionPage::RegisterOption
/// @0x004F2E90, called from each page's InitOptions.
public void Register(IOptionRow row)
{
ArgumentNullException.ThrowIfNull(row);
row.AttachPageNotify(() => OnOptionChanged?.Invoke());
_rows.Add(row);
}
/// OptionPage::Changed @0x004F2D60: true if ANY
/// registered row's own is true.
public bool Changed => _rows.Any(static row => row.Changed);
/// OptionPage::SaveCurrentValues @0x004F2C60 — Apply:
/// commits every row's baseline unconditionally, flushes via
/// , then re-evaluates .
public void Apply()
{
foreach (IOptionRow row in _rows)
row.SaveCurrentValue();
AfterApply?.Invoke();
OnOptionChanged?.Invoke();
}
/// OptionPage::RestoreSavedValues @0x004F2D00 — Reset:
/// reverts only the rows that are currently ,
/// then re-evaluates .
/// Snapshotted before iterating so a row's own revert (which flips
/// back to false) cannot skip a later
/// row.
public void Reset()
{
foreach (IOptionRow row in _rows.Where(static row => row.Changed).ToArray())
row.RestoreSavedValue();
OnOptionChanged?.Invoke();
}
/// OptionPage::RestoreDefaultValues @0x004F2CB0 —
/// Defaults: restores every row unconditionally, live, without
/// committing, then re-evaluates .
public void Defaults()
{
foreach (IOptionRow row in _rows)
row.RestoreDefaultValue();
OnOptionChanged?.Invoke();
}
/// PlayerOptionPage::OnVisibilityChanged(true) — the page
/// became visible (initial default tab, a tab switch in, or the window
/// (re)opening): applies + commits, same as .
public void OnShown() => Apply();
/// PlayerOptionPage::OnVisibilityChanged(false) — the page
/// became hidden (a tab switch away, or the window closing): reverts
/// uncommitted edits, same as .
public void OnHidden() => Reset();
}