fix #391: curated modern-only resolution list from the monitor's modes
User-directed (2026-08-13): "we should only support modern resolutions. Not any old format." New DisplayModeCatalog enumerates the window's monitor (Silk IMonitor.GetAllVideoModes) once at GameWindow load and curates via a pure, tested rule: modern widescreen families only (16:9/16:10/21:9/32:9 within 2.5%), at least 1280 wide, must fit the desktop (an impossible windowed pick is not offered - the measured 3840x2160-on-2560x1440 silent clamp class), desktop mode always included, refresh-rate duplicates collapsed, ascending order. The Config Resolution row consumes the catalog through two new optional Bind parameters; its Defaults value becomes the desktop's own mode. Fixture/headless callers keep the static preset ladder, which now drops 800x600 and is pinned by test to pass the same curation rule (the OP6 S4 "default must be re-selectable" invariant holds on both paths). Deliberate retail deviation, register row IA-22: retail listed the adapter's complete enumeration including 4:3 legacy modes and authored 800x600 as the Config default (gmConfigUI::InitOptions SetDefaultValue(0x03200258); gmClient::Init @0x004047af). The catalog is also the designated fullscreen mode-switch validation source for #376/#388 - an offered mode is supported by construction. Tests: DisplayModeCatalogTests (8 - filter/clamp/dedupe/sort/ultrawide/ desktop-inclusion/fallback-consistency); ConfigOptionsPageControllerTests row-12 default updated. App suite 4,961/3 skips; UI.Abstractions 916. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7e0c130344
commit
13d388e5a9
9 changed files with 324 additions and 23 deletions
145
src/AcDream.App/Rendering/DisplayModeCatalog.cs
Normal file
145
src/AcDream.App/Rendering/DisplayModeCatalog.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Silk.NET.Windowing;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #391 (user-directed, 2026-08-13): the ONE source of the resolutions the
|
||||
/// client offers. Production enumerates the display's real mode list
|
||||
/// (<see cref="IMonitor.GetAllVideoModes"/>) 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.
|
||||
///
|
||||
/// <para>Retail deviation, register-rowed with #391: retail listed the
|
||||
/// adapter's complete enumeration including 4:3 legacy modes and authored
|
||||
/// <c>800x600</c> as the Config default
|
||||
/// (<c>gmConfigUI::InitOptions SetDefaultValue(0x03200258)</c>). We curate
|
||||
/// deliberately — modern formats only — and the Defaults value becomes the
|
||||
/// desktop's own mode (always present in the curated list).</para>
|
||||
///
|
||||
/// <para>Write-once static owner: the catalog is immutable hardware truth
|
||||
/// captured at startup on the windowing thread (the same shape as the
|
||||
/// platform facts <c>GraphicalHostPlatformServices</c> owns). Fixture,
|
||||
/// UI-Studio, and headless callers never install one and fall back to
|
||||
/// <c>DisplaySettings.AvailableResolutions</c> at the consuming seam.</para>
|
||||
/// </summary>
|
||||
internal static class DisplayModeCatalog
|
||||
{
|
||||
private static IReadOnlyList<string>? _resolutions;
|
||||
private static string? _desktopResolution;
|
||||
|
||||
/// <summary>The curated list, or null when no catalog was installed
|
||||
/// (fixture/headless callers — consumers fall back to the static
|
||||
/// preset ladder).</summary>
|
||||
public static IReadOnlyList<string>? Resolutions => _resolutions;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static string? DesktopResolution => _desktopResolution;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<string> curated = Curate(modes, (desktop.X, desktop.Y));
|
||||
if (curated.Count == 0)
|
||||
return;
|
||||
|
||||
_resolutions = curated;
|
||||
_desktopResolution = $"{desktop.X}x{desktop.Y}";
|
||||
}
|
||||
|
||||
/// <summary>Test seam: clears the installed catalog.</summary>
|
||||
internal static void ResetForTests()
|
||||
{
|
||||
_resolutions = null;
|
||||
_desktopResolution = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<string> Curate(
|
||||
IEnumerable<(int W, int H)> modes,
|
||||
(int W, int H) desktop)
|
||||
{
|
||||
// The modern aspect families, as width/height ratios.
|
||||
ReadOnlySpan<float> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GameWindowGraphics, IInputContext>,
|
||||
HostInputCameraResult,
|
||||
|
|
|
|||
|
|
@ -362,6 +362,16 @@ public static class ConfigOptionsPageController
|
|||
/// <param name="debugFont">#378: fallback bitmap font when
|
||||
/// <paramref name="datFont"/> is unavailable — same convention
|
||||
/// <see cref="VendorUiController"/> uses.</param>
|
||||
/// <param name="availableResolutions">#391: the curated monitor-derived
|
||||
/// resolution list the Resolution dropdown offers. Null (fixture/
|
||||
/// conformance callers with no display) falls back to
|
||||
/// <see cref="DisplaySettings.AvailableResolutions"/>.</param>
|
||||
/// <param name="resolutionDefault">#391: the value the Defaults button
|
||||
/// restores for the Resolution row — production passes the desktop's own
|
||||
/// mode (see <c>DisplayModeCatalog</c>'s class doc for the deliberate,
|
||||
/// register-rowed deviation from retail's authored 800x600). Null falls
|
||||
/// back to <see cref="DisplaySettings.Default"/>.Resolution so the
|
||||
/// fallback default is always a member of the fallback list.</param>
|
||||
public static bool Bind(
|
||||
ImportedLayout layout,
|
||||
OptionPage page,
|
||||
|
|
@ -370,7 +380,9 @@ public static class ConfigOptionsPageController
|
|||
Bindings bindings,
|
||||
Func<uint, (uint tex, int w, int h)>? resolveSprite = null,
|
||||
UiDatFont? datFont = null,
|
||||
BitmapFont? debugFont = null)
|
||||
BitmapFont? debugFont = null,
|
||||
IReadOnlyList<string>? 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<uint, (uint tex, int w, int h)>? resolveSprite,
|
||||
UiDatFont? datFont,
|
||||
BitmapFont? debugFont)
|
||||
BitmapFont? debugFont,
|
||||
IReadOnlyList<string>? 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,23 +97,22 @@ public sealed record DisplaySettings(
|
|||
ParticleRange: ParticleRange.Extended);
|
||||
|
||||
/// <summary>
|
||||
/// Resolution presets offered in the dropdown. <c>800x600</c> is retail's
|
||||
/// OWN Config-tab default (OP6 rework, review S4) — a genuine legacy
|
||||
/// display mode, not an invented entry: <c>gmClient::Init @0x004047af</c>
|
||||
/// calls <c>Device::ForceDisplayResolution(1, 0x320, 0x258)</c> (0x320 =
|
||||
/// 800, 0x258 = 600) at startup, and <c>gmConfigUI::InitOptions</c>'s own
|
||||
/// <c>SetDefaultValue(0x03200258)</c> (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 <c>AcDream.App.Rendering.DisplayModeCatalog</c>, 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
|
||||
/// <c>800x600</c> as the authored Config-tab default
|
||||
/// (<c>gmConfigUI::InitOptions SetDefaultValue(0x03200258)</c>,
|
||||
/// <c>gmClient::Init @0x004047af</c>) — the curation and the desktop-mode
|
||||
/// default that replaces it are a deliberate deviation carried in the
|
||||
/// divergence register (see the #391 row).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> AvailableResolutions { get; } = new[]
|
||||
{
|
||||
"800x600",
|
||||
"1280x720",
|
||||
"1366x768",
|
||||
"1600x900",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue