fix #376/#388 review round: post-condition truth, idempotence, one
position memory, unified monitor, maximized restore; AD-92
Dual-lens Opus review of e56aa511 (reports committed under
docs/research/). The consolidated corrections:
- Mechanism M1 (load-bearing): on Windows, Silk's GLFW error callback
QUEUES exceptions on a static list instead of throwing - they detonate
later at window close, which is exactly #388's original two-stage
crash shape. catch(GlfwException) was dead code here and a failed
SetWindowMonitor "succeeded". Success is now judged by the NATIVE
POST-CONDITION (GetWindowMonitor after the call) on both enter and
exit; the catches remain only for the throwing platforms.
- M2 (both lenses): same-mode fullscreen re-apply is a no-op BEFORE any
native work (new IDisplayModeSwitcher.CurrentFullscreenMode). Every
Display-backed Config row applies per change - sliders per DRAG TICK -
so without this every tick while fullscreen re-issued a real
display-mode change.
- M3/M5 (both): the remembered windowed placement is process state (two
target instances exist - startup and live-save); a fullscreen boot now
exits through either instance to the real placement, not the (60,60)
literal.
- M4 (both): the switcher resolves the WINDOW'S monitor (attached
monitor when fullscreen, else IWindow.Monitor's index into the GLFW
array - the same monitor DisplayModeCatalog enumerated), primary only
as a last resort; the offered-list/switch-target mismatch is gone.
- Blast M2b: the offered-mode validator falls back to the SAME static
ladder the dropdown falls back to - Full Screen is no longer a
permanent silent no-op on catalog-less hosts (the switcher's own
monitor-mode-list check remains the hard guard).
- Blast M3: a windowed pick on a MAXIMIZED window restores it first
(Size writes are silently ignored while maximized; the deleted
WindowState=Normal write used to do this incidentally). New
IWindowedSizeSurface.IsMaximized/Restore.
- Mechanism M5: no silent bail-outs - the unparseable-resolution
fullscreen path logs, and the failure line no longer claims "staying
windowed" when the state is unchanged (#392 noted inline).
- Q1 nit: one cached Glfw wrapper (per-call GetApi allocated + took a
native refcount); IsFullscreen/CurrentFullscreenMode guarded.
- AD-92: highest-refresh-for-WxH + refuse-and-log versus retail's
pass-through-and-error ForceDisplayResolution.
Known-open tail, filed not hidden: #392 (persisted-flag divergence on a
refused enter - needs an apply-result seam); the mechanism report's
pacing-refresh WATCH rides the same seam.
Tests: +3 (same-mode no-op, unparseable-while-fullscreen refusal,
maximized restore-before-write). App suite 4,975/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
229242e1fe
commit
ec2a7b0cce
6 changed files with 1257 additions and 28 deletions
|
|
@ -20,6 +20,12 @@ internal interface IDisplayModeSwitcher
|
|||
/// monitor attached).</summary>
|
||||
bool IsFullscreen { get; }
|
||||
|
||||
/// <summary>The active fullscreen mode's WxH, or null when windowed.
|
||||
/// The apply's idempotence guard (review M2: every Display-backed Config
|
||||
/// row applies per change — sliders per DRAG TICK — so a same-mode
|
||||
/// re-apply must be a no-op before any native call).</summary>
|
||||
(int Width, int Height)? CurrentFullscreenMode { get; }
|
||||
|
||||
/// <summary>Switches the window to exclusive fullscreen at the given
|
||||
/// mode. False (with a reason) instead of throwing on any failure —
|
||||
/// a settings apply must never crash the client (#388).</summary>
|
||||
|
|
@ -32,19 +38,38 @@ internal interface IDisplayModeSwitcher
|
|||
|
||||
/// <summary>
|
||||
/// The native GLFW implementation. Uses the SAME handle path the #348
|
||||
/// cursor cache proved (<c>IWindow.Native.Glfw</c>) and the primary
|
||||
/// monitor — retail's <c>ForceDisplayResolution</c> likewise drove the
|
||||
/// primary display device. The refresh rate for a mode is the highest the
|
||||
/// monitor reports for that WxH (retail passed the device's mode as-is).
|
||||
/// Callers validate the requested WxH against <c>DisplayModeCatalog</c>
|
||||
/// BEFORE calling — an offered mode is supported by construction, which is
|
||||
/// what makes the old "Graphics mode not supported" crash class
|
||||
/// unreachable from the dropdown.
|
||||
/// cursor cache proved (<c>IWindow.Native.Glfw</c>) and the WINDOW'S OWN
|
||||
/// monitor (<c>IWindow.Monitor</c>'s index into the GLFW monitor array —
|
||||
/// the same monitor <c>DisplayModeCatalog</c> enumerated, so the offered
|
||||
/// list and the switch target agree; review M4 caught the earlier
|
||||
/// primary-monitor mismatch). The refresh rate for a mode is the highest
|
||||
/// the monitor reports for that WxH. Callers validate the requested WxH
|
||||
/// against <c>DisplayModeCatalog</c> BEFORE calling.
|
||||
///
|
||||
/// <para><b>Error model (review M1 — load-bearing):</b> on Windows, Silk's
|
||||
/// GLFW error callback does NOT throw — it QUEUES the exception on a static
|
||||
/// list that detonates much later (window close), which is exactly the
|
||||
/// two-stage crash #388 originally recorded. `catch (GlfwException)` is
|
||||
/// therefore dead code on this platform; success is judged by the NATIVE
|
||||
/// POST-CONDITION (does the window have / not have a monitor afterwards),
|
||||
/// never by the absence of an exception. The catches stay only for the
|
||||
/// non-Windows platforms where Silk does throw.</para>
|
||||
/// </summary>
|
||||
internal sealed unsafe class GlfwDisplayModeSwitcher : IDisplayModeSwitcher
|
||||
{
|
||||
private readonly IWindow _window;
|
||||
private (int X, int Y) _windowedPosition = (60, 60);
|
||||
|
||||
// Q1 nit: one cached wrapper (a Glfw.GetApi() per call allocates and
|
||||
// takes a native library refcount) — the same pattern GlfwCursorCache
|
||||
// uses.
|
||||
private static readonly Lazy<Glfw> Api = new(Glfw.GetApi);
|
||||
|
||||
// Review M3/M5: the remembered windowed placement is PROCESS state, not
|
||||
// per-instance state — the startup target and the live-save target are
|
||||
// two instances over the one window, and a fullscreen boot must exit
|
||||
// through whichever instance the untick reaches. Static, single-window
|
||||
// process (the same write-once shape DisplayModeCatalog uses).
|
||||
private static (int X, int Y) _windowedPosition = (60, 60);
|
||||
|
||||
public GlfwDisplayModeSwitcher(IWindow window)
|
||||
{
|
||||
|
|
@ -55,9 +80,40 @@ internal sealed unsafe class GlfwDisplayModeSwitcher : IDisplayModeSwitcher
|
|||
{
|
||||
get
|
||||
{
|
||||
WindowHandle* handle = Handle();
|
||||
if (handle is null) return false;
|
||||
return Glfw.GetApi().GetWindowMonitor(handle) is not null;
|
||||
try
|
||||
{
|
||||
WindowHandle* handle = Handle();
|
||||
if (handle is null) return false;
|
||||
return Api.Value.GetWindowMonitor(handle) is not null;
|
||||
}
|
||||
catch (GlfwException)
|
||||
{
|
||||
// Never throws on Windows (queued instead) — this guard is
|
||||
// for the throwing platforms; a monitor query must never
|
||||
// take the caller down (#377 family).
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public (int Width, int Height)? CurrentFullscreenMode
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
WindowHandle* handle = Handle();
|
||||
if (handle is null) return null;
|
||||
Glfw glfw = Api.Value;
|
||||
Silk.NET.GLFW.Monitor* monitor = glfw.GetWindowMonitor(handle);
|
||||
if (monitor is null) return null;
|
||||
Silk.NET.GLFW.VideoMode* mode = glfw.GetVideoMode(monitor);
|
||||
return mode is null ? null : (mode->Width, mode->Height);
|
||||
}
|
||||
catch (GlfwException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,11 +129,11 @@ internal sealed unsafe class GlfwDisplayModeSwitcher : IDisplayModeSwitcher
|
|||
|
||||
try
|
||||
{
|
||||
Glfw glfw = Glfw.GetApi();
|
||||
Silk.NET.GLFW.Monitor* monitor = glfw.GetPrimaryMonitor();
|
||||
Glfw glfw = Api.Value;
|
||||
Silk.NET.GLFW.Monitor* monitor = ResolveWindowMonitor(glfw, handle);
|
||||
if (monitor is null)
|
||||
{
|
||||
error = "no primary monitor";
|
||||
error = "no monitor";
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -96,14 +152,22 @@ internal sealed unsafe class GlfwDisplayModeSwitcher : IDisplayModeSwitcher
|
|||
}
|
||||
|
||||
glfw.SetWindowMonitor(handle, monitor, 0, 0, width, height, refresh);
|
||||
|
||||
// Review M1: judge by the native post-condition, not by the
|
||||
// absence of an exception (Windows queues GLFW errors).
|
||||
if (glfw.GetWindowMonitor(handle) is null)
|
||||
{
|
||||
error = "the mode switch did not take (GLFW reports no monitor attached)";
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"display: fullscreen mode switch {width}x{height}@{refresh}");
|
||||
return true;
|
||||
}
|
||||
catch (GlfwException ex)
|
||||
{
|
||||
// #388: a failed switch is a logged failure the caller reverts
|
||||
// from, never a crash. Validation makes this path exceptional.
|
||||
// Non-Windows platforms throw; Windows never reaches here.
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
|
|
@ -121,13 +185,20 @@ internal sealed unsafe class GlfwDisplayModeSwitcher : IDisplayModeSwitcher
|
|||
|
||||
try
|
||||
{
|
||||
Glfw glfw = Glfw.GetApi();
|
||||
Glfw glfw = Api.Value;
|
||||
if (glfw.GetWindowMonitor(handle) is null)
|
||||
return true; // already windowed
|
||||
glfw.SetWindowMonitor(
|
||||
handle, null,
|
||||
_windowedPosition.X, _windowedPosition.Y,
|
||||
width, height, 0);
|
||||
|
||||
if (glfw.GetWindowMonitor(handle) is not null)
|
||||
{
|
||||
error = "the window is still fullscreen (GLFW reports a monitor attached)";
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"display: left fullscreen to windowed {width}x{height}");
|
||||
return true;
|
||||
|
|
@ -139,8 +210,30 @@ internal sealed unsafe class GlfwDisplayModeSwitcher : IDisplayModeSwitcher
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>The monitor the window belongs to: the attached monitor when
|
||||
/// fullscreen, else the window's own monitor per Silk's assignment
|
||||
/// (index into the GLFW monitor array — the SAME monitor
|
||||
/// <c>DisplayModeCatalog.InstallFromWindow</c> enumerated), falling back
|
||||
/// to the primary.</summary>
|
||||
private Silk.NET.GLFW.Monitor* ResolveWindowMonitor(Glfw glfw, WindowHandle* handle)
|
||||
{
|
||||
Silk.NET.GLFW.Monitor* attached = glfw.GetWindowMonitor(handle);
|
||||
if (attached is not null) return attached;
|
||||
|
||||
int? index = _window.Monitor?.Index;
|
||||
if (index is int i && i >= 0)
|
||||
{
|
||||
Silk.NET.GLFW.Monitor** monitors = glfw.GetMonitors(out int count);
|
||||
if (monitors is not null && i < count)
|
||||
return monitors[i];
|
||||
}
|
||||
return glfw.GetPrimaryMonitor();
|
||||
}
|
||||
|
||||
/// <summary>The monitor's highest refresh rate for an exact WxH, or
|
||||
/// false when the monitor does not report the mode at all.</summary>
|
||||
/// false when the monitor does not report the mode at all. (Register
|
||||
/// row AD-92: retail passed the device mode's own refresh as-is;
|
||||
/// highest-for-WxH is a deliberate adaptation.)</summary>
|
||||
private static bool TryFindRefreshRate(
|
||||
Glfw glfw, Silk.NET.GLFW.Monitor* monitor, int width, int height, out int refresh)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -45,13 +45,20 @@ internal interface IRuntimeChatOpacityTarget
|
|||
void Apply(float defaultOpacity, float activeOpacity);
|
||||
}
|
||||
|
||||
/// <summary>The one window property the display apply touches — a narrow
|
||||
/// <summary>The window properties the display apply touches — a narrow
|
||||
/// seam so the #388 state machine is testable without faking all of
|
||||
/// <see cref="IWindow"/> (same idiom as <c>FakePacingSurface</c>'s
|
||||
/// surface).</summary>
|
||||
internal interface IWindowedSizeSurface
|
||||
{
|
||||
Vector2D<int> Size { get; set; }
|
||||
|
||||
/// <summary>#388 blast M3: a maximized window silently ignores a Size
|
||||
/// write — the apply un-maximizes first (the deleted
|
||||
/// <c>WindowState = Normal</c> write used to do this incidentally).</summary>
|
||||
bool IsMaximized { get; }
|
||||
|
||||
void Restore();
|
||||
}
|
||||
|
||||
internal sealed class SilkWindowSizeSurface(IWindow window) : IWindowedSizeSurface
|
||||
|
|
@ -64,6 +71,10 @@ internal sealed class SilkWindowSizeSurface(IWindow window) : IWindowedSizeSurfa
|
|||
get => _window.Size;
|
||||
set => _window.Size = value;
|
||||
}
|
||||
|
||||
public bool IsMaximized => _window.WindowState == WindowState.Maximized;
|
||||
|
||||
public void Restore() => _window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarget
|
||||
|
|
@ -76,11 +87,14 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
|
|||
: this(
|
||||
new SilkWindowSizeSurface(window),
|
||||
new GlfwDisplayModeSwitcher(window),
|
||||
// #391's catalog is the validation source: an offered mode is
|
||||
// supported by construction. With no catalog installed
|
||||
// (fixture/uninitialised hosts) nothing is "offered", so
|
||||
// fullscreen entry is refused rather than guessed.
|
||||
spec => Rendering.DisplayModeCatalog.Resolutions?.Contains(spec) == true)
|
||||
// #391's catalog is the validation source. With no catalog
|
||||
// installed, the dropdown falls back to the static preset
|
||||
// ladder — the validator must fall back to the SAME list
|
||||
// (blast M2: an asymmetric fallback made Full Screen a permanent
|
||||
// silent no-op on catalog-less hosts). The switcher's own
|
||||
// monitor-mode-list check remains the hard guard either way.
|
||||
spec => (Rendering.DisplayModeCatalog.Resolutions
|
||||
?? DisplaySettings.AvailableResolutions).Contains(spec))
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +130,19 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
|
|||
if (display.Fullscreen)
|
||||
{
|
||||
if (!haveResolution)
|
||||
{
|
||||
// Mechanism M5: never a SILENT bail-out — §D6's acceptance is
|
||||
// "any refused/failed switch logs a line".
|
||||
Console.WriteLine(
|
||||
$"display: fullscreen refused — unparseable resolution '{display.Resolution}'");
|
||||
return;
|
||||
}
|
||||
// Mechanism/blast M2: idempotence BEFORE any native work — every
|
||||
// Display-backed Config row applies per change (sliders per drag
|
||||
// tick), and only this guard keeps those from re-issuing a real
|
||||
// display-mode change per mouse sample.
|
||||
if (_modeSwitcher.CurrentFullscreenMode is (int curW, int curH)
|
||||
&& curW == width && curH == height)
|
||||
return;
|
||||
if (!_isOfferedMode.Invoke($"{width}x{height}"))
|
||||
{
|
||||
|
|
@ -125,7 +152,7 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
|
|||
}
|
||||
if (!_modeSwitcher.TryEnterFullscreen(width, height, out string? error))
|
||||
Console.WriteLine(
|
||||
$"display: fullscreen {width}x{height} failed ({error}) — staying windowed");
|
||||
$"display: fullscreen {width}x{height} failed ({error}) — window state unchanged (#392 tracks the persisted-flag divergence)");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -146,6 +173,11 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
|
|||
|
||||
if (haveResolution && (_window.Size.X != width || _window.Size.Y != height))
|
||||
{
|
||||
// Blast M3: a maximized window ignores Size writes — restore
|
||||
// first (the deleted WindowState=Normal write did this
|
||||
// incidentally; now it is explicit and only-when-needed).
|
||||
if (_window.IsMaximized)
|
||||
_window.Restore();
|
||||
// #387 evidence line (permanent): the resolution-pick write path.
|
||||
Console.WriteLine(
|
||||
$"display: resolution pick {width}x{height} " +
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue