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

@ -202,7 +202,18 @@ public sealed record OptionsRuntimeBindings(
// Campaign OP slice OP4 (2026-08-11): reads a live character-option
// bit by its linear id (RuntimeCharacterOptionsState.GetOptionBit) —
// the Character-tab panel's row-seed source.
Func<uint, bool> CurrentCharacterOption);
Func<uint, bool> CurrentCharacterOption,
// Campaign OP slice OP6 (2026-08-11): the Config tab's Display/Audio-
// backed rows. RuntimeSettingsController is the SOLE writer of these
// two sections (unlike Chat, which OP5 already routes through a raw
// SettingsStore side channel — Config's Chat Font Face/Size rows reuse
// THAT same store directly at the composition site instead of these
// two delegates, to avoid a stale-cache clobber of CH6's filter/opacity
// writes; see RetailUiRuntime.MountOptionsPanel).
Func<DisplaySettings> LoadDisplay,
Action<DisplaySettings> SaveDisplay,
Func<AudioSettings> LoadAudio,
Action<AudioSettings> SaveAudio);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
@ -2131,6 +2142,50 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[UI] options panel: Chat tab rows did not bind.");
}
// Campaign OP slice OP6 (2026-08-11): the Config tab's 6 headers +
// 27 rows. Same "runs before ActivateTabs, own dat-lock scope"
// shape as the Character/Chat blocks above.
lock (_bindings.Assets.DatLock)
{
var strings = new DatStringResolver(_bindings.Assets.Dats);
bool configBound = Layout.ConfigOptionsPageController.Bind(
layout,
controller.ConfigPage,
templateResolver: (templateLayoutId, templateElementId) =>
{
ElementInfo? info = LayoutImporter.ImportInfos(
_bindings.Assets.Dats, templateLayoutId, templateElementId);
return info is null
? null
: LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
},
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
new Layout.ConfigOptionsPageController.Bindings(
LoadDisplay: _bindings.Options.LoadDisplay,
SaveDisplay: _bindings.Options.SaveDisplay,
LoadAudio: _bindings.Options.LoadAudio,
SaveAudio: _bindings.Options.SaveAudio,
LoadCameraTurning: _bindings.Options.LoadCameraTurning,
SaveCameraTurning: _bindings.Options.SaveCameraTurning,
// Chat Font Face/Size route through the SAME raw
// SettingsStore side channel SaveChatWindowFilters/
// SaveChatOpacity already use — RuntimeSettingsController's
// cached ChatSettings is NOT refreshed by those direct
// writes, so reading/writing through it here would
// silently clobber CH6's filter/opacity edits with a
// stale snapshot the next time either surface saves.
LoadChat: () => _bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default,
SaveChat: chat => _bindings.Chat.Store?.SaveChat(chat)));
if (!configBound)
Console.WriteLine("[UI] options panel: Config tab rows did not bind.");
}
controller.ActivateTabs();
RetailWindowHandle handle = RetailWindowFrame.Mount(