acdream/docs/research/2026-08-13-376-388-mechanism-review.md
Erik ec2a7b0cce 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>
2026-08-13 18:10:07 +02:00

29 KiB
Raw Permalink Blame History

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:

  1. A failing glfw.SetWindowMonitor (DisplayModeSwitching.cs:98) — e.g. "Failed to set video mode: Graphics mode not supported", the exact #388 error — returns normally. TryEnterFullscreen returns true and prints display: 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.
  2. The queued GlfwException detonates at the next ThrowExceptions(), which in this client is GlfwWindow::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.
  3. 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, 754bindings.SaveDisplay(...)RuntimeSettingsController.SaveDisplay (:386-399) → ApplyDisplayWindowStateApply.
  • FloatOptionRow.SetCurrentValue applies live, on every drag tick, not on release — stated verbatim in OptionPageModel.cs:176-181 and 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 ~23 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 runs ApplyDisplay during composition phase 3.
  • src/AcDream.App/Composition/SessionPlayerComposition.cs:334 — the live target (RuntimeSettingsTargets), which runs ApplyDisplayWindowState on every Config save.

Sequence that breaks: settings.json has fullscreen: trueinstance 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) enumerates window.Monitor. Silk's GlfwWindow::get_Monitor (IL: GetWindowMonitor → else walk GlfwMonitorEnumerable for the monitor whose bounds contain the window centre → else GetPrimaryMonitor) means at OnLoad this is the monitor the window happens to be on.
  • GlfwDisplayModeSwitcher.TryEnterFullscreen (:77) enumerates and switches on GetPrimaryMonitor().

Two failure shapes on a heterogeneous multi-monitor desktop:

  1. A mode offered from the secondary's list is absent from the primary's → TryFindRefreshRate fails (:84-88) → fullscreen refused. The user ticks the box, nothing happens, settings.json says fullscreen: true (see M5).
  2. Even on success, entering fullscreen moves the client to the primary monitor. That may be retail-faithful (Device::ForceDisplayResolution drove 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 queueingGlfwWindow::<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 into ValidScreenSize() — the client area — via handle.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, and TryFindRefreshRate re-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.Resolutions is null, and RuntimeSettingsTargets.cs:83 is spec => DisplayModeCatalog.Resolutions?.Contains(spec) == true — a null-conditional whose null == true is false. Fullscreen entry is refused with a log; nothing dereferences null. PASS.
  • Nit (not blocking): Contains is Enumerable.Contains over an IReadOnlyList<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. TryParseResolution splits 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:

  1. AcquirePlatform()VulkanGraphicsContext.Acquire — swapchain created at the 1280x720 startup size (GameWindow.cs:1219-1238, :752-757).
  2. DisplayModeCatalog.InstallFromWindow(_window!)GameWindow.cs:1246, before the pipeline, so the startup fullscreen entry is validated against a real catalog.
  3. Composition phase 1 binds the framebuffer targets: FramebufferResize.BindViewport (HostInputCameraComposition.cs:204) and BindCamera (:317).
  4. 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.OnWindowMovedRefreshActiveMonitor()SilkDisplayFramePacingSurface.TryGetActiveMonitorRefreshHz_window.Monitor?.VideoMode.RefreshRate (DisplayFramePacingController.cs:35, 121-128) from inside glfwSetWindowMonitor, mid-mode-transition. GlfwWindow::get_Monitor resolves through GetWindowMonitorGetMonitorsnew GlfwMonitor and GlfwMonitor.VideoModeGetVideoMode — 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:

  1. Fullscreen=true while IsFullscreen=true at the same mode → expect Assert.Empty(switcher.Calls) (M2).
  2. Fullscreen=true with an unparseable Resolution → currently a silent return; assert whatever the decided behaviour is (M5).
  3. 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).
  4. Fullscreen=false while IsFullscreen=true with 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/refused line" 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.md row accompanies this commit. At minimum the always-switch-the-primary-monitor behaviour (M4) is a deviation ("retail's ForceDisplayResolution drove 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; glfwSetWindowMonitor will report success without switching. Slice L is parked, so this is documentation-only today — one line in the GlfwDisplayModeSwitcher class doc.