feat(ui): Campaign OP slice OP6 — the Config tab

Binds the retail Options panel's Config tab (LayoutDesc 0x21000029, 27
authored rows across 6 sections) through OP2's template mechanism and
OP3's per-page OptionPage model, matching the Character/Chat tab
controllers' established pattern.

The row table is transcribed directly from two decompiled sources —
gmConfigUI::InitOptions @0x0049E400 (row order, widget shape, defaults)
and gmClient::InitUIPreferences @0x004035b0 (the complete
UIPreferences::AttachPreference registration: every label/tooltip key,
every slider's real-unit range, every menu's enum choices) — which
resolves the research docs' own "U4" unverified slider-caption pairing:
retail ships ZERO range captions on this tab (every SetSliderLabel call
passes literal string id 0).

Consumer disposition: LIVE — Sound/Ambient volume-trio sliders and their
toggle halves (AudioSettings.SfxDisabled/AmbientDisabled now gate the
already-live engine write; RuntimeSettingsController.SaveAudio newly
pushes into OpenAlAudioEngine on every change, not just at startup),
Resolution/Full Screen (immediate window resize on save). NEXT-LAUNCH
(pre-existing precedent): Sync To Refresh, Field of View. STORE-ONLY
(register rows AP-198/199/200, TS-74 extended): Sound Features/Interface
trio/Play-Only-When-Active, the nine Graphics/Rendering-Quality rows
(Vulkan has no per-feature render knobs), Camera/Input's six rows and
Use Mouse Turning (no persistent mouse-turning camera mode), Chat Font
Face/Size (distinct new fields from the existing live ChatSettings.FontSize).

AudioSettings/DisplaySettings/CameraTurningSettings/ChatSettings each
gain new fields for their slice of the 27 rows, backed by SettingsStore
round-trips. A real bug caught by testing: the scrollbar scope lookup
used the standalone-layout root id (0x100001FF), which does not survive
base-merge into the host-mounted tree — fixed to scope from the tab
host's own page-slot id (0x10000213), matching Chat's established
pattern for the same shared-scrollbar-id hazard (0x10000201, authored by
both the Chat and Config ListBoxes).

30 new tests (27 authored rows register as 30 IOptionRow instances — the
three toggle+slider trios each register two). Full Release suite:
13,107 passed / 4 skipped / 0 failed (was 13,083/4/0 — net +24, the one
existing RuntimeSettingsControllerTests case updated for SaveAudio's new
live-apply call, not a regression).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 07:16:35 +02:00
parent 527a3a4056
commit f5ac1742ba
16 changed files with 2188 additions and 30 deletions

View file

@ -279,6 +279,159 @@ public sealed class FloatOptionRow : IOptionRow
}
}
/// <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