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
/// @0x00486f60 — PlayerModule::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();
}