From e601a496dba278050d9b448f4823a608b35d4469 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 09:03:36 +0200 Subject: [PATCH] fix #407: windowed resolution offering decoupled from the video-mode list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../retail-divergence-register.md | 2 +- .../Rendering/DisplayModeCatalog.cs | 73 ++++++++++++++++++- src/AcDream.App/UI/RetailUiRuntime.cs | 7 +- .../Rendering/DisplayModeCatalogTests.cs | 42 +++++++++++ 4 files changed, 119 insertions(+), 5 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 31e42d69..ffebb196 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -59,7 +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 | +| 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. **Amended 2026-08-16 (#407, Campaign CC gate round 1):** the 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 no modes (the live RDP display exposed only 1920x1080 + the 2056x1290 desktop, starving the dropdown). The original "an offered mode is supported by construction" invariant now holds for the FULLSCREEN half only: the fullscreen apply still validates against the hardware `Resolutions` list plus `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard, so a fullscreen pick of a windowed-only entry refuses safely (log-and-stay, #388; the #392 apply-result seam is that family's open follow-up) — "Graphics mode not supported" crashes remain 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 index 71f48f40..986b8fb0 100644 --- a/src/AcDream.App/Rendering/DisplayModeCatalog.cs +++ b/src/AcDream.App/Rendering/DisplayModeCatalog.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using AcDream.UI.Abstractions.Panels.Settings; using Silk.NET.Windowing; namespace AcDream.App.Rendering; @@ -29,13 +30,28 @@ namespace AcDream.App.Rendering; internal static class DisplayModeCatalog { private static IReadOnlyList? _resolutions; + private static IReadOnlyList? _windowedResolutions; 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). + /// 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. public static IReadOnlyList? Resolutions => _resolutions; + /// #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 + 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). + public static IReadOnlyList? WindowedResolutions => _windowedResolutions; + /// 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 @@ -69,6 +85,7 @@ internal static class DisplayModeCatalog return; _resolutions = curated; + _windowedResolutions = BuildWindowedOffering(curated, (desktop.X, desktop.Y)); _desktopResolution = $"{desktop.X}x{desktop.Y}"; } @@ -76,9 +93,59 @@ internal static class DisplayModeCatalog internal static void ResetForTests() { _resolutions = null; + _windowedResolutions = null; _desktopResolution = null; } + /// + /// #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 + /// emits so the dropdown reads identically on + /// physical and remote displays. + /// + internal static IReadOnlyList BuildWindowedOffering( + IReadOnlyList 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; + } + } + /// /// The pure curation rule (#391): keep a mode iff /// - it is a modern widescreen format (16:9, 16:10, or ultrawide 21:9 / diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 07381f1d..a62fb663 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -2578,7 +2578,12 @@ public sealed class RetailUiRuntime : IDisposable // 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, + // #407: the dropdown offers the WINDOWED union (hardware + // modes + static-ladder sizes that fit the desktop) — a + // windowed Size write needs no video mode, and remote/RDP + // displays advertise almost none. The fullscreen APPLY + // still validates against the hardware list only. + availableResolutions: Rendering.DisplayModeCatalog.WindowedResolutions, resolutionDefault: Rendering.DisplayModeCatalog.DesktopResolution); if (!configBound) Console.WriteLine("[UI] options panel: Config tab rows did not bind."); diff --git a/tests/AcDream.App.Tests/Rendering/DisplayModeCatalogTests.cs b/tests/AcDream.App.Tests/Rendering/DisplayModeCatalogTests.cs index 623d2ce4..954bab9b 100644 --- a/tests/AcDream.App.Tests/Rendering/DisplayModeCatalogTests.cs +++ b/tests/AcDream.App.Tests/Rendering/DisplayModeCatalogTests.cs @@ -105,4 +105,46 @@ public sealed class DisplayModeCatalogTests // be re-selectable from the offered list. Assert.Contains(DisplaySettings.Default.Resolution, DisplaySettings.AvailableResolutions); } + + // ── #407: the windowed offering union (Campaign CC gate round 1) ──── + + [Fact] + public void BuildWindowedOffering_RdpStarvedModeList_GainsTheLadderSizesThatFit() + { + // The live RDP shape that motivated #407: the virtual display + // advertised exactly two modes (1920x1080 + the 2056x1290 desktop), + // so the hardware-gated dropdown offered nothing below 1920. The + // windowed union restores every static-ladder size that fits. + var offering = DisplayModeCatalog.BuildWindowedOffering( + ["1920x1080", "2056x1290"], (2056, 1290)); + + Assert.Equal( + ["1280x720", "1366x768", "1600x900", "1920x1080", "2056x1290"], + offering); + } + + [Fact] + public void BuildWindowedOffering_RichMonitor_IsTheDedupedUnion() + { + // On a physical monitor the hardware list already contains the + // ladder sizes — the union adds nothing and stays ascending/deduped. + var offering = DisplayModeCatalog.BuildWindowedOffering( + ["1280x720", "1366x768", "1600x900", "1920x1080", "1920x1200", "2560x1440"], + Desktop2560); + + Assert.Equal( + ["1280x720", "1366x768", "1600x900", "1920x1080", "1920x1200", "2560x1440"], + offering); + } + + [Fact] + public void BuildWindowedOffering_LadderEntriesLargerThanTheDesktop_StayExcluded() + { + // A 1600x900 desktop admits only the ladder sizes that fit; the + // desktop's own (hardware-curated) mode always survives the union. + var offering = DisplayModeCatalog.BuildWindowedOffering( + ["1600x900"], (1600, 900)); + + Assert.Equal(["1280x720", "1366x768", "1600x900"], offering); + } }