diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index b151c3f6..348e9212 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -26,7 +26,21 @@ What does NOT go here:
## #391 — Resolution list: offer only modern modes from the monitor's real mode list (user-directed curation)
-**Status:** OPEN — filed 2026-08-13, user-directed ("we should only
+**Status:** DONE 2026-08-13 (this commit) — display block slice 2, pending
+the user's gate. `DisplayModeCatalog` (App/Rendering) enumerates the
+window's monitor via Silk (`IMonitor.GetAllVideoModes`), curates through
+the pure `Curate` rule (modern families 16:9/16:10/21:9/32:9 ±2.5%,
+≥1280 wide, fits the desktop, desktop mode always included, refresh-rate
+duplicates collapsed, ascending), and is installed once at `GameWindow`
+load. The Config Resolution row takes the curated list + the desktop-mode
+Defaults value through two new optional `Bind` parameters; fixture
+callers keep the static ladder (800x600 now removed from it — the ladder
+itself passes the curation rule, pinned by test). Register row IA-22
+carries the deliberate deviation (retail listed every adapter mode and
+authored 800x600 as the default). The same catalog is the designated
+mode-validation source for #376/#388. Original filing below.
+
+**Original filing:** OPEN — filed 2026-08-13, user-directed ("we should only
support modern resolutions. Not any old format"). Today's Resolution
dropdown offers a list that includes legacy 4:3 modes (800x600 was
pickable) and modes the desktop cannot host (3840x2160 on a 2560x1440
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index c7150251..83748f0b 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 1. Intentional architecture (IA) — 18 active rows
+## 1. Intentional architecture (IA) — 19 active rows (IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -59,6 +59,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`). This row is auto-acquisition-only: as of #298, explicit-target admission and the combat camera route through the separate, retail-exact `WorldSelectionQuery.IsAttackableTarget` (`ObjectIsAttackable`-backed) instead, so a compatible-PK player is a valid manual attack/camera target — do not assume one predicate still serves both concerns. | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` |
| IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` |
| IA-21 | When ACE sends player BoolProperty `68` (`SpellComponentsRequired`) false, acdream presents the retail scarab/prismatic-taper formula even without a directly carried school focus. With component enforcement enabled, retail's exact focus/infusion versus account-customized selection remains intact. | `src/AcDream.App/Spells/SpellComponentRequirementService.cs` | A component-disabled server has no actionable legacy recipe; explicit product direction is that this client/server mode uses the modern scarab/taper component presentation | A custom server could expect retail's legacy recipe to remain visible even though casting consumes no components | `ClientMagicSystem::AreSpellComponentsRequired @ 0x00567B90`; `ClientMagicSystem::GetAppropriateSpellFormula @ 0x00567D50`; `CSpellBase::InqScarabOnlyFormula @ 0x00597050` |
+| IA-22 | **Filed 2026-08-13 (#391, user-directed: "we should only support modern resolutions. Not any old format").** The Config Resolution dropdown offers a CURATED list — the monitor's real mode enumeration filtered to modern widescreen families (16:9/16:10/21:9/32:9, ≥1280 wide, fitting the desktop; `DisplayModeCatalog.Curate`) — and its Defaults value is the desktop's own mode. Retail offered the adapter's complete enumeration including 4:3 legacy modes and authored `800x600` as the row default (`gmConfigUI::InitOptions SetDefaultValue(0x03200258)`; `gmClient::Init @0x004047af` `Device::ForceDisplayResolution(1, 0x320, 0x258)`). | `src/AcDream.App/Rendering/DisplayModeCatalog.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (Resolution row); fixture fallback `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs` (`AvailableResolutions`, 800x600 removed) | Explicit product direction; the curated list is also the fullscreen mode-switch validation source (#376/#388), so an offered mode is supported by construction — "Graphics mode not supported" crashes become unreachable from the dropdown. | A user wanting a genuine legacy 4:3 mode cannot pick it; retail-parity comparisons of the Config tab's list/default will show the deviation. | decomp sites in the Divergence column; ISSUES #391 |
---
diff --git a/src/AcDream.App/Rendering/DisplayModeCatalog.cs b/src/AcDream.App/Rendering/DisplayModeCatalog.cs
new file mode 100644
index 00000000..71f48f40
--- /dev/null
+++ b/src/AcDream.App/Rendering/DisplayModeCatalog.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Silk.NET.Windowing;
+
+namespace AcDream.App.Rendering;
+
+///
+/// #391 (user-directed, 2026-08-13): the ONE source of the resolutions the
+/// client offers. Production enumerates the display's real mode list
+/// () and curates it to modern
+/// widescreen formats that fit the desktop; the Config dropdown, and later
+/// the fullscreen mode-switch validation (#376/#388), read the same catalog
+/// so an offered mode is by construction a supported one.
+///
+/// Retail deviation, register-rowed with #391: retail listed the
+/// adapter's complete enumeration including 4:3 legacy modes and authored
+/// 800x600 as the Config default
+/// (gmConfigUI::InitOptions SetDefaultValue(0x03200258)). We curate
+/// deliberately — modern formats only — and the Defaults value becomes the
+/// desktop's own mode (always present in the curated list).
+///
+/// Write-once static owner: the catalog is immutable hardware truth
+/// captured at startup on the windowing thread (the same shape as the
+/// platform facts GraphicalHostPlatformServices owns). Fixture,
+/// UI-Studio, and headless callers never install one and fall back to
+/// DisplaySettings.AvailableResolutions at the consuming seam.
+///
+internal static class DisplayModeCatalog
+{
+ private static IReadOnlyList? _resolutions;
+ private static string? _desktopResolution;
+
+ /// The curated list, or null when no catalog was installed
+ /// (fixture/headless callers — consumers fall back to the static
+ /// preset ladder).
+ public static IReadOnlyList? Resolutions => _resolutions;
+
+ /// The desktop's current mode as a "WxH" string — the Config
+ /// Resolution row's Defaults value in production (see the class doc for
+ /// why this replaces retail's authored 800x600). Null when no catalog
+ /// was installed.
+ public static string? DesktopResolution => _desktopResolution;
+
+ /// Captures the catalog from the window's monitor at startup.
+ /// A null monitor or an empty curated result leaves the catalog
+ /// uninstalled (consumers keep the static fallback). Safe to call once
+ /// per process launch; a repeat call overwrites with equally-fresh
+ /// hardware truth.
+ public static void InstallFromWindow(IWindow window)
+ {
+ ArgumentNullException.ThrowIfNull(window);
+ IMonitor? monitor = window.Monitor;
+ if (monitor is null)
+ return;
+
+ VideoMode current = monitor.VideoMode;
+ if (current.Resolution is not { } desktop || desktop.X <= 0 || desktop.Y <= 0)
+ return;
+
+ IEnumerable<(int W, int H)> modes = monitor
+ .GetAllVideoModes()
+ .Select(m => m.Resolution)
+ .Where(r => r.HasValue)
+ .Select(r => (r!.Value.X, r.Value.Y));
+
+ IReadOnlyList curated = Curate(modes, (desktop.X, desktop.Y));
+ if (curated.Count == 0)
+ return;
+
+ _resolutions = curated;
+ _desktopResolution = $"{desktop.X}x{desktop.Y}";
+ }
+
+ /// Test seam: clears the installed catalog.
+ internal static void ResetForTests()
+ {
+ _resolutions = null;
+ _desktopResolution = null;
+ }
+
+ ///
+ /// The pure curation rule (#391): keep a mode iff
+ /// - it is a modern widescreen format (16:9, 16:10, or ultrawide 21:9 /
+ /// 32:9, matched with a small tolerance so 1366x768 and friends pass),
+ /// - it is at least 1280 wide (no legacy-era sizes), and
+ /// - it fits the desktop (a windowed pick larger than the desktop can
+ /// only silently clamp — if it cannot exist, it is not offered).
+ /// The desktop mode itself is always included even if its aspect is
+ /// unusual (it is by definition displayable), refresh-rate duplicates
+ /// collapse to one WxH entry, and the list sorts ascending by width then
+ /// height so the dropdown reads naturally.
+ ///
+ internal static IReadOnlyList Curate(
+ IEnumerable<(int W, int H)> modes,
+ (int W, int H) desktop)
+ {
+ // The modern aspect families, as width/height ratios.
+ ReadOnlySpan modernAspects =
+ [
+ 16f / 9f,
+ 16f / 10f,
+ 21f / 9f,
+ 32f / 9f,
+ ];
+
+ var keep = new SortedSet<(int W, int H)>(
+ Comparer<(int W, int H)>.Create(static (a, b) =>
+ a.W != b.W ? a.W.CompareTo(b.W) : a.H.CompareTo(b.H)));
+
+ foreach ((int w, int h) in modes)
+ {
+ if (w <= 0 || h <= 0)
+ continue;
+ if (w > desktop.W || h > desktop.H)
+ continue;
+ if ((w, h) == desktop)
+ {
+ keep.Add((w, h));
+ continue;
+ }
+ if (w < 1280)
+ continue;
+
+ float aspect = w / (float)h;
+ bool modern = false;
+ foreach (float family in modernAspects)
+ {
+ // ±2.5% covers the near-miss members of a family (1366x768 is
+ // 1.7786 vs 16:9's 1.7778; 3440x1440 is 2.3889 vs 21:9's
+ // 2.3333, a 2.4% miss — while a real 4:3 (1.3333) or 5:4
+ // (1.25) stays an order of magnitude outside every family).
+ if (MathF.Abs(aspect - family) <= family * 0.025f)
+ {
+ modern = true;
+ break;
+ }
+ }
+ if (modern)
+ keep.Add((w, h));
+ }
+
+ return keep.Select(static m => $"{m.W}x{m.H}").ToArray();
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 5baab269..8b0839a1 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1241,6 +1241,10 @@ public sealed class GameWindow :
// equivalent gate already ran inside VulkanGraphicsContext.Acquire and
// wrote its own report.
+ // #391: capture the monitor's curated resolution catalog once, on the
+ // windowing thread, before any Options-panel mount reads it.
+ DisplayModeCatalog.InstallFromWindow(_window!);
+
GameWindowCompositionPipeline.Run<
GameWindowPlatformResult,
HostInputCameraResult,
diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs
index 310591e0..12cbd91a 100644
--- a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs
+++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs
@@ -362,6 +362,16 @@ public static class ConfigOptionsPageController
/// #378: fallback bitmap font when
/// is unavailable — same convention
/// uses.
+ /// #391: the curated monitor-derived
+ /// resolution list the Resolution dropdown offers. Null (fixture/
+ /// conformance callers with no display) falls back to
+ /// .
+ /// #391: the value the Defaults button
+ /// restores for the Resolution row — production passes the desktop's own
+ /// mode (see DisplayModeCatalog's class doc for the deliberate,
+ /// register-rowed deviation from retail's authored 800x600). Null falls
+ /// back to .Resolution so the
+ /// fallback default is always a member of the fallback list.
public static bool Bind(
ImportedLayout layout,
OptionPage page,
@@ -370,7 +380,9 @@ public static class ConfigOptionsPageController
Bindings bindings,
Func? resolveSprite = null,
UiDatFont? datFont = null,
- BitmapFont? debugFont = null)
+ BitmapFont? debugFont = null,
+ IReadOnlyList? availableResolutions = null,
+ string? resolutionDefault = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(page);
@@ -425,7 +437,10 @@ public static class ConfigOptionsPageController
BuildSeparatorRow(listBox);
BindCameraSection(listBox, page, resolveString, bindings, ref cameraTurning);
BuildSeparatorRow(listBox);
- BindGraphicsSection(listBox, page, resolveString, bindings, ref display, resolveSprite, datFont, debugFont);
+ BindGraphicsSection(
+ listBox, page, resolveString, bindings, ref display,
+ resolveSprite, datFont, debugFont,
+ availableResolutions, resolutionDefault);
BuildSeparatorRow(listBox);
BindRenderingQualitySection(listBox, page, resolveString, bindings, ref display, resolveSprite, datFont, debugFont);
BuildSeparatorRow(listBox);
@@ -591,7 +606,9 @@ public static class ConfigOptionsPageController
ref DisplaySettings display,
Func? resolveSprite,
UiDatFont? datFont,
- BitmapFont? debugFont)
+ BitmapFont? debugFont,
+ IReadOnlyList? availableResolutions,
+ string? resolutionDefault)
{
BuildHeaderRow(listBox, "ID_Graphics_GraphicsSection", resolveString);
@@ -613,12 +630,17 @@ public static class ConfigOptionsPageController
// @gmClient::Init 0x004047af, not an invented preset), so clicking
// Defaults both resizes the window AND leaves the dropdown showing
// a highlighted, re-selectable row.
+ // #391 (user-directed): the choices are the curated monitor-derived
+ // list in production (see DisplayModeCatalog), and Defaults restores
+ // the desktop's own mode rather than retail's authored 800x600 —
+ // both halves of one register-rowed deviation. Fixture callers with
+ // no display keep the static modern preset ladder + its default.
BuildStringMenuRow(
listBox, "ID_Rendering_DisplayResolution",
- DisplaySettings.AvailableResolutions, page, resolveString,
+ availableResolutions ?? DisplaySettings.AvailableResolutions, page, resolveString,
read: () => bindings.LoadDisplay().Resolution,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Resolution = value }),
- defaultValue: "800x600",
+ defaultValue: resolutionDefault ?? DisplaySettings.Default.Resolution,
storeOnly: false, // LIVE
resolveSprite, datFont, debugFont);
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index ce17be18..2898ef83 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -2387,7 +2387,13 @@ public sealed class RetailUiRuntime : IDisposable
// ConfigOptionsPageController.MenuChromeSprites' own doc.
resolveSprite: _bindings.Assets.ResolveSprite,
datFont: _bindings.Assets.DefaultFont,
- debugFont: _bindings.Assets.DebugFont);
+ debugFont: _bindings.Assets.DebugFont,
+ // #391: the monitor-derived curated list + desktop-mode
+ // default, installed at startup by the graphical host;
+ // fixture/headless mounts leave the catalog empty and the
+ // controller falls back to the static preset ladder.
+ availableResolutions: Rendering.DisplayModeCatalog.Resolutions,
+ resolutionDefault: Rendering.DisplayModeCatalog.DesktopResolution);
if (!configBound)
Console.WriteLine("[UI] options panel: Config tab rows did not bind.");
}
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
index abb2a3c5..93a27cad 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
@@ -97,23 +97,22 @@ public sealed record DisplaySettings(
ParticleRange: ParticleRange.Extended);
///
- /// Resolution presets offered in the dropdown. 800x600 is retail's
- /// OWN Config-tab default (OP6 rework, review S4) — a genuine legacy
- /// display mode, not an invented entry: gmClient::Init @0x004047af
- /// calls Device::ForceDisplayResolution(1, 0x320, 0x258) (0x320 =
- /// 800, 0x258 = 600) at startup, and gmConfigUI::InitOptions's own
- /// SetDefaultValue(0x03200258) (byte-verified) names it as the
- /// Resolution row's default. Without it in this list, clicking Defaults
- /// resized the window correctly but left the dropdown showing an entry
- /// that could never be re-selected — the same "opaque default" shape
- /// LandscapeDrawDistance has for a genuinely different reason (AP-198's
- /// sub-note); this one has a one-line fix instead of an opaque default,
- /// so it gets the fix. The rest of the list is acdream's own modern
- /// 16:9 preset ladder, not retail-authored.
+ /// FALLBACK resolution presets — used only when the monitor's real mode
+ /// list is unavailable (fixture/conformance callers, headless mounts). In
+ /// production the Config dropdown is populated from the display's actual
+ /// modes, curated to modern formats (#391, user-directed 2026-08-13:
+ /// "we should only support modern resolutions. Not any old format") —
+ /// see AcDream.App.Rendering.DisplayModeCatalog, whose curation
+ /// filter this fallback list also passes through. Retail's own list was
+ /// the adapter's full mode enumeration including 4:3 legacy modes, with
+ /// 800x600 as the authored Config-tab default
+ /// (gmConfigUI::InitOptions SetDefaultValue(0x03200258),
+ /// gmClient::Init @0x004047af) — the curation and the desktop-mode
+ /// default that replaces it are a deliberate deviation carried in the
+ /// divergence register (see the #391 row).
///
public static IReadOnlyList AvailableResolutions { get; } = new[]
{
- "800x600",
"1280x720",
"1366x768",
"1600x900",
diff --git a/tests/AcDream.App.Tests/Rendering/DisplayModeCatalogTests.cs b/tests/AcDream.App.Tests/Rendering/DisplayModeCatalogTests.cs
new file mode 100644
index 00000000..623d2ce4
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/DisplayModeCatalogTests.cs
@@ -0,0 +1,108 @@
+using AcDream.App.Rendering;
+using AcDream.UI.Abstractions.Panels.Settings;
+
+namespace AcDream.App.Tests.Rendering;
+
+/// #391 (user-directed): the curation rule that turns a monitor's
+/// raw mode enumeration into the modern-only resolution list. Pure-function
+/// tests — the Silk monitor adapter is a thin projection over this.
+public sealed class DisplayModeCatalogTests
+{
+ private static readonly (int W, int H) Desktop2560 = (2560, 1440);
+
+ [Fact]
+ public void Curate_DropsLegacyFormats_KeepsModernFamilies()
+ {
+ var modes = new (int, int)[]
+ {
+ (640, 480), (800, 600), (1024, 768), (1280, 1024), // 4:3 / 5:4 legacy
+ (1280, 720), (1366, 768), (1600, 900), (1920, 1080), // 16:9
+ (1920, 1200), // 16:10
+ (2560, 1440),
+ };
+
+ var curated = DisplayModeCatalog.Curate(modes, Desktop2560);
+
+ Assert.Equal(
+ ["1280x720", "1366x768", "1600x900", "1920x1080", "1920x1200", "2560x1440"],
+ curated);
+ }
+
+ [Fact]
+ public void Curate_ExcludesModesLargerThanTheDesktop()
+ {
+ // A windowed pick larger than the desktop can only silently clamp
+ // (measured live 2026-08-13: a 3840x2160 pick on this desktop
+ // produced a 2564x1421 window) — if it cannot exist, it is not
+ // offered.
+ var curated = DisplayModeCatalog.Curate(
+ [(1920, 1080), (2560, 1440), (3840, 2160)], Desktop2560);
+
+ Assert.Equal(["1920x1080", "2560x1440"], curated);
+ }
+
+ [Fact]
+ public void Curate_CollapsesRefreshRateDuplicates_AndSortsAscending()
+ {
+ // Real enumerations repeat each WxH once per refresh rate and are
+ // not ordered; the catalog is one entry per size, ascending.
+ var curated = DisplayModeCatalog.Curate(
+ [(2560, 1440), (1920, 1080), (1920, 1080), (1920, 1080), (1280, 720)],
+ Desktop2560);
+
+ Assert.Equal(["1280x720", "1920x1080", "2560x1440"], curated);
+ }
+
+ [Fact]
+ public void Curate_KeepsUltrawideFamilies()
+ {
+ var curated = DisplayModeCatalog.Curate(
+ [(2560, 1080), (3440, 1440), (3840, 1080)], (3840, 1600));
+
+ Assert.Equal(["2560x1080", "3440x1440", "3840x1080"], curated);
+ }
+
+ [Fact]
+ public void Curate_AlwaysIncludesTheDesktopModeItself()
+ {
+ // The desktop mode is displayable by definition — it stays even when
+ // its aspect matches no listed family (and it is the Defaults value).
+ var curated = DisplayModeCatalog.Curate(
+ [(1920, 1080), (1920, 1440)], (1920, 1440)); // desktop is 4:3!
+
+ Assert.Contains("1920x1440", curated);
+ Assert.Contains("1920x1080", curated);
+ }
+
+ [Fact]
+ public void Curate_DropsSubMinimumWidths_EvenWhenWidescreen()
+ {
+ // 1024x576 is exactly 16:9 but below the modern floor.
+ var curated = DisplayModeCatalog.Curate(
+ [(1024, 576), (1280, 720)], Desktop2560);
+
+ Assert.Equal(["1280x720"], curated);
+ }
+
+ [Fact]
+ public void FallbackPresetLadder_ItselfPassesTheCurationRule()
+ {
+ // The static fixture-fallback list must never offer something the
+ // production rule would reject (a big desktop accepts all of it).
+ var parsed = DisplaySettings.AvailableResolutions
+ .Select(static s => s.Split('x'))
+ .Select(static p => (int.Parse(p[0]), int.Parse(p[1])));
+
+ var curated = DisplayModeCatalog.Curate(parsed, (3840, 2160));
+
+ Assert.Equal(DisplaySettings.AvailableResolutions, curated);
+ }
+
+ [Fact]
+ public void FallbackDefault_IsAMemberOfTheFallbackList()
+ {
+ // The S4 rule survives the curation: the Defaults value must always
+ // be re-selectable from the offered list.
+ Assert.Contains(DisplaySettings.Default.Resolution, DisplaySettings.AvailableResolutions);
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs
index e134f2eb..c1cd9b66 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs
@@ -726,7 +726,9 @@ public sealed class ConfigOptionsPageControllerTests
90.0f, // 10 Field Of View
true, // 11 Align To Slope
- "800x600", // 12 Resolution
+ "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