fix(diag): resolve the multisampled backbuffer before reading it

Every automated pixel gate and every blank-world verdict in Campaign V is
produced by FrameScreenshotController reading the default framebuffer with
glReadPixels. The window is created with the quality preset's MSAA sample
count, so that framebuffer is normally 4x multisampled -- and glReadPixels
against a multisampled read framebuffer is undefined per the GL spec. The
instrument the campaign has been using to decide "did the world render?"
rested on an operation with no specified result.

That is not a theoretical complaint. The blank-world investigation spent
several rounds unable to tell "the renderer drew nothing" apart from "the
readback did not return what the renderer drew", and it took an out-of-process
desktop grab to separate them. A gate cannot arbitrate a rendering defect
while its own read is unspecified.

So the capture resolves first: when the default framebuffer is multisampled
it blits the whole colour buffer into a single-sampled RGBA8 framebuffer with
identical rectangles and GL_NEAREST -- the defined resolve -- and reads that.
A single-sampled default framebuffer keeps the original direct read, so
non-MSAA captures stay byte-for-byte what they were. The blit disables and
restores the scissor test, because a blit is subject to it and a frame that
left a rectangle armed would otherwise resolve only part of the image; that
is the same self-contained-GL-state rule the render passes follow. The
resolve target is created and destroyed per capture -- captures are rare, and
a cache would have to track resize and context teardown for no gain.

GlGpuDevice.CaptureBackbuffer had the identical undefined read. It now routes
through the same path rather than being a second instrument to keep sound.

The IDefaultFramebufferSurface seam grows the draw binding, the sample count,
and the resolve operations, so the bind/query/blit/read/restore order stays
assertable without a GL context; two new tests pin the resolve order and the
resolve target's release on a failing read.

Gates: Release build green. App tests 3,866 passed / 3 skipped. Offline pixel
gate against fed636b9 passes at a differing fraction of 4.08e-05 against the
0.001 threshold -- which is exactly the same-commit control pair measured at
this commit, i.e. indistinguishable from ambient noise. Same-commit controls
re-measured at 17 px (fed636b9) and 23 px (here) out of 563,200; the recorded
band in plan section 5.1 widens to 15-23 px, fraction <= 4.1e-05.

Plan section 5.5.1 records what the connected investigation established: the
interleaved A/B attribution (4/5 vs 0/5, p ~ 0.024), the desktop witness
showing every depth-tested draw missing while the atmosphere clear and the
complete retained UI present, the probe evidence that the CPU dispatched
3,331 statics with no GL error, and the falsification list -- including the
ring glBufferSubData hazard, which condition 1 shipped against and did not
fix.

Section 5.5.2 records this session's second investigation, run against a
staged (never committed) V4c with log-only glGet* probes, and it closes the
shared-3-D-state hypothesis. The depth plane is bit-identical on blank and
rendered frames -- test on, write mask on, GL_LESS, clear value 1.0, range
[0,1], full viewport, full colour mask, no clip distances. The camera
constants are sane and advancing. Forcing gl_ClipDistance off left the blank
rate unchanged at 3/5. glGetGraphicsResetStatus returned NO_ERROR in all
1,814 samples across four blank runs, which also retires the "GPU-side fault"
reading in its context-reset form.

Two sharper facts replace it. Replacing only the frame clear colour with
magenta makes a blank frame come back uniformly magenta under the complete
retained UI, so no 3-D fragment is rasterized at all -- the world is not
drawn-then-hidden, fogged, or overdrawn. And on a blank run the client's own
capture of framebuffer 0 is RGBA(0,0,0,0) in every pixel, including pixels
where the UI is visibly on screen at that moment. That survives this commit's
resolve fix, so it is a second, independent instrument fault: the screenshot-
byte verdict used by the repeat and A/B gates measures the readback, not the
renderer, and those gates need to assert on the desktop witness instead.

V4c is NOT re-landed. No fix was attempted, because the mechanism is not
renderer state and does not sit in V4c's surface as this hypothesis predicted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 00:19:03 +02:00
parent fed636b9c0
commit e6362da5c2
4 changed files with 456 additions and 26 deletions

View file

@ -480,6 +480,19 @@ masked, two independent same-commit pairs differ by **15 and 17 pixels out of
threshold. The gate is a strict identity check on everything it covers, rather
than a loose tolerance that would hide real regressions.
**Noise band re-measured 2026-07-28**, after the capture began resolving the
multisampled default framebuffer instead of reading it through an unspecified
operation (see §5.5). Two fresh same-commit control pairs — one at `fed636b9`,
one at the resolve commit — differ by **17 and 23 pixels**, fractions
`3.02e-05` and `4.08e-05`. The change itself measured `4.08e-05` against
`fed636b9`, i.e. exactly its own same-commit control and therefore
indistinguishable from ambient noise. The band is now **1523 differing pixels,
fraction ≤ 4.1e-05**, a ~24× margin under the 0.001 threshold. Two facts are
worth keeping: the resolve moved essentially nothing in this scene, which says
AMD's unspecified read was usually returning the resolved image already; and
"usually" is exactly the property that makes an unspecified read useless as an
instrument.
**Coverage.** Terrain and terrain blending, scenery, static world meshes, water,
fog, and the entire retained UI (vitals, spell bar, toolbar, chat, radar).
@ -637,6 +650,107 @@ correct.
3. The offline pixel gate still passes, and the gate location stays pinned
(failure rate is location-sensitive; stray input moves the character).
#### 5.5.1 What the connected investigation established (2026-07-27/28)
Condition 1 landed at `8dec163f` and **did not fix the defect**. The ring's
`glBufferSubData` hazard is therefore *falsified as the cause*; the map change is
kept because it is the correct idiom regardless, but the paragraph above naming
it "best-supported cause" is superseded by what follows.
**The defect does follow the V4c binary.** Blank rate drifts with machine state —
the same binary measured 3/10 in one block and 5/5 in another — so consecutive
blocks of A then B confound the change with the drift, and the first attributions
were made that way. `tools/run-blank-world-ab-probe.ps1` interleaves the two
builds inside one block so the drift is shared: **4/5 blank in the V4c arm versus
0/5 in the parent arm, p ≈ 0.024**. That is the attribution; everything below is
about mechanism.
**What a blank frame actually looks like, from outside the process.**
`tools/run-blank-world-surface-probe.ps1` grabs the composited window off the
desktop with `CopyFromScreen` — a witness that shares nothing with the renderer
below the compositor. On a blank frame the desktop shows the atmosphere clear
colour and the **complete retained UI**, with **all 3-D absent — including the
raw-GL terrain and sky that V4c does not touch.** So the frame is drawn and
presented; what is missing is every depth-tested draw, and only those.
Meanwhile `ACDREAM_PROBE_FLAP` reports, on those same blank frames, 3,331 statics
dispatched, the correct PView branch, `fbo=0`, the full viewport, scissor off, and
zero GL errors. The CPU decided to draw the world and the GL calls were accepted.
**Falsified:** the ring `glBufferSubData` hazard; a capture-FBO binding leak;
reveal ordering; the AMD TDR; and CPU-side visibility. **Observed and unexplained:**
`glReadPixels` probes *heal* the bug, while `glGetIntegerv`-shaped state queries do
not — so any instrument that reads pixels changes the thing it measures.
**The verdicts themselves were unsound until `2026-07-28`.** The window is created
with the quality preset's MSAA sample count, so the default framebuffer is 4x
multisampled, and `glReadPixels` against a multisampled read framebuffer is
undefined per the GL spec. Every automated pixel gate and every blank/rendered
verdict in this campaign came through that read. `FrameScreenshotController` now
blit-resolves the default framebuffer into a single-sampled RGBA8 framebuffer and
reads that; a single-sampled default framebuffer keeps the original direct read.
`GlGpuDevice.CaptureBackbuffer` routes through the same path, so there is one
backbuffer read in the process rather than two instruments to keep sound. The
offline gate's re-measured noise band is in §5.1: the resolve moved 23 pixels out
of 563,200, exactly its own same-commit control, which says AMD's unspecified read
was usually already returning the resolved image — and "usually" is what made it
worthless as an instrument.
#### 5.5.2 The shared-3-D-state hypothesis is falsified (2026-07-28)
The natural reading of "all 3-D dies, depth-disabled UI survives, the atmosphere
clear shows" is that something shared by every depth-tested draw — and by no UI
draw — is poisoned. Four candidates were tested against a V4c build staged from
`git revert --no-commit 543bc79f` (never committed) with log-only `glGet*` probes
at the frame clear and at world-pass entry/exit. **All four are dead.**
| Candidate | How it was tested | Result |
|---|---|---|
| Depth plane (mask latched off across the clear, poisoned `glClearDepth`/`glDepthFunc`/depth range) | State sampled at `pre-clear`, `post-clear`, `landscape-in`, `landscape-out` | **Bit-identical on blank and rendered frames.** `DEPTH_TEST=on`, `DEPTH_WRITEMASK=on`, `DEPTH_FUNC=GL_LESS`, `DEPTH_CLEAR_VALUE=1.0`, range `[0,1]`, viewport `0,0,1280,720`, colour mask `1111`, scissor/stencil/blend/cull off, MSAA on, no clip distances enabled |
| Camera constants | `ViewProjection` and eye logged at world-pass entry | Sane and advancing on blank frames; determinant `-1.688e-01`, eye stable at the pinned cell |
| `gl_ClipDistance` (all `MaxPlanes` are enabled unconditionally around sky/terrain/entities, so an unwritten distance would clip everything 3-D and nothing 2-D) | `EnableClipDistances` forced to a no-op | Blank rate **3/5**, i.e. unchanged |
| GPU context reset (the "GPU-side fault" reading) | `glGetGraphicsResetStatus` in the same probe | **1,814 samples across four blank runs: `NO_ERROR` every time** |
Two new facts were established, and they are sharper than anything before them.
**1. Zero 3-D fragments are rasterized — the world is not drawn-then-hidden.**
Replacing only the frame clear colour with magenta (nothing else) makes a blank
frame come back **uniformly magenta with the complete retained UI on top**. So
the world is not being shaded to the fog colour, not being fogged out, and not
being overdrawn: between the clear and the UI, not one 3-D fragment reaches the
default framebuffer — while the CPU has dispatched the draws, GL accepted them,
and every piece of state above is correct.
**2. The in-process capture does not observe the presented surface at all.** On a
blank run the desktop grab shows the magenta clear plus the complete UI, and at
that same moment the client's own capture of framebuffer 0 is
**RGBA(0,0,0,0) in every pixel — including the pixels where the UI is visibly on
screen.** This survives the §5.5.1 resolve fix, so it is a *second*, independent
instrument fault: on a blank run, reading framebuffer 0 returns nothing even for
content that demonstrably reached the display. Any verdict derived from
screenshot bytes is therefore reporting the readback, not the renderer, and the
`MinRenderedBytes` test in `run-repeat-connected-gate.ps1` /
`run-blank-world-ab-probe.ps1` conflates the two. **The desktop witness is
currently the only trustworthy verdict** and should be what those gates assert
on.
The failure reproduces readily with a *visible* window (`WasIconic=False`
throughout), so it is not a pixel-ownership artefact of the minimized gate
window: 5/6, 4/5, 3/5 and 4/4 blank across four blocks at the pinned cell.
**Where this leaves the mechanism.** It is not renderer state and not a context
reset; clears and UI draws reach the display while 3-D draws and pixel reads
against the same framebuffer both come back empty. That combination points below
the API — at how the default framebuffer's colour is being handled for this
context — rather than at anything V4c writes. **V4c has therefore not been
re-landed**, and no fix was attempted: the re-land conditions in §5.5 stand, but
condition 2's gate must first be re-armed on the desktop witness, because the
screenshot-byte verdict it uses is now known to be measuring the wrong thing.
The next instrument should be an occlusion query (`GL_SAMPLES_PASSED`) around the
world pass, read back a frame later so it adds no sync point — that separates
"the draws never executed" from "they executed and their output was discarded",
which is the remaining fork.
### 5.4 The null-target `BeginPass` divergence (V4c) — must be undone at V6
V4c had to stop GL's `BeginPass` from binding framebuffer 0 when a pass declares