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>
528 lines
29 KiB
Markdown
528 lines
29 KiB
Markdown
# 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, 754` → `bindings.SaveDisplay(...)` →
|
||
`RuntimeSettingsController.SaveDisplay` (`:386-399`) →
|
||
`ApplyDisplayWindowState` → `Apply`.
|
||
- `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 ~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 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: 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`)
|
||
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 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 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.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`:
|
||
|
||
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.
|