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

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
# Campaign OP connected-gate test script
**Status:** OP3, OP4, OP5, and OP7 sections. Later slices (OP6, OP8) append
**Status:** OP3, OP4, OP5, OP6, and OP7 sections. Later slices (OP8) append
their own sections here as they land; the campaign's OP9 closeout gate is
this document complete plus every slice code-complete.
@ -509,6 +509,157 @@ chat windows already read when deciding which lines to show.
---
## OP6 — the Config tab
The Config tab (`0x21000029`) has six sections: **Sound Options** (5 rows —
a menu, three toggle+slider volume trios, one toggle), **Camera Options** (4
rows), **Graphics Options** (7 rows), **Rendering Quality Options** (6
rows), **Input Options** (3 rows), **UI Options** (2 rows) — 27 authored
rows total, byte-verified directly from `gmConfigUI::InitOptions
@0x0049E400` and `gmClient::InitUIPreferences @0x004035b0` (the retail
registration table that supplies every row's label/tooltip/range/enum-
choice — a stronger source than this campaign's own research docs, which
had left the slider-caption pairing explicitly UNVERIFIED; OP6 resolved it:
**retail ships zero range captions on this tab** — do not expect
"Dark/Bright"-style low/high labels on any slider).
### Opening the tab and reading the rows
1. **Open the Options panel (F11) and click the Config tab.** Six section
headers appear top-to-bottom: **Sound Options**, **Camera Options**,
**Graphics Options**, **Rendering Quality Options**, **Input Options**,
**UI Options**. Every row has a label (a menu dropdown, a checkbox, or a
slider — three of the Sound rows are a combined checkbox+slider in one
row). If ANY row shows no label at all, that is a DAT string-resolution
miss worth reporting (the code deliberately renders nothing rather than
invented English when a string fails to resolve).
2. **Scroll the list** via the scrollbar. All 27 rows are reachable; the
list does not clip or overlap the Apply/Reset/Defaults buttons at the
bottom.
### Live rows — audio
3. **Drag the "Sound Effects" slider (first Sound Options trio) while a
sound effect is audibly looping or repeating** (e.g. stand near an
ambient sound source, or trigger a combat/UI sound repeatedly). The
volume should change LIVE, continuously, as you drag — not just on
release.
4. **Check the "Disable Sound Effects" checkbox** (the SAME row's toggle
half). Sound effects should go SILENT immediately, regardless of where
the slider is set. Uncheck it — sound effects resume at the slider's
current level.
5. **Repeat steps 3-4 for the "Ambient Sound" trio** (the second row) using
an ambient loop (wind, water, torches) as your audible test — same live
drag + mute-on-check behaviour.
6. **Note the retail-faithful surprise:** on a FRESH character (never
touched these settings), BOTH "Disable Sound Effects" and "Disable
Ambient Sound" checkboxes are CHECKED by default — i.e., sound starts
MUTED out of the box. This is retail's own byte-verified 2013 EoR
default (`gmConfigUI::InitOptions`'s `SetDefaultValue(1, ...)` on both
trios), reproduced faithfully — **not a bug**, however counterintuitive.
Uncheck both to hear anything.
7. **Drag the third slider / check its toggle ("Disable Interface Sound",
third trio).** Expect NO audible change either way — this is retail's
OWN dead knob (register row AP-174/AP-199): retail registers this
preference and then never reads it; UI/interface sounds are always
scaled by the Sound Effects slider instead. Confirm the row is
clickable/draggable and does not crash; do not expect it to do anything.
### Live rows — display
8. **Open the Resolution menu (Graphics Options, first row) and pick a
different resolution.** The window should resize IMMEDIATELY, live, no
restart needed.
9. **Toggle "Full Screen".** The window should switch between windowed and
fullscreen IMMEDIATELY, live.
10. **Toggle "Sync To Refresh" (VSync) and drag the "Field of View"
slider.** Both persist to `settings.json`, but — matching this
project's pre-existing (not new) behaviour for these two fields —
neither re-applies until the NEXT LAUNCH. Do not expect an immediate
visual change; do a full relaunch afterward (step 15) to confirm the
new value took effect at startup.
### Store-only rows — no observable effect is the CONTRACTED behaviour
11. **Sound Features menu (Stereo/Mono), Play Sound Only When Active
toggle.** No consumer exists (register row AP-199). Confirm they're
clickable and persist (step 14); expect no audible/behavioural change.
12. **Screen Brightness slider, Automatic Degrades toggle, Graphics
Performance slider, Degrade Distance slider, the four Rendering
Quality menus (Landscape/Environment Texture Detail, Texture
Filtering, Landscape Draw Distance), Building Detail Textures,
Multi-Pass Alpha.** No consumer exists — acdream's Vulkan renderer is
driven by one aggregate quality preset, not these per-feature knobs
(register row AP-198). Confirm every one is clickable/draggable and
persists; expect zero visual change from any of them. **Landscape
Draw Distance specifically may show NO highlighted item** even right
after Defaults — that is retail's own decompiled data being
ambiguous (AP-198's own sub-note), not a rendering bug.
13. **Mouse Look Sensitivity slider, Invert Mouselook Y Axis toggle, Use
Mouse Turning toggle (Input Options), Camera Stiffness/Adjustment
Speed sliders, Align To Slope toggle (Camera Options), Chat Font
Face/Size menus (UI Options).** No consumer exists for any of these
six Camera/Input rows (register row TS-74 — the SAME "no persistent
mouse-turning camera mode" gap the Gameplay tab's macro already
exposed) or the two Chat font rows (register row AP-200 — distinct
fields from the chat panel's own separately-live font-size control,
wherever that is currently exposed). Confirm all eight are
clickable/draggable and persist; expect no visual/audible change.
### Apply/Reset/Defaults
14. **Toggle a mix of row types (a checkbox, a slider, a menu selection),
then click Reset.** Every row you touched reverts to its
last-shown/Applied value — live audio/display rows revert their
ACTUAL effect too (sound un-mutes or re-mutes, resolution changes
back), not just the checkbox art.
15. **Click Defaults.** Every row jumps LIVE to retail's byte-verified
default (Sound/Ambient trios back to muted+100% volume, Resolution to
800×600, Full Screen on, Camera Stiffness to 0.45, etc.) — Apply/Reset
light up afterward if anything actually changed (Defaults never
commits by itself).
16. **Switch to another tab (e.g. Gameplay) without clicking Apply after
an edit.** Uncommitted edits silently revert — switching tabs is a
Reset, not a save.
### Persistence — local-only, survives relaunch
17. **Set a distinctive combination** (e.g. unmute Sound Effects at 40%
volume, pick a non-default Resolution, drag Field of View partway) —
every row here writes to `settings.json` on change, not just on
Apply.
18. **Fully relaunch the client** (close the window, start a new
process — a relog is not required, this is all local-only state).
Reopen the Options panel's Config tab — every row (live AND
store-only) should read back at your set values. The Sync To
Refresh/Field Of View rows deferred to next-launch (step 10) should
now ALSO be visibly applied (window vsync behaviour / camera FOV).
### What to report
- Any section/row/label missing, or a slider showing a low/high range
caption (retail ships none on this tab — see this section's own intro).
- A live row (Sound/Ambient volume+mute, Resolution, Full Screen) that
does not change live, or a store-only row that unexpectedly DOES change
something (a sign its "no consumer" premise is stale).
- Reset/Defaults not behaving as described in 14-15.
- Any row that fails to persist across a full relaunch (step 18),
including the two next-launch-only rows (Sync To Refresh, Field of
View) not taking effect at the NEXT startup.
- Any crash, freeze, or scroll glitch anywhere in the 27-row list.
### Explicitly NOT in scope for this gate
- Any audible/visual change from the store-only rows listed in steps
11-13 — no acdream consumer exists for any of them (register rows
AP-198, AP-199, AP-200, TS-74).
- The camera actually turning from "Use Mouse Turning" (Config tab OR the
Gameplay tab's macro) — no persistent mouse-turning camera mode exists
(TS-74, already covered by OP3's gate).
- Configure Keyboard — OP8.
---
## OP7 — headless `characterOptions`
Unlike OP3-OP6, this is not a graphical-client gate: no window is launched.

View file

@ -857,7 +857,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
SaveCameraTurning: d.Settings.SaveCameraTurning,
// Campaign OP slice OP4 (2026-08-11): the Character-tab
// panel's row-seed source.
CurrentCharacterOption: id => d.Character.Options.GetOptionBit(id)),
CurrentCharacterOption: id => d.Character.Options.GetOptionBit(id),
// Campaign OP slice OP6 (2026-08-11): the Config tab's
// Display/Audio-backed rows — RuntimeSettingsController
// is the sole writer of both sections (unlike Chat; see
// RetailUiRuntime.MountOptionsPanel's own note).
LoadDisplay: () => d.Settings.Display,
SaveDisplay: d.Settings.SaveDisplay,
LoadAudio: () => d.Settings.Audio,
SaveAudio: d.Settings.SaveAudio),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,

View file

@ -341,7 +341,10 @@ internal sealed class SessionPlayerCompositionPhase
// CH6c: null when no retained UI exists (e.g. a no-window host) — the
// Chat tab's opacity sliders then apply through NullRuntimeChatOpacityTarget.
chatOpacity: interaction.RetainedUi?.Runtime.WindowOpacity,
log: d.Log);
log: d.Log,
// OP6: null on a no-audio/headless host — ApplyAudio then
// silently no-ops, same shape as chatOpacity above.
audio: content.Audio?.Engine);
bindings.Adopt(
"runtime settings targets",
d.Settings.BindRuntimeTargetsOwned(settingsTargets));

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);

View file

@ -0,0 +1,987 @@
using System;
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign OP slice OP6 (2026-08-11): binds the Config tab (LayoutDesc
/// <c>0x21000029</c>, root <c>0x100001FF</c>, ListBox <c>0x10000200</c>) —
/// retail's <c>gmConfigUI::InitOptions @0x0049E400</c> — through OP2's
/// template-list mechanism and OP3's per-page <see cref="OptionPage"/>
/// model, exactly like <see cref="CharacterOptionsPageController"/>/
/// <see cref="ChatOptionsPageController"/>.
///
/// <para>
/// <b>The row table below is transcribed directly from the decompiled
/// registration, not from the research docs' own row table.</b> Two
/// grep-named-first sources supersede
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> §4/§9's
/// summary (which itself flagged the slider-caption pairing as UNVERIFIED —
/// its own "U4"):
/// <list type="bullet">
/// <item><description><c>gmConfigUI::InitOptions @0x0049E400</c> — the
/// authored row ORDER, widget shape per row (<c>AddMenuOption</c>/
/// <c>AddToggleOption</c>/<c>AddSliderOption</c>/
/// <c>AddToggleWithSliderOption</c>), and every <c>SetDefaultValue</c>
/// literal.</description></item>
/// <item><description><c>gmClient::InitUIPreferences @0x004035b0</c> — the
/// COMPLETE <c>UIPreferences::AttachPreference</c> registration table:
/// every row's exact label string key (<c>ID_&lt;Key&gt;</c>, hashed
/// against string table <c>0x23000003</c>, table-enum <c>0x10000003</c>
/// matching <c>AddHeader</c>'s own convention), its tooltip key
/// (<c>&lt;label&gt;_Help</c> — verified at every non-truncated call site,
/// applied uniformly), every slider's real-unit
/// <c>UIPreferences::SetPreferenceRange</c> (NOT the widget's [0,1] scalar
/// space — <see cref="ToNormalized"/>/<see cref="FromNormalized"/> convert
/// at the build site), and every menu's <c>UIPreferences::SetEnumChoices</c>
/// string array.</description></item>
/// </list>
/// </para>
///
/// <para>
/// <b>U4 resolved: retail ships ZERO slider range captions on this tab.</b>
/// Every one of the seven <c>PlayerOptionPage::SetSliderLabel</c> calls this
/// tab makes passes literal <c>(0, 0)</c> for the low/high string ids
/// (<c>0x0049E4E0</c>/<c>0x0049E51D</c>/<c>0x0049E556</c>/<c>0x0049E614</c>/
/// <c>0x0049E65D</c>/<c>0x0049E68E</c> — six calls; the seventh slider,
/// Mouse Look Sensitivity, gets NO <c>SetSliderLabel</c> call at all).
/// <c>SetSliderLabel</c>'s own body (<c>0x004F2B80</c>) has no <c>IsValid</c>
/// guard before writing — unlike <c>AddHeader</c> — so <c>StringId=0</c>
/// resolves to blank text either way. The <c>ID_Graphics_Value_*</c> globals
/// (Dark/Bright, Speed/Detail, ...) the structure doc's §4 found ARE real
/// strings elsewhere in the binary, but THIS tab's <c>InitOptions</c> never
/// wires them to any row — they are dead literals for this build. This
/// controller therefore never touches the range-caption text children
/// (<c>0x1000021E</c>/<c>0x1000021F</c>) at all — omitting them is the
/// faithful port, not a shortcut.
/// </para>
///
/// <para>
/// <b>The row-template array (8 entries, Config's OWN ListBox property
/// <c>0x64</c>, cross-checked against the committed
/// <c>options_config_21000029.json</c>/<c>options_2100002B.json</c>
/// fixtures):</b> idx0 header (Type 12 text, <c>0x10000216</c>); idx1
/// separator (Type 3, <c>0x10000217</c>); idx2 toggle row (Type 3,
/// <c>0x10000218</c>, one <c>UIOption_Checkbox</c> child <c>0x10000219</c>);
/// idx3 simple slider row (Type 3, <c>0x1000021A</c>, name text
/// <c>0x1000021B</c> + slider <c>0x1000021C</c> — used ONLY for Mouse Look
/// Sensitivity, retail's own <c>arg3=0</c> row); idx4 menu row (Type 3,
/// <c>0x10000222</c>, name text <c>0x10000223</c> + menu
/// <c>0x10000224</c>); idx5 toggle+slider trio (Type
/// <c>0x10000036</c> = <see cref="UiOptionToggleSlider"/>,
/// <c>0x10000220</c>, checkbox <c>0x10000219</c> + slider
/// <c>0x1000021C</c>, no separate name text — the checkbox's OWN label
/// carries the row); idx6 range-captioned slider row (Type 3,
/// <c>0x1000021D</c>, name text <c>0x1000021B</c> + slider
/// <c>0x1000021C</c> + UNUSED range-caption texts <c>0x1000021E</c>/
/// <c>0x1000021F</c> — the six <c>arg3=1</c> sliders); idx7 a SECOND
/// toggle+slider trio template with range-caption children
/// (<c>0x10000221</c>) that retail's own <c>InitOptions</c> never
/// invokes — authored but dead, matching Chat's own unused-index pattern.
/// </para>
///
/// <para>
/// <b>Consumer disposition (OP6 contract §4):</b> LIVE — the Sound/Ambient
/// volume-trio sliders (<c>AudioSettings.Sfx</c>/<c>Ambient</c>, already
/// live via <c>ApplyAudio</c>) and their toggle halves
/// (<see cref="AudioSettings.SfxDisabled"/>/<see cref="AudioSettings.AmbientDisabled"/>,
/// gating the SAME engine write); Resolution/Full Screen
/// (<c>DisplaySettings.Resolution</c>/<c>Fullscreen</c>, immediately live —
/// resizes the window on Save). NEXT-LAUNCH (existing precedent, not a new
/// gap — neither the pre-existing dev-tools Settings panel nor this
/// controller re-applies these outside <c>ApplyStartup</c>): Sync To
/// Refresh, Field of View. STORE-ONLY (register rows, cited per group below):
/// Sound Features menu, Interface Sound trio (AP-174 — retail's own
/// registered-and-never-read knob), Play Sound Only When Active; Screen
/// Brightness (reuses the existing inert <c>DisplaySettings.Gamma</c> — no
/// gamma-correction pass exists); Automatic Degrades/Graphics
/// Performance/Degrade Distance/the four texture-detail-family
/// menus/Building Detail Textures/Multi-Pass Alpha (the renderer is Vulkan +
/// one aggregate <c>QualityPreset</c>, no per-feature knobs); Camera
/// Stiffness/Adjustment Speed/Align To Slope/Mouse Look
/// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no
/// persistent mouse-turning camera mode exists for ANY of these six to
/// drive, already registered before this slice); Chat Font Face/Size
/// (distinct NEW fields from the existing live <c>ChatSettings.FontSize</c>
/// — no verified index-to-point/face mapping).
/// </para>
/// </summary>
public static class ConfigOptionsPageController
{
/// <summary>Config page root — <c>gmConfigUI</c> — the STANDALONE
/// <c>0x21000029</c> layout's own root id. Only present as its own
/// distinct node when <c>0x21000029</c> is imported directly (e.g.
/// <c>FixtureLoader.LoadOptionsConfig()</c>); when mounted through the
/// tab host (<c>0x2100006E</c>/<c>0x2100002B</c>, what <see cref="Bind"/>
/// actually operates on), <c>ElementReader.Merge</c>'s "derived id wins"
/// rule means the PAGE SLOT keeps its own id
/// (<see cref="PageSlotElementId"/>) instead — use that one for any
/// host-tree lookup (same split <see cref="ChatOptionsPageController"/>'s
/// own <c>RootElementId</c> doc explains).</summary>
public const uint RootElementId = 0x100001FFu;
/// <summary>The Config page's SLOT element within the tab host
/// (<c>OptionsPanelController</c>'s own private <c>ConfigPageId</c>) —
/// the id that actually survives base-merge in the host-mounted tree
/// <see cref="Bind"/> operates on. Used to SCOPE the scrollbar lookup
/// below (the shared-id hazard with the Chat tab).</summary>
private const uint PageSlotElementId = 0x10000213u;
/// <summary>The row ListBox (dat Type 5) — <c>m_pOptionBox</c>.</summary>
public const uint ListBoxElementId = 0x10000200u;
/// <summary>The ListBox's linked scrollbar — SHARED with the Chat tab
/// (research doc §10.1: both tabs author the same scrollbar element
/// id). Scoped from <see cref="RootElementId"/>'s page slot, exactly
/// like <see cref="ChatOptionsPageController"/>'s own scrollbar lookup —
/// the shared-id hazard this campaign's binding pattern exists for.</summary>
public const uint ScrollbarElementId = 0x10000201u;
private const int HeaderTemplateIndex = 0;
private const int SeparatorTemplateIndex = 1;
private const int ToggleTemplateIndex = 2;
private const int SimpleSliderTemplateIndex = 3;
private const int MenuTemplateIndex = 4;
private const int TrioTemplateIndex = 5;
private const int RangedSliderTemplateIndex = 6;
private const uint StringTableId = 0x23000003u;
/// <summary>Slider row templates' (idx3/idx6) name-label text child.</summary>
private const uint SliderLabelElementId = 0x1000021Bu;
/// <summary>Slider leaf shared by every slider-bearing template
/// (idx3/idx5/idx6) — retail's own single <c>0x1000021C</c> convention,
/// same id CharacterOptions/ChatOptions controllers already cite.</summary>
private const uint SliderElementId = 0x1000021Cu;
/// <summary>Menu row template's (idx4) name-label text child.</summary>
private const uint MenuLabelElementId = 0x10000223u;
/// <summary>Menu row template's (idx4) <see cref="UiMenu"/> leaf.</summary>
private const uint MenuElementId = 0x10000224u;
/// <summary>Toggle checkbox leaf shared by the plain toggle row (idx2)
/// and the trio row's toggle half (idx5) — retail's own single
/// <c>0x10000219</c> convention.</summary>
private const uint ToggleCheckboxElementId = 0x10000219u;
/// <summary>The live read/write seam every row on this page writes/reads
/// through — four settings groups, each read once per row-build and
/// mutated read-modify-write per change (the SAME per-change persistence
/// shape OP3's mouse-turning macro and OP4's Character rows use).</summary>
public sealed record Bindings(
Func<DisplaySettings> LoadDisplay,
Action<DisplaySettings> SaveDisplay,
Func<AudioSettings> LoadAudio,
Action<AudioSettings> SaveAudio,
Func<CameraTurningSettings> LoadCameraTurning,
Action<CameraTurningSettings> SaveCameraTurning,
Func<ChatSettings> LoadChat,
Action<ChatSettings> SaveChat);
/// <summary>
/// Builds the six authored sections (Sound/Camera/Graphics/Rendering
/// Quality/Input/UI Options) into <paramref name="layout"/>'s Config
/// ListBox, links its scrollbar, seeds every row's current/default
/// state, and registers each row into <paramref name="page"/>.
/// </summary>
public static bool Bind(
ImportedLayout layout,
OptionPage page,
Func<uint, uint, UiElement?> templateResolver,
Func<uint, uint, string?> resolveString,
Bindings bindings)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(page);
ArgumentNullException.ThrowIfNull(templateResolver);
ArgumentNullException.ThrowIfNull(resolveString);
ArgumentNullException.ThrowIfNull(bindings);
if (layout.FindElement(ListBoxElementId) is not UiTemplateListBox listBox)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: ListBox 0x{ListBoxElementId:X8} "
+ "not found (or not a UiTemplateListBox) in the built Options panel tree — "
+ "the Config tab will have no rows.");
return false;
}
listBox.TemplateResolver = templateResolver;
// ScrollbarElementId (0x10000201) is SHARED with the Chat tab
// (0x1000050D's own scrollbar lookup cites the same hazard) —
// ImportedLayout.FindElement is a flat id->widget dictionary
// (last-build-wins on a collision), so a plain lookup here could
// silently wire THIS scroll model onto the Chat tab's own
// scrollbar instance. Scope the search to the Config page's own
// subtree instead. Scoped from PageSlotElementId, NOT RootElementId
// — see that field's own doc for why the standalone layout's root
// id does not survive base-merge here.
UiElement? configPageSlot = layout.FindElement(PageSlotElementId);
UiElement? scrollbarElement = configPageSlot is null
? null
: UiElement.FindDescendant(configPageSlot, ScrollbarElementId);
if (scrollbarElement is UiScrollbar scrollbar)
scrollbar.Model = listBox.Scroll;
else
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: scrollbar 0x{ScrollbarElementId:X8} "
+ $"not found under Config page slot 0x{PageSlotElementId:X8} — the Config "
+ "tab's row list will not scroll.");
DisplaySettings display = bindings.LoadDisplay();
AudioSettings audio = bindings.LoadAudio();
CameraTurningSettings cameraTurning = bindings.LoadCameraTurning();
ChatSettings chat = bindings.LoadChat();
BindSoundSection(listBox, page, resolveString, bindings, ref audio);
BuildSeparatorRow(listBox);
BindCameraSection(listBox, page, resolveString, bindings, ref cameraTurning);
BuildSeparatorRow(listBox);
BindGraphicsSection(listBox, page, resolveString, bindings, ref display);
BuildSeparatorRow(listBox);
BindRenderingQualitySection(listBox, page, resolveString, bindings, ref display);
BuildSeparatorRow(listBox);
BindInputSection(listBox, page, resolveString, bindings, ref cameraTurning);
BuildSeparatorRow(listBox);
BindUiSection(listBox, page, resolveString, bindings, ref chat);
return true;
}
// ── Section 1: Sound Options ────────────────────────────────────────
private static void BindSoundSection(
UiTemplateListBox listBox,
OptionPage page,
Func<uint, uint, string?> resolveString,
Bindings bindings,
ref AudioSettings audio)
{
BuildHeaderRow(listBox, "ID_Sound_SoundSection", resolveString);
BuildMenuRow(
listBox, "ID_Sound_SoundFeatures",
new[] { "ID_Sound_Stereo", "ID_Sound_Mono" },
page, resolveString,
read: () => bindings.LoadAudio().SoundFeatures,
apply: value =>
{
AudioSettings updated = bindings.LoadAudio() with { SoundFeatures = value };
bindings.SaveAudio(updated);
},
defaultValue: 0);
BuildTrioRow(
listBox, "ID_Sound_DisableSound", toggleDefault: true,
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
page, resolveString,
toggleRead: () => bindings.LoadAudio().SfxDisabled,
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { SfxDisabled = value }),
sliderRead: () => bindings.LoadAudio().Sfx,
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Sfx = value }));
BuildTrioRow(
listBox, "ID_Sound_DisableAmbientSound", toggleDefault: true,
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
page, resolveString,
toggleRead: () => bindings.LoadAudio().AmbientDisabled,
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { AmbientDisabled = value }),
sliderRead: () => bindings.LoadAudio().Ambient,
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Ambient = value }));
// Interface Sound: retail's own dead knob (AP-174 — "interface
// sounds are scaled by the EFFECT knob"; registered and never
// read). Store-only, same shape as every other row here — this
// trio simply has no live consumer to gate.
BuildTrioRow(
listBox, "ID_Sound_DisableInterfaceSound", toggleDefault: true,
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
page, resolveString,
toggleRead: () => bindings.LoadAudio().InterfaceDisabled,
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceDisabled = value }),
sliderRead: () => bindings.LoadAudio().InterfaceVolume,
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceVolume = value }));
BuildToggleRow(
listBox, "ID_Sound_NoFocusNoSound", defaultValue: true, page, resolveString,
read: () => bindings.LoadAudio().PlaySoundOnlyWhenActive,
apply: value => bindings.SaveAudio(bindings.LoadAudio() with { PlaySoundOnlyWhenActive = value }));
audio = bindings.LoadAudio();
}
// ── Section 2: Camera Options ───────────────────────────────────────
private static void BindCameraSection(
UiTemplateListBox listBox,
OptionPage page,
Func<uint, uint, string?> resolveString,
Bindings bindings,
ref CameraTurningSettings cameraTurning)
{
BuildHeaderRow(listBox, "ID_Camera_CameraSection", resolveString);
// Camera Stiffness / Adjustment Speed / Align To Slope: TS-74 —
// store-only, no persistent mouse-turning camera mode exists.
BuildSliderRow(
listBox, RangedSliderTemplateIndex, "ID_Camera_Stiffness",
min: 0.285714298f, max: 1f, defaultValue: 0.45f, page, resolveString,
read: () => bindings.LoadCameraTurning().Stiffness,
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { Stiffness = value }));
BuildSliderRow(
listBox, RangedSliderTemplateIndex, "ID_Camera_AdjustmentSpeed",
min: 5f, max: 80f, defaultValue: 40.0f, page, resolveString,
read: () => bindings.LoadCameraTurning().AdjustmentSpeed,
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AdjustmentSpeed = value }));
// Field of View: NEXT-LAUNCH via DisplaySettings.FieldOfView + the
// existing RuntimeSettingsController.ApplyStartup path — matches
// the pre-existing (dev-tools Settings panel era) behaviour, not a
// new gap this slice introduces.
BuildSliderRow(
listBox, RangedSliderTemplateIndex, "ID_Graphics_FieldOfView",
min: 10f, max: 160f, defaultValue: 90.0f, page, resolveString,
read: () => bindings.LoadDisplay().FieldOfView,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { FieldOfView = value }));
BuildToggleRow(
listBox, "ID_Camera_AlignToSlope", defaultValue: true, page, resolveString,
read: () => bindings.LoadCameraTurning().AlignToSlope,
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AlignToSlope = value }));
cameraTurning = bindings.LoadCameraTurning();
}
// ── Section 3: Graphics Options ─────────────────────────────────────
private static void BindGraphicsSection(
UiTemplateListBox listBox,
OptionPage page,
Func<uint, uint, string?> resolveString,
Bindings bindings,
ref DisplaySettings display)
{
BuildHeaderRow(listBox, "ID_Graphics_GraphicsSection", resolveString);
// Resolution: LIVE — DisplaySettings.Resolution already resizes
// the window immediately on Save (ApplyDisplayWindowState). The
// menu's payload is the resolution STRING itself (not an index —
// retail's Display_Resolution is the ONE row on this tab built
// via arg3=0/SetUserPreference, a genuinely different code path
// from every other menu here; this controller resolves its label
// directly rather than through UIPreferences::InqPreference's
// generic path, matching that divergence honestly). SetConfirmChange
// ships when a resolution-change confirmation flow exists — not
// this slice (plan §4 OP6). Retail's literal default ("800x600",
// preserved below as the value Defaults restores) is NOT one of
// DisplaySettings.AvailableResolutions' modern presets — the same
// "opaque default the menu may not highlight" shape as
// LandscapeDrawDistance (register row AP-198's own sub-note), NOT
// a functional gap: TryParseResolution accepts any "WxH" string,
// so clicking Defaults still resizes the window correctly to
// 800x600, it just may not show a highlighted dropdown row. A
// FRESH profile's own current value (DisplaySettings.Default =
// "1280x720") IS in the preset list and highlights normally.
BuildStringMenuRow(
listBox, "ID_Rendering_DisplayResolution",
DisplaySettings.AvailableResolutions, page, resolveString,
read: () => bindings.LoadDisplay().Resolution,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Resolution = value }),
defaultValue: "800x600");
BuildToggleRow(
listBox, "ID_Rendering_FullScreen", defaultValue: true, page, resolveString,
read: () => bindings.LoadDisplay().Fullscreen,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Fullscreen = value }));
// Sync To Refresh: NEXT-LAUNCH (same DisplaySettings.VSync
// pre-existing precedent as FieldOfView above).
BuildToggleRow(
listBox, "ID_Rendering_SyncToDisplayRefresh", defaultValue: false, page, resolveString,
read: () => bindings.LoadDisplay().VSync,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { VSync = value }));
// Screen Brightness: reuses the existing DisplaySettings.Gamma
// field (no gamma-correction render pass exists — inert, store-only).
BuildSliderRow(
listBox, RangedSliderTemplateIndex, "ID_Graphics_ScreenBrightness",
min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
read: () => bindings.LoadDisplay().Gamma,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Gamma = value }));
BuildToggleRow(
listBox, "ID_Graphics_AdaptiveDegrade", defaultValue: false, page, resolveString,
read: () => bindings.LoadDisplay().AutomaticDegrades,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { AutomaticDegrades = value }));
BuildSliderRow(
listBox, RangedSliderTemplateIndex, "ID_Graphics_AdaptiveDegradeBias",
min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
read: () => bindings.LoadDisplay().GraphicsPerformance,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { GraphicsPerformance = value }));
BuildSliderRow(
listBox, RangedSliderTemplateIndex, "ID_Graphics_DegradeDistance",
min: 0f, max: 100f, defaultValue: 50.0f, page, resolveString,
read: () => bindings.LoadDisplay().DegradeDistance,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { DegradeDistance = value }));
display = bindings.LoadDisplay();
}
// ── Section 4: Rendering Quality Options ────────────────────────────
private static readonly string[] TextureDetailChoices =
{
"ID_Graphics_Value_VeryLow", "ID_Graphics_Value_Low", "ID_Graphics_Value_Medium",
"ID_Graphics_Value_High", "ID_Graphics_Value_VeryHigh",
};
private static readonly string[] TextureFilteringChoices =
{
"ID_Graphics_TextureFiltering_Bilinear", "ID_Graphics_TextureFiltering_Trilinear",
"ID_Graphics_TextureFiltering_Sharp", "ID_Graphics_TextureFiltering_Anisotropic",
};
private static readonly string[] LandscapeDrawDistanceChoices =
{
"ID_Graphics_Value_VeryLow", "ID_Graphics_Value_Low", "ID_Graphics_Value_Medium",
"ID_Graphics_Value_High", "ID_Graphics_Value_VeryHigh", "ID_Graphics_Value_Extreme",
};
private static void BindRenderingQualitySection(
UiTemplateListBox listBox,
OptionPage page,
Func<uint, uint, string?> resolveString,
Bindings bindings,
ref DisplaySettings display)
{
BuildHeaderRow(listBox, "ID_Graphics_TextureSection", resolveString);
// The whole section is store-only: the world renderer is
// Vulkan + one aggregate QualityPreset, not per-feature knobs
// (register row, OP6).
BuildMenuRow(
listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString,
read: () => bindings.LoadDisplay().LandscapeTextureDetail,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeTextureDetail = value }),
defaultValue: 2);
BuildMenuRow(
listBox, "ID_Graphics_EnvironmentTextureDetail", TextureDetailChoices, page, resolveString,
read: () => bindings.LoadDisplay().EnvironmentTextureDetail,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { EnvironmentTextureDetail = value }),
defaultValue: 1);
BuildMenuRow(
listBox, "ID_Graphics_TextureFiltering", TextureFilteringChoices, page, resolveString,
read: () => bindings.LoadDisplay().TextureFiltering,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { TextureFiltering = value }),
defaultValue: 1);
// UNRESOLVED (see class doc / register row): retail's own
// SetDefaultValue(8) does not index this 6-entry choice array.
// Reproduced as an opaque int; the menu simply shows no
// highlighted item at the default (no crash, no invented mapping).
BuildMenuRow(
listBox, "ID_Graphics_LandscapeDrawDistance", LandscapeDrawDistanceChoices, page, resolveString,
read: () => bindings.LoadDisplay().LandscapeDrawDistance,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeDrawDistance = value }),
defaultValue: 8);
BuildToggleRow(
listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString,
read: () => bindings.LoadDisplay().BuildingDetailTextures,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { BuildingDetailTextures = value }));
BuildToggleRow(
listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString,
read: () => bindings.LoadDisplay().MultiPassAlpha,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { MultiPassAlpha = value }));
display = bindings.LoadDisplay();
}
// ── Section 5: Input Options ────────────────────────────────────────
private static void BindInputSection(
UiTemplateListBox listBox,
OptionPage page,
Func<uint, uint, string?> resolveString,
Bindings bindings,
ref CameraTurningSettings cameraTurning)
{
BuildHeaderRow(listBox, "ID_Input_InputSection", resolveString);
// Retail's own arg3=0 row (AddSliderOption(this,
// &Input_MouseLookSensitivity, 0)) — template idx3, the ONLY row
// that uses it (structural cross-check: idx3 appears exactly once
// in Config's authored template array). TS-74 — store-only.
BuildSliderRow(
listBox, SimpleSliderTemplateIndex, "ID_Input_MouseLookSensitivity",
min: 0.00999999978f, max: 1f, defaultValue: 0.55f, page, resolveString,
read: () => bindings.LoadCameraTurning().MouseLookSensitivity,
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { MouseLookSensitivity = value }));
BuildToggleRow(
listBox, "ID_Input_InvertMouseLookYAxis", defaultValue: false, page, resolveString,
read: () => bindings.LoadCameraTurning().InvertMouseLookYAxis,
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { InvertMouseLookYAxis = value }));
// Input_UseMouseTurning: the Config tab's OWN client-local
// UIPreference — DISTINCT from the Gameplay tab macro's
// server-synced PlayerOption.UseMouseTurning bit (see
// CameraTurningSettings.UseMouseTurning's own doc). TS-74.
BuildToggleRow(
listBox, "ID_Input_UseMouseTurning", defaultValue: false, page, resolveString,
read: () => bindings.LoadCameraTurning().UseMouseTurning,
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { UseMouseTurning = value }));
cameraTurning = bindings.LoadCameraTurning();
}
// ── Section 6: UI Options ───────────────────────────────────────────
private static readonly string[] ChatFontFaceChoices =
{
"ID_UI_Value_Arial",
};
private static readonly string[] ChatFontSizeChoices =
{
"ID_UI_Value_Tiny", "ID_UI_Value_Small", "ID_UI_Value_Medium",
"ID_UI_Value_Large", "ID_UI_Value_XLarge",
};
private static void BindUiSection(
UiTemplateListBox listBox,
OptionPage page,
Func<uint, uint, string?> resolveString,
Bindings bindings,
ref ChatSettings chat)
{
BuildHeaderRow(listBox, "ID_UI_UISection", resolveString);
// Chat Font Face/Size: store-only, distinct from the existing live
// ChatSettings.FontSize (see that field's own doc for why). The
// face-choice array is authored with only ONE literal decoded so
// far ("Arial") — the remaining installed system fonts are a
// per-machine list retail enumerates at runtime
// (UIPreferences::AttachPreference's own SmartArray grow loop has
// no fixed upper bound in the decompile); the menu still shows
// every choice this table names rather than guessing a longer list.
BuildMenuRow(
listBox, "ID_UI_ChatFontFace", ChatFontFaceChoices, page, resolveString,
read: () => bindings.LoadChat().ChatFontFace,
apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontFace = value }),
defaultValue: 2);
BuildMenuRow(
listBox, "ID_UI_ChatFontSize", ChatFontSizeChoices, page, resolveString,
read: () => bindings.LoadChat().ChatFontSizeIndex,
apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontSizeIndex = value }),
defaultValue: 1);
chat = bindings.LoadChat();
}
// ── Row builders (shared shapes) ────────────────────────────────────
private static void BuildHeaderRow(
UiTemplateListBox listBox, string headerKey, Func<uint, uint, string?> resolveString)
{
if (listBox.AddItemFromTemplateList(HeaderTemplateIndex) is not UiText header)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: header template did not build as "
+ $"UiText for '{headerKey}'.");
return;
}
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(headerKey));
if (label is null)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: header string '{headerKey}' did not "
+ "resolve from the DAT string table — the row renders with no text rather "
+ "than an invented label.");
return;
}
header.LinesProvider = () => new[] { new UiText.Line(label, header.DefaultColor) };
}
private static void BuildSeparatorRow(UiTemplateListBox listBox)
{
if (listBox.AddItemFromTemplateList(SeparatorTemplateIndex) is null)
Console.WriteLine("[D.2b] ConfigOptionsPageController: separator template did not build.");
}
private static void BuildToggleRow(
UiTemplateListBox listBox,
string labelKey,
bool defaultValue,
OptionPage page,
Func<uint, uint, string?> resolveString,
Func<bool> read,
Action<bool> apply)
{
UiElement? row = listBox.AddItemFromTemplateList(ToggleTemplateIndex);
if (row is null)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: toggle template did not build for "
+ $"'{labelKey}'.");
return;
}
UiButton? checkbox = FindCheckbox(row);
if (checkbox is null)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: no checkbox child found in the "
+ $"toggle row for '{labelKey}'.");
return;
}
ApplyLabelAndTooltip(checkbox, labelKey, resolveString);
bool initial = read();
checkbox.Selected = initial;
var row_ = new BoolOptionRow(
initial,
defaultValue,
apply: value =>
{
checkbox.Selected = value;
apply(value);
},
read: read,
refresh: value => checkbox.Selected = value);
page.Register(row_);
checkbox.OnClick = () => row_.SetCurrentValue(checkbox.Selected);
}
/// <summary>Builds a slider row from EITHER <see cref="SimpleSliderTemplateIndex"/>
/// (idx3, Mouse Look Sensitivity only) or <see cref="RangedSliderTemplateIndex"/>
/// (idx6, the other six) — structurally identical leaves for this
/// controller's purposes (name text + slider; idx6's extra range-caption
/// children are deliberately left untouched — see class doc's U4 note).
/// Converts between the row's REAL-unit current/default (what
/// <paramref name="read"/>/<paramref name="apply"/> traffic in — the
/// settings-store unit) and the widget's normalized [0,1] scalar space.</summary>
private static void BuildSliderRow(
UiTemplateListBox listBox,
int templateIndex,
string labelKey,
float min,
float max,
float defaultValue,
OptionPage page,
Func<uint, uint, string?> resolveString,
Func<float> read,
Action<float> apply)
{
UiElement? row = listBox.AddItemFromTemplateList(templateIndex);
if (row is null)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: slider template did not build for "
+ $"'{labelKey}'.");
return;
}
if (UiElement.FindDescendant(row, SliderLabelElementId) is UiText label)
SetLabelText(label, labelKey, resolveString);
if (UiElement.FindDescendant(row, SliderElementId) is not UiScrollbar slider)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: no slider leaf found in the row for "
+ $"'{labelKey}'.");
return;
}
float initial = read();
slider.SetScalarPosition(ToNormalized(initial, min, max));
var row_ = new FloatOptionRow(
initial,
defaultValue,
apply: value =>
{
slider.SetScalarPosition(ToNormalized(value, min, max));
apply(value);
},
read: read,
refresh: value => slider.SetScalarPosition(ToNormalized(value, min, max)));
page.Register(row_);
slider.ScalarChanged = normalized => row_.SetCurrentValue(FromNormalized(normalized, min, max));
}
/// <summary>Builds the toggle+slider trio (idx5) — the checkbox half
/// carries the ONLY row label (retail's own row shape has no separate
/// name text; see class doc). Both halves are [0,1]-ranged in this
/// campaign (every trio here is a volume knob), so no unit conversion
/// is needed for the slider half.</summary>
private static void BuildTrioRow(
UiTemplateListBox listBox,
string toggleLabelKey,
bool toggleDefault,
float sliderMin,
float sliderMax,
float sliderDefault,
OptionPage page,
Func<uint, uint, string?> resolveString,
Func<bool> toggleRead,
Action<bool> toggleApply,
Func<float> sliderRead,
Action<float> sliderApply)
{
if (listBox.AddItemFromTemplateList(TrioTemplateIndex) is not UiOptionToggleSlider trio)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: trio template did not build as "
+ $"UiOptionToggleSlider for '{toggleLabelKey}'.");
return;
}
UiButton? checkbox = trio.Toggle;
UiScrollbar? slider = trio.Slider;
if (checkbox is null || slider is null)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: trio row for '{toggleLabelKey}' is "
+ $"missing its toggle or slider child (toggle={checkbox is not null}, "
+ $"slider={slider is not null}).");
return;
}
ApplyLabelAndTooltip(checkbox, toggleLabelKey, resolveString);
bool toggleInitial = toggleRead();
checkbox.Selected = toggleInitial;
var toggleRow = new BoolOptionRow(
toggleInitial,
toggleDefault,
apply: value =>
{
checkbox.Selected = value;
toggleApply(value);
},
read: toggleRead,
refresh: value => checkbox.Selected = value);
page.Register(toggleRow);
checkbox.OnClick = () => toggleRow.SetCurrentValue(checkbox.Selected);
float sliderInitial = sliderRead();
slider.SetScalarPosition(ToNormalized(sliderInitial, sliderMin, sliderMax));
var sliderRow = new FloatOptionRow(
sliderInitial,
sliderDefault,
apply: value =>
{
slider.SetScalarPosition(ToNormalized(value, sliderMin, sliderMax));
sliderApply(value);
},
read: sliderRead,
refresh: value => slider.SetScalarPosition(ToNormalized(value, sliderMin, sliderMax)));
page.Register(sliderRow);
slider.ScalarChanged = normalized =>
sliderRow.SetCurrentValue(FromNormalized(normalized, sliderMin, sliderMax));
}
/// <summary>Builds a menu row (idx4) over an <see cref="int"/> payload —
/// every Config-tab menu except Resolution (see
/// <see cref="BuildStringMenuRow"/>).</summary>
private static void BuildMenuRow(
UiTemplateListBox listBox,
string labelKey,
string[] choiceKeys,
OptionPage page,
Func<uint, uint, string?> resolveString,
Func<int> read,
Action<int> apply,
int defaultValue)
{
UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex);
if (row is null)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: menu template did not build for "
+ $"'{labelKey}'.");
return;
}
if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label)
SetLabelText(label, labelKey, resolveString);
if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: no UiMenu leaf found in the row for "
+ $"'{labelKey}'.");
return;
}
string[] choiceLabels = new string[choiceKeys.Length];
var items = new UiMenu.MenuItem[choiceKeys.Length];
for (int i = 0; i < choiceKeys.Length; i++)
{
string? choiceLabel = resolveString(StringTableId, DatStringResolver.ComputeHash(choiceKeys[i]));
choiceLabels[i] = choiceLabel ?? string.Empty;
if (choiceLabel is null)
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: menu choice '{choiceKeys[i]}' "
+ $"(for '{labelKey}') did not resolve — item renders with no caption "
+ "rather than invented English.");
items[i] = new UiMenu.MenuItem(choiceLabels[i], i);
}
menu.Items = items;
int initial = read();
menu.Selected = initial;
menu.ButtonLabelProvider = () =>
{
int current = menu.Selected is int selected ? selected : initial;
return current >= 0 && current < choiceLabels.Length ? choiceLabels[current] : string.Empty;
};
var row_ = new IntOptionRow(
initial,
defaultValue,
apply: value =>
{
menu.Selected = value;
apply(value);
},
read: read,
refresh: value => menu.Selected = value);
page.Register(row_);
menu.OnSelect = payload =>
{
if (payload is int value)
row_.SetCurrentValue(value);
};
}
/// <summary>Resolution's own menu — the one Config-tab row built via
/// retail's <c>arg3=0</c>/<c>SetUserPreference</c> path rather than the
/// generic <c>UIPreferences::InqPreference</c> label lookup every other
/// menu here uses; its payload is the resolution string itself.</summary>
private static void BuildStringMenuRow(
UiTemplateListBox listBox,
string labelKey,
IReadOnlyList<string> choices,
OptionPage page,
Func<uint, uint, string?> resolveString,
Func<string> read,
Action<string> apply,
string defaultValue)
{
UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex);
if (row is null)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: menu template did not build for "
+ $"'{labelKey}'.");
return;
}
if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label)
SetLabelText(label, labelKey, resolveString);
if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: no UiMenu leaf found in the row for "
+ $"'{labelKey}'.");
return;
}
var items = new UiMenu.MenuItem[choices.Count];
for (int i = 0; i < choices.Count; i++)
items[i] = new UiMenu.MenuItem(choices[i], choices[i]);
menu.Items = items;
string initial = read();
menu.Selected = initial;
menu.ButtonLabelProvider = () => menu.Selected as string ?? initial;
var stringRow = new StringOptionRow(
initial,
defaultValue,
apply: value =>
{
menu.Selected = value;
apply(value);
},
read: read,
refresh: value => menu.Selected = value);
page.Register(stringRow);
menu.OnSelect = payload =>
{
if (payload is string value)
stringRow.SetCurrentValue(value);
};
}
private static void ApplyLabelAndTooltip(
UiButton checkbox, string labelKey, Func<uint, uint, string?> resolveString)
{
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
if (label is not null)
checkbox.Label = label;
else
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: label '{labelKey}' did not resolve — "
+ "row renders with no caption rather than invented English.");
string? tooltip = resolveString(
StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
if (tooltip is not null)
checkbox.TooltipText = tooltip;
}
private static void SetLabelText(UiText label, string labelKey, Func<uint, uint, string?> resolveString)
{
string? text = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
if (text is null)
{
Console.WriteLine(
$"[D.2b] ConfigOptionsPageController: label '{labelKey}' did not resolve — "
+ "row renders with no caption rather than invented English.");
return;
}
label.LinesProvider = () => new[] { new UiText.Line(text, label.DefaultColor) };
}
private static UiButton? FindCheckbox(UiElement root)
{
if (root is UiButton direct) return direct;
foreach (UiElement child in root.Children)
if (child is UiButton button)
return button;
return UiElement.FindDescendant(root, ToggleCheckboxElementId) as UiButton;
}
/// <summary>Real-unit value → the widget's normalized [0,1] scalar
/// space (<see cref="UiScrollbar.SetScalarPosition"/>'s own clamp).</summary>
private static float ToNormalized(float real, float min, float max)
=> max > min ? (real - min) / (max - min) : 0f;
/// <summary>Inverse of <see cref="ToNormalized"/>.</summary>
private static float FromNormalized(float normalized, float min, float max)
=> min + normalized * (max - min);
}

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

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(

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)

View file

@ -300,6 +300,10 @@ public sealed class RuntimeSettingsControllerTests
"target-display",
"target-quality",
"save-audio",
// Campaign OP slice OP6 (2026-08-11): SaveAudio now pushes the
// saved snapshot into the live engine, same shape as
// save-display's own target-display push a few lines up.
"target-audio",
"save-gameplay",
"target-ui-lock:True",
"save-chat",
@ -1153,6 +1157,14 @@ public sealed class RuntimeSettingsControllerTests
throw new InvalidOperationException("quality target failed");
}
public List<AudioSettings> AudioCalls { get; } = [];
public void ApplyAudio(AudioSettings audio)
{
AudioCalls.Add(audio);
events.Add("target-audio");
}
public void ApplyUiLock(bool locked)
{
UiLockCalls++;

View file

@ -0,0 +1,596 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign OP slice OP6 (2026-08-11) conformance + behavior tests for
/// <see cref="ConfigOptionsPageController"/> — the same hermetic pattern
/// <c>CharacterOptionsPageControllerTests</c>/<c>ChatOptionsPageControllerTests</c>
/// established: pure-data conformance against the byte-verified decomp
/// tables (<c>gmConfigUI::InitOptions</c> + <c>gmClient::InitUIPreferences</c>),
/// then behavioral tests against the committed
/// <c>options_panel_2100006E_1000018D.json</c> + <c>options_2100002B.json</c>
/// fixtures — no live DAT access. Also covers the shared scrollbar-id hazard
/// (Config and Chat both author element <c>0x10000201</c>) and a settings-
/// store round-trip per record OP6 touched.
/// </summary>
public sealed class ConfigOptionsPageControllerTests
{
// ── Pure data conformance ────────────────────────────────────────────────
[Fact]
public void SixSectionHeaders_AreDistinct()
{
string[] headers =
[
"ID_Sound_SoundSection",
"ID_Camera_CameraSection",
"ID_Graphics_GraphicsSection",
"ID_Graphics_TextureSection",
"ID_Input_InputSection",
"ID_UI_UISection",
];
Assert.Equal(6, headers.Distinct().Count());
}
[Fact]
public void AudioSettings_Default_MatchesRetailByteVerifiedTrioDefaults()
{
// gmClient::InitUIPreferences: every Sound trio's checkbox defaults
// to CHECKED ("Disabled"=true) — SetDefaultValue(1, 0x3f800000) in
// gmConfigUI::InitOptions, ported faithfully (see
// AudioSettings.Default's own doc for why this looks backwards but
// isn't a "fix").
AudioSettings d = AudioSettings.Default;
Assert.Equal(0, d.SoundFeatures);
Assert.True(d.SfxDisabled);
Assert.True(d.AmbientDisabled);
Assert.True(d.InterfaceDisabled);
Assert.Equal(1.0f, d.InterfaceVolume);
Assert.True(d.PlaySoundOnlyWhenActive);
Assert.Equal(1.0f, d.Sfx);
Assert.Equal(1.0f, d.Ambient);
}
[Fact]
public void CameraTurningSettings_Default_MatchesRetailByteVerifiedConfigDefaults()
{
CameraTurningSettings d = CameraTurningSettings.Default;
Assert.Equal(0.45f, d.Stiffness);
Assert.Equal(40.0f, d.AdjustmentSpeed);
Assert.Equal(0.55f, d.MouseLookSensitivity);
Assert.True(d.AlignToSlope);
Assert.False(d.InvertMouseLookYAxis);
Assert.False(d.UseMouseTurning);
}
[Fact]
public void DisplaySettings_Default_MatchesRetailByteVerifiedConfigDefaults()
{
DisplaySettings d = DisplaySettings.Default;
Assert.False(d.AutomaticDegrades);
Assert.Equal(0f, d.GraphicsPerformance);
Assert.Equal(50f, d.DegradeDistance);
Assert.Equal(2, d.LandscapeTextureDetail);
Assert.Equal(1, d.EnvironmentTextureDetail);
Assert.Equal(1, d.TextureFiltering);
Assert.Equal(8, d.LandscapeDrawDistance);
Assert.True(d.BuildingDetailTextures);
Assert.False(d.MultiPassAlpha);
}
[Fact]
public void ChatSettings_Default_MatchesRetailByteVerifiedConfigDefaults()
{
ChatSettings d = ChatSettings.Default;
Assert.Equal(2, d.ChatFontFace);
Assert.Equal(1, d.ChatFontSizeIndex);
}
// ── Settings-store round trips (SettingsStore, no live DAT) ─────────────
[Fact]
public void SettingsStore_AudioRoundTrip_PreservesTheSixOP6Fields()
{
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"acdream-op6-audio-{Guid.NewGuid():N}.json");
try
{
var store = new SettingsStore(path);
var written = AudioSettings.Default with
{
SoundFeatures = 1,
SfxDisabled = false,
AmbientDisabled = false,
InterfaceDisabled = false,
InterfaceVolume = 0.4f,
PlaySoundOnlyWhenActive = false,
};
store.SaveAudio(written);
AudioSettings read = store.LoadAudio();
Assert.Equal(written, read);
}
finally
{
System.IO.File.Delete(path);
}
}
[Fact]
public void SettingsStore_DisplayRoundTrip_PreservesTheNineOP6Fields()
{
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"acdream-op6-display-{Guid.NewGuid():N}.json");
try
{
var store = new SettingsStore(path);
var written = DisplaySettings.Default with
{
AutomaticDegrades = true,
GraphicsPerformance = 0.5f,
DegradeDistance = 75f,
LandscapeTextureDetail = 4,
EnvironmentTextureDetail = 3,
TextureFiltering = 2,
LandscapeDrawDistance = 3,
BuildingDetailTextures = false,
MultiPassAlpha = true,
};
store.SaveDisplay(written);
DisplaySettings read = store.LoadDisplay();
Assert.Equal(written, read);
}
finally
{
System.IO.File.Delete(path);
}
}
[Fact]
public void SettingsStore_CameraTurningRoundTrip_PreservesUseMouseTurning()
{
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"acdream-op6-camera-{Guid.NewGuid():N}.json");
try
{
var store = new SettingsStore(path);
var written = CameraTurningSettings.Default with { UseMouseTurning = true };
store.SaveCameraTurning(written);
CameraTurningSettings read = store.LoadCameraTurning();
Assert.Equal(written, read);
}
finally
{
System.IO.File.Delete(path);
}
}
[Fact]
public void SettingsStore_ChatRoundTrip_PreservesFontFaceAndSize()
{
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"acdream-op6-chat-{Guid.NewGuid():N}.json");
try
{
var store = new SettingsStore(path);
var written = ChatSettings.Default with { ChatFontFace = 0, ChatFontSizeIndex = 3 };
store.SaveChat(written);
ChatSettings read = store.LoadChat();
Assert.Equal(written.ChatFontFace, read.ChatFontFace);
Assert.Equal(written.ChatFontSizeIndex, read.ChatFontSizeIndex);
}
finally
{
System.IO.File.Delete(path);
}
}
// ── Behavioral: built against the committed fixtures ─────────────────────
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
private static ElementInfo? Find(ElementInfo n, uint id)
{
if (n.Id == id) return n;
foreach (ElementInfo c in n.Children)
{
ElementInfo? f = Find(c, id);
if (f is not null) return f;
}
return null;
}
/// <summary>The SAME "resolve a row template from the standalone
/// 0x2100002B fixture" resolver every other Options-panel page-controller
/// test file uses — Config's own template array points at 0x2100002B too
/// (the tab-host layout), not the standalone 0x21000029 fixture.</summary>
private static Func<uint, uint, UiElement?> MakeTemplateResolver()
{
ElementInfo panelRoot = FixtureLoader.LoadOptionsPanelInfos();
return (layoutId, elementId) =>
{
if (layoutId != 0x2100002Bu) return null;
ElementInfo? templateInfo = Find(panelRoot, elementId);
return templateInfo is null ? null : LayoutImporter.Build(templateInfo, NoTex, null).Root;
};
}
private sealed class FakeBindings
{
public DisplaySettings Display = DisplaySettings.Default;
public AudioSettings Audio = AudioSettings.Default;
public CameraTurningSettings CameraTurning = CameraTurningSettings.Default;
public ChatSettings Chat = ChatSettings.Default;
public List<DisplaySettings> DisplaySaves { get; } = new();
public List<AudioSettings> AudioSaves { get; } = new();
public List<CameraTurningSettings> CameraTurningSaves { get; } = new();
public List<ChatSettings> ChatSaves { get; } = new();
public ConfigOptionsPageController.Bindings ToBindings() => new(
LoadDisplay: () => Display,
SaveDisplay: value => { Display = value; DisplaySaves.Add(value); },
LoadAudio: () => Audio,
SaveAudio: value => { Audio = value; AudioSaves.Add(value); },
LoadCameraTurning: () => CameraTurning,
SaveCameraTurning: value => { CameraTurning = value; CameraTurningSaves.Add(value); },
LoadChat: () => Chat,
SaveChat: value => { Chat = value; ChatSaves.Add(value); });
}
private static (OptionsPanelController Panel, FakeBindings Bindings, bool Bound) BindReal(
Func<uint, uint, string?>? resolveString = null)
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
var fakeBindings = new FakeBindings();
bool bound = ConfigOptionsPageController.Bind(
layout,
controller.ConfigPage,
MakeTemplateResolver(),
resolveString ?? ((_, _) => null),
fakeBindings.ToBindings());
return (controller, fakeBindings, bound);
}
[Fact]
public void Bind_Succeeds_AndRegistersExactly30Rows()
{
// 27 visible rows; the 3 toggle+slider trios each register TWO
// IOptionRow instances (toggle half + slider half) — 27 + 3 = 30,
// matching the doc's own "27 Add* calls... three toggle+slider rows
// are one widget each" accounting.
(OptionsPanelController controller, _, bool bound) = BindReal();
Assert.True(bound);
Assert.Equal(30, controller.ConfigPage.Rows.Count);
}
[Fact]
public void Bind_RowTypeSequence_MatchesAuthoredSectionOrder()
{
(OptionsPanelController controller, _, bool bound) = BindReal();
Assert.True(bound);
Type[] expected =
[
typeof(IntOptionRow), // Sound Features menu
typeof(BoolOptionRow), typeof(FloatOptionRow), // Sound trio
typeof(BoolOptionRow), typeof(FloatOptionRow), // Ambient trio
typeof(BoolOptionRow), typeof(FloatOptionRow), // Interface trio
typeof(BoolOptionRow), // Play sound only when active
typeof(FloatOptionRow), // Camera Stiffness
typeof(FloatOptionRow), // Camera Adjustment Speed
typeof(FloatOptionRow), // Field of View
typeof(BoolOptionRow), // Align To Slope
typeof(StringOptionRow), // Resolution
typeof(BoolOptionRow), // Full Screen
typeof(BoolOptionRow), // Sync To Refresh
typeof(FloatOptionRow), // Screen Brightness
typeof(BoolOptionRow), // Automatic Degrades
typeof(FloatOptionRow), // Graphics Performance
typeof(FloatOptionRow), // Degrade Distance
typeof(IntOptionRow), // Landscape Texture Detail
typeof(IntOptionRow), // Environment Texture Detail
typeof(IntOptionRow), // Texture Filtering
typeof(IntOptionRow), // Landscape Draw Distance
typeof(BoolOptionRow), // Building Detail Textures
typeof(BoolOptionRow), // Multi-Pass Alpha
typeof(FloatOptionRow), // Mouse Look Sensitivity
typeof(BoolOptionRow), // Invert Mouselook Y Axis
typeof(BoolOptionRow), // Use Mouse Turning
typeof(IntOptionRow), // Chat Font Face
typeof(IntOptionRow), // Chat Font Size
];
Type[] actual = controller.ConfigPage.Rows.Select(r => r.GetType()).ToArray();
Assert.Equal(expected, actual);
}
[Fact]
public void ToggleRow_SfxDisabled_WritesThroughAudioBindings()
{
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
var row = (BoolOptionRow)controller.ConfigPage.Rows[1]; // Sound trio toggle
row.SetCurrentValue(!AudioSettings.Default.SfxDisabled);
Assert.Single(bindings.AudioSaves);
Assert.Equal(!AudioSettings.Default.SfxDisabled, bindings.AudioSaves[0].SfxDisabled);
// The slider half's own field must be untouched by the toggle write.
Assert.Equal(AudioSettings.Default.Sfx, bindings.AudioSaves[0].Sfx);
}
[Fact]
public void SliderRow_SoundVolume_ConvertsScalarToRealUnitRange()
{
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
var row = (FloatOptionRow)controller.ConfigPage.Rows[2]; // Sound trio slider, [0,1]
row.SetCurrentValue(0.25f);
Assert.Equal(0.25f, bindings.AudioSaves[^1].Sfx);
}
[Fact]
public void SliderRow_CameraAdjustmentSpeed_ConvertsRealUnitOutOf0To1Range()
{
// Camera_AdjustmentSpeed's retail range is [5,80] — the widget only
// ever sees a normalized [0,1] scalar; SetCurrentValue below drives
// the ROW directly in real units (what CameraTurningSettings stores),
// so this pins the row-level write, not the widget conversion (a
// separate concern already covered by ToNormalized/FromNormalized's
// own math, exercised implicitly by every slider apply/refresh path).
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
var row = (FloatOptionRow)controller.ConfigPage.Rows[9]; // AdjustmentSpeed
row.SetCurrentValue(62.5f);
Assert.Equal(62.5f, bindings.CameraTurningSaves[^1].AdjustmentSpeed);
}
[Fact]
public void MenuRow_SoundFeatures_WritesThroughAudioBindings()
{
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
var row = (IntOptionRow)controller.ConfigPage.Rows[0]; // Sound Features
row.SetCurrentValue(1);
Assert.Equal(1, bindings.AudioSaves[^1].SoundFeatures);
}
[Fact]
public void MenuRow_Resolution_IsStringBacked_AndWritesThroughDisplayBindings()
{
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
var row = (StringOptionRow)controller.ConfigPage.Rows[12]; // Resolution
row.SetCurrentValue("1920x1080");
Assert.Equal("1920x1080", bindings.DisplaySaves[^1].Resolution);
}
[Fact]
public void ToggleRow_UseMouseTurning_WritesTheConfigTabOwnPreference_NotTheWireBit()
{
// CameraTurningSettings.UseMouseTurning is the Config tab's OWN
// client-local checkbox — distinct from the Gameplay-tab macro's
// server-synced PlayerOption.UseMouseTurning bit. This pins that
// this row writes ONLY the store, never a wire command (there is no
// wire seam wired into ConfigOptionsPageController.Bindings at all).
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
var row = (BoolOptionRow)controller.ConfigPage.Rows[27]; // Use Mouse Turning
row.SetCurrentValue(true);
Assert.True(bindings.CameraTurningSaves[^1].UseMouseTurning);
}
[Fact]
public void MenuRow_ChatFontSize_WritesThroughChatBindings_WithoutTouchingHearFlags()
{
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
var row = (IntOptionRow)controller.ConfigPage.Rows[29]; // Chat Font Size
row.SetCurrentValue(3);
Assert.Equal(3, bindings.ChatSaves[^1].ChatFontSizeIndex);
Assert.Equal(ChatSettings.Default.HearGeneralChat, bindings.ChatSaves[^1].HearGeneralChat);
}
[Fact]
public void Apply_CommitsBaseline_ForAMixOfRowTypes()
{
(OptionsPanelController controller, _, _) = BindReal();
var toggle = (BoolOptionRow)controller.ConfigPage.Rows[1];
var slider = (FloatOptionRow)controller.ConfigPage.Rows[2];
var menu = (IntOptionRow)controller.ConfigPage.Rows[0];
toggle.SetCurrentValue(!toggle.Current);
slider.SetCurrentValue(0.1f);
menu.SetCurrentValue(1);
Assert.True(controller.ConfigPage.Changed);
controller.ConfigPage.Apply();
Assert.False(controller.ConfigPage.Changed);
Assert.Equal(toggle.Current, toggle.Saved);
Assert.Equal(slider.Current, slider.Saved);
Assert.Equal(menu.Current, menu.Saved);
}
[Fact]
public void Defaults_RestoresRetailDefaultValue_ForEveryRow_WithoutCommitting()
{
(OptionsPanelController controller, _, _) = BindReal();
foreach (IOptionRow r in controller.ConfigPage.Rows)
{
switch (r)
{
case BoolOptionRow b: b.SetCurrentValue(!b.DefaultValue); break;
case FloatOptionRow f: f.SetCurrentValue(f.DefaultValue + 1000f); break;
case IntOptionRow i: i.SetCurrentValue(i.DefaultValue + 1); break;
case StringOptionRow s: s.SetCurrentValue(s.DefaultValue + "-x"); break;
}
}
controller.ConfigPage.Defaults();
foreach (IOptionRow r in controller.ConfigPage.Rows)
{
switch (r)
{
case BoolOptionRow b: Assert.Equal(b.DefaultValue, b.Current); break;
case FloatOptionRow f: Assert.Equal(f.DefaultValue, f.Current); break;
case IntOptionRow i: Assert.Equal(i.DefaultValue, i.Current); break;
case StringOptionRow s: Assert.Equal(s.DefaultValue, s.Current); break;
}
}
}
// The tab host's own private per-page SLOT ids (OptionsPanelController's
// ConfigPageId/ChatPageId) — the ids that actually survive base-merge in
// the host-mounted tree (each page's own standalone-layout root id does
// NOT survive; see ConfigOptionsPageController.PageSlotElementId's own
// doc). Both controllers keep their own copy private; these test-local
// literals mirror them for scoped lookups exactly the way the
// controllers themselves scope their scrollbar linkage.
private const uint ConfigPageSlotId = 0x10000213u;
private const uint ChatPageSlotId = 0x1000050Cu;
[Fact]
public void ScrollbarLinkage_ModelPointsAtTheConfigListBoxScroll()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
var fakeBindings = new FakeBindings();
bool bound = ConfigOptionsPageController.Bind(
layout, controller.ConfigPage, MakeTemplateResolver(), (_, _) => null,
fakeBindings.ToBindings());
Assert.True(bound);
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
var scrollbar = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ScrollbarElementId));
Assert.Same(listBox.Scroll, scrollbar.Model);
}
[Fact]
public void SharedScrollbarId_ChatAndConfigBoundTogether_EachOwnsItsOwnScrollbar()
{
// The shared-id hazard this campaign's binding pattern exists for:
// Chat's ListBox (0x1000050D) and Config's ListBox (0x10000200) both
// author scrollbar element id 0x10000201. Binding BOTH pages against
// the SAME host layout must not let the second Bind() call clobber
// the first page's scrollbar linkage (or vice versa).
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
var chatBindings = new ChatOptionsPageControllerFakeBindings();
bool chatBound = ChatOptionsPageController.Bind(
layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null,
chatBindings.ToBindings());
var configBindings = new FakeBindings();
bool configBound = ConfigOptionsPageController.Bind(
layout, controller.ConfigPage, MakeTemplateResolver(), (_, _) => null,
configBindings.ToBindings());
Assert.True(chatBound);
Assert.True(configBound);
var chatSlot = UiElement.FindDescendant(controller.TabPanel, ChatPageSlotId)!;
var chatListBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(chatSlot, ChatOptionsPageController.ListBoxElementId));
var chatScrollbar = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(chatSlot, ChatOptionsPageController.ScrollbarElementId));
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
var configListBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
var configScrollbar = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ScrollbarElementId));
Assert.Same(chatListBox.Scroll, chatScrollbar.Model);
Assert.Same(configListBox.Scroll, configScrollbar.Model);
Assert.NotSame(chatScrollbar, configScrollbar);
Assert.NotSame(chatListBox.Scroll, configListBox.Scroll);
}
private sealed class ChatOptionsPageControllerFakeBindings
{
public float DefaultOpacity = 0.5f;
public float ActiveOpacity = 1.0f;
public ChatOptionsPageController.Bindings ToBindings() => new(
CurrentDefaultOpacity: () => DefaultOpacity,
CurrentActiveOpacity: () => ActiveOpacity,
SetDefaultOpacity: value => DefaultOpacity = value,
SetActiveOpacity: value => ActiveOpacity = value,
DefaultOpacityDatDefault: 0.5f,
ActiveOpacityDatDefault: 1.0f,
CurrentFilter: _ => 0xFBFFFFFFul,
SetFilter: (_, _) => { });
}
[Fact]
public void Bind_MissingListBox_ReturnsFalse_AndDoesNotThrow()
{
var emptyRoot = new ElementInfo { Id = 0, Type = 3 };
ImportedLayout emptyLayout = LayoutImporter.Build(emptyRoot, NoTex, null);
var page = new OptionPage();
var fakeBindings = new FakeBindings();
bool bound = ConfigOptionsPageController.Bind(
emptyLayout, page, MakeTemplateResolver(), (_, _) => null,
fakeBindings.ToBindings());
Assert.False(bound);
Assert.Empty(page.Rows);
}
[Fact]
public void LabelResolutionFailure_LeavesLabelsNull_NeverInventsEnglish_AndStillRegistersAllRows()
{
(OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => null);
Assert.True(bound);
Assert.Equal(30, controller.ConfigPage.Rows.Count);
}
}