Campaign CC gate round 1. The Config Resolution dropdown now offers DisplayModeCatalog.WindowedResolutions — the curated hardware modes UNIONed with the static modern-ladder sizes that fit the desktop — because a windowed pick is a plain Size write needing no video mode, and remote/RDP virtual displays advertise almost none (the live RDP display exposed exactly 1920x1080 + the 2056x1290 desktop, leaving the dropdown with nothing below 1920). The fullscreen apply still validates against the hardware Resolutions list plus the switcher's monitor-mode-list hard guard, so a fullscreen pick of a windowed-only entry refuses safely (log-and-stay, #388/#392) — IA-22's offered-implies-supported invariant narrows to the fullscreen half and its register row carries the amendment. Three new pure-union tests including the exact live RDP shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
212 lines
8.5 KiB
C#
212 lines
8.5 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AcDream.UI.Abstractions.Panels.Settings;
|
|
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 IReadOnlyList<string>? _windowedResolutions;
|
|
private static string? _desktopResolution;
|
|
|
|
/// <summary>The curated HARDWARE mode list, or null when no catalog was
|
|
/// installed (fixture/headless callers — consumers fall back to the
|
|
/// static preset ladder). This is the fullscreen mode-switch validation
|
|
/// source (#376/#388): a fullscreen pick must be a real adapter mode.</summary>
|
|
public static IReadOnlyList<string>? Resolutions => _resolutions;
|
|
|
|
/// <summary>#407: the WINDOWED size offering — the curated hardware
|
|
/// modes UNIONed with the static modern ladder entries that fit the
|
|
/// desktop. A windowed client needs no video mode (a Size write is
|
|
/// displayable at any size ≤ desktop), so gating the windowed dropdown
|
|
/// on the adapter's mode list starved remote/virtual displays whose
|
|
/// drivers advertise almost nothing (the RDP display that exposed only
|
|
/// 1920x1080 + the desktop mode, found live at the Campaign CC gate).
|
|
/// Null when no catalog was installed. The fullscreen APPLY still
|
|
/// validates against <see cref="Resolutions"/> + the switcher's own
|
|
/// monitor-mode-list hard guard, so a fullscreen pick of a
|
|
/// windowed-only entry refuses safely (log-and-stay, #388/#392).</summary>
|
|
public static IReadOnlyList<string>? WindowedResolutions => _windowedResolutions;
|
|
|
|
/// <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;
|
|
_windowedResolutions = BuildWindowedOffering(curated, (desktop.X, desktop.Y));
|
|
_desktopResolution = $"{desktop.X}x{desktop.Y}";
|
|
}
|
|
|
|
/// <summary>Test seam: clears the installed catalog.</summary>
|
|
internal static void ResetForTests()
|
|
{
|
|
_resolutions = null;
|
|
_windowedResolutions = null;
|
|
_desktopResolution = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// #407's pure union rule: the windowed offering is every curated
|
|
/// hardware mode plus every static-ladder entry that fits the desktop,
|
|
/// deduped, ascending by width then height — the same ordering
|
|
/// <see cref="Curate"/> emits so the dropdown reads identically on
|
|
/// physical and remote displays.
|
|
/// </summary>
|
|
internal static IReadOnlyList<string> BuildWindowedOffering(
|
|
IReadOnlyList<string> curated,
|
|
(int W, int H) desktop)
|
|
{
|
|
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 (string spec in curated)
|
|
{
|
|
if (TryParse(spec, out (int W, int H) mode))
|
|
keep.Add(mode);
|
|
}
|
|
foreach (string spec in DisplaySettings.AvailableResolutions)
|
|
{
|
|
if (TryParse(spec, out (int W, int H) mode)
|
|
&& mode.W <= desktop.W
|
|
&& mode.H <= desktop.H)
|
|
{
|
|
keep.Add(mode);
|
|
}
|
|
}
|
|
|
|
return keep.Select(static m => $"{m.W}x{m.H}").ToArray();
|
|
|
|
static bool TryParse(string spec, out (int W, int H) mode)
|
|
{
|
|
mode = default;
|
|
string[] parts = spec.Split('x', 2);
|
|
if (parts.Length == 2
|
|
&& int.TryParse(parts[0], out int w)
|
|
&& int.TryParse(parts[1], out int h)
|
|
&& w > 0
|
|
&& h > 0)
|
|
{
|
|
mode = (w, h);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
}
|
|
}
|