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 readonly Func? _read; private readonly Action? _refresh; private Action? _notifyPageOptionChanged; private bool _current; private bool _saved; private bool _default; /// /// MUST-FIX 1 (OP4 review-fix round, 2026-08-11 — converged mechanism /// MF-1 / blast M1): retail's UIOption_Checkbox::GetValue /// @0x00486f60PlayerModule::GetOption, the LIVE /// server-synced option word, not a widget-local cache. Optional so /// every pre-existing non-DAT-backed caller (the synthetic pages in /// OptionPageModelTests) keeps working unchanged; when supplied, /// re-reads through it instead of /// trusting the row's own possibly-stale . /// /// /// Retail's own Refresh() push of the re-read value onto the /// widget (e.g. checkbox.Selected = value) — invoked ONLY from /// 's re-read path, never through /// , so a re-seed never sends the value back /// out over the wire. /// public BoolOptionRow( bool initial, bool defaultValue, Action? apply = null, Func? read = null, Action? refresh = null) { _current = initial; _saved = initial; _default = defaultValue; _apply = apply; _read = read; _refresh = refresh; } /// 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; /// /// MUST-FIX 1 (OP4 review-fix round, 2026-08-11): retail /// SaveCurrentValue @0x004868E0 is m_current = GetValue(); /// m_saved = m_current; — it re-reads the LIVE option word, not /// just m_saved = m_current over whatever m_current /// already held. calls /// , which calls this on every row — /// so every panel (re)open, tab switch in, and the initial default- /// tab activation self-corrects this row from the CURRENT server /// truth, exactly on retail's own schedule. When no read /// delegate was supplied (the synthetic non-DAT-backed test pages), /// this degrades to the pre-fix m_saved = m_current shape. /// public void SaveCurrentValue() { if (_read is not null) { _current = _read(); _refresh?.Invoke(_current); } _saved = _current; } public void RestoreSavedValue() { _current = _saved; _apply?.Invoke(_current); } public void RestoreDefaultValue() { _current = _default; _apply?.Invoke(_current); } } /// /// Campaign OP slice OP5: the Chat tab's two linked opacity sliders' leaf — /// UIOption_Slider's current/saved/default triple over a , /// same shape as (research doc §3.3's leaf semantics apply /// identically; retail's UIOption_Slider shares the same base UIOption /// verbs as UIOption_Checkbox, just over a float value). /// is what dragging the slider thumb runs — retail's SetCurrentValue → Apply(1) /// applies LIVE, immediately, on every drag tick (not just mouse-up), which is what /// makes the two Chat-tab sliders visibly fade windows WHILE dragging rather than only /// on release. /// public sealed class FloatOptionRow : IOptionRow { private readonly Action? _apply; private readonly Func? _read; private readonly Action? _refresh; private Action? _notifyPageOptionChanged; private float _current; private float _saved; private float _default; /// Re-reads the LIVE value on /// (OnShown/Apply) — same MUST-FIX 1 discipline as 's /// own read parameter. /// Pushes a re-read/linked value onto the slider widget /// WITHOUT invoking — used by both the OnShown re-seed /// AND by (the linked slider's own drag moving THIS /// row's value as a side effect, per ChatOpacityLink's never-clamp-always- /// drag-the-other invariant). public FloatOptionRow( float initial, float defaultValue, Action? apply = null, Func? read = null, Action? refresh = null) { _current = initial; _saved = initial; _default = defaultValue; _apply = apply; _read = read; _refresh = refresh; } /// The live value — what the slider thumb currently shows. public float Current => _current; /// The committed baseline Reset reverts to. public float Saved => _saved; /// The value Defaults restores (the DAT DBProperties-extracted /// slider default for the Chat tab's two sliders — U1's /// InqDefaultGameplayOptionProperty mechanism, this tab's genuine /// consumer). public float DefaultValue => _default; public bool Changed => _saved != _current; public void SetDefaultValue(float value) => _default = value; /// Retail SetCurrentValue — the drag-tick entry point. Writes /// m_current and applies it live IMMEDIATELY; does not touch /// . public void SetCurrentValue(float value) { _current = value; _apply?.Invoke(value); _notifyPageOptionChanged?.Invoke(); } /// /// The LINKED slider's own drag already ran ChatOpacityLink's math and /// applied the new pair live (a single RetailWindowOpacityController call /// covers both values) — this pushes the resulting value onto THIS row/widget /// without re-applying (that would double-fire the live opacity write) but still /// notifies the page, since the OTHER slider's Changed baseline may now /// differ too (retail's DualHash link moves both sliders' own /// m_current). /// public void RefreshFromLink(float value) { _current = value; _refresh?.Invoke(value); _notifyPageOptionChanged?.Invoke(); } public void AttachPageNotify(Action notify) => _notifyPageOptionChanged = notify; public void SaveCurrentValue() { if (_read is not null) { _current = _read(); _refresh?.Invoke(_current); } _saved = _current; } public void RestoreSavedValue() { _current = _saved; _apply?.Invoke(_current); } public void RestoreDefaultValue() { _current = _default; _apply?.Invoke(_current); } } /// /// Campaign OP slice OP5: one of the Chat tab's five per-window text-filter /// UIOption_CheckboxBitfield64 blocks — the current/saved/default triple over /// the block's combined 64-bit filter value (retail's genuine one-register /// m_llTextTypeFilter shape; see UiCheckboxBitfield64's own class doc for /// why the widget's CurrentLow/CurrentHigh pair is just that ONE 64-bit /// value split at the 32-bit boundary, not a 128-bit value). Unlike /// /, the WIDGET already IS the /// live UI for N individual checkboxes and fires /// on any row toggle — this row wraps that composite value the same way a page needs, /// translating widget-originated edits into / /// bindings.SetFilter and page-originated reverts (Reset/Defaults/OnShown) into /// pushes. /// public sealed class BitfieldOptionRow : IOptionRow { private readonly Action? _apply; private readonly Func? _read; private readonly Action? _refresh; private Action? _notifyPageOptionChanged; private ulong _current; private ulong _saved; private ulong _default; public BitfieldOptionRow( ulong initial, ulong defaultValue, Action? apply = null, Func? read = null, Action? refresh = null) { _current = initial; _saved = initial; _default = defaultValue; _apply = apply; _read = read; _refresh = refresh; } public ulong Current => _current; public ulong Saved => _saved; public ulong DefaultValue => _default; public bool Changed => _saved != _current; public void SetDefaultValue(ulong value) => _default = value; /// The widget-originated entry point — wired to /// , fired the instant a row /// checkbox toggles (retail's own per-checkbox Apply(1)). public void SetCurrentValue(ulong value) { _current = value; _apply?.Invoke(value); _notifyPageOptionChanged?.Invoke(); } public void AttachPageNotify(Action notify) => _notifyPageOptionChanged = notify; public void SaveCurrentValue() { if (_read is not null) { _current = _read(); _refresh?.Invoke(_current); } _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(); /// /// OP4 re-review R2 (2026-08-11): a fresh PlayerDescription seed /// replaced the live option words while this page may be VISIBLE — /// re-read every row's (current, saved) from the live source WITHOUT /// 's flush (the seed just cleared the dirty module; /// there is nothing to flush, and an publication /// here would be spurious). Retail cannot reach this state — its panels /// are closed during login/reconnect — so this adaptation exists only /// because acdream's retained panels survive the session boundary; the /// stale (current, saved) it clears would otherwise let Reset restore /// pre-reconnect values over the new character's server truth. /// public void ReloadFromLive() { foreach (IOptionRow row in _rows) row.SaveCurrentValue(); OnOptionChanged?.Invoke(); } /// PlayerOptionPage::OnVisibilityChanged(false) — the page /// became hidden (a tab switch away, or the window closing): reverts /// uncommitted edits, same as . public void OnHidden() => Reset(); }