fix #376/#388 review round: post-condition truth, idempotence, one

position memory, unified monitor, maximized restore; AD-92

Dual-lens Opus review of e56aa511 (reports committed under
docs/research/). The consolidated corrections:

- Mechanism M1 (load-bearing): on Windows, Silk's GLFW error callback
  QUEUES exceptions on a static list instead of throwing - they detonate
  later at window close, which is exactly #388's original two-stage
  crash shape. catch(GlfwException) was dead code here and a failed
  SetWindowMonitor "succeeded". Success is now judged by the NATIVE
  POST-CONDITION (GetWindowMonitor after the call) on both enter and
  exit; the catches remain only for the throwing platforms.
- M2 (both lenses): same-mode fullscreen re-apply is a no-op BEFORE any
  native work (new IDisplayModeSwitcher.CurrentFullscreenMode). Every
  Display-backed Config row applies per change - sliders per DRAG TICK -
  so without this every tick while fullscreen re-issued a real
  display-mode change.
- M3/M5 (both): the remembered windowed placement is process state (two
  target instances exist - startup and live-save); a fullscreen boot now
  exits through either instance to the real placement, not the (60,60)
  literal.
- M4 (both): the switcher resolves the WINDOW'S monitor (attached
  monitor when fullscreen, else IWindow.Monitor's index into the GLFW
  array - the same monitor DisplayModeCatalog enumerated), primary only
  as a last resort; the offered-list/switch-target mismatch is gone.
- Blast M2b: the offered-mode validator falls back to the SAME static
  ladder the dropdown falls back to - Full Screen is no longer a
  permanent silent no-op on catalog-less hosts (the switcher's own
  monitor-mode-list check remains the hard guard).
- Blast M3: a windowed pick on a MAXIMIZED window restores it first
  (Size writes are silently ignored while maximized; the deleted
  WindowState=Normal write used to do this incidentally). New
  IWindowedSizeSurface.IsMaximized/Restore.
- Mechanism M5: no silent bail-outs - the unparseable-resolution
  fullscreen path logs, and the failure line no longer claims "staying
  windowed" when the state is unchanged (#392 noted inline).
- Q1 nit: one cached Glfw wrapper (per-call GetApi allocated + took a
  native refcount); IsFullscreen/CurrentFullscreenMode guarded.
- AD-92: highest-refresh-for-WxH + refuse-and-log versus retail's
  pass-through-and-error ForceDisplayResolution.

Known-open tail, filed not hidden: #392 (persisted-flag divergence on a
refused enter - needs an apply-result seam); the mechanism report's
pacing-refresh WATCH rides the same seam.

Tests: +3 (same-mode no-op, unparseable-while-fullscreen refusal,
maximized restore-before-write). App suite 4,975/3 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-13 18:10:07 +02:00
parent 229242e1fe
commit ec2a7b0cce
6 changed files with 1257 additions and 28 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,497 @@
# Blast-radius review — `e56aa511` (#376 + #388: real fullscreen mode switching, state-aware display apply)
**Reviewer lens:** BLAST RADIUS (who else touches the changed surfaces, and who
read the values they used to write). Report only — no edits made.
**Date:** 2026-08-13
**Base:** `e56aa511`, worktree `.claude/worktrees/eloquent-hugle-42119e`,
branch `claude/latest-commits-cb0c8f`.
The commit is correct about the thing it set out to fix: a raw `Size` write
against a fullscreen GLFW window is genuinely gone, the mode switch is genuinely
native, and the failure paths are genuinely no-throw. Everything below is about
the *other* edges those two changes now sit on.
---
## Verdict summary
| # | Surface | Verdict |
|---|---|---|
| M1 | Every Config-tab display row re-runs the fullscreen switch; enter has no idempotence guard; sliders apply per drag tick | **MUST-FIX** |
| M2 | Validation source ≠ switch target (catalog monitor vs primary monitor; UI fallback vs validator fallback) | **MUST-FIX** |
| M3 | #388's *maximized* half is unfixed, and the only un-maximize in the codebase was deleted — but the issue is marked DONE | **MUST-FIX** |
| M4 | Refused/failed enter leaves `fullscreen: true` persisted + checkbox ticked, with no surface but a console line | **MUST-FIX** |
| M5 | Two `GlfwDisplayModeSwitcher` instances → the windowed-position memory is dead on the boot-fullscreen exit path (gate §D5.3 asserts the opposite) | **MUST-FIX** |
| M6 | No divergence-register row for the deviations this commit introduces | **MUST-FIX (process)** |
| W1 | Pacing monitor-refresh read happens *before* the switch, and `StateChanged` (the signal the deleted `WindowState` write used to fire) is now dead | WATCH |
| W2 | `IsFullscreen` is the one unguarded native call in a "never throws" class | WATCH |
| W3 | Windowed exit with no parseable resolution falls back to the *fullscreen* size | WATCH |
| W4 | `glfwSetWindowMonitor` dispatches Silk callbacks synchronously, inside the quiescence lock, inside the update phase | WATCH (verified safe, undocumented) |
| W5 | Hard-kill while fullscreen now leaves a real display-mode change to Windows to unwind | WATCH |
| W6 | `DisplayModeCatalog.InstallFromWindow` is unguarded, and a silent non-install disables fullscreen entirely | WATCH |
| A1 | Startup `ApplyDisplay` ordering vs `InstallFromWindow` | **CLEAN** |
| A2 | Readers of `_window.WindowState` across `src/` | **CLEAN** (there are none) |
| A3 | `RetailWindowLayoutPersistence` resolution key shared fullscreen/windowed | **CLEAN** (correct: same client extent) |
| A4 | #390 edge-detector sequence for enter and exit | **CLEAN** |
| A5 | Tests / `InspectingDisplayWindowTarget` | **CLEAN** |
| A6 | Headless / no-window hosts | **CLEAN** (never reach this code) |
---
## Axis 1 — callers of `SilkRuntimeDisplayWindowTarget` / `IRuntimeDisplayWindowTarget`
Exactly **two production construction sites**, both in `AcDream.App`, both
inside `GameWindow.OnLoad`'s single composition pipeline run:
| Site | Path | Instance lifetime |
|---|---|---|
| `src/AcDream.App/Rendering/GameWindow.cs:1296` | startup — `RuntimeSettingsStartupTargets``ApplyDisplay` | process |
| `src/AcDream.App/Composition/SessionPlayerComposition.cs:334` | live save — `RuntimeSettingsTargets``ApplyDisplayWindowState` | process (composition runs once at `OnLoad`, not per generation) |
Consumers of the interface: `RuntimeSettingsStartupTargets.ApplyDisplay`
(`RuntimeSettingsTargets.cs:194`) and
`RuntimeSettingsTargets.ApplyDisplayWindowState` (`RuntimeSettingsTargets.cs:424`).
No headless / no-window host reaches either — `AcDream.Headless` references only
`AcDream.Runtime`, and `SessionPlayerDependencies.Window` is a non-nullable
`IWindow` supplied only by `GameWindow` (`SessionPlayerComposition.cs:41`,
`GameWindow.cs:1445`). **CLEAN.**
### Callers relying on the OLD behaviour
- *"`Size` write while fullscreen persists a windowed size for later."* No
consumer depended on this. A resolution pick while fullscreen is now a real
mode switch, and the later windowed exit takes its size from
`display.Resolution` (`RuntimeSettingsTargets.cs:141`), which is the same
value the old `Size` write would have parked. **CLEAN.**
- *"`WindowState.Fullscreen` borderless."* Nothing in `src/` reads window state
(see Axis 2). **CLEAN** — with one exception, the *maximized* case, which is
M3 below.
### M5 — two switcher instances, one live position memory
`GlfwDisplayModeSwitcher._windowedPosition` (`DisplayModeSwitching.cs:47`) is
per-instance and defaults to `(60, 60)`. It is written only inside
`TryEnterFullscreen` (`DisplayModeSwitching.cs:90-96`).
Boot-fullscreen flow:
1. Phase 3 (`SettingsDevToolsComposition.cs:53``ApplyStartup`
`ApplyDisplay`) enters fullscreen through the **startup** target's switcher.
That instance captures the real windowed position.
2. Phase 8 (`SessionPlayerComposition.cs:334`) builds the **live** target with a
fresh switcher whose `_windowedPosition` is still `(60, 60)`.
3. User unticks Full Screen → live path → `TryLeaveFullscreen` restores to
`(60, 60)` at the fullscreen resolution.
On a 1920×1080 desktop that is a 1920×1080 client area placed at (60,60) — title
bar and bottom edge off-screen. The gate script's own acceptance text for §D5
step 3 says the client must return "positioned where it was before entering
fullscreen"; for the §D4 boot-fullscreen path it will not.
Cheapest correct fix: one shared switcher (or one shared placement record) rather
than one per target instance.
---
## Axis 2 — readers of `_window.WindowState`
`grep '\.WindowState|WindowState\.'` over `src/**/*.cs` returns **zero** reads of
`IWindow.WindowState` outside the deleted lines. Nothing keys window chrome,
screenshots, input mapping, or the L1 capability probes off it:
- Screenshots: `PrivatePresentationRenderer.cs:81` takes `input.ViewportWidth/Height`,
seeded from `_window.Size` at `GameWindow.cs:1575`. Size-driven, not state-driven.
- Input: cursor mode is driven by camera mode only
(`CameraPointerInputController.cs:445-447`); nothing consults fullscreen.
- #348's native cursor cache takes the GLFW window handle
(`GameWindow.cs:1026`), which `glfwSetWindowMonitor` does not change.
- L1 probes (`GraphicalHostPlatformServices`, the bindless/MDI/SSBO gates) read
no window state.
**CLEAN** on the read side. The one consumer of the *event*:
### W1 — the `StateChanged` signal is now dead, and pacing reads the monitor too early
`SilkWindowCallbackBinding.cs:153` wires `IWindow.StateChanged`
`DisplayFramePacingController.OnWindowStateChanged`
(`DisplayFramePacingController.cs:132`) → `RefreshActiveMonitor()`. Silk raises
`StateChanged` from its own `WindowState` setter and its iconify/maximize
callbacks; a native `glfwSetWindowMonitor` bypasses all of them. So the deleted
`_window.WindowState = Fullscreen` write was, incidentally, the thing that
re-read the active monitor's refresh rate after a fullscreen transition. That is
gone.
Compounding it, the startup path refreshes the monitor **before** the switch:
```
RuntimeSettingsTargets.cs:194-201
_pacing.RefreshActiveMonitor(); // desktop mode, e.g. 60 Hz
_pacing.ApplyPreference(display.VSync);
_displayWindow.Apply(display); // switches the display to 1920x1080@300
```
and the live path (`ApplyDisplayWindowState`, `RuntimeSettingsTargets.cs:424-434`)
never touches pacing at all. With VSync **off** and capped rendering,
`FramePacingPolicy.Resolve` (`FramePacingPolicy.cs:26-29`) caps the software
pacer at the cached Hz — which after a fullscreen enter is the pre-switch desktop
refresh. The only thing that rescues it is the incidental `Move` callback
(`SilkWindowCallbackBinding.cs:152`) firing because the window jumped to the
monitor origin — which does not fire if the window was already at that position.
Rated WATCH, not MUST-FIX, because VSync defaults on (`DisplaySettings.cs:92`)
and the VSync branch ignores the refresh value. Fix is one line: refresh the
active monitor *after* `_displayWindow.Apply`, on both paths.
### W2 — `IsFullscreen` can throw out of a "never throws" class
`DisplayModeSwitching.cs:54-62` calls `Glfw.GetApi().GetWindowMonitor(handle)`
with no `try`, while both `Try*` methods around it are wrapped. The class doc
promises "every failure is a no-throw (bool, reason) result". `Apply` calls
`IsFullscreen` on the windowed branch (`RuntimeSettingsTargets.cs:134`), which is
the branch that runs on every single Config-tab save. Low probability
(`glfwGetWindowMonitor` on a live handle does not error), but the contract is
stated and not honoured.
---
## Axis 3 — `DisplaySettings.Fullscreen` consumers
Complete reader set (`grep Fullscreen --include=*.cs src`):
| Reader | Site |
|---|---|
| Startup apply | `RuntimeSettingsTargets.cs:116` (via `ApplyDisplay`, `:199`) |
| Live apply | `RuntimeSettingsTargets.cs:116` (via `ApplyDisplayWindowState`, `:426`) |
| Config tab "Full Screen" row | `ConfigOptionsPageController.cs:650-651` |
| Persistence | `SettingsStore.cs:82` (read), `:640` (write) |
`RetailWindowLayoutPersistence` does **not** read it — see A3.
### Fullscreen boot path, end-to-end
1. `GameWindow.Run` creates the window unconditionally at 1280×720
(`GameWindow.cs:752-754`).
2. `OnLoad``DisplayModeCatalog.InstallFromWindow(_window!)`
(`GameWindow.cs:1246`).
3. Composition phase 3 builds `SilkRuntimeDisplayWindowTarget(_window!)`
(`GameWindow.cs:1296`) and `SettingsDevToolsCompositionPhase.Compose` runs
`ApplyStartup` (`SettingsDevToolsComposition.cs:53`) →
`RuntimeSettingsController.ApplyStartup:216``ApplyDisplay`
`Apply(display)`.
4. Fullscreen branch → catalog check → `TryEnterFullscreen`
`glfwSetWindowMonitor`.
5. GLFW dispatches the framebuffer-size callback synchronously; the viewport
target arms `VulkanGraphicsContext.RequestRecreate`
(`VulkanHostInputCameraCompositionFactory.cs:136`) and the camera aspect
updates (`FramebufferResizeController.cs:125-127`). Both targets are already
bound by phase 2, so the mid-`OnLoad` event lands on live owners.
Functionally sound. The problem is what happens when step 4 says no.
### M4 — flag/reality divergence has no surface
The Config toggle persists first, applies second
(`ConfigOptionsPageController.cs:651``RuntimeSettingsController.cs:386-392`).
Both refusal paths (`RuntimeSettingsTargets.cs:120-125` "not an offered mode",
`:126-128` "failed") only `Console.WriteLine`. Consequences:
- `settings.json` keeps `fullscreen: true` while the client is windowed.
- The checkbox reads `bindings.LoadDisplay().Fullscreen`
(`ConfigOptionsPageController.cs:650`) → renders **ticked** against a windowed
client.
- Every subsequent launch refuses again. The client boots windowed forever with
the box ticked and no visible explanation. The user's only feedback is a
console line they will not see in a normal launch.
This matters because the refusal is *reachable in ordinary use*, not just in
pathological setups — see M2. At minimum the refusal should reset the persisted
flag (or the row should read live state rather than the stored flag) so that what
the panel shows is what the client is.
Note the interaction with #377's documented recovery ("hand-edit settings.json
back to false"): the new refusal path re-creates exactly that stuck-flag shape,
minus the crash.
---
## Axis 4 — the #390 interaction (`2153bee2`)
`RetailUiRuntime.Draw` (`RetailUiRuntime.cs:620-652`) is a two-step edge detector
on the per-frame screen size. That size is `_window.Size`, captured once per
render at `GameWindow.cs:1575` and threaded through
`RenderFrameInput.ViewportWidth/Height`
`PrivatePresentationRenderer.cs:72-76``RetainedGameplayUiFrame.Render`
`Draw`.
**Enter (1280×720 → 1920×1080).** The toggle is handled in the *update* phase
(this is the same seam the #389 review deliberately chose over the render-phase
preview seam — `RuntimeSettingsTargets.cs:427-433`), so the native switch
completes before the frame's render:
- frame N render: `_window.Size` = 1920×1080 ≠ `_lastScreenSize` → one
`ClampAllToScreen()` (`RetailWindowLayoutPersistence.cs:102-129`, no store I/O,
`_restoring` suppresses per-move saves), `_screenSizeSettling = true`.
- frame N+1: size repeats → one `RestoreAll(saveBack: false)` for key
`"1920x1080"`.
One clamp, one reload, no transient size (the intermediate 1280×720 frame never
renders — the switch is synchronous within the update). **CLEAN.**
**Exit.** `TryLeaveFullscreen` is called with the *same* `display.Resolution`
(`RuntimeSettingsTargets.cs:141`) — unticking Full Screen does not change the
resolution setting. So the screen size does not change, the edge detector sees
nothing, and there is no clamp/reload. Correct, and consistent: the layout key is
unchanged. **CLEAN.**
**A3 — the shared layout key.** `ResolutionKey` is `"{W}x{H}"` with no fullscreen
discriminator (`RetailWindowLayoutPersistence.cs:304-305`). Fullscreen 1920×1080
and windowed 1920×1080 have the *same client extent*, so sharing the key is the
right answer, not a collision. **CLEAN.**
**No storm risk from the detector itself** — but see M1, which can produce a
native-call storm upstream of it.
---
## Axis 5 — tests
- No test in `tests/` asserts the old `WindowState` write. The only `WindowState`
hits are `ChatWindowState`, `RetainedWindowState`, `CreateWindowState`, and
`DisplayFramePacingControllerTests.cs:65`'s `OnWindowStateChanged(default)`
smoke call — none coupled to the display apply.
- `InspectingDisplayWindowTarget`
(`tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs:1072`) is a
fake `IRuntimeDisplayWindowTarget`; the interface is unchanged, so all 7 uses
stay valid.
- `GameWindowSlice8BoundaryTests.cs:298/331` asserts on `GameWindow.cs` source
text only — unaffected.
- The 5 new state-machine facts
(`RuntimeSettingsControllerTests.cs:191-283`) cover exactly the five branches
and are honest tests (they assert `surface.Writes == 0` on the fullscreen paths,
which is the real invariant).
**CLEAN.** Two gaps worth noting rather than fixing-as-bugs:
- No test pins the **idempotence** of a repeated fullscreen apply (M1). A
`FakeModeSwitcher` already records `Calls`; asserting `["enter:1920x1080"]`
after two identical applies would have caught it.
- No test covers `Fullscreen = true` + unparseable resolution
(`RuntimeSettingsTargets.cs:118-119`, silent return).
---
## Axis 6 — the catalog dependency and construction order
**A1 — ordering: CLEAN.** Two facts make it safe:
1. The offered-mode check is a **lazy** lambda, not a captured snapshot:
`spec => Rendering.DisplayModeCatalog.Resolutions?.Contains(spec) == true`
(`RuntimeSettingsTargets.cs:83`). Construction order relative to
`InstallFromWindow` is therefore irrelevant; only *apply* order matters.
2. `DisplayModeCatalog.InstallFromWindow(_window!)` is at `GameWindow.cs:1246`,
before `GameWindowCompositionPipeline.Run` at `:1249`; the startup apply is
phase 3 (`SettingsDevToolsComposition.cs:53`). Install strictly precedes apply.
A legitimate fullscreen boot is **not** refused for ordering reasons. But it can
be refused for two other reasons, which is the real finding:
### M2 — "an offered mode is supported by construction" is false along two axes
The commit's central safety claim is that validating against `DisplayModeCatalog`
makes the unsupported-mode crash class unreachable. That holds only if the
offered set is a subset of the *switch target's* mode list. Two ways it isn't:
**(a) Different monitors.** The catalog is curated from `window.Monitor` — the
monitor the window currently sits on (`DisplayModeCatalog.cs:53`). The switch
targets `glfwGetPrimaryMonitor()` (`DisplayModeSwitching.cs:77`), and the refresh
lookup enumerates *that* monitor's modes (`DisplayModeSwitching.cs:144-156`). On a
multi-monitor machine with the client on a secondary display, a mode that is
offered (secondary supports it) can be absent from the primary's list →
`"mode WxH is not in the monitor's mode list"` → silent refusal (M4). It fails
safe rather than crashing, but the invariant the design leans on is not there.
**(b) Different fallbacks at the two seams.** When no catalog installs
(`window.Monitor` null, or `Curate` returns empty — `DisplayModeCatalog.cs:54,68`):
- the dropdown falls back to the 6-entry static ladder
(`RetailUiRuntime.cs:2429``ConfigOptionsPageController.cs:641`
`availableResolutions ?? DisplaySettings.AvailableResolutions`), so the user
sees six selectable resolutions;
- the validator falls back to **nothing offered**
(`RuntimeSettingsTargets.cs:83`, `Resolutions?.Contains(...) == true` on null →
`false`).
So on any host where the catalog does not install, the Full Screen checkbox is a
permanent silent no-op while the dropdown still advertises six modes. The two
seams must share one fallback.
**(c) The likely everyday case.** `DisplaySettings.Default.Resolution` is
`"1280x720"` (`DisplaySettings.cs:90`). If the monitor does not enumerate a
1280×720 mode (common on high-refresh panels that expose a short mode list), a
fresh user who simply ticks Full Screen at the default resolution gets nothing —
no fullscreen, no message. Likewise any settings.json carrying a pre-#391
resolution (`800x600`, `1024x768` — both selectable in earlier builds, per the
OP6 note at `ConfigOptionsPageController.cs:625-633`) now boots windowed forever
with the flag ticked.
---
## M1 — the fullscreen enter is not idempotent, and every Config row re-runs it
This is the highest-blast-radius finding, because it comes in through a surface
the commit never looked at.
`storeOnly` on the Config rows is **only a caption-dim marker**
(`ConfigOptionsPageController.cs:136-152`) — it does not suppress the apply.
Every Graphics row's `apply:` lambda calls `bindings.SaveDisplay(...)`
(`ConfigOptionsPageController.cs:587, 643, 651, 659, 671, 678, 685, 693, 738,
746, 754, 766, 774, 780`), which routes
`RuntimeSettingsController.SaveDisplay:386-392`
`ApplyDisplayWindowState``SilkRuntimeDisplayWindowTarget.Apply`.
And `Apply`'s fullscreen branch has **no idempotence guard**
(`RuntimeSettingsTargets.cs:116-129`) — unlike the windowed branch, which still
has `if (haveResolution && (_window.Size.X != width || ...))` at `:147`. The old
code had `if (_window.WindowState != desired)` at the deleted `:83`; that guard
was removed with the rest of the block and nothing replaced it.
So, while `fullscreen: true`:
- toggling *any* store-only graphics row (MultiPassAlpha, BuildingDetailTextures,
LandscapeDrawDistance, TextureFiltering, …) fires a full
`glfwSetWindowMonitor(handle, primary, 0, 0, W, H, refresh)`;
- **dragging a slider** does it *per drag tick*. `FloatOptionRow.SetCurrentValue`
is documented as "the drag-tick entry point… applies it live IMMEDIATELY"
(`OptionPageModel.cs:232-240`), and `slider.ScalarChanged` calls it directly
(`ConfigOptionsPageController.cs:1044`). Field of View (`:587`), Screen
Brightness (`:671`) and Degrade Distance (`:693`) are all sliders on this tab.
Per drag tick that means: a full `glfwGetVideoModes` enumeration
(`DisplayModeSwitching.cs:148`), a `glfwSetWindowMonitor`, and one
`display: fullscreen mode switch WxH@R` console line
(`DisplayModeSwitching.cs:99-100`) — a permanent evidence line turned into a
per-frame log storm.
What saves it from being a visible mode-change storm on Windows is GLFW's
*internal* early-out (`setVideoModeWin32` returns before `ChangeDisplaySettingsEx`
when the chosen mode equals the current one). Correctness of a user-visible
behaviour should not rest on an undocumented internal of a third-party library —
especially on the exact path whose failure mode was "the client dies mid-session".
The gate script's own §D6 step 1 tells the user to "drag sliders in between"
while fullscreen, so this is directly in the acceptance path.
**Fix shape:** guard the enter the way the windowed branch guards its write —
remember the last applied `(W, H, monitor)` or query the current window monitor +
mode, and return early when the request already holds.
---
## M3 — #388's maximized half is unfixed, and the only un-maximize was deleted
#388's own title and filing include the maximized case: *"fullscreen/maximized
windows silently ignore resolution picks"*, with the evidence being five
consecutive picks producing no framebuffer event at `window was 2056x1290`
(`docs/ISSUES.md:126, 158-165`). The commit marks #388 **DONE**
(`docs/ISSUES.md:127`) having addressed only the fullscreen half.
Worse, the deleted block was the codebase's *only* write of `WindowState`
(Axis 2: there is now zero). Under the new `Apply`, a maximized window takes the
`_modeSwitcher.IsFullscreen == false` path (a maximized window has no attached
monitor) and receives a plain `_window.Size = ...`
(`RuntimeSettingsTargets.cs:147-154`) — precisely the write the issue documents
as silently ignored. Nothing anywhere restores the window to `Normal` first.
So: resolution picks on a maximized window are now permanently a no-op, with the
issue closed. Either restore-then-resize in the windowed branch, or reopen the
maximized half of #388 with the evidence carried forward.
---
## M6 — missing divergence-register rows (process)
`git show e56aa511 --stat` touches no register file. CLAUDE.md's rule is binding:
*"Any commit that introduces a deviation adds its register row IN THE SAME
COMMIT."* At least two deviations ship here, both acknowledged in the code's own
comments:
1. **Refresh-rate selection.** `TryFindRefreshRate` picks the monitor's
*highest* refresh for the requested WxH (`DisplayModeSwitching.cs:142-156`),
while the class doc states "retail passed the device's mode as-is"
(`DisplayModeSwitching.cs:37-38`). Entering fullscreen therefore changes the
panel's refresh rate as a side effect of a *resolution* pick (the live run
went to 300 Hz).
2. **Silent refusal.** Retail surfaced "Graphics mode not supported"; acdream
refuses to a console line and leaves the preference inconsistent (M4).
Arguably a third: primary-monitor-only targeting (`DisplayModeSwitching.cs:77`),
which the doc justifies against retail's primary display device — that one is
probably a citation rather than a deviation, but it is the assumption M2(a)
breaks, so it deserves to be written down somewhere auditable.
IA-22 (#391's curated list) is the natural neighbour row; these are separate
deviations and need their own.
---
## Remaining WATCH items
**W3 — windowed exit with no parseable resolution uses the fullscreen size.**
`RuntimeSettingsTargets.cs:136-140`: when `display.Resolution` does not parse, the
exit falls back to `_window.Size` — which, while fullscreen, *is* the fullscreen
extent. The window then leaves fullscreen as a desktop-sized window at
`(60, 60)`. Narrow (requires a corrupt/blank resolution string), but the fallback
reads "current windowed size" and means "current fullscreen size".
**W4 — synchronous native dispatch inside the quiescence lock.** On Win32,
`glfwSetWindowMonitor` dispatches `WM_SIZE`/`WM_MOVE` synchronously, so
`FramebufferResize` and `Move` re-enter `HostQuiescenceGate.Invoke`
(`SilkWindowCallbackBinding.cs:152-154`) on the same thread while the update
callback already holds `_sync`. This is **safe**`Monitor` is re-entrant
(`HostQuiescenceGate.cs:36-54`) — and the Vulkan side only *arms* recreation
(`VulkanHostInputCameraCompositionFactory.cs:136`), never recreating mid-frame.
Both are load-bearing invariants that nothing records; a comment at the
`SetWindowMonitor` call site would keep a future change from breaking them.
Also thread-affinity: `glfwSetWindowMonitor` and `glfwGetVideoModes` are
main-thread-only. Both call paths (composition `OnLoad`, update-phase save) are
main-thread today; nothing enforces it.
**W5 — hard kill while fullscreen.** The project's documented launch workflow
falls back to `Stop-Process -Force` when `CloseMainWindow` times out (CLAUDE.md,
"Logout-before-reconnect"). Previously that left a borderless window behind;
now it abandons a real `ChangeDisplaySettingsEx` mode change for Windows to
unwind. Low risk (CDS_FULLSCREEN changes are process-scoped and revert on exit),
but worth one line in the gate script's black-screen-risk note.
**W6 — `InstallFromWindow` is unguarded and fails silently.**
`GameWindow.cs:1246` has no `try`, while `SilkDisplayFramePacingSurface` already
documents that `window.Monitor` can throw `GlfwException` during an RDP/console
display-topology handoff (`DisplayFramePacingController.cs:42-49`). A throw there
kills `OnLoad`; a silent early-return (`DisplayModeCatalog.cs:54-69`) now also
silently disables fullscreen entirely (M2b). The two failure modes deserve
different treatment, and neither is currently distinguishable in a log.
---
## Ranked must-fixes
1. **M1** — guard the fullscreen enter against a no-change request.
`src/AcDream.App/Settings/RuntimeSettingsTargets.cs:116-129`; drivers at
`src/AcDream.App/UI/Layout/OptionPageModel.cs:232-240` and
`src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs:587,671,693`.
2. **M2** — make the offered set and the switch target agree: same monitor
(`DisplayModeCatalog.cs:53` vs `DisplayModeSwitching.cs:77`) and same fallback
(`RuntimeSettingsTargets.cs:83` vs `RetailUiRuntime.cs:2429`
`ConfigOptionsPageController.cs:641`).
3. **M3** — either fix the maximized half of #388 (restore-to-`Normal` before the
windowed size write, `RuntimeSettingsTargets.cs:147-154`) or reopen it;
`docs/ISSUES.md:126-127` currently claims it closed.
4. **M4** — give a refused/failed enter a surface: reset the persisted flag, or
have the Config row read live state.
`RuntimeSettingsTargets.cs:120-128`, `ConfigOptionsPageController.cs:650-651`.
5. **M5** — share one windowed-placement record between the startup and live
switchers. `DisplayModeSwitching.cs:47`, `GameWindow.cs:1296`,
`SessionPlayerComposition.cs:334`.
6. **M6** — add the divergence-register rows (refresh-rate selection; silent
refusal), `docs/architecture/retail-divergence-register.md`.

View file

@ -0,0 +1,528 @@
# 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 ~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: 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.

View file

@ -20,6 +20,12 @@ internal interface IDisplayModeSwitcher
/// monitor attached).</summary>
bool IsFullscreen { get; }
/// <summary>The active fullscreen mode's WxH, or null when windowed.
/// The apply's idempotence guard (review M2: every Display-backed Config
/// row applies per change — sliders per DRAG TICK — so a same-mode
/// re-apply must be a no-op before any native call).</summary>
(int Width, int Height)? CurrentFullscreenMode { get; }
/// <summary>Switches the window to exclusive fullscreen at the given
/// mode. False (with a reason) instead of throwing on any failure —
/// a settings apply must never crash the client (#388).</summary>
@ -32,19 +38,38 @@ internal interface IDisplayModeSwitcher
/// <summary>
/// The native GLFW implementation. Uses the SAME handle path the #348
/// cursor cache proved (<c>IWindow.Native.Glfw</c>) and the primary
/// monitor — retail's <c>ForceDisplayResolution</c> likewise drove the
/// primary display device. The refresh rate for a mode is the highest the
/// monitor reports for that WxH (retail passed the device's mode as-is).
/// Callers validate the requested WxH against <c>DisplayModeCatalog</c>
/// BEFORE calling — an offered mode is supported by construction, which is
/// what makes the old "Graphics mode not supported" crash class
/// unreachable from the dropdown.
/// cursor cache proved (<c>IWindow.Native.Glfw</c>) and the WINDOW'S OWN
/// monitor (<c>IWindow.Monitor</c>'s index into the GLFW monitor array —
/// the same monitor <c>DisplayModeCatalog</c> enumerated, so the offered
/// list and the switch target agree; review M4 caught the earlier
/// primary-monitor mismatch). The refresh rate for a mode is the highest
/// the monitor reports for that WxH. Callers validate the requested WxH
/// against <c>DisplayModeCatalog</c> BEFORE calling.
///
/// <para><b>Error model (review M1 — load-bearing):</b> on Windows, Silk's
/// GLFW error callback does NOT throw — it QUEUES the exception on a static
/// list that detonates much later (window close), which is exactly the
/// two-stage crash #388 originally recorded. `catch (GlfwException)` is
/// therefore dead code on this platform; success is judged by the NATIVE
/// POST-CONDITION (does the window have / not have a monitor afterwards),
/// never by the absence of an exception. The catches stay only for the
/// non-Windows platforms where Silk does throw.</para>
/// </summary>
internal sealed unsafe class GlfwDisplayModeSwitcher : IDisplayModeSwitcher
{
private readonly IWindow _window;
private (int X, int Y) _windowedPosition = (60, 60);
// Q1 nit: one cached wrapper (a Glfw.GetApi() per call allocates and
// takes a native library refcount) — the same pattern GlfwCursorCache
// uses.
private static readonly Lazy<Glfw> Api = new(Glfw.GetApi);
// Review M3/M5: the remembered windowed placement is PROCESS state, not
// per-instance state — the startup target and the live-save target are
// two instances over the one window, and a fullscreen boot must exit
// through whichever instance the untick reaches. Static, single-window
// process (the same write-once shape DisplayModeCatalog uses).
private static (int X, int Y) _windowedPosition = (60, 60);
public GlfwDisplayModeSwitcher(IWindow window)
{
@ -55,9 +80,40 @@ internal sealed unsafe class GlfwDisplayModeSwitcher : IDisplayModeSwitcher
{
get
{
WindowHandle* handle = Handle();
if (handle is null) return false;
return Glfw.GetApi().GetWindowMonitor(handle) is not null;
try
{
WindowHandle* handle = Handle();
if (handle is null) return false;
return Api.Value.GetWindowMonitor(handle) is not null;
}
catch (GlfwException)
{
// Never throws on Windows (queued instead) — this guard is
// for the throwing platforms; a monitor query must never
// take the caller down (#377 family).
return false;
}
}
}
public (int Width, int Height)? CurrentFullscreenMode
{
get
{
try
{
WindowHandle* handle = Handle();
if (handle is null) return null;
Glfw glfw = Api.Value;
Silk.NET.GLFW.Monitor* monitor = glfw.GetWindowMonitor(handle);
if (monitor is null) return null;
Silk.NET.GLFW.VideoMode* mode = glfw.GetVideoMode(monitor);
return mode is null ? null : (mode->Width, mode->Height);
}
catch (GlfwException)
{
return null;
}
}
}
@ -73,11 +129,11 @@ internal sealed unsafe class GlfwDisplayModeSwitcher : IDisplayModeSwitcher
try
{
Glfw glfw = Glfw.GetApi();
Silk.NET.GLFW.Monitor* monitor = glfw.GetPrimaryMonitor();
Glfw glfw = Api.Value;
Silk.NET.GLFW.Monitor* monitor = ResolveWindowMonitor(glfw, handle);
if (monitor is null)
{
error = "no primary monitor";
error = "no monitor";
return false;
}
@ -96,14 +152,22 @@ internal sealed unsafe class GlfwDisplayModeSwitcher : IDisplayModeSwitcher
}
glfw.SetWindowMonitor(handle, monitor, 0, 0, width, height, refresh);
// Review M1: judge by the native post-condition, not by the
// absence of an exception (Windows queues GLFW errors).
if (glfw.GetWindowMonitor(handle) is null)
{
error = "the mode switch did not take (GLFW reports no monitor attached)";
return false;
}
Console.WriteLine(
$"display: fullscreen mode switch {width}x{height}@{refresh}");
return true;
}
catch (GlfwException ex)
{
// #388: a failed switch is a logged failure the caller reverts
// from, never a crash. Validation makes this path exceptional.
// Non-Windows platforms throw; Windows never reaches here.
error = ex.Message;
return false;
}
@ -121,13 +185,20 @@ internal sealed unsafe class GlfwDisplayModeSwitcher : IDisplayModeSwitcher
try
{
Glfw glfw = Glfw.GetApi();
Glfw glfw = Api.Value;
if (glfw.GetWindowMonitor(handle) is null)
return true; // already windowed
glfw.SetWindowMonitor(
handle, null,
_windowedPosition.X, _windowedPosition.Y,
width, height, 0);
if (glfw.GetWindowMonitor(handle) is not null)
{
error = "the window is still fullscreen (GLFW reports a monitor attached)";
return false;
}
Console.WriteLine(
$"display: left fullscreen to windowed {width}x{height}");
return true;
@ -139,8 +210,30 @@ internal sealed unsafe class GlfwDisplayModeSwitcher : IDisplayModeSwitcher
}
}
/// <summary>The monitor the window belongs to: the attached monitor when
/// fullscreen, else the window's own monitor per Silk's assignment
/// (index into the GLFW monitor array — the SAME monitor
/// <c>DisplayModeCatalog.InstallFromWindow</c> enumerated), falling back
/// to the primary.</summary>
private Silk.NET.GLFW.Monitor* ResolveWindowMonitor(Glfw glfw, WindowHandle* handle)
{
Silk.NET.GLFW.Monitor* attached = glfw.GetWindowMonitor(handle);
if (attached is not null) return attached;
int? index = _window.Monitor?.Index;
if (index is int i && i >= 0)
{
Silk.NET.GLFW.Monitor** monitors = glfw.GetMonitors(out int count);
if (monitors is not null && i < count)
return monitors[i];
}
return glfw.GetPrimaryMonitor();
}
/// <summary>The monitor's highest refresh rate for an exact WxH, or
/// false when the monitor does not report the mode at all.</summary>
/// false when the monitor does not report the mode at all. (Register
/// row AD-92: retail passed the device mode's own refresh as-is;
/// highest-for-WxH is a deliberate adaptation.)</summary>
private static bool TryFindRefreshRate(
Glfw glfw, Silk.NET.GLFW.Monitor* monitor, int width, int height, out int refresh)
{

View file

@ -45,13 +45,20 @@ internal interface IRuntimeChatOpacityTarget
void Apply(float defaultOpacity, float activeOpacity);
}
/// <summary>The one window property the display apply touches — a narrow
/// <summary>The window properties the display apply touches — a narrow
/// seam so the #388 state machine is testable without faking all of
/// <see cref="IWindow"/> (same idiom as <c>FakePacingSurface</c>'s
/// surface).</summary>
internal interface IWindowedSizeSurface
{
Vector2D<int> Size { get; set; }
/// <summary>#388 blast M3: a maximized window silently ignores a Size
/// write — the apply un-maximizes first (the deleted
/// <c>WindowState = Normal</c> write used to do this incidentally).</summary>
bool IsMaximized { get; }
void Restore();
}
internal sealed class SilkWindowSizeSurface(IWindow window) : IWindowedSizeSurface
@ -64,6 +71,10 @@ internal sealed class SilkWindowSizeSurface(IWindow window) : IWindowedSizeSurfa
get => _window.Size;
set => _window.Size = value;
}
public bool IsMaximized => _window.WindowState == WindowState.Maximized;
public void Restore() => _window.WindowState = WindowState.Normal;
}
internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarget
@ -76,11 +87,14 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
: this(
new SilkWindowSizeSurface(window),
new GlfwDisplayModeSwitcher(window),
// #391's catalog is the validation source: an offered mode is
// supported by construction. With no catalog installed
// (fixture/uninitialised hosts) nothing is "offered", so
// fullscreen entry is refused rather than guessed.
spec => Rendering.DisplayModeCatalog.Resolutions?.Contains(spec) == true)
// #391's catalog is the validation source. With no catalog
// installed, the dropdown falls back to the static preset
// ladder — the validator must fall back to the SAME list
// (blast M2: an asymmetric fallback made Full Screen a permanent
// silent no-op on catalog-less hosts). The switcher's own
// monitor-mode-list check remains the hard guard either way.
spec => (Rendering.DisplayModeCatalog.Resolutions
?? DisplaySettings.AvailableResolutions).Contains(spec))
{
}
@ -116,6 +130,19 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
if (display.Fullscreen)
{
if (!haveResolution)
{
// Mechanism M5: never a SILENT bail-out — §D6's acceptance is
// "any refused/failed switch logs a line".
Console.WriteLine(
$"display: fullscreen refused — unparseable resolution '{display.Resolution}'");
return;
}
// Mechanism/blast M2: idempotence BEFORE any native work — every
// Display-backed Config row applies per change (sliders per drag
// tick), and only this guard keeps those from re-issuing a real
// display-mode change per mouse sample.
if (_modeSwitcher.CurrentFullscreenMode is (int curW, int curH)
&& curW == width && curH == height)
return;
if (!_isOfferedMode.Invoke($"{width}x{height}"))
{
@ -125,7 +152,7 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
}
if (!_modeSwitcher.TryEnterFullscreen(width, height, out string? error))
Console.WriteLine(
$"display: fullscreen {width}x{height} failed ({error}) — staying windowed");
$"display: fullscreen {width}x{height} failed ({error}) — window state unchanged (#392 tracks the persisted-flag divergence)");
return;
}
@ -146,6 +173,11 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
if (haveResolution && (_window.Size.X != width || _window.Size.Y != height))
{
// Blast M3: a maximized window ignores Size writes — restore
// first (the deleted WindowState=Normal write did this
// incidentally; now it is explicit and only-when-needed).
if (_window.IsMaximized)
_window.Restore();
// #387 evidence line (permanent): the resolution-pick write path.
Console.WriteLine(
$"display: resolution pick {width}x{height} " +

View file

@ -160,16 +160,26 @@ public sealed class RuntimeSettingsControllerTests
{
public Silk.NET.Maths.Vector2D<int> Size { get; set; } = new(1280, 720);
public int Writes { get; private set; }
public bool IsMaximized { get; set; }
public int Restores { get; private set; }
Silk.NET.Maths.Vector2D<int> IWindowedSizeSurface.Size
{
get => Size;
set { Size = value; Writes++; }
}
public void Restore()
{
IsMaximized = false;
Restores++;
}
}
private sealed class FakeModeSwitcher : IDisplayModeSwitcher
{
public bool IsFullscreen { get; set; }
public (int Width, int Height)? CurrentFullscreenMode { get; set; }
public bool EnterSucceeds { get; set; } = true;
public List<string> Calls { get; } = [];
@ -177,7 +187,11 @@ public sealed class RuntimeSettingsControllerTests
{
Calls.Add($"enter:{width}x{height}");
error = EnterSucceeds ? null : "injected failure";
if (EnterSucceeds) IsFullscreen = true;
if (EnterSucceeds)
{
IsFullscreen = true;
CurrentFullscreenMode = (width, height);
}
return EnterSucceeds;
}
@ -186,10 +200,74 @@ public sealed class RuntimeSettingsControllerTests
Calls.Add($"leave:{width}x{height}");
error = null;
IsFullscreen = false;
CurrentFullscreenMode = null;
return true;
}
}
[Fact]
public void DisplayApply_SameFullscreenMode_IsANoOp_BeforeAnyNativeWork()
{
// Review M2: sliders apply per drag tick; without this guard every
// tick while fullscreen re-issued a real display-mode change.
var surface = new FakeSizeSurface();
var switcher = new FakeModeSwitcher
{
IsFullscreen = true,
CurrentFullscreenMode = (1920, 1080),
};
var target = new SilkRuntimeDisplayWindowTarget(
surface, switcher, _ => true);
target.Apply(DisplaySettings.Default with
{
Fullscreen = true,
Resolution = "1920x1080",
});
Assert.Empty(switcher.Calls);
Assert.Equal(0, surface.Writes);
}
[Fact]
public void DisplayApply_UnparseableResolutionWhileFullscreen_RefusesWithoutCalls()
{
var surface = new FakeSizeSurface();
var switcher = new FakeModeSwitcher();
var target = new SilkRuntimeDisplayWindowTarget(
surface, switcher, _ => true);
target.Apply(DisplaySettings.Default with
{
Fullscreen = true,
Resolution = "garbage",
});
Assert.Empty(switcher.Calls);
Assert.Equal(0, surface.Writes);
}
[Fact]
public void DisplayApply_MaximizedWindowedPick_RestoresBeforeTheSizeWrite()
{
// Blast M3: a maximized window silently ignores Size writes; the
// deleted WindowState=Normal write used to un-maximize incidentally.
var surface = new FakeSizeSurface { IsMaximized = true };
var switcher = new FakeModeSwitcher();
var target = new SilkRuntimeDisplayWindowTarget(
surface, switcher, _ => true);
target.Apply(DisplaySettings.Default with
{
Fullscreen = false,
Resolution = "1600x900",
});
Assert.Equal(1, surface.Restores);
Assert.Equal(1, surface.Writes);
Assert.False(surface.IsMaximized);
}
[Fact]
public void DisplayApply_FullscreenPick_IsAValidatedModeSwitch_NeverASizeWrite()
{