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 againstfed636b9passes 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:
parent
fed636b9c0
commit
e6362da5c2
4 changed files with 456 additions and 26 deletions
|
|
@ -103,23 +103,37 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
[Fact]
|
||||
public void DefaultFramebufferRead_BindsFramebufferZeroAndRestoresTheCallersBinding()
|
||||
{
|
||||
var surface = new RecordingFramebufferSurface(boundOnEntry: 7u, fill: 0x42);
|
||||
var surface = new RecordingFramebufferSurface(
|
||||
boundOnEntry: 7u,
|
||||
drawBoundOnEntry: 3u,
|
||||
fill: 0x42);
|
||||
|
||||
byte[] pixels = FrameScreenshotController.ReadDefaultFramebuffer(surface, 2, 3);
|
||||
|
||||
Assert.Equal(
|
||||
["bind 0", "read 2x3", "bind 7"],
|
||||
[
|
||||
"bind-read 0",
|
||||
"bind-draw 0",
|
||||
"samples",
|
||||
"read 2x3",
|
||||
"bind-read 7",
|
||||
"bind-draw 3",
|
||||
],
|
||||
surface.Calls);
|
||||
Assert.Equal(0u, surface.ReadFramebufferBindingDuringRead);
|
||||
Assert.Equal(2 * 3 * 4, pixels.Length);
|
||||
Assert.All(pixels, value => Assert.Equal(0x42, value));
|
||||
Assert.Equal(7u, surface.ReadFramebufferBinding);
|
||||
Assert.Equal(3u, surface.DrawFramebufferBinding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultFramebufferRead_RestoresTheCallersBindingWhenTheReadThrows()
|
||||
{
|
||||
var surface = new RecordingFramebufferSurface(boundOnEntry: 9u, fill: 0)
|
||||
var surface = new RecordingFramebufferSurface(
|
||||
boundOnEntry: 9u,
|
||||
drawBoundOnEntry: 0u,
|
||||
fill: 0)
|
||||
{
|
||||
ReadFailure = new InvalidOperationException("GL_OUT_OF_MEMORY"),
|
||||
};
|
||||
|
|
@ -127,33 +141,151 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
Assert.Throws<InvalidOperationException>(
|
||||
() => FrameScreenshotController.ReadDefaultFramebuffer(surface, 1, 1));
|
||||
|
||||
Assert.Equal(["bind 0", "read 1x1", "bind 9"], surface.Calls);
|
||||
Assert.Equal(
|
||||
["bind-read 0", "bind-draw 0", "samples", "read 1x1", "bind-read 9", "bind-draw 0"],
|
||||
surface.Calls);
|
||||
Assert.Equal(9u, surface.ReadFramebufferBinding);
|
||||
}
|
||||
|
||||
private sealed class RecordingFramebufferSurface(uint boundOnEntry, byte fill)
|
||||
/// <summary>
|
||||
/// glReadPixels against a multisampled read framebuffer is undefined per the
|
||||
/// GL spec, and the window is created with the quality preset's MSAA sample
|
||||
/// count — so every pixel gate and every blank-world verdict in Campaign V
|
||||
/// was reading through an unspecified operation. The capture must resolve
|
||||
/// the default framebuffer into a single-sampled target first and read that.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MultisampledDefaultFramebufferRead_ResolvesBeforeReading()
|
||||
{
|
||||
var surface = new RecordingFramebufferSurface(
|
||||
boundOnEntry: 7u,
|
||||
drawBoundOnEntry: 4u,
|
||||
fill: 0x11)
|
||||
{
|
||||
Samples = 4,
|
||||
};
|
||||
|
||||
byte[] pixels = FrameScreenshotController.ReadDefaultFramebuffer(surface, 2, 3);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"bind-read 0",
|
||||
"bind-draw 0",
|
||||
"samples",
|
||||
"create-resolve 2x3",
|
||||
"bind-draw 64",
|
||||
"blit 2x3",
|
||||
"bind-read 64",
|
||||
"read 2x3",
|
||||
"delete-resolve 64",
|
||||
"bind-read 7",
|
||||
"bind-draw 4",
|
||||
],
|
||||
surface.Calls);
|
||||
|
||||
// The blit source must still be the multisampled default framebuffer,
|
||||
// and the read source must be the resolved single-sampled one.
|
||||
Assert.Equal(0u, surface.ReadFramebufferBindingDuringBlit);
|
||||
Assert.Equal(64u, surface.DrawFramebufferBindingDuringBlit);
|
||||
Assert.Equal(64u, surface.ReadFramebufferBindingDuringRead);
|
||||
Assert.Equal(2 * 3 * 4, pixels.Length);
|
||||
Assert.All(pixels, value => Assert.Equal(0x11, value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultisampledDefaultFramebufferRead_DeletesTheResolveTargetWhenTheReadThrows()
|
||||
{
|
||||
var surface = new RecordingFramebufferSurface(
|
||||
boundOnEntry: 7u,
|
||||
drawBoundOnEntry: 4u,
|
||||
fill: 0)
|
||||
{
|
||||
Samples = 4,
|
||||
ReadFailure = new InvalidOperationException("GL_OUT_OF_MEMORY"),
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => FrameScreenshotController.ReadDefaultFramebuffer(surface, 1, 1));
|
||||
|
||||
Assert.Contains("delete-resolve 64", surface.Calls);
|
||||
Assert.Equal(0, surface.LiveResolveTargets);
|
||||
Assert.Equal(7u, surface.ReadFramebufferBinding);
|
||||
Assert.Equal(4u, surface.DrawFramebufferBinding);
|
||||
}
|
||||
|
||||
private sealed class RecordingFramebufferSurface(
|
||||
uint boundOnEntry,
|
||||
uint drawBoundOnEntry,
|
||||
byte fill)
|
||||
: FrameScreenshotController.IDefaultFramebufferSurface
|
||||
{
|
||||
private uint _binding = boundOnEntry;
|
||||
private uint _readBinding = boundOnEntry;
|
||||
private uint _drawBinding = drawBoundOnEntry;
|
||||
private uint _nextResolveName = 64u;
|
||||
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public Exception? ReadFailure { get; init; }
|
||||
|
||||
public int Samples { get; init; } = 1;
|
||||
|
||||
public int LiveResolveTargets { get; private set; }
|
||||
|
||||
public uint? ReadFramebufferBindingDuringRead { get; private set; }
|
||||
|
||||
public uint ReadFramebufferBinding => _binding;
|
||||
public uint? ReadFramebufferBindingDuringBlit { get; private set; }
|
||||
|
||||
public uint? DrawFramebufferBindingDuringBlit { get; private set; }
|
||||
|
||||
public uint ReadFramebufferBinding => _readBinding;
|
||||
|
||||
public uint DrawFramebufferBinding => _drawBinding;
|
||||
|
||||
public int DefaultFramebufferSamples
|
||||
{
|
||||
get
|
||||
{
|
||||
Calls.Add("samples");
|
||||
return Samples;
|
||||
}
|
||||
}
|
||||
|
||||
public void BindReadFramebuffer(uint framebuffer)
|
||||
{
|
||||
_binding = framebuffer;
|
||||
Calls.Add($"bind {framebuffer}");
|
||||
_readBinding = framebuffer;
|
||||
Calls.Add($"bind-read {framebuffer}");
|
||||
}
|
||||
|
||||
public void BindDrawFramebuffer(uint framebuffer)
|
||||
{
|
||||
_drawBinding = framebuffer;
|
||||
Calls.Add($"bind-draw {framebuffer}");
|
||||
}
|
||||
|
||||
public uint CreateResolveTarget(int width, int height)
|
||||
{
|
||||
Calls.Add($"create-resolve {width}x{height}");
|
||||
LiveResolveTargets++;
|
||||
return _nextResolveName++;
|
||||
}
|
||||
|
||||
public void DeleteResolveTarget(uint framebuffer)
|
||||
{
|
||||
Calls.Add($"delete-resolve {framebuffer}");
|
||||
LiveResolveTargets--;
|
||||
}
|
||||
|
||||
public void BlitColorNearest(int width, int height)
|
||||
{
|
||||
Calls.Add($"blit {width}x{height}");
|
||||
ReadFramebufferBindingDuringBlit = _readBinding;
|
||||
DrawFramebufferBindingDuringBlit = _drawBinding;
|
||||
}
|
||||
|
||||
public void ReadRgba(int width, int height, byte[] destination)
|
||||
{
|
||||
Calls.Add($"read {width}x{height}");
|
||||
ReadFramebufferBindingDuringRead = _binding;
|
||||
ReadFramebufferBindingDuringRead = _readBinding;
|
||||
if (ReadFailure is not null)
|
||||
throw ReadFailure;
|
||||
Array.Fill(destination, fill);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue