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

@ -129,6 +129,17 @@ internal interface IRuntimeSettingsTargets
{
void ApplyDisplayWindowState(DisplaySettings display);
/// <summary>
/// Campaign OP slice OP6 (2026-08-11): pushes the CURRENT audio
/// snapshot into the live <c>OpenAlAudioEngine</c> — the SAME
/// <c>RuntimeSettingsStartupTargets.ApplyAudio</c> static helper the
/// startup path already used, now also reachable on every
/// <c>SaveAudio</c>. Before this, an Audio-tab change never took
/// effect until the next process launch; the mechanism already
/// existed, it was just never invoked outside startup.
/// </summary>
void ApplyAudio(AudioSettings audio);
void ApplyQuality(QualitySettings quality);
void ApplyUiLock(bool locked);
@ -485,7 +496,13 @@ internal sealed class RuntimeSettingsController :
_runtimeTargets?.ApplyQuality(resolved);
}
private void SaveDisplay(DisplaySettings display)
/// <summary>Widened from private to public at Campaign OP slice OP6 —
/// same shape as the already-public <see cref="SaveCameraTurning"/>,
/// now also called directly by <c>ConfigOptionsPageController</c>'s
/// Display-backed rows (resolution/fullscreen/vsync/FOV/gamma/quality
/// family) rather than only through the (soon-retired) SettingsVM
/// callback wiring.</summary>
public void SaveDisplay(DisplaySettings display)
{
try
{
@ -501,12 +518,20 @@ internal sealed class RuntimeSettingsController :
}
}
private void SaveAudio(AudioSettings audio)
/// <summary>Widened from private to public at Campaign OP slice OP6,
/// same reason as <see cref="SaveDisplay"/>. Also now pushes the saved
/// snapshot into the live engine via
/// <see cref="IRuntimeSettingsTargets.ApplyAudio"/> — previously this
/// method only persisted; Audio-tab changes took effect on the NEXT
/// launch only. OP6's Sound-trio sliders/toggles are the first live
/// consumer.</summary>
public void SaveAudio(AudioSettings audio)
{
try
{
_storage.SaveAudio(audio);
Audio = audio;
_runtimeTargets?.ApplyAudio(audio);
_log($"settings: audio saved to {_storage.Location}");
}
catch (Exception ex)
@ -533,7 +558,12 @@ internal sealed class RuntimeSettingsController :
}
}
private void SaveChat(ChatSettings chat)
/// <summary>Widened from private to public at Campaign OP slice OP6,
/// same reason as <see cref="SaveDisplay"/> — the Config tab's Chat
/// Font Face/Size rows (store-only, no diff against the five wired
/// Hear*Chat fields) reuse this exact seam rather than a parallel
/// save path.</summary>
public void SaveChat(ChatSettings chat)
{
ChatSettings previous = Chat;
try

View file

@ -136,8 +136,16 @@ internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTar
if (engine is not { IsAvailable: true })
return;
engine.MasterVolume = audio.Master;
engine.SfxVolume = audio.Sfx;
engine.AmbientVolume = audio.Ambient;
// Campaign OP slice OP6: the Config tab's toggle halves of the
// Sound/Ambient volume trios (Sound_SoundDisabled/
// Sound_AmbientSoundDisabled) gate the SAME slider value — retail's
// UIOption_CheckboxSlider is one row over two preferences, and a
// checked "Disable Sound Effects" LED mutes the category regardless
// of what the slider itself is set to. No engine-level "disabled"
// concept is needed: the effective volume sent IS zero when
// disabled, exactly as if the user dragged the slider to zero.
engine.SfxVolume = audio.SfxDisabled ? 0f : audio.Sfx;
engine.AmbientVolume = audio.AmbientDisabled ? 0f : audio.Ambient;
}
}
@ -240,6 +248,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
private readonly IRuntimeChatOpacityTarget _chatOpacity;
private readonly ICommandBus _commands;
private readonly Action<string> _log;
private readonly OpenAlAudioEngine? _audio;
public RuntimeSettingsTargets(
IRuntimeDisplayWindowTarget displayWindow,
@ -250,7 +259,12 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
UiRoot? uiRoot,
ICommandBus commands,
RetailWindowOpacityController? chatOpacity = null,
Action<string>? log = null)
Action<string>? log = null,
// Campaign OP slice OP6: the live engine reference — see
// ApplyAudio's doc. Optional/trailing so every pre-existing
// construction site keeps compiling unchanged (matches
// chatOpacity/log's own optional-trailing shape).
OpenAlAudioEngine? audio = null)
: this(
displayWindow,
new RuntimeQualityApplicationTarget(
@ -265,7 +279,8 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
log,
chatOpacity is null
? NullRuntimeChatOpacityTarget.Instance
: new RuntimeChatOpacityTarget(chatOpacity))
: new RuntimeChatOpacityTarget(chatOpacity),
audio)
{
}
@ -275,7 +290,8 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
IRuntimeUiLockTarget uiLock,
ICommandBus commands,
Action<string>? log = null,
IRuntimeChatOpacityTarget? chatOpacity = null)
IRuntimeChatOpacityTarget? chatOpacity = null,
OpenAlAudioEngine? audio = null)
{
_displayWindow = displayWindow
?? throw new ArgumentNullException(nameof(displayWindow));
@ -284,11 +300,22 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
_chatOpacity = chatOpacity ?? NullRuntimeChatOpacityTarget.Instance;
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
_log = log ?? Console.WriteLine;
_audio = audio;
}
public void ApplyDisplayWindowState(DisplaySettings display) =>
_displayWindow.Apply(display);
/// <summary>Campaign OP slice OP6: reuses the SAME static helper the
/// startup path (<see cref="RuntimeSettingsStartupTargets.ApplyAudio"/>)
/// already runs — one mixer-apply implementation, two call sites (once
/// at process start, now also on every <c>RuntimeSettingsController.
/// SaveAudio</c>). <see langword="null"/> engine (a headless/no-audio
/// host) is a silent no-op, matching every other optional target in
/// this class.</summary>
public void ApplyAudio(AudioSettings audio) =>
RuntimeSettingsStartupTargets.ApplyAudio(_audio, audio);
public void ApplyQuality(QualitySettings quality)
{
_quality.SetAlphaToCoverage(quality.AlphaToCoverage);