Ports retail's Configure Keyboard screen (gmKeyboardUI, LayoutDesc 0x21000009) — its own separate full-screen window, not a fifth Options- panel tab. Retires OP3's INERT contract for the Gameplay tab's Configure Keyboard button (0x10000204). DAT reader (src/AcDream.Core/Input/RetailActionMap.cs): reads the ActionMap singleton (DID 0x26000000, empirically the only one — not 0x27000000 as GetDBOType's Turbine-internal tag would suggest) and both MasterInputMap defaults (0x14000000 "gmDefaultMap"/0x14000002 "DefaultMap"), union-merged per (InputMapId, ActionId) — proven order- independent since the two maps' one shared context (0x5) has disjoint action-id sets. Empirically resolved three lane-D unknowns against the live DAT: the six ActionClass values (1=Movement, 2=Camera, 3=UI, 4=Combat, 5=Emote, 7=CharacterSettings — 6 is genuinely absent), that the six unnamed InputMaps are 100% non-bindable (render nothing, not an unlabeled group), and that the enum-to-DID pairing for the two master maps is inconsequential to the merge result. Identity table (src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs): maps DAT (InputMapId, ActionId) pairs to acdream's InputAction where a live consumer exists (~140 of 306 user-bindable rows — Movement/Camera/ Combat map almost completely; UI/Quickslot/Chat partially; only 5 of 87 Emotes and none of 48 CharacterSettings hotkeys, since acdream has no general emote player or hotkey-to-option-toggle dispatcher yet). Every entry cross-verified by label match AND a DAT-default-vs- KeyBindings.RetailDefaults() byte comparison (RetailActionIdentityRoundTripTests), which caught a real off-by-one in the Quickslot 13-18 block before it shipped and found three genuine pre-existing RetailDefaults() gaps (walk-mode's Shift-echoed chord, ten CameraAlternateControls arrow-key alternates, and the Quickslot Ctrl+N use-vs-select ambiguity) — none introduced by this slice, all documented rather than silently patched. KeyboardConfigController: six ActionClass list boxes built from the DAT, merged with live KeyBindings for mapped rows (rebind applies immediately through the same InputDispatcher every other input path uses) and a new sibling RetailUnmappedKeyBindings store for rows with no InputAction yet. Left-click a key button opens real InputDispatcher modal capture; right-click erases that slot. N-way conflict detection scans every other row plus the live KeyBindings table for acdream-only actions (Ctrl+M mute, debug F-keys) as the non-user-bindable refusal analogue, using retail's own byte-verified "Could not overwrite " string (table 0x23000004). OK/Cancel/Defaults/Revert reuse the OptionPage/IOptionRow verb model via a new ActionKeyMapOptionRow. Persistence is keybinds.json only (D4 — no .keymap file interchange). Five register rows: AP-202 (.keymap interchange narrowing), AP-203 (store-only rows with no live consumer), AP-204 (silent auto-reassign instead of retail's confirm dialog; OK/Cancel ported as left-click not right-click-release). Small supporting additions: UiButton.OnRightClick (additive, no existing behavior changed), InputDispatcher.Bindings getter (the screen's single live-truth read seam), RetailScanCodeMap (DIK scan code <-> Silk.NET Key, keyboard + the one mouse-device row). 19 new tests (6 ActionMap reader conformance incl. live-DAT row-count/ label pins, 1 DAT-vs-RetailDefaults round-trip, 12 controller behavior tests against the committed keyboard_config_21000009.json fixture) — full solution suite 13,147 passed / 4 skipped / 0 failed (baseline 13,128/4/0, zero regressions). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
747 lines
29 KiB
C#
747 lines
29 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AcDream.UI.Abstractions.Input;
|
|
|
|
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->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 OP6: a <c>UIOption_Menu</c> leaf's current/saved/default
|
|
/// triple over an <see cref="int"/> — the Config tab's menu rows (Sound
|
|
/// Features, Resolution, the four texture-detail-family selectors, Chat Font
|
|
/// Face/Size). Same shape as <see cref="BoolOptionRow"/>/<see cref="FloatOptionRow"/>
|
|
/// (research doc §3.3's leaf semantics apply identically — every
|
|
/// <c>UIOption</c> subclass shares the same base current/saved/default verbs).
|
|
/// <see cref="SetCurrentValue"/> is what picking a popup row runs — applies
|
|
/// live immediately, does not touch <see cref="Saved"/>.
|
|
/// </summary>
|
|
public sealed class IntOptionRow : IOptionRow
|
|
{
|
|
private readonly Action<int>? _apply;
|
|
private readonly Func<int>? _read;
|
|
private readonly Action<int>? _refresh;
|
|
private Action? _notifyPageOptionChanged;
|
|
private int _current;
|
|
private int _saved;
|
|
private int _default;
|
|
|
|
public IntOptionRow(
|
|
int initial,
|
|
int defaultValue,
|
|
Action<int>? apply = null,
|
|
Func<int>? read = null,
|
|
Action<int>? refresh = null)
|
|
{
|
|
_current = initial;
|
|
_saved = initial;
|
|
_default = defaultValue;
|
|
_apply = apply;
|
|
_read = read;
|
|
_refresh = refresh;
|
|
}
|
|
|
|
/// <summary>The live value — the menu's currently-selected payload.</summary>
|
|
public int Current => _current;
|
|
|
|
/// <summary>The committed baseline Reset reverts to.</summary>
|
|
public int Saved => _saved;
|
|
|
|
/// <summary>The value Defaults restores.</summary>
|
|
public int DefaultValue => _default;
|
|
|
|
public bool Changed => _saved != _current;
|
|
|
|
public void SetDefaultValue(int value) => _default = value;
|
|
|
|
public void SetCurrentValue(int 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>
|
|
/// Campaign OP slice OP6: a <c>UIOption_Menu</c> leaf's current/saved/default
|
|
/// triple over a <see cref="string"/> payload — used ONLY for the Config
|
|
/// tab's Resolution row, whose retail <c>UIOption_Menu::SetUserPreference</c>
|
|
/// path (built via <c>AddMenuOption(..., arg3=0)</c>, the tab's own
|
|
/// documented outlier) carries the resolution string itself rather than an
|
|
/// enum-choice index. Same current/saved/default shape as
|
|
/// <see cref="IntOptionRow"/>/<see cref="BoolOptionRow"/>.
|
|
/// </summary>
|
|
public sealed class StringOptionRow : IOptionRow
|
|
{
|
|
private readonly Action<string>? _apply;
|
|
private readonly Func<string>? _read;
|
|
private readonly Action<string>? _refresh;
|
|
private Action? _notifyPageOptionChanged;
|
|
private string _current;
|
|
private string _saved;
|
|
private string _default;
|
|
|
|
public StringOptionRow(
|
|
string initial,
|
|
string defaultValue,
|
|
Action<string>? apply = null,
|
|
Func<string>? read = null,
|
|
Action<string>? refresh = null)
|
|
{
|
|
_current = initial;
|
|
_saved = initial;
|
|
_default = defaultValue;
|
|
_apply = apply;
|
|
_read = read;
|
|
_refresh = refresh;
|
|
}
|
|
|
|
public string Current => _current;
|
|
public string Saved => _saved;
|
|
public string DefaultValue => _default;
|
|
public bool Changed => _saved != _current;
|
|
|
|
public void SetDefaultValue(string value) => _default = value;
|
|
|
|
public void SetCurrentValue(string 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>
|
|
/// 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>
|
|
/// Campaign OP slice OP8: one Configure Keyboard row's current/saved/default
|
|
/// triple — up to 3 <see cref="KeyChord"/> slots (<c>UIOption_ActionKeyMap</c>'s
|
|
/// <c>m_qclCurrent</c>/<c>m_qclSaved</c>/<c>m_qclDefaults</c>, research doc §5.4).
|
|
/// Unlike <see cref="BoolOptionRow"/>'s single scalar, <see cref="SetCurrentValue"/>
|
|
/// here replaces the WHOLE slot list at once — the controller computes the new list
|
|
/// (one slot rebound via capture, or one slot erased) and calls this with the
|
|
/// result, mirroring retail's per-slot <c>SetBinding</c>/<c>EraseBinding</c> both
|
|
/// funnelling through the same <c>UIOption::Apply(1)</c> live-write path.
|
|
/// </summary>
|
|
public sealed class ActionKeyMapOptionRow : IOptionRow
|
|
{
|
|
private readonly Action<IReadOnlyList<KeyChord>>? _apply;
|
|
private Action? _notifyPageOptionChanged;
|
|
private IReadOnlyList<KeyChord> _current;
|
|
private IReadOnlyList<KeyChord> _saved;
|
|
private IReadOnlyList<KeyChord> _default;
|
|
|
|
public ActionKeyMapOptionRow(
|
|
IReadOnlyList<KeyChord> initial,
|
|
IReadOnlyList<KeyChord> defaultValue,
|
|
Action<IReadOnlyList<KeyChord>>? apply = null)
|
|
{
|
|
_current = initial;
|
|
_saved = initial;
|
|
_default = defaultValue;
|
|
_apply = apply;
|
|
}
|
|
|
|
/// <summary>The live slot list — what the row's key buttons currently show.</summary>
|
|
public IReadOnlyList<KeyChord> Current => _current;
|
|
|
|
/// <summary>The committed baseline Revert/Cancel reverts to.</summary>
|
|
public IReadOnlyList<KeyChord> Saved => _saved;
|
|
|
|
/// <summary>The DAT master-map default slot list Reset-to-Defaults restores.</summary>
|
|
public IReadOnlyList<KeyChord> DefaultValue => _default;
|
|
|
|
public bool Changed => !_current.SequenceEqual(_saved);
|
|
|
|
/// <summary>Reset-to-Defaults reloads the DAT master maps fresh
|
|
/// (<c>gmKeyboardUI::RestoreDefaultValues</c> — research doc §5.6) before
|
|
/// restoring each row, so the default slot list itself can change between
|
|
/// presses (a fresh DAT read), not just at construction time.</summary>
|
|
public void SetDefaultValue(IReadOnlyList<KeyChord> value) => _default = value;
|
|
|
|
/// <summary>The capture/erase entry point — writes <c>m_current</c> and applies
|
|
/// it live immediately (retail's per-slot <c>SetBinding</c>/<c>EraseBinding</c>,
|
|
/// both ending in <c>Apply(1)</c>); does not touch <see cref="Saved"/>.</summary>
|
|
public void SetCurrentValue(IReadOnlyList<KeyChord> 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);
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
}
|