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