using System; using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.App.Tests.UI.Layout; /// /// Campaign OP slice OP6 (2026-08-11) conformance + behavior tests for /// — the same hermetic pattern /// CharacterOptionsPageControllerTests/ChatOptionsPageControllerTests /// established: pure-data conformance against the byte-verified decomp /// tables (gmConfigUI::InitOptions + gmClient::InitUIPreferences), /// then behavioral tests against the committed /// options_panel_2100006E_1000018D.json + options_2100002B.json /// fixtures — no live DAT access. Also covers the shared scrollbar-id hazard /// (Config and Chat both author element 0x10000201) and a settings- /// store round-trip per record OP6 touched. /// 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; } /// 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. private static Func 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 DisplaySaves { get; } = new(); public List AudioSaves { get; } = new(); public List CameraTurningSaves { get; } = new(); public List 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? 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); } /// Every built under a subtree, in /// document (build) order — the same recursive-walk shape /// ChatOptionsPageControllerTests.CollectScalarSliders uses for /// its own widget-type collection. private static List CollectMenus(UiElement root) { var found = new List(); Walk(root, found); return found; static void Walk(UiElement node, List acc) { if (node is UiMenu menu) acc.Add(menu); foreach (UiElement child in node.Children) Walk(child, acc); } } [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( 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); } /// /// #378 regression (2026-08-11, gate 4): before this fix, NOTHING wired /// any Config-tab dropdown's chrome — every sprite id was 0, no arrow /// cap, no popup. Drives the REAL hit-test/pick /// path /// already established for vendor's own dropdown — the SAME /// authored popup catalog (LayoutDesc 0x21000043) both dropdowns /// derive from, byte-verified identical (see /// 's own /// doc), so the SAME click-geometry formula applies. /// [Fact] public void MenuRow_SoundFeatures_OpensAndSelectsThroughRealHitPath_UsingAuthoredPopupGeometry() { 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(), resolveSprite: _ => (1u, 8, 8)); Assert.True(bound); var listBox = Assert.IsType( layout.FindElement(ConfigOptionsPageController.ListBoxElementId)); List menus = CollectMenus(listBox); Assert.Equal(8, menus.Count); // the 8 Config-tab dropdown rows UiMenu soundFeatures = menus[0]; // build order matches Rows order — Sound Features first // Wiring: without these UiMenu.OnDrawOverlay early-returns (nothing // renders) and the button face never draws either — the bare-text // symptom #378 reported. Assert.NotNull(soundFeatures.SpriteResolve); Assert.NotEqual(0u, soundFeatures.NormalSprite); Assert.NotEqual(0u, soundFeatures.PressedSprite); Assert.NotEqual(0u, soundFeatures.ItemNormalSprite); Assert.NotEqual(0u, soundFeatures.ItemHighlightSprite); Assert.NotEqual(0u, soundFeatures.ArrowCapClosedSprite); Assert.NotEqual(0u, soundFeatures.ArrowCapOpenSprite); // Geometry from the live-DAT-verified popup catalog (0x21000043) — // see MenuChromeSprites' own doc. Assert.True(soundFeatures.Scrollable); Assert.Equal(6, soundFeatures.RowsPerColumn); Assert.Equal(18f, soundFeatures.RowHeight); Assert.Equal(100f, soundFeatures.ColumnWidth); Assert.False(soundFeatures.OpenUpward); // no authored attribute 5 — absent defaults false Assert.False(soundFeatures.IsOpen); // Open via the real widget event path (button click). Assert.True(soundFeatures.OnEvent(new UiEvent(0, soundFeatures, UiEventType.MouseDown, 0, 10, 5))); Assert.True(soundFeatures.IsOpen); // "Mono" is the SECOND choice (Stereo, then Mono) -> row index 1. // Popup opens DOWNWARD (OpenUpward=false), so its top sits at the // button's own bottom edge (ly = Height) — same formula // VendorUiControllerTests already established for the identical // popup catalog. const int border = 5; // RetailChromeSprites.Border (UiMenu's private bevel thickness) const int targetRow = 1; float iy = targetRow * soundFeatures.RowHeight + soundFeatures.RowHeight / 2f; float ly = soundFeatures.Height + iy + border; Assert.True(soundFeatures.OnEvent(new UiEvent(0, soundFeatures, UiEventType.MouseDown, 0, 10, (int)ly))); Assert.False(soundFeatures.IsOpen); // picking a row closes the popup Assert.Equal(1, fakeBindings.Audio.SoundFeatures); Assert.Equal(1, fakeBindings.AudioSaves[^1].SoundFeatures); } /// User gate report 2026-08-13: every Config dropdown drew its /// text gold + left-aligned and its popup a fixed 6 rows. The corrected /// values are MEASURED authored facts (menuprobe3, /// ): /// label child 0x10000355 + row template 0x1000035A are white + /// hJustify=Center, and popup ListBox 0x10000358 is edge-docked /// (L=T=R=B=1), arming retail's RecalculatePopupSize size-to-content /// path. Asserted on ALL 8 menus — one shared ApplyMenuChrome must not /// quietly skip any. [Fact] public void MenuRows_All8_UseTheAuthoredTextStyleAndSizeToContent() { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; var fakeBindings = new FakeBindings(); Assert.True(ConfigOptionsPageController.Bind( layout, controller.ConfigPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings(), resolveSprite: _ => (1u, 8, 8))); var listBox = Assert.IsType( layout.FindElement(ConfigOptionsPageController.ListBoxElementId)); List menus = CollectMenus(listBox); Assert.Equal(8, menus.Count); foreach (UiMenu menu in menus) { Assert.Equal(System.Numerics.Vector4.One, menu.TextColor); Assert.True(menu.ButtonTextCentered); Assert.True(menu.ItemTextCentered); Assert.True(menu.PopupSizeToContent); } } [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 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 "1280x720", // 12 Resolution (#391: fixture fallback default — // DisplaySettings.Default.Resolution; production // passes the desktop mode via DisplayModeCatalog) 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 "_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(); foreach (string key in expectedKeys) expectedByHash[DatStringResolver.ComputeHash(key)] = key; var seen = new HashSet(); Func 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 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( UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId)); var scrollbar = Assert.IsType( 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( UiElement.FindDescendant(chatSlot, ChatOptionsPageController.ListBoxElementId)); var chatScrollbar = Assert.IsType( UiElement.FindDescendant(chatSlot, ChatOptionsPageController.ScrollbarElementId)); var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; var configListBox = Assert.IsType( UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId)); var configScrollbar = Assert.IsType( 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: (_, _) => { }, DefaultOpacityCaption: new ChatOptionsDatCaptions.Caption("Inactive Opacity", null), ActiveOpacityCaption: new ChatOptionsDatCaptions.Caption("Active Opacity", null)); } [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); } // ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ─────────── private enum RowKind { Toggle, TrioToggle, Slider, Menu } /// /// One literal expectation per option-row-bearing viewport item, in the /// SAME order builds them /// (39 stacked items: 6 headers + 6 separators + 27 option rows — see /// /// for the index arithmetic this table shares). Wiring a future /// consumer for any row here means flipping BOTH this literal's /// StoreOnly AND the matching ConfigOptionsPageController /// BindXxxSection call site's own storeOnly: argument /// consciously — leaving either one stale fails this test. /// private static readonly (int ViewportIndex, RowKind Kind, bool StoreOnly, string Label)[] DimmingExpectations = { (1, RowKind.Menu, true, "Sound Features"), // AP-199 (2, RowKind.TrioToggle, false, "Disable Sound Effects"), // LIVE (3, RowKind.TrioToggle, false, "Disable Ambient Sound"), // LIVE (4, RowKind.TrioToggle, true, "Disable Interface Sound"), // AP-174/AP-199 (5, RowKind.Toggle, true, "Play Sound Only When Active"), // AP-199 (8, RowKind.Slider, true, "Camera Stiffness"), // TS-74 (9, RowKind.Slider, true, "Camera Adjustment Speed"), // TS-74 (10, RowKind.Slider, false, "Field Of View"), // NEXT-LAUNCH (11, RowKind.Toggle, true, "Align To Slope"), // TS-74 (14, RowKind.Menu, false, "Resolution"), // LIVE (15, RowKind.Toggle, false, "Full Screen"), // LIVE (16, RowKind.Toggle, false, "Sync To Refresh"), // NEXT-LAUNCH (17, RowKind.Slider, true, "Screen Brightness"), // review S2 (18, RowKind.Toggle, true, "Automatic Degrades"), // AP-198 (19, RowKind.Slider, true, "Graphics Performance"), // AP-198 (20, RowKind.Slider, true, "Degrade Distance"), // AP-198 (23, RowKind.Menu, true, "Landscape Texture Detail"), // AP-198 (24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198 (25, RowKind.Menu, true, "Texture Filtering"), // AP-198 (26, RowKind.Menu, true, "Landscape Draw Distance"), // AP-198 (27, RowKind.Toggle, true, "Building Detail Textures"), // AP-198 (28, RowKind.Toggle, true, "Multi-Pass Alpha"), // AP-198 (31, RowKind.Slider, true, "Mouse Look Sensitivity"), // TS-74 (32, RowKind.Toggle, true, "Invert Mouselook Y Axis"), // TS-74 (33, RowKind.Toggle, true, "Use Mouse Turning"), // TS-74 (36, RowKind.Menu, true, "Chat Font Face"), // AP-200 (37, RowKind.Menu, true, "Chat Font Size"), // AP-200 }; private static Vector4? FindTextLineColor(UiElement root, uint elementId) { if (UiElement.FindDescendant(root, elementId) is not UiText text) return null; IReadOnlyList lines = text.LinesProvider(); return lines.Count == 0 ? null : lines[0].Color; } [Fact] public void CaptionDimming_MatchesTheStoreOnlySetExactly() { // A non-null constant resolver is required here (unlike most of this // file's BindReal() default): SetLabelText's dim color is baked into // the UiText.Line closure only when a label actually resolves, so a // null resolver would leave every slider/menu label's LinesProvider // at its empty default and hide the very thing this test checks. (OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => "x"); Assert.True(bound); var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; var listBox = Assert.IsType( UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId)); UiElement viewport = Assert.Single(listBox.Children); IReadOnlyList items = viewport.Children.ToList(); Assert.Equal(39, items.Count); const uint ToggleCheckboxElementId = 0x10000219u; const uint SliderLabelElementId = 0x1000021Bu; const uint MenuLabelElementId = 0x10000223u; foreach ((int index, RowKind kind, bool storeOnly, string label) in DimmingExpectations) { Vector4 expected = storeOnly ? UiRenderContext.StoreOnlyCaptionColor : Vector4.One; Vector4? actual = kind switch { RowKind.Toggle => (UiElement.FindDescendant(items[index], ToggleCheckboxElementId) as UiButton)?.LabelColor, RowKind.TrioToggle => Assert.IsType(items[index]).Toggle?.LabelColor, RowKind.Slider => FindTextLineColor(items[index], SliderLabelElementId), RowKind.Menu => FindTextLineColor(items[index], MenuLabelElementId), _ => throw new InvalidOperationException($"unhandled row kind {kind}"), }; Assert.True( actual.HasValue, $"{label} (viewport index {index}, kind {kind}): could not locate the " + "caption widget/line to check its color."); Assert.True( expected == actual.Value, $"{label} (viewport index {index}): expected " + $"{(storeOnly ? "DIMMED" : "LIVE")} caption color {expected} but the " + $"built widget rendered {actual.Value}."); } } }