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(); }