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>
29 KiB
Mechanism review — e56aa511 (#376 + #388): native fullscreen mode switching + state-aware display apply
Reviewer lens: MECHANISM (does the machine do what it claims, on the
platform it runs on).
Scope: src/AcDream.App/Settings/DisplayModeSwitching.cs (new),
src/AcDream.App/Settings/RuntimeSettingsTargets.cs
(SilkRuntimeDisplayWindowTarget.Apply rework), and their live
surroundings.
Date: 2026-08-13. Report only — no files were edited.
Primary evidence for the Silk/GLFW claims below is IL read directly out of
the pinned packages
(Silk.NET.GLFW 2.23.0, Silk.NET.Windowing.Glfw 2.23.0 under
%USERPROFILE%\.nuget\packages), not recollection. Where a claim rests on
IL, the decoded bytes are quoted.
Verdict summary
| # | Question | Verdict |
|---|---|---|
| 1 | GLFW API correctness (Glfw.GetApi(), main thread) |
PASS on both halves — but the error-handling contract built on top of it is broken (M1) |
| 2 | State-machine holes | FAIL — M2 (no idempotence → video-mode storm), M5 (persisted state diverges, one path silent). The _windowedPosition clobber the question asked about is correctly guarded. |
| 3 | Silk-vs-native WindowState desync |
PASS — no desync exists; the getter is native-backed and no writer remains. Events fire on both paths. One latent trap (W3). |
| 4 | Exit path / remembered position | PARTIAL FAIL — M3: two switcher instances, so the startup-fullscreen → live-exit path never restores the remembered placement. True off-screen stranding is unlikely (W5). |
| 5 | Validation seam | PARTIAL FAIL — M4: catalog and switcher enumerate different monitors. The catalog-uninstalled (fixture/headless) case refuses cleanly rather than crashing — that half PASSES. |
| 6 | #377 regression surface (startup ordering) | PASS on ordering — the composition order is safe and the quiescence gate is reentrant. But the commit does not close #377's mechanism; it makes it reachable more often (W1). |
MUST-FIX findings, ranked
M1 — catch (GlfwException) is dead code on Windows: a failed native switch reports SUCCESS and arms a deferred crash
DisplayModeSwitching.cs:103-109 (and :135-139) wrap the native calls in
catch (GlfwException), and the interface doc at :24-25 promises
"False (with a reason) instead of throwing on any failure". The commit
message repeats it: "every failure is a no-throw (bool, reason) result."
On Windows that is false. Silk's process-global GLFW error callback is
installed by GlfwProvider::GetGlfw
(… callvirt Glfw::Init … call Glfw::get_ErrorCallback; callvirt Glfw::SetErrorCallback) and its body is Glfw.<>c::<.cctor>b__143_0.
Raw IL (51 bytes):
00: 72 3F160070 ldstr "{0}: {1}"
05: 03 8C 11000002 ldarg.3 ; box (error code)
0B: 02 ldarg.2 (description)
0C: 28 4F00000A call String::Format
11: 73 9E000006 newobj GlfwException::.ctor
16: 25 03 6F A2..06 dup; ldarg.3; set_ErrorCode
1D: 0A stloc.0 (ex)
1E: 7E 40010004 ldsfld Glfw::_isWindows
23: 2C 02 brfalse.s -> 0x27
25: 06 7A ldloc.0; THROW <- non-Windows only
27: 7E 41010004 ldsfld Glfw::_exceptions
2C: 06 6F 5600000A ldloc.0; callvirt List::Add <- WINDOWS: DEFERRED
32: 2A ret
i.e. if (!_isWindows) throw ex; else Glfw._exceptions.Add(ex);.
The drain is Glfw.ThrowExceptions(), and the only callers in the entire
Silk closure are seven GlfwWindow methods:
GlfwWindow::Create, CoreInitialize, CoreReset, RegisterCallbacks,
GlfwWindow::SetWindowIcon, set_Monitor, GetProcAddress
(verified by scanning Silk.NET.Windowing.Glfw, Silk.NET.Input.Glfw,
Silk.NET.Windowing.Common, Silk.NET.Input.Common, Silk.NET.Core —
the last four contain zero call sites). None of them is per-frame, and
none of them is on any path this commit takes. ThrowExceptions also never
clears the list — its 66-byte body contains get_Count, get_Item(0),
AggregateException, throw, and no Clear — so a queued error is
re-raised at every later drain.
Consequences, in order of severity:
- A failing
glfw.SetWindowMonitor(DisplayModeSwitching.cs:98) — e.g. "Failed to set video mode: Graphics mode not supported", the exact #388 error — returns normally.TryEnterFullscreenreturnstrueand printsdisplay: fullscreen mode switch WxH@R(:99-100) for a switch that did not happen. Every downstream consumer, including the §D5/§D6 gate script and the "live-verified" evidence line in the commit message, reads that log as proof of success. The gate's own oracle can lie. - The queued
GlfwExceptiondetonates at the nextThrowExceptions(), which in this client isGlfwWindow::CoreReset— window close. A failed mode switch mid-session therefore surfaces as an unexplained crash at shutdown, which is exactly the path the project needs graceful for ACE session cleanup. - This also retro-explains #388's "first surfaced as a caught
settings: display save failed: PlatformError…, then a second fired as an UNHANDLED exception": with a never-cleared static list, the same exception instance is thrown twice from two different drains. That pattern is a signature of this mechanism, not of two independent errors.
Fix direction (no workaround): do not rely on exceptions at all. Verify
the post-condition natively inside TryEnterFullscreen /
TryLeaveFullscreen: after SetWindowMonitor, re-read
glfw.GetWindowMonitor(handle) and glfw.GetVideoMode(monitor) and require
they match the request; return false with the observed state otherwise.
That is platform-independent, needs no knowledge of Silk's deferral, and
turns the log line into a measurement instead of an assumption. Draining
Glfw.ThrowExceptions() is a second-choice option only, and must account
for the never-cleared static list (a stale error from anywhere in the
process would be misattributed to the switch).
M2 — No idempotence guard on the fullscreen branch: every Display-backed Config row re-issues a REAL video-mode change, once per slider drag tick
RuntimeSettingsTargets.cs:116-130 — the fullscreen branch calls
TryEnterFullscreen unconditionally. The windowed branch immediately
below keeps its change guard (:147,
if (haveResolution && (_window.Size.X != width || _window.Size.Y != height))),
and the code this commit deleted had one too
(if (_window.WindowState != desired) _window.WindowState = desired;). The
idempotence that existed before this commit was removed on the fullscreen
side only.
Why that matters here specifically:
- Every Display-backed Config row funnels through the same apply:
ConfigOptionsPageController.cs:587, 643, 651, 659, 671, 678, 685, 693, 738, 746, 754→bindings.SaveDisplay(...)→RuntimeSettingsController.SaveDisplay(:386-399) →ApplyDisplayWindowState→Apply. FloatOptionRow.SetCurrentValueapplies live, on every drag tick, not on release — stated verbatim inOptionPageModel.cs:176-181and relied on by the Chat tab's fade-while-dragging behaviour.
So, while fullscreen, dragging Field of View / Screen Brightness / Degrade
Distance / Graphics Performance issues one glfwSetWindowMonitor
(→ ChangeDisplaySettingsEx) per mouse-move sample, each one taking the
monitor through a real mode set + re-sync, each one re-entrantly firing
FramebufferResize + Move (see W1) and arming a swapchain recreate. Every
non-slider Display row (texture detail, filtering, degrades, quality preset,
VSync) does the same once per click.
Secondary cost on the same path: Glfw.GetApi() is called fresh on every
IsFullscreen read (:60) and every Try* entry (:76, :124), and
Glfw::GetApi is newobj GlfwLibraryNameContainer → GetLibraryNames → CreateDefaultContext → newobj Glfw — a new DefaultNativeContext (and a
LoadLibrary/dlopen refcount) per call, never disposed. That is ~2–3 per
apply, i.e. per drag tick under this bug.
Fix: early-return when already fullscreen at the requested mode. That
needs a current-mode read on the seam (e.g.
IDisplayModeSwitcher.TryGetCurrentMode(out int w, out int h)), which the
fake can implement trivially — and a test asserting a same-state re-apply
issues zero enter: calls (FakeModeSwitcher already records them, so this
gap was one assertion away from being caught).
M3 — Two SilkRuntimeDisplayWindowTarget instances: the remembered windowed placement is lost exactly on the startup-fullscreen → live-exit path
There are two production construction sites, each building its own
GlfwDisplayModeSwitcher with its own _windowedPosition field
(DisplayModeSwitching.cs:47, initial value (60, 60)):
src/AcDream.App/Rendering/GameWindow.cs:1296— the startup target (RuntimeSettingsStartupTargets), which runsApplyDisplayduring composition phase 3.src/AcDream.App/Composition/SessionPlayerComposition.cs:334— the live target (RuntimeSettingsTargets), which runsApplyDisplayWindowStateon every Config save.
Sequence that breaks: settings.json has fullscreen: true → instance A
captures the real windowed position at :90-96 and enters fullscreen →
user later unticks Full Screen in the Config tab → instance B runs
TryLeaveFullscreen, and its _windowedPosition has never been written, so
the window is placed at the (60, 60) literal.
On a single monitor that is merely wrong-but-harmless; on multi-monitor it
teleports the client to the primary monitor's top-left regardless of
where it was. It also makes the commit message's claim ("the windowed
placement is remembered for the exit path") and gate-script step §D5
("positioned where it was before entering fullscreen",
docs/research/2026-08-13-display-block-test-script.md) false for that
ordering — and only for that ordering, so a tester who toggles fullscreen
on and off within one session will not reproduce it. The gate must
explicitly exercise launch-fullscreen → untick.
Fix: one process-wide switcher (or hoist the remembered placement into a single owner both targets borrow).
M4 — Catalog and switcher enumerate DIFFERENT monitors, so "an offered mode is supported by construction" does not hold
The whole crash-class argument rests on the catalog and the switcher agreeing about which display's mode list is authoritative. They do not:
DisplayModeCatalog.InstallFromWindow(Rendering/DisplayModeCatalog.cs:53) enumerateswindow.Monitor. Silk'sGlfwWindow::get_Monitor(IL:GetWindowMonitor→ else walkGlfwMonitorEnumerablefor the monitor whose bounds contain the window centre → elseGetPrimaryMonitor) means atOnLoadthis is the monitor the window happens to be on.GlfwDisplayModeSwitcher.TryEnterFullscreen(:77) enumerates and switches onGetPrimaryMonitor().
Two failure shapes on a heterogeneous multi-monitor desktop:
- A mode offered from the secondary's list is absent from the primary's →
TryFindRefreshRatefails (:84-88) → fullscreen refused. The user ticks the box, nothing happens,settings.jsonsaysfullscreen: true(see M5). - Even on success, entering fullscreen moves the client to the primary
monitor. That may be retail-faithful (
Device::ForceDisplayResolutiondrove the primary display device, per the class doc), but it is a behavioural deviation from "fullscreen the window where it is" and is not in the divergence register.
Fix: pick one monitor authority and use it at both ends (catalog +
switcher). If "always primary" is kept as the retail-faithful choice, the
catalog must enumerate the primary too, and a
docs/architecture/retail-divergence-register.md row must record the
window-jumps-to-primary behaviour.
M5 — Every fullscreen refusal/failure leaves settings.json inconsistent with reality; one path is entirely silent
RuntimeSettingsController.SaveDisplay persists first, applies second
(:390 then :392). Apply's three fullscreen exits never revert:
| Path | Line | Logged? | Persisted state after |
|---|---|---|---|
| Resolution unparseable | :118-119 |
no log at all | fullscreen: true, window windowed |
| Mode not offered | :120-125 |
yes (refused — not an offered mode) |
fullscreen: true, window windowed |
| Switch failed | :126-128 |
yes (failed (…) — staying windowed) |
fullscreen: true, window windowed |
The unparseable case is the one the review brief asked about, and it is the
worst of the three: it produces no display: line whatsoever, so the
gate script's "any refused/failed switch logs a display: … failed/refused
line" acceptance criterion (§D6 step 1) cannot be met and the tester has
nothing to read. The Config checkbox then reads true from storage forever
and every subsequent launch silently re-refuses.
A second accuracy defect lives at :128: when the re-entry attempt fails
while the window is already fullscreen, the message says "staying
windowed", which is factually wrong.
Fix: log the unparseable case; make the failure message report the actual
resulting state; and decide deliberately whether a refusal should revert the
persisted Fullscreen (retail has a confirmation flow — SetConfirmChange
— already noted as unported in ConfigOptionsPageController.cs:623).
Per-question findings
Q1 — GLFW API correctness
Glfw.GetApi() is the right instance. PASS. Glfw::GetApi IL is
newobj GlfwLibraryNameContainer → SearchPathContainer::GetLibraryNames → Glfw::CreateDefaultContext → newobj Glfw: a new managed wrapper over the
same native library. GLFW's state (init flag, window list, monitor list,
error callback) lives in the native module, which the OS loader returns as
one instance per process — so a second wrapper drives the same GLFW.
The GraphicalWindowBackendSelection.cs:135-138 comment ("A separate
Glfw.GetApi() instance would receive the hint but would not own the window
backend's process-global GLFW state") is about init-hint ordering, not
about later calls: GlfwProvider.UninitializedGLFW is itself literally
new Lazy<Glfw>(Glfw.GetApi) (GlfwProvider::.cctor → <>O::<0>__GetApi),
so it is the same kind of object. Using GlfwProvider.UninitializedGLFW.Value
there matters because Silk must later Init() that instance; it does not
imply later GetApi() calls are wrong. GlfwCursorCache.TryCreate
(Rendering/GlfwCursorCache.cs:47) already established this in production
(#348).
Caveat (W4): GlfwCursorCache caches its Glfw for the object's
lifetime; GlfwDisplayModeSwitcher calls Glfw.GetApi() on every
property read and method entry (:60, :76, :124). Each call allocates a
Glfw + DefaultNativeContext and takes a native-library refcount that is
never released (NativeApiContainer.Dispose is never called). Cache one
instance in the field, as the #348 precedent does.
Thread: PASS. Program.cs constructs GameWindow and calls
window.Run() on the process main thread (Program.cs:142);
GameWindow.Run calls Window.Create (GameWindow.cs:761) and
_window.Run() (:785) on that same thread; Silk's loop and all callbacks
(Load/Update/Render/FramebufferResize) run on the calling thread. Both the
startup ApplyDisplay (composition, inside OnLoad) and the live
ApplyDisplayWindowState (Config-tab click handling in the update phase)
therefore execute on the GLFW main thread. glfwSetWindowMonitor,
glfwGetVideoModes and glfwGetWindowPos are all main-thread-only, and all
three are satisfied.
Q2 — State-machine hole enumeration
haveResolution = TryParseResolution succeeded (:113-114);
offered = _isOfferedMode("{w}x{h}"); fs = _modeSwitcher.IsFullscreen.
| # | target | fs | haveRes | offered | switcher result | Outcome | Assessment |
|---|---|---|---|---|---|---|---|
| 1 | FS | no | yes | yes | ok | enters fullscreen | correct |
| 2 | FS | no | yes | yes | fail | logs, stays windowed | correct but see M1 (on Windows "fail" is not observable) |
| 3 | FS | no | yes | no | — | logs refusal, stays windowed | correct; M5 (settings now lie) |
| 4 | FS | no | no | — | — | silent return | M5 — no log, settings lie |
| 5 | FS | yes | yes | yes | ok | redundant real mode set | M2 — storm |
| 6 | FS | yes | yes | yes | fail | logs "staying windowed" while fullscreen | M5 (wrong message) |
| 7 | FS | yes | yes | no | — | logs refusal, remains fullscreen at old mode | acceptable; message is accurate |
| 8 | FS | yes | no | — | — | silent return, remains fullscreen | benign, but silent |
| 9 | win | yes | yes | — | ok | native exit at picked size + _windowedPosition |
correct; M3 (wrong position across instances), W5 (size==desktop) |
| 10 | win | yes | yes | — | fail | logs, remains fullscreen | correct |
| 11 | win | yes | no | — | ok | exit at current fullscreen size (:136-140 reads _window.Size, which under native fullscreen is the mode size) |
window client = desktop size → frame overflows desktop (W5) |
| 12 | win | no | yes | — | — | size write iff changed (:147) |
correct, guarded |
| 13 | win | no | no | — | — | no-op | correct |
On the specific idempotency sub-question: the guard at :90-96 is
correct. _windowedPosition is only captured when
glfw.GetWindowMonitor(handle) is null, so re-entering fullscreen while
already fullscreen cannot clobber it with (0,0). GLFW itself tolerates
glfwSetWindowMonitor with the same monitor (it re-applies the mode); the
problem is not correctness of a single repeat but the rate of repeats
(M2).
Q3 — Silk-vs-native desync
No desync exists. PASS. GlfwWindow::get_CoreWindowState is
native-backed:
_glfw.GetWindowAttrib(_glfwWindow, Iconified) -> Minimized
_glfw.GetWindowAttrib(_glfwWindow, Maximized) -> Maximized
_glfw.GetWindowMonitor(_glfwWindow) != null -> Fullscreen
else -> Normal
So after a native SetWindowMonitor, _window.WindowState correctly reports
Fullscreen. Silk's _extendedState-style caching only applies before
IsInitialized.
No readers to misbehave. grep over src/ and tests/ finds zero
WindowState = writers left after this commit, and the only consumer is
DisplayFramePacingController.OnWindowStateChanged(WindowState _)
(Rendering/DisplayFramePacingController.cs:132), which discards the value
and calls RefreshActiveMonitor(). UI/picking read IWindow.Size
(Composition/InteractionUiRuntimeSources.cs:559) and the render path reads
IWindow.FramebufferSize (Rendering/RetailPViewPassExecutor.cs:27,
Rendering/Gpu/Vk/VulkanGraphicsContext.cs:261, 406) — both are live native
reads (get_FramebufferSize = GetFramebufferSize), both correct under
native fullscreen. There is no screenshot path that reads WindowState.
Events fire on both paths, and directly. Silk raises them straight from
the GLFW callback with no queueing —
GlfwWindow::<RegisterCallbacks>b__94_2 is
ldsfld FramebufferResize; newobj Vector2D<int>; callvirt Invoke, and
b__94_0 (pos) is UpdatePosition; ldsfld Move; Invoke. Since
glfwSetWindowMonitor calls SetWindowPos, the WM_SIZE / WM_MOVE handlers
run synchronously inside it, so FramebufferResize and Move are delivered
re-entrantly, on both enter and exit. (Confirmed safe: see W1/W8.)
StateChanged does NOT fire for a monitor change — b__94_6/b__94_7
are the iconify/maximize callbacks and are the only sites that call
UpdateState + raise StateChanged. pacing.OnWindowStateChanged therefore
never runs for our switch; pacing.OnWindowMoved covers it instead
(SilkWindowCallbackBinding.cs:152). No action needed — just do not build
anything new on StateChanged.
W3 (latent trap): GlfwWindow::set_CoreWindowState is the only writer of
Silk's _nonFullscreenPosition / _nonFullscreenSize. Bypassing it means
those stay stale forever. If any future code sets WindowState = Normal
while natively fullscreen, Silk calls glfwRestoreWindow, which for a
fullscreen window restores the video mode on the same monitor — it does
not leave fullscreen. Worth a one-line comment in
DisplayModeSwitching.cs so the next author does not reach for the Silk
setter as a "simpler" exit.
Q4 — The exit path
(60,60)default: reachable in production, via M3 (not via the "never captured" path the question hypothesised — startup entry does capture, just on the other instance).- Off-screen stranding proper: unlikely.
glfwSetWindowMonitor(NULL, …)restores the monitor's original video mode, so a position captured pre-fullscreen is still valid for the restored desktop.(60,60)is always on-screen on the primary. - W5 (real, milder): the exit uses the picked resolution as the
client size. The catalog's Defaults value is the desktop mode
(
DisplayModeCatalog.cs:39-43), so the common case produces a windowed client exactly the size of the desktop — its frame and title bar then overflow the work area, and row 11 of the Q2 table (unparseable resolution) produces the same via_window.Size. Recommend clamping the restored placement/size to the monitor work area (glfwGetMonitorWorkarea) on the exit path. - #390 interaction — confirmed, the UI clamp does NOT cover the OS
window.
RetailWindowLayoutPersistence.ClampAllToScreen(src/AcDream.App/UI/RetailWindowLayoutPersistence.cs:102-129) clamps each attached retail-UI window intoValidScreenSize()— the client area — viahandle.MoveTo. It never touches the native window. So #390 keeps the panels reachable inside whatever client rect exists; it cannot rescue a client rect that itself overflows the desktop. That is the right layering; it just means W5 needs its own fix rather than leaning on #390.
Q5 — Validation seam
- Are valid sub-desktop fullscreen modes wrongly refused? Not by the
curation itself.
DisplayModeCatalog.Curate(:94-144) keeps any monitor mode that fits the desktop, is ≥1280 wide and is 16:9/16:10/21:9/32:9 within ±2.5% — plus the desktop mode unconditionally. Those are all genuine fullscreen targets, andTryFindRefreshRatere-checks against the live GLFW mode list, so nothing supported-and-offered is refused on a single-monitor machine. On multi-monitor, M4 breaks it. - Catalog-uninstalled (fixture/headless): refuses cleanly, no crash.
DisplayModeCatalog.Resolutionsisnull, andRuntimeSettingsTargets.cs:83isspec => DisplayModeCatalog.Resolutions?.Contains(spec) == true— a null-conditional whosenull == trueisfalse. Fullscreen entry is refused with a log; nothing dereferences null. PASS. - Nit (not blocking):
ContainsisEnumerable.Containsover anIReadOnlyList<string>(ordinal, O(n≈10)) — fine. The spec string is rebuilt from the parsed ints ($"{width}x{height}"), so"01920x1080"-style input normalises before lookup.TryParseResolutionsplits on lowercase'x'only (:165) — pre-existing. - W7: the catalog is captured once at
OnLoad(GameWindow.cs:1246) and never refreshed; monitor hot-plug or a topology change leaves it stale, and (with M4) increasingly wrong.
Q6 — #377 regression surface at startup
The ordering is safe. PASS. Verified chain inside OnLoad:
AcquirePlatform()→VulkanGraphicsContext.Acquire— swapchain created at the 1280x720 startup size (GameWindow.cs:1219-1238,:752-757).DisplayModeCatalog.InstallFromWindow(_window!)—GameWindow.cs:1246, before the pipeline, so the startup fullscreen entry is validated against a real catalog.- Composition phase 1 binds the framebuffer targets:
FramebufferResize.BindViewport(HostInputCameraComposition.cs:204) andBindCamera(:317). - Composition phase 3 runs
Settings.ApplyStartup(SettingsDevToolsComposition.cs:53) →ApplyDisplay→ native switch.
So the re-entrant FramebufferResize fired from inside
glfwSetWindowMonitor finds both targets bound —
FramebufferResizeController.Resize (:109-129) is null-conditional
throughout, and the Vulkan viewport target only arms a frame-boundary
swapchain recreate (VulkanHostInputCameraCompositionFactory.cs:38, 122)
rather than doing GPU work re-entrantly. Had step 4 preceded step 3, the
resize would have been silently dropped ("late binding never replays an
earlier resize transition", FramebufferResizeController.cs:123-124) and
the client would have rendered a 1280x720-aspect image stretched over the
fullscreen surface — the #387 symptom, resurrected at startup. This
ordering is load-bearing and undocumented; it deserves a comment at
GameWindow.cs:1246 or a boundary test.
Re-entrancy itself cannot deadlock: HostQuiescenceGate.Invoke uses lock
(Rendering/HostQuiescenceGate.cs:39, 49), and Monitor is reentrant on the
same thread — the class doc already calls this out.
W1 (the residual, and the honest answer to "could this resurrect the old
access violation"): yes, the mechanism is still reachable — more often than
before. The re-entrant Move event runs
pacing.OnWindowMoved → RefreshActiveMonitor() →
SilkDisplayFramePacingSurface.TryGetActiveMonitorRefreshHz →
_window.Monitor?.VideoMode.RefreshRate
(DisplayFramePacingController.cs:35, 121-128) from inside
glfwSetWindowMonitor, mid-mode-transition. GlfwWindow::get_Monitor
resolves through GetWindowMonitor → GetMonitors → new GlfwMonitor and
GlfwMonitor.VideoMode → GetVideoMode — which is precisely #377's crash
site (0xC0000005 in Glfw.GetVideoMode, "reading a monitor
mid-mode-transition", docs/ISSUES.md §#377). Its catch (GlfwException)
guard (:42-49) does not help: per M1 GLFW errors do not throw on
Windows, and an access violation is not a managed exception at all.
This is not a new class — Silk's own WindowState = Fullscreen setter also
called SetWindowMonitor — but before this commit the fullscreen write was
guarded by if (_window.WindowState != desired) and therefore rare. With
M2, it now runs on every Display-tab save and every slider drag tick.
Recommendation: suppress the pacing monitor refresh for the duration of a
deliberate mode switch (a re-entrancy latch around
SetWindowMonitor, then one explicit RefreshActiveMonitor() after it
returns) — that is a correctness fix for a re-entrancy hazard, not a
symptom-hiding guard.
Test-coverage gaps (all five new tests pass on their own facts)
tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs:157-288
covers: validated switch / never-size-write, unoffered refusal, failed-switch
usability, native exit, plain windowed write. Missing, each one assertion
away with the existing FakeModeSwitcher:
Fullscreen=truewhileIsFullscreen=trueat the same mode → expectAssert.Empty(switcher.Calls)(M2).Fullscreen=truewith an unparseableResolution→ currently a silentreturn; assert whatever the decided behaviour is (M5).- Enter-then-exit round trip on one switcher instance, asserting the position is round-tripped (would not catch M3, which is a composition fact — that one needs a construction-site assertion or a single-owner refactor).
Fullscreen=falsewhileIsFullscreen=truewith an unparseable resolution → asserts the row-11 fallback reads the surface size.
Gate-script gaps
docs/research/2026-08-13-display-block-test-script.md §D4–§D6 is otherwise
well-shaped (the black-screen-risk flagging is right). Add:
- §D5 must include launch fullscreen → untick Full Screen as a distinct step from tick → untick in one session; only the former exposes M3.
- §D6 must include drag a slider (Field of View) while fullscreen — the M2 storm.
- §D6's "any refused/failed switch logs a
display: … failed/refusedline" is not currently guaranteed (M1 makes failure invisible, M5 case 4 logs nothing). Treat that acceptance line as unmet until M1/M5 land.
Register / bookkeeping
- No
docs/architecture/retail-divergence-register.mdrow accompanies this commit. At minimum the always-switch-the-primary-monitor behaviour (M4) is a deviation ("retail'sForceDisplayResolutiondrove the primary display device" is asserted in the class doc but the window also relocates), and the CLAUDE.md rule is that a deviation ships with its row in the same commit. - On Wayland (
GraphicalDisplayProtocol.Wayland), a client cannot change the display mode;glfwSetWindowMonitorwill report success without switching. Slice L is parked, so this is documentation-only today — one line in theGlfwDisplayModeSwitcherclass doc.