fix #407: windowed resolution offering decoupled from the video-mode list

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>
This commit is contained in:
Erik 2026-08-16 09:03:36 +02:00
parent 26e6f984f4
commit e601a496db
4 changed files with 119 additions and 5 deletions

View file

@ -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-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-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-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 |
--- ---

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using AcDream.UI.Abstractions.Panels.Settings;
using Silk.NET.Windowing; using Silk.NET.Windowing;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -29,13 +30,28 @@ namespace AcDream.App.Rendering;
internal static class DisplayModeCatalog internal static class DisplayModeCatalog
{ {
private static IReadOnlyList<string>? _resolutions; private static IReadOnlyList<string>? _resolutions;
private static IReadOnlyList<string>? _windowedResolutions;
private static string? _desktopResolution; private static string? _desktopResolution;
/// <summary>The curated list, or null when no catalog was installed /// <summary>The curated HARDWARE mode list, or null when no catalog was
/// (fixture/headless callers — consumers fall back to the static /// installed (fixture/headless callers — consumers fall back to the
/// preset ladder).</summary> /// 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; 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 /// <summary>The desktop's current mode as a "WxH" string — the Config
/// Resolution row's Defaults value in production (see the class doc for /// Resolution row's Defaults value in production (see the class doc for
/// why this replaces retail's authored 800x600). Null when no catalog /// why this replaces retail's authored 800x600). Null when no catalog
@ -69,6 +85,7 @@ internal static class DisplayModeCatalog
return; return;
_resolutions = curated; _resolutions = curated;
_windowedResolutions = BuildWindowedOffering(curated, (desktop.X, desktop.Y));
_desktopResolution = $"{desktop.X}x{desktop.Y}"; _desktopResolution = $"{desktop.X}x{desktop.Y}";
} }
@ -76,9 +93,59 @@ internal static class DisplayModeCatalog
internal static void ResetForTests() internal static void ResetForTests()
{ {
_resolutions = null; _resolutions = null;
_windowedResolutions = null;
_desktopResolution = 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> /// <summary>
/// The pure curation rule (#391): keep a mode iff /// The pure curation rule (#391): keep a mode iff
/// - it is a modern widescreen format (16:9, 16:10, or ultrawide 21:9 / /// - it is a modern widescreen format (16:9, 16:10, or ultrawide 21:9 /

View file

@ -2578,7 +2578,12 @@ public sealed class RetailUiRuntime : IDisposable
// default, installed at startup by the graphical host; // default, installed at startup by the graphical host;
// fixture/headless mounts leave the catalog empty and the // fixture/headless mounts leave the catalog empty and the
// controller falls back to the static preset ladder. // 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); resolutionDefault: Rendering.DisplayModeCatalog.DesktopResolution);
if (!configBound) if (!configBound)
Console.WriteLine("[UI] options panel: Config tab rows did not bind."); Console.WriteLine("[UI] options panel: Config tab rows did not bind.");

View file

@ -105,4 +105,46 @@ public sealed class DisplayModeCatalogTests
// be re-selectable from the offered list. // be re-selectable from the offered list.
Assert.Contains(DisplaySettings.Default.Resolution, DisplaySettings.AvailableResolutions); 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);
}
} }