acdream/src/AcDream.App/UI/Layout/OptionPageModel.cs
Erik ac0304dcf0 fix(ui,runtime): OP4 re-review residuals R1-R4 (coordinator pass) — OP4 CLOSED
R1: the timestamp prefix moves from ChatLog.Append (which stamped the
stored BODY, rendering 'Alice says, "13:05:09 hi"') to ChatVM's display
composition — FormatTimestampPrefix(entry.Received) prepends the COMPOSED
line, matching retail's separate-leading-string model (fprintf("%ls%ls",
ts, text) @0x00563e5b; AddTextToScroll receives composed lines). The
prefix renders entry.Received in LOCAL time (retail strftime), invariant
literal colons. The ten defect-pinning test cases across
ChatLogTests/RuntimeCommunicationStateTests are rewritten to pin the
corrected contract (stored bodies stay clean; the composed line carries
the stamp outside the quotes — ChatVMTests).

R2: open option-bearing panels converge on every PlayerDescription seed:
OptionPage.ReloadFromLive (per-row live re-read + gating re-eval, NO
AfterApply flush — the seed just cleared the dirty module),
OptionsPanelController.OnServerOptionsSeeded (active page),
CombatUiController.OnServerOptionsSeeded (SyncControls), wired through
RuntimeSettingsController.ServerOptionsSeeded from the same factory hook
LockUI already uses. Retail cannot reach this state (its panels close
across login); the adaptation exists because retained panels survive the
session boundary — documented at the seam.

R3: tests drive the refresh widget push (model AND checkbox converge) and
ReloadFromLive's no-flush contract. R4: AP-196 addendum names the
headless AutoRepeatAttack false->true effective-default flip and the
characterOptions escape hatch.

Full Release suite: 13,083 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 06:36:14 +02:00

520 lines
22 KiB
C#

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>
/// Wires this row's owning-page notify hook — retail's
/// <c>UIOption::m_pOCH</c> (option-change-handler) pointer, invoked by
/// <c>UIOption::HandleDialogAndNotices @0x004EFB90</c>'s
/// <c>m_pOCH-&gt;OnOptionChanged(this)</c> call after a LIVE user edit
/// (a widget's own <c>SetCurrentValue</c>/<c>Apply(1)</c> path ONLY —
/// <c>RestoreSavedValue</c>/<c>RestoreDefaultValue</c> use retail's
/// <c>Apply(0)</c>, which skips this per-row notify because the OWNING
/// VERB (<see cref="OptionPage.Reset"/>/<see cref="OptionPage.Defaults"/>)
/// already calls <see cref="OptionPage.OnOptionChanged"/> once itself,
/// at its own tail). Called once by <see cref="OptionPage.Register"/>;
/// mechanism review S2, 2026-08-11 fix round.
/// </summary>
void AttachPageNotify(Action notify);
}
/// <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 readonly Func<bool>? _read;
private readonly Action<bool>? _refresh;
private Action? _notifyPageOptionChanged;
private bool _current;
private bool _saved;
private bool _default;
/// <param name="read">
/// MUST-FIX 1 (OP4 review-fix round, 2026-08-11 — converged mechanism
/// MF-1 / blast M1): retail's <c>UIOption_Checkbox::GetValue
/// @0x00486f60</c> — <c>PlayerModule::GetOption</c>, the LIVE
/// server-synced option word, not a widget-local cache. Optional so
/// every pre-existing non-DAT-backed caller (the synthetic pages in
/// <c>OptionPageModelTests</c>) keeps working unchanged; when supplied,
/// <see cref="SaveCurrentValue"/> re-reads through it instead of
/// trusting the row's own possibly-stale <see cref="_current"/>.
/// </param>
/// <param name="refresh">
/// Retail's own <c>Refresh()</c> push of the re-read value onto the
/// widget (e.g. <c>checkbox.Selected = value</c>) — invoked ONLY from
/// <see cref="SaveCurrentValue"/>'s re-read path, never through
/// <paramref name="apply"/>, so a re-seed never sends the value back
/// out over the wire.
/// </param>
public BoolOptionRow(
bool initial,
bool defaultValue,
Action<bool>? apply = null,
Func<bool>? read = null,
Action<bool>? refresh = null)
{
_current = initial;
_saved = initial;
_default = defaultValue;
_apply = apply;
_read = read;
_refresh = refresh;
}
/// <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). Also
/// notifies the owning page (<see cref="AttachPageNotify"/>) — retail's
/// <c>Apply(1)</c>-only <c>HandleDialogAndNotices</c> path.
/// </summary>
public void SetCurrentValue(bool value)
{
_current = value;
_apply?.Invoke(value);
_notifyPageOptionChanged?.Invoke();
}
public void AttachPageNotify(Action notify) => _notifyPageOptionChanged = notify;
/// <summary>
/// MUST-FIX 1 (OP4 review-fix round, 2026-08-11): retail
/// <c>SaveCurrentValue @0x004868E0</c> is <c>m_current = GetValue();
/// m_saved = m_current;</c> — it re-reads the LIVE option word, not
/// just <c>m_saved = m_current</c> over whatever <c>m_current</c>
/// already held. <see cref="OptionPage.OnShown"/> calls
/// <see cref="OptionPage.Apply"/>, 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 <c>read</c>
/// delegate was supplied (the synthetic non-DAT-backed test pages),
/// this degrades to the pre-fix <c>m_saved = m_current</c> shape.
/// </summary>
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);
}
}
/// <summary>
/// Campaign OP slice OP5: the Chat tab's two linked opacity sliders' leaf —
/// <c>UIOption_Slider</c>'s current/saved/default triple over a <see cref="float"/>,
/// same shape as <see cref="BoolOptionRow"/> (research doc §3.3's leaf semantics apply
/// identically; retail's <c>UIOption_Slider</c> shares the same base <c>UIOption</c>
/// verbs as <c>UIOption_Checkbox</c>, just over a float value). <see cref="SetCurrentValue"/>
/// is what dragging the slider thumb runs — retail's <c>SetCurrentValue → Apply(1)</c>
/// 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.
/// </summary>
public sealed class FloatOptionRow : IOptionRow
{
private readonly Action<float>? _apply;
private readonly Func<float>? _read;
private readonly Action<float>? _refresh;
private Action? _notifyPageOptionChanged;
private float _current;
private float _saved;
private float _default;
/// <param name="read">Re-reads the LIVE value on <see cref="SaveCurrentValue"/>
/// (OnShown/Apply) — same MUST-FIX 1 discipline as <see cref="BoolOptionRow"/>'s
/// own <c>read</c> parameter.</param>
/// <param name="refresh">Pushes a re-read/linked value onto the slider widget
/// WITHOUT invoking <paramref name="apply"/> — used by both the OnShown re-seed
/// AND by <see cref="RefreshFromLink"/> (the linked slider's own drag moving THIS
/// row's value as a side effect, per <c>ChatOpacityLink</c>'s never-clamp-always-
/// drag-the-other invariant).</param>
public FloatOptionRow(
float initial,
float defaultValue,
Action<float>? apply = null,
Func<float>? read = null,
Action<float>? refresh = null)
{
_current = initial;
_saved = initial;
_default = defaultValue;
_apply = apply;
_read = read;
_refresh = refresh;
}
/// <summary>The live value — what the slider thumb currently shows.</summary>
public float Current => _current;
/// <summary>The committed baseline Reset reverts to.</summary>
public float Saved => _saved;
/// <summary>The value Defaults restores (the DAT <c>DBProperties</c>-extracted
/// slider default for the Chat tab's two sliders — U1's
/// <c>InqDefaultGameplayOptionProperty</c> mechanism, this tab's genuine
/// consumer).</summary>
public float DefaultValue => _default;
public bool Changed => _saved != _current;
public void SetDefaultValue(float value) => _default = value;
/// <summary>Retail <c>SetCurrentValue</c> — the drag-tick entry point. Writes
/// <c>m_current</c> and applies it live IMMEDIATELY; does not touch
/// <see cref="Saved"/>.</summary>
public void SetCurrentValue(float value)
{
_current = value;
_apply?.Invoke(value);
_notifyPageOptionChanged?.Invoke();
}
/// <summary>
/// The LINKED slider's own drag already ran <c>ChatOpacityLink</c>'s math and
/// applied the new pair live (a single <c>RetailWindowOpacityController</c> 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 <c>Changed</c> baseline may now
/// differ too (retail's <c>DualHash</c> link moves both sliders' own
/// <c>m_current</c>).
/// </summary>
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);
}
}
/// <summary>
/// Campaign OP slice OP5: one of the Chat tab's five per-window text-filter
/// <c>UIOption_CheckboxBitfield64</c> blocks — the current/saved/default triple over
/// the block's combined 64-bit filter value (retail's genuine one-register
/// <c>m_llTextTypeFilter</c> shape; see <c>UiCheckboxBitfield64</c>'s own class doc for
/// why the widget's <c>CurrentLow</c>/<c>CurrentHigh</c> pair is just that ONE 64-bit
/// value split at the 32-bit boundary, not a 128-bit value). Unlike
/// <see cref="BoolOptionRow"/>/<see cref="FloatOptionRow"/>, the WIDGET already IS the
/// live UI for N individual checkboxes and fires <see cref="UiCheckboxBitfield64.ValueChanged"/>
/// on any row toggle — this row wraps that composite value the same way a page needs,
/// translating widget-originated edits into <see cref="AttachPageNotify"/>/
/// <c>bindings.SetFilter</c> and page-originated reverts (Reset/Defaults/OnShown) into
/// <see cref="UiCheckboxBitfield64.SetCurrentValue"/> pushes.
/// </summary>
public sealed class BitfieldOptionRow : IOptionRow
{
private readonly Action<ulong>? _apply;
private readonly Func<ulong>? _read;
private readonly Action<ulong>? _refresh;
private Action? _notifyPageOptionChanged;
private ulong _current;
private ulong _saved;
private ulong _default;
public BitfieldOptionRow(
ulong initial,
ulong defaultValue,
Action<ulong>? apply = null,
Func<ulong>? read = null,
Action<ulong>? 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;
/// <summary>The widget-originated entry point — wired to
/// <see cref="UiCheckboxBitfield64.ValueChanged"/>, fired the instant a row
/// checkbox toggles (retail's own per-checkbox <c>Apply(1)</c>).</summary>
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);
}
}
/// <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) makes every verb a no-op and
/// <see cref="Changed"/> permanently false; when <see cref="AfterApply"/> IS
/// wired, it still fires on <see cref="Apply"/>/<see cref="OnShown"/> even
/// with zero rows (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). <b>This is a property of
/// the generic empty-page shape, not a description of the Gameplay tab.</b>
/// <c>gmGameplayOptionsUI</c> (<c>acclient.h:55857</c>) derives from
/// <c>UIElement_Field</c>, not <c>OptionPage</c>/<c>PlayerOptionPage</c> at
/// all, so retail never calls <c>SaveCurrentValues</c> for it in the first
/// place — <see cref="OptionsPanelController"/> deliberately
/// constructs the Gameplay slot's <see cref="OptionPage"/> instance with
/// <see cref="AfterApply"/> left <c>null</c> so entering/leaving that tab
/// never flushes (mechanism review S1, 2026-08-11 fix round).
/// </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 reach <see cref="OnOptionChanged"/>
/// directly — they never reach <c>PlayerOptionPage::SaveCurrentValues</c>,
/// so they never flush).
/// </summary>
public Action? AfterApply { get; set; }
/// <summary>
/// Retail <c>OptionPage::OnOptionChanged(0)</c> — the ONLY thing that
/// enable-gates Apply/Reset (<c>PlayerOptionPage::OnOptionChanged
/// @0x004F27D0</c>: disabled when <see cref="Changed"/> 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 (<c>0x004F2C95</c> Apply, <c>0x004F2CE5</c> Defaults,
/// <c>0x004F2D4A</c> Reset); a live user edit
/// (<see cref="IOptionRow.AttachPageNotify"/>'s
/// <c>SetCurrentValue</c>-only path) also reaches it directly via
/// <c>UIOption::HandleDialogAndNotices @0x004EFB90</c>. OP4-6 bind
/// buttons to this seam; mechanism review S2, 2026-08-11 fix round.
/// </summary>
public Action? OnOptionChanged { 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);
row.AttachPageNotify(() => OnOptionChanged?.Invoke());
_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, flushes via
/// <see cref="AfterApply"/>, then re-evaluates <see cref="OnOptionChanged"/>.</summary>
public void Apply()
{
foreach (IOptionRow row in _rows)
row.SaveCurrentValue();
AfterApply?.Invoke();
OnOptionChanged?.Invoke();
}
/// <summary><c>OptionPage::RestoreSavedValues @0x004F2D00</c> — Reset:
/// reverts only the rows that are currently <see cref="IOptionRow.Changed"/>,
/// then re-evaluates <see cref="OnOptionChanged"/>.
/// 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();
OnOptionChanged?.Invoke();
}
/// <summary><c>OptionPage::RestoreDefaultValues @0x004F2CB0</c> —
/// Defaults: restores every row unconditionally, live, without
/// committing, then re-evaluates <see cref="OnOptionChanged"/>.</summary>
public void Defaults()
{
foreach (IOptionRow row in _rows)
row.RestoreDefaultValue();
OnOptionChanged?.Invoke();
}
/// <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>
/// OP4 re-review R2 (2026-08-11): a fresh <c>PlayerDescription</c> seed
/// replaced the live option words while this page may be VISIBLE —
/// re-read every row's (current, saved) from the live source WITHOUT
/// <see cref="Apply"/>'s flush (the seed just cleared the dirty module;
/// there is nothing to flush, and an <see cref="AfterApply"/> 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.
/// </summary>
public void ReloadFromLive()
{
foreach (IOptionRow row in _rows)
row.SaveCurrentValue();
OnOptionChanged?.Invoke();
}
/// <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();
}