acdream/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs
Erik 472525b99e fix(ui): OP6 rework — six range captions, un-invert Sound enabled flags, five font faces
Fixes all three MUST-FIX findings from the OP6 REJECT review
(docs/research/2026-08-11-op6-review.md) plus its SHOULD-FIXes and NOTEs.

M1 — the "retail ships zero range captions" claim was a Binary Ninja
constant-folding artifact (the same class the header-string globals a few
lines above already worked around). The six SetSliderLabel call sites
byte-decode to reads of runtime-filled ID_Graphics_Value_* globals, not
immediate zeros (PE-byte-verified against the PDB-paired acclient.exe,
independently re-derived in this session, not just re-asserted from the
review). ConfigOptionsPageController.BuildSliderRow gained optional
rangeLowKey/rangeHighKey parameters wired for all six idx6 sliders (Camera
Stiffness Soft/Hard, Adjustment Speed Slow/Fast, FOV Narrow/Wide, Screen
Brightness Dark/Bright, Graphics Performance Speed/Detail, Degrade Distance
Close/Far) via the same SetRangeLabel mechanism OP5's Chat opacity sliders
already established. Mouse Look Sensitivity (idx3) correctly stays
uncaptioned — the one genuine SetSliderLabel omission. Class doc corrected;
gate-script lines 535/653-equivalent corrected in place.

M2 — the three Sound "Disabled" toggles were semantically inverted:
SoundManager::effect_sounds_enabled/ambient_sounds_enabled/
interface_sounds_enabled are all compiled = 1 in .data, and
UserPreferences::RegisterPreference binds the checkbox's boolean value
DIRECTLY onto those enabled-sense statics — checked-by-default means
enabled-by-default, not disabled. AudioSettings.SfxDisabled/AmbientDisabled/
InterfaceDisabled renamed to SfxEnabled/AmbientEnabled/InterfaceEnabled
(fresh JSON keys — the rejected slice's keys never shipped in an accepted
build); RuntimeSettingsStartupTargets.ApplyAudio now computes effective
volume through the extracted, independently-unit-tested pure function
ComputeEffectiveCategoryVolumes (enabled ? slider : 0f). This closes the
blast radius the review flagged: a missing key in an EXISTING settings.json
now falls back to AudioSettings.Default, which is enabled=true, so a fresh
launch is audible, not muted. AP-199's wording and gate-script step 6
corrected; the enshrined-inversion test rewritten to assert the correct
default and a new SettingsStore test pins the legacy-file fallback path.

M3 — UI_ChatFontFace now ships all five of retail's authored choices
(Arial, CourierNew, PalatinoLinotype, Tahoma, TimesNewRoman — a fixed
compile-time array at gmClient::InitUIPreferences, PE-byte-verified
present verbatim in .rdata, not a per-machine runtime enumeration as the
rejected slice's comment claimed). Default index 2 (PalatinoLinotype) now
indexes a real entry.

S1 — Bind() now emits the sixth trailing AddSeperator retail's own
InitOptions ends with (0x0049E80D), matching retail's 39-item ListBox (6
headers + 6 separators + 27 option-widget-rows) instead of 38.

S2 — Screen Brightness gets its own DisplaySettings.ScreenBrightness field
([-1,1], default 0) instead of overloading Gamma, which has a different
unit system (default 1.0, legacy [0.5,2.0] slider) and its own live
Settings-panel consumer.

S3 — UiScrollbar and UiMenu gained a settable TooltipText surfaced through
GetTooltipText (UiButton's existing pattern). Every slider and menu row's
own interactive widget (not just toggle/trio rows) now carries retail's
"<label>_Help" tooltip, verified as a universal suffix convention across
every AttachPreference site touched by this tab.

S4 — "800x600" added to DisplaySettings.AvailableResolutions: a genuine
retail display mode (Device::ForceDisplayResolution(1,0x320,0x258) at
startup) and the Config tab's own byte-verified Resolution row default, not
an invented preset. Defaults now lands on a highlighted, re-selectable
dropdown entry instead of an orphaned value.

S5 — four new/extended tests: ComputeEffectiveCategoryVolumes gets a
dedicated pure-function value assertion (Theory + a default-profile-is-
audible Fact) in RuntimeSettingsControllerTests, closing the "only event
order was asserted" gap that let M2 ship; a label/choice-key conformance
table in ConfigOptionsPageControllerTests enumerates every key this tab
queries (traced directly from the fixed code paths, not guessed) and fails
on an invented OR a dropped key; a per-row DefaultValue pin asserts every
row's default against the retail literal directly, independent of the
underlying settings-record defaults; and the S1 separator fix gets its own
39-item stacked-ListBox count pin.

NOTEs — AP-198's row count was always ten (its own enumeration never said
nine); the commit-message inconsistency N1 flagged is reconciled in both
the row and the section-summary line, and its Screen Brightness sub-clause
now matches S2. N2: Bind() now reads the scrollbar id from
UiTemplateListBox.ScrollbarElementId (dat property 0x72) instead of a
hardcoded constant. N3 (batch Defaults writes) and N4 (AfterApply on
Config-tab entry, needs no action) are left as recorded — out of this
rework's scope per the review's own disposition.

Full Release suite: 13,125 passed / 4 skipped / 0 failed (baseline
13,117/4/0 — net +8 tests added, 0 regressions, 0 removed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 08:06:20 +02:00

861 lines
38 KiB
C#

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()
{
// OP6 rework (2026-08-11, review M2): every Sound trio's checkbox
// defaults to CHECKED — SetDefaultValue(1, 0x3f800000) in
// gmConfigUI::InitOptions — AND checked means ENABLED, not
// "Disabled" (SoundManager::effect_sounds_enabled/
// ambient_sounds_enabled/interface_sounds_enabled are all compiled
// = 1 in .data, and UserPreferences::RegisterPreference binds the
// checkbox's boolean value directly onto those statics — see
// AudioSettings' own class doc for the full byte evidence). A
// fresh character therefore hears sound by default; this is NOT
// the "looks backwards but isn't a fix" case the rejected slice
// claimed — the rejected slice's inversion was the actual bug.
AudioSettings d = AudioSettings.Default;
Assert.Equal(0, d.SoundFeatures);
Assert.True(d.SfxEnabled);
Assert.True(d.AmbientEnabled);
Assert.True(d.InterfaceEnabled);
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;
// OP6 rework (review S2): ScreenBrightness is its own field now.
Assert.Equal(0f, d.ScreenBrightness);
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);
// OP6 rework (review M2): renamed Enabled-sense fields, fresh
// JSON keys ("sfxEnabled" etc.) — see AudioSettings' own doc.
var written = AudioSettings.Default with
{
SoundFeatures = 1,
SfxEnabled = false,
AmbientEnabled = false,
InterfaceEnabled = 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_ExistingProfileMissingTheEnabledKeys_LoadsWithSoundOn()
{
// OP6 rework (review M2 blast radius): an existing settings.json
// written by a build that never had the sfxEnabled/ambientEnabled/
// interfaceEnabled keys (e.g. only the "display" section present)
// must fall back to AudioSettings.Default — enabled=true — NOT to
// an inverted/missing-means-muted reading. This is the exact
// scenario the review's blast-radius finding named: "every existing
// settings.json... loads as disabled" under the rejected slice.
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"acdream-op6-audio-legacy-{Guid.NewGuid():N}.json");
try
{
System.IO.File.WriteAllText(path, """
{
"version": 2,
"display": { "resolution": "1920x1080" }
}
""");
var store = new SettingsStore(path);
AudioSettings read = store.LoadAudio();
Assert.True(read.SfxEnabled);
Assert.True(read.AmbientEnabled);
Assert.True(read.InterfaceEnabled);
Assert.Equal(1.0f, read.Sfx);
Assert.Equal(1.0f, read.Ambient);
(float sfx, float ambient) =
AcDream.App.Settings.RuntimeSettingsStartupTargets.ComputeEffectiveCategoryVolumes(read);
Assert.Equal(1.0f, sfx);
Assert.Equal(1.0f, ambient);
}
finally
{
System.IO.File.Delete(path);
}
}
[Fact]
public void SettingsStore_DisplayRoundTrip_PreservesTheTenOP6Fields()
{
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
{
// OP6 rework (review S2): ScreenBrightness's own field.
ScreenBrightness = 0.25f,
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 Bind_ListBoxStacks39Items_SixHeaders_SixSeparators_27OptionRows()
{
// OP6 rework (review S1): retail's own InitOptions ends with a
// SIXTH AddSeperator tailcall (0x0049e80d @Bind) — a trailing
// separator after the LAST section, not just the five interior
// ones. 6 headers + 6 separators + 27 option-widget-rows (the 3
// trios are ONE widget row each despite registering TWO
// IOptionRow instances — see Bind_Succeeds_AndRegistersExactly30Rows)
// = 39 stacked ListBox items total, matching retail exactly. The
// rejected slice built 38 (five interior separators only).
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));
UiElement viewport = Assert.Single(listBox.Children);
Assert.Equal(39, viewport.Children.Count);
}
[Fact]
public void ToggleRow_SfxEnabled_WritesThroughAudioBindings()
{
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
var row = (BoolOptionRow)controller.ConfigPage.Rows[1]; // Sound trio toggle
row.SetCurrentValue(!AudioSettings.Default.SfxEnabled);
Assert.Single(bindings.AudioSaves);
Assert.Equal(!AudioSettings.Default.SfxEnabled, bindings.AudioSaves[0].SfxEnabled);
// 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;
}
}
}
[Fact]
public void EveryRow_DefaultValue_MatchesRetailLiteral_NotJustSelfConsistency()
{
// OP6 rework (review S5): Defaults_RestoresRetailDefaultValue...
// above only proves each row's Current becomes its OWN
// DefaultValue — it says nothing about whether that DefaultValue
// IS retail's byte-verified literal. This pins every row's
// DefaultValue directly against the decompiled
// gmConfigUI::InitOptions/gmClient::InitUIPreferences constant,
// independent of the underlying settings-record defaults (already
// pinned separately above) — Build*Row's own inline
// `defaultValue:` parameters could drift from those record
// defaults without either test alone catching it.
(OptionsPanelController controller, _, _) = BindReal();
IReadOnlyList<IOptionRow> rows = controller.ConfigPage.Rows;
Assert.Equal(30, rows.Count);
object[] expected =
[
0, // 0 Sound Features menu (Stereo=0)
true, 1.0f, // 1-2 Sound trio (Enabled, Sfx)
true, 1.0f, // 3-4 Ambient trio
true, 1.0f, // 5-6 Interface trio
true, // 7 Play Sound Only When Active
0.45f, // 8 Camera Stiffness
40.0f, // 9 Camera Adjustment Speed
90.0f, // 10 Field Of View
true, // 11 Align To Slope
"800x600", // 12 Resolution
true, // 13 Full Screen
false, // 14 Sync To Refresh
0f, // 15 Screen Brightness
false, // 16 Automatic Degrades
0f, // 17 Graphics Performance
50.0f, // 18 Degrade Distance
2, // 19 Landscape Texture Detail
1, // 20 Environment Texture Detail
1, // 21 Texture Filtering
8, // 22 Landscape Draw Distance (opaque — AP-198 sub-note)
true, // 23 Building Detail Textures
false, // 24 Multi-Pass Alpha
0.55f, // 25 Mouse Look Sensitivity
false, // 26 Invert Mouselook Y Axis
false, // 27 Use Mouse Turning
2, // 28 Chat Font Face (PalatinoLinotype)
1, // 29 Chat Font Size
];
for (int i = 0; i < rows.Count; i++)
{
object actual = rows[i] switch
{
BoolOptionRow b => b.DefaultValue,
FloatOptionRow f => f.DefaultValue,
IntOptionRow n => n.DefaultValue,
StringOptionRow s => s.DefaultValue,
_ => throw new InvalidOperationException(
$"row {i} has unexpected type {rows[i].GetType()}"),
};
Assert.True(
Equals(expected[i], actual),
$"row {i}: expected retail default {expected[i]} but row.DefaultValue was {actual}.");
}
}
[Fact]
public void Bind_ResolvesOnlyTheAuthoredStringKeys_NoInventedOrDroppedKey()
{
// OP6 rework (review S5): the Character tab's registry-conformance
// pattern ("an invented row or a dropped row fails the build")
// applied to Config's label/tooltip/choice STRING KEYS — the exact
// dimension M3's 1-vs-5-entry ChatFontFaceChoices bug lived in and
// that LabelResolutionFailure... (null resolver) cannot see, since
// a null resolver makes every key invisible. Every string this
// page queries is transcribed here directly from
// gmClient::InitUIPreferences/gmConfigUI::InitOptions (bare label
// keys, their "<key>_Help" tooltip siblings, and every menu choice
// key) — an extra query (a typo'd/invented key) fails immediately;
// a query this test expected but never saw (a dropped key) fails
// at the end.
string[] expectedKeys =
[
// Headers
"ID_Sound_SoundSection", "ID_Camera_CameraSection",
"ID_Graphics_GraphicsSection", "ID_Graphics_TextureSection",
"ID_Input_InputSection", "ID_UI_UISection",
// Sound section
"ID_Sound_SoundFeatures", "ID_Sound_SoundFeatures_Help",
"ID_Sound_Stereo", "ID_Sound_Mono",
"ID_Sound_DisableSound", "ID_Sound_DisableSound_Help",
"ID_Sound_EffectVolume_Help",
"ID_Sound_DisableAmbientSound", "ID_Sound_DisableAmbientSound_Help",
"ID_Sound_AmbientVolume_Help",
"ID_Sound_DisableInterfaceSound", "ID_Sound_DisableInterfaceSound_Help",
"ID_Sound_InterfaceVolume_Help",
"ID_Sound_NoFocusNoSound", "ID_Sound_NoFocusNoSound_Help",
// Camera section
"ID_Camera_Stiffness", "ID_Camera_Stiffness_Help",
"ID_Graphics_Value_Soft", "ID_Graphics_Value_Hard",
"ID_Camera_AdjustmentSpeed", "ID_Camera_AdjustmentSpeed_Help",
"ID_Graphics_Value_Slow", "ID_Graphics_Value_Fast",
"ID_Graphics_FieldOfView", "ID_Graphics_FieldOfView_Help",
"ID_Graphics_Value_Narrow", "ID_Graphics_Value_Wide",
"ID_Camera_AlignToSlope", "ID_Camera_AlignToSlope_Help",
// Graphics section
"ID_Rendering_DisplayResolution", "ID_Rendering_DisplayResolution_Help",
"ID_Rendering_FullScreen", "ID_Rendering_FullScreen_Help",
"ID_Rendering_SyncToDisplayRefresh", "ID_Rendering_SyncToDisplayRefresh_Help",
"ID_Graphics_ScreenBrightness", "ID_Graphics_ScreenBrightness_Help",
"ID_Graphics_Value_Dark", "ID_Graphics_Value_Bright",
"ID_Graphics_AdaptiveDegrade", "ID_Graphics_AdaptiveDegrade_Help",
"ID_Graphics_AdaptiveDegradeBias", "ID_Graphics_AdaptiveDegradeBias_Help",
"ID_Graphics_Value_Speed", "ID_Graphics_Value_Detail",
"ID_Graphics_DegradeDistance", "ID_Graphics_DegradeDistance_Help",
"ID_Graphics_Value_Close", "ID_Graphics_Value_Far",
// Rendering Quality section
"ID_Graphics_LandscapeTextureDetail", "ID_Graphics_LandscapeTextureDetail_Help",
"ID_Graphics_EnvironmentTextureDetail", "ID_Graphics_EnvironmentTextureDetail_Help",
"ID_Graphics_Value_VeryLow", "ID_Graphics_Value_Low", "ID_Graphics_Value_Medium",
"ID_Graphics_Value_High", "ID_Graphics_Value_VeryHigh",
"ID_Graphics_TextureFiltering", "ID_Graphics_TextureFiltering_Help",
"ID_Graphics_TextureFiltering_Bilinear", "ID_Graphics_TextureFiltering_Trilinear",
"ID_Graphics_TextureFiltering_Sharp", "ID_Graphics_TextureFiltering_Anisotropic",
"ID_Graphics_LandscapeDrawDistance", "ID_Graphics_LandscapeDrawDistance_Help",
"ID_Graphics_Value_Extreme",
"ID_Graphics_BuildingDetailTextures", "ID_Graphics_BuildingDetailTextures_Help",
"ID_Graphics_MultiPassAlpha", "ID_Graphics_MultiPassAlpha_Help",
// Input section
"ID_Input_MouseLookSensitivity", "ID_Input_MouseLookSensitivity_Help",
"ID_Input_InvertMouseLookYAxis", "ID_Input_InvertMouseLookYAxis_Help",
"ID_Input_UseMouseTurning", "ID_Input_UseMouseTurning_Help",
// UI section
"ID_UI_ChatFontFace", "ID_UI_ChatFontFace_Help",
"ID_UI_Value_Arial", "ID_UI_Value_CourierNew", "ID_UI_Value_PalatinoLinotype",
"ID_UI_Value_Tahoma", "ID_UI_Value_TimesNewRoman",
"ID_UI_ChatFontSize", "ID_UI_ChatFontSize_Help",
"ID_UI_Value_Tiny", "ID_UI_Value_Small", "ID_UI_Value_Medium",
"ID_UI_Value_Large", "ID_UI_Value_XLarge",
];
var expectedByHash = new Dictionary<uint, string>();
foreach (string key in expectedKeys)
expectedByHash[DatStringResolver.ComputeHash(key)] = key;
var seen = new HashSet<string>();
Func<uint, uint, string?> recordingResolver = (table, hash) =>
{
Assert.Equal(0x23000003u, table);
Assert.True(
expectedByHash.TryGetValue(hash, out string? key),
$"resolveString queried hash 0x{hash:X8} — no key in the expected table hashes "
+ "to this value. Either an invented/typo'd key was added, or this test's "
+ "expected-key table is stale.");
seen.Add(key!);
return "x"; // any non-null value keeps the row-build path fully populated.
};
(_, _, bool bound) = BindReal(recordingResolver);
Assert.True(bound);
IEnumerable<string> missing = expectedByHash.Values.Except(seen);
Assert.True(
!missing.Any(),
"Bind never queried these expected keys (a dropped row/key): "
+ string.Join(", ", missing));
}
// 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,
FlushOpacity: () => { },
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);
}
}