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

@ -18,16 +18,56 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
/// who never opens the Audio tab gets identical behaviour to the
/// previous env-var-only world.
/// </para>
///
/// <para>
/// Campaign OP slice OP6 (2026-08-11): the six trailing fields are the
/// remaining Config-tab "Sound Options" rows, byte-verified from
/// <c>gmClient::InitUIPreferences @0x004035b0</c>
/// (<c>UIPreferences::AttachPreference</c> calls). <see cref="SfxDisabled"/>/
/// <see cref="AmbientDisabled"/> gate the EXISTING live <see cref="Sfx"/>/
/// <see cref="Ambient"/> knobs (<c>RuntimeSettingsStartupTargets.ApplyAudio</c>
/// sends 0 to the engine when disabled, else the slider value — retail's
/// own <c>Sound_SoundDisabled</c>/<c>Sound_AmbientSoundDisabled</c> toggle
/// halves of the SAME <c>UIOption_CheckboxSlider</c> row). The other four
/// fields are honest store-only round-trips (register row, OP6): retail
/// registers <c>Sound_InterfaceSoundVolume</c>/<c>Sound_InterfaceSoundDisabled</c>
/// and then never reads them either — AP-174 already documents "interface
/// sounds are scaled by the EFFECT knob" — so acdream matches retail's own
/// dead-knob behaviour rather than building a working one; <see cref="SoundFeatures"/>
/// (Stereo/Mono) and <see cref="PlaySoundOnlyWhenActive"/> have no acdream
/// mixer-channel-count or window-focus-mute consumer.
/// </para>
/// </summary>
public sealed record AudioSettings(
float Master,
float Sfx,
float Ambient)
float Ambient,
// OP6: Sound Options menu row — Sound_SoundFeatures (Stereo=0/Mono=1).
// Store-only: acdream's OpenAL backend has no channel-count toggle.
int SoundFeatures = 0,
// OP6: the toggle halves of the Sound/Ambient volume trios
// (Sound_SoundDisabled / Sound_AmbientSoundDisabled). LIVE — gate the
// existing Sfx/Ambient knobs (RuntimeSettingsStartupTargets.ApplyAudio).
bool SfxDisabled = true,
bool AmbientDisabled = true,
// OP6: the Interface Sound trio — retail's own dead knob (AP-174).
// Store-only.
bool InterfaceDisabled = true,
float InterfaceVolume = 1.0f,
// OP6: Sound_PlaySoundOnlyWhenActive — store-only, no window-focus
// mute subsystem exists.
bool PlaySoundOnlyWhenActive = true)
{
/// <summary>
/// Values used on first launch. Retail's own defaults are 1.0 for every
/// sound preference (<c>SoundManager::InitPrefs</c> @ <c>0x005503F0</c>),
/// so ambient starts at unity rather than the invented 0.8.
/// so ambient starts at unity rather than the invented 0.8. The six OP6
/// trailing fields default to retail's OWN byte-verified
/// <c>gmClient::InitUIPreferences</c> literals: <c>SetDefaultValue(1,
/// 0x3f800000)</c> on every trio (toggle=checked/"Disabled"=true,
/// slider=1.0) — retail's 2013 EoR build genuinely ships every Sound
/// category checkbox CHECKED (disabled) by default; ported faithfully,
/// not "fixed".
/// </summary>
public static AudioSettings Default { get; } = new(
Master: 1.0f,

View file

@ -21,14 +21,26 @@ public sealed record CameraTurningSettings(
float AdjustmentSpeed,
float MouseLookSensitivity,
bool AlignToSlope,
bool InvertMouseLookYAxis)
bool InvertMouseLookYAxis,
// Campaign OP slice OP6: Input_UseMouseTurning — the Config tab's OWN
// client-local UIPreference checkbox (gmClient::InitUIPreferences,
// AttachPreference(&Input_UseMouseTurning, 4, ...)), DISTINCT from the
// server-synced PlayerOption.UseMouseTurning bit the Gameplay tab's
// "Use Mouse Turning Settings" macro sends via SetSingleCharacterOption
// (0x0005) — that bit is NOT modeled here (MouseTurningSettingsMacro's
// own sixth target). Store-only, same TS-74 disposition as the five
// fields above: acdream has no persistent mouse-turning camera mode for
// EITHER preference to drive.
bool UseMouseTurning = false)
{
/// <summary>
/// Retail's ORDINARY Config-tab defaults (NOT the mouse-turning macro's
/// targets — <c>gmConfigUI::InitOptions @0x0049E400</c>, research doc §4):
/// Stiffness 0.45 (<c>0x3EE66666</c>), AdjustmentSpeed 40.0
/// (<c>0x42200000</c>), MouseLookSensitivity 0.55 (<c>0x3F0CCCCD</c>),
/// AlignToSlope on, InvertMouseLookYAxis off.
/// AlignToSlope on, InvertMouseLookYAxis off, UseMouseTurning off
/// (<c>gmConfigUI::InitOptions @0x0049E7AA</c>:
/// <c>AddToggleOption(this, &amp;Input_UseMouseTurning)-&gt;SetDefaultValue(0)</c>).
/// </summary>
public static CameraTurningSettings Default { get; } = new(
Stiffness: 0.45f,

View file

@ -91,7 +91,19 @@ public sealed record ChatSettings(
// retail 0.5-while-idle fade by default, but the Settings → Chat
// transparency slider remains fully user-settable (AP-190).
float DefaultOpacity = 1.0f,
float ActiveOpacity = 1.0f)
float ActiveOpacity = 1.0f,
// Campaign OP slice OP6: the Config tab's "UI Options" section —
// UI_ChatFontFace (retail Windows TrueType face name, enum choices
// starting "Arial") and UI_ChatFontSize (index into
// Tiny/Small/Medium/Large/XLarge, gmClient::InitUIPreferences
// @0x0040387b/@0x00403a1a). Deliberately NOT the same field as
// FontSize above: FontSize is acdream's own live 10..20pt render
// knob with no verified index-to-point mapping to retail's five-tier
// enum, and acdream's text rendering has no arbitrary system-font-face
// swap (DAT-baked/bitmap fonts only) — both new fields are honest
// store-only round-trips (register row, OP6).
int ChatFontFace = 2,
int ChatFontSizeIndex = 1)
{
/// <summary>
/// N4 (CH3 Opus review): matches ACE's ACTUAL

View file

@ -16,9 +16,19 @@ public enum ParticleRange
/// <summary>
/// Display-related preferences persisted to <c>settings.json</c>.
/// Modern addition (no retail equivalent for FOV / vsync etc) — replaces
/// the various <c>ACDREAM_*</c> environment variables for resolution +
/// windowed mode with an in-game UI.
/// Originally documented as "no retail equivalent for FOV / vsync etc" —
/// Campaign OP slice OP6's Config-tab research corrected that: retail's
/// <c>gmClient::InitUIPreferences @0x004035b0</c> DOES register
/// <c>Render_FieldOfView</c> (<c>ID_Graphics_FieldOfView</c>, range
/// [10,160]), <c>Display_SyncToRefresh</c>, <c>Display_Resolution</c>, and
/// <c>Render_ScreenBrightness</c> (range [-1,1], mapped onto
/// <see cref="Gamma"/> below) as genuine <c>UserPreferences.ini</c> rows —
/// they simply had no acdream UI surface until OP6's Config tab. Resolution/
/// Fullscreen are LIVE on save (<c>RuntimeSettingsTargets.
/// ApplyDisplayWindowState</c> resizes the window immediately); VSync/FOV/
/// Gamma apply at the next launch only (<c>RuntimeSettingsController.
/// ApplyStartup</c>), matching this record's pre-existing behaviour — OP6
/// did not change when these three take effect, only how they're reached.
///
/// <para>
/// Records are immutable; mutation goes through
@ -34,7 +44,26 @@ public sealed record DisplaySettings(
float Gamma,
bool ShowFps,
QualityPreset Quality,
ParticleRange ParticleRange)
ParticleRange ParticleRange,
// Campaign OP slice OP6: the Config tab's "Graphics Options" +
// "Rendering Quality Options" rows with no acdream renderer consumer —
// the world renderer is Vulkan + one aggregate QualityPreset, not
// per-feature knobs (register row, OP6). Persisted faithfully; every
// default below is retail's own byte-verified
// gmClient::InitUIPreferences / gmConfigUI::InitOptions literal.
bool AutomaticDegrades = false,
float GraphicsPerformance = 0f,
float DegradeDistance = 50f,
int LandscapeTextureDetail = 2,
int EnvironmentTextureDetail = 1,
int TextureFiltering = 1,
// UNRESOLVED (OP6, cite in register row): retail's own
// SetDefaultValue(8) does not index its 6-entry SetEnumChoices array
// (VeryLow..Extreme) — reproduced faithfully as an opaque int, not
// guessed into a clamped index.
int LandscapeDrawDistance = 8,
bool BuildingDetailTextures = true,
bool MultiPassAlpha = false)
{
/// <summary>Values used on first launch / when settings.json is absent.
/// Geometry defaults preserve the pre-L.0 runtime state: Resolution

View file

@ -70,7 +70,16 @@ public sealed class SettingsStore
ShowFps: ReadBool (disp, "showFps", d.ShowFps),
Quality: ReadQuality (disp, "quality", d.Quality),
ParticleRange: ReadParticleRange(
disp, "particleRange", d.ParticleRange));
disp, "particleRange", d.ParticleRange),
AutomaticDegrades: ReadBool (disp, "automaticDegrades", d.AutomaticDegrades),
GraphicsPerformance: ReadFloat(disp, "graphicsPerformance", d.GraphicsPerformance),
DegradeDistance: ReadFloat(disp, "degradeDistance", d.DegradeDistance),
LandscapeTextureDetail: ReadInt (disp, "landscapeTextureDetail", d.LandscapeTextureDetail),
EnvironmentTextureDetail:ReadInt (disp, "environmentTextureDetail",d.EnvironmentTextureDetail),
TextureFiltering: ReadInt (disp, "textureFiltering", d.TextureFiltering),
LandscapeDrawDistance: ReadInt (disp, "landscapeDrawDistance", d.LandscapeDrawDistance),
BuildingDetailTextures: ReadBool (disp, "buildingDetailTextures", d.BuildingDetailTextures),
MultiPassAlpha: ReadBool (disp, "multiPassAlpha", d.MultiPassAlpha));
}
catch (Exception ex)
{
@ -109,7 +118,13 @@ public sealed class SettingsStore
return new AudioSettings(
Master: ReadFloat(audio, "master", d.Master),
Sfx: ReadFloat(audio, "sfx", d.Sfx),
Ambient: ReadFloat(audio, "ambient", d.Ambient));
Ambient: ReadFloat(audio, "ambient", d.Ambient),
SoundFeatures: ReadInt (audio, "soundFeatures", d.SoundFeatures),
SfxDisabled: ReadBool (audio, "sfxDisabled", d.SfxDisabled),
AmbientDisabled: ReadBool (audio, "ambientDisabled", d.AmbientDisabled),
InterfaceDisabled: ReadBool (audio, "interfaceDisabled", d.InterfaceDisabled),
InterfaceVolume: ReadFloat(audio, "interfaceVolume", d.InterfaceVolume),
PlaySoundOnlyWhenActive: ReadBool (audio, "playSoundOnlyWhenActive", d.PlaySoundOnlyWhenActive));
}
catch (Exception ex)
{
@ -199,7 +214,9 @@ public sealed class SettingsStore
ChatWindow4Filter: ReadULong(chat, "chatWindow4Filter", d.ChatWindow4Filter),
ChatWindowMainFilter: ReadULong(chat, "chatWindowMainFilter", d.ChatWindowMainFilter),
DefaultOpacity: ReadFloat(chat, "defaultOpacity", d.DefaultOpacity),
ActiveOpacity: ReadFloat(chat, "activeOpacity", d.ActiveOpacity));
ActiveOpacity: ReadFloat(chat, "activeOpacity", d.ActiveOpacity),
ChatFontFace: ReadInt(chat, "chatFontFace", d.ChatFontFace),
ChatFontSizeIndex: ReadInt(chat, "chatFontSizeIndex", d.ChatFontSizeIndex));
}
catch (Exception ex)
{
@ -235,7 +252,8 @@ public sealed class SettingsStore
AdjustmentSpeed: ReadFloat(ct, "adjustmentSpeed", d.AdjustmentSpeed),
MouseLookSensitivity: ReadFloat(ct, "mouseLookSensitivity", d.MouseLookSensitivity),
AlignToSlope: ReadBool (ct, "alignToSlope", d.AlignToSlope),
InvertMouseLookYAxis: ReadBool (ct, "invertMouseLookYAxis", d.InvertMouseLookYAxis));
InvertMouseLookYAxis: ReadBool (ct, "invertMouseLookYAxis", d.InvertMouseLookYAxis),
UseMouseTurning: ReadBool (ct, "useMouseTurning", d.UseMouseTurning));
}
catch (Exception ex)
{
@ -580,6 +598,8 @@ public sealed class SettingsStore
{
["activeOpacity"] = c.ActiveOpacity,
["appearOffline"] = c.AppearOffline,
["chatFontFace"] = c.ChatFontFace,
["chatFontSizeIndex"] = c.ChatFontSizeIndex,
["chatWindow1Filter"] = c.ChatWindow1Filter,
["chatWindow2Filter"] = c.ChatWindow2Filter,
["chatWindow3Filter"] = c.ChatWindow3Filter,
@ -617,13 +637,22 @@ public sealed class SettingsStore
private static SortedDictionary<string, object> BuildDisplayObject(DisplaySettings d)
=> new(StringComparer.Ordinal)
{
["automaticDegrades"] = d.AutomaticDegrades,
["buildingDetailTextures"] = d.BuildingDetailTextures,
["degradeDistance"] = d.DegradeDistance,
["environmentTextureDetail"] = d.EnvironmentTextureDetail,
["fieldOfView"] = d.FieldOfView,
["fullscreen"] = d.Fullscreen,
["gamma"] = d.Gamma,
["graphicsPerformance"] = d.GraphicsPerformance,
["landscapeDrawDistance"] = d.LandscapeDrawDistance,
["landscapeTextureDetail"] = d.LandscapeTextureDetail,
["multiPassAlpha"] = d.MultiPassAlpha,
["particleRange"] = d.ParticleRange.ToString(),
["quality"] = d.Quality.ToString(),
["resolution"] = d.Resolution,
["showFps"] = d.ShowFps,
["textureFiltering"] = d.TextureFiltering,
["vsync"] = d.VSync,
};
@ -635,14 +664,21 @@ public sealed class SettingsStore
["invertMouseLookYAxis"] = c.InvertMouseLookYAxis,
["mouseLookSensitivity"] = c.MouseLookSensitivity,
["stiffness"] = c.Stiffness,
["useMouseTurning"] = c.UseMouseTurning,
};
private static SortedDictionary<string, object> BuildAudioObject(AudioSettings a)
=> new(StringComparer.Ordinal)
{
["ambient"] = a.Ambient,
["master"] = a.Master,
["sfx"] = a.Sfx,
["ambient"] = a.Ambient,
["ambientDisabled"] = a.AmbientDisabled,
["interfaceDisabled"] = a.InterfaceDisabled,
["interfaceVolume"] = a.InterfaceVolume,
["master"] = a.Master,
["playSoundOnlyWhenActive"] = a.PlaySoundOnlyWhenActive,
["sfx"] = a.Sfx,
["sfxDisabled"] = a.SfxDisabled,
["soundFeatures"] = a.SoundFeatures,
};
/// <summary>
@ -710,6 +746,10 @@ public sealed class SettingsStore
=> obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number
? el.GetSingle() : fallback;
private static int ReadInt(JsonElement obj, string name, int fallback)
=> obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number
? el.GetInt32() : fallback;
private static ulong ReadULong(JsonElement obj, string name, ulong fallback)
{
if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.Number)