diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md
index 62c93057..c42ad18e 100644
--- a/docs/plans/2026-07-27-vulkan-campaign.md
+++ b/docs/plans/2026-07-27-vulkan-campaign.md
@@ -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 **15–23 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
diff --git a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs
index a11380be..70dd0e16 100644
--- a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs
+++ b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs
@@ -1,3 +1,4 @@
+using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
@@ -45,6 +46,21 @@ internal sealed class FrameScreenshotController
return (width, height) => ReadDefaultFramebuffer(surface, width, height);
}
+ ///
+ /// Reads the default framebuffer through the same resolve-aware path the
+ /// screenshot gates use. Shared so there is exactly one implementation of
+ /// "read the backbuffer" in the process — see
+ /// .
+ ///
+ internal static byte[] ReadDefaultFramebuffer(GL gl, int width, int height)
+ {
+ ArgumentNullException.ThrowIfNull(gl);
+ return ReadDefaultFramebuffer(
+ new GlDefaultFramebufferSurface(gl),
+ width,
+ height);
+ }
+
internal FrameScreenshotController(
Func readRgba,
string directory,
@@ -144,8 +160,36 @@ internal sealed class FrameScreenshotController
/// The name currently bound to GL_READ_FRAMEBUFFER.
uint ReadFramebufferBinding { get; }
+ /// The name currently bound to GL_DRAW_FRAMEBUFFER.
+ uint DrawFramebufferBinding { get; }
+
+ ///
+ /// GL_SAMPLES for the default framebuffer. Queried with
+ /// framebuffer 0 bound to both targets, because the value is
+ /// framebuffer-dependent state and would otherwise report whichever
+ /// offscreen target the frame left bound.
+ ///
+ int DefaultFramebufferSamples { get; }
+
void BindReadFramebuffer(uint framebuffer);
+ void BindDrawFramebuffer(uint framebuffer);
+
+ ///
+ /// Creates a single-sampled RGBA8 colour framebuffer of the given size
+ /// and returns its name.
+ ///
+ uint CreateResolveTarget(int width, int height);
+
+ void DeleteResolveTarget(uint framebuffer);
+
+ ///
+ /// Blits the whole colour buffer from the bound read framebuffer to the
+ /// bound draw framebuffer with GL_NEAREST and identical rectangles
+ /// — the multisample resolve.
+ ///
+ void BlitColorNearest(int width, int height);
+
void ReadRgba(int width, int height, byte[] destination);
}
@@ -170,6 +214,27 @@ internal sealed class FrameScreenshotController
/// of inheriting one, and restores the caller's binding so a diagnostic
/// capture cannot perturb the frame it observes.
///
+ ///
+ /// Multisampling. The window is created with the quality preset's
+ /// MSAA sample count, so the default framebuffer is normally 4x multisampled,
+ /// and glReadPixels against a multisampled read framebuffer is
+ /// undefined per the GL spec (GL 4.6 §18.2: an INVALID_OPERATION is
+ /// generated only for framebuffer objects; for the default framebuffer the
+ /// result is simply unspecified, and AMD returns real pixels most of the time
+ /// and something else the rest). Every automated pixel gate and every blank-
+ /// world verdict in Campaign V reads through here, so an unspecified read is
+ /// an unsound instrument, not a cosmetic issue. When the default framebuffer
+ /// is multisampled the capture resolves it first — blit the whole colour
+ /// buffer into a single-sampled RGBA8 framebuffer with identical rectangles
+ /// and GL_NEAREST, which is 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 resolve target is created and destroyed per capture rather than
+ /// cached: captures are rare (a handful per gate run), and a cache would have
+ /// to track window resizes and context teardown for no measurable gain.
+ ///
///
internal static byte[] ReadDefaultFramebuffer(
IDefaultFramebufferSurface surface,
@@ -178,22 +243,50 @@ internal sealed class FrameScreenshotController
{
ArgumentNullException.ThrowIfNull(surface);
byte[] pixels = new byte[checked(width * height * 4)];
- uint previous = surface.ReadFramebufferBinding;
+ uint previousRead = surface.ReadFramebufferBinding;
+ uint previousDraw = surface.DrawFramebufferBinding;
surface.BindReadFramebuffer(0u);
+ surface.BindDrawFramebuffer(0u);
try
{
+ if (surface.DefaultFramebufferSamples > 1)
+ ResolveThenRead(surface, width, height, pixels);
+ else
+ surface.ReadRgba(width, height, pixels);
+ }
+ finally
+ {
+ surface.BindReadFramebuffer(previousRead);
+ surface.BindDrawFramebuffer(previousDraw);
+ }
+ return pixels;
+ }
+
+ private static void ResolveThenRead(
+ IDefaultFramebufferSurface surface,
+ int width,
+ int height,
+ byte[] pixels)
+ {
+ uint resolve = surface.CreateResolveTarget(width, height);
+ try
+ {
+ // Read is still framebuffer 0 — the multisampled source.
+ surface.BindDrawFramebuffer(resolve);
+ surface.BlitColorNearest(width, height);
+ surface.BindReadFramebuffer(resolve);
surface.ReadRgba(width, height, pixels);
}
finally
{
- surface.BindReadFramebuffer(previous);
+ surface.DeleteResolveTarget(resolve);
}
- return pixels;
}
private sealed class GlDefaultFramebufferSurface : IDefaultFramebufferSurface
{
private readonly GL _gl;
+ private uint _resolveRenderbuffer;
public GlDefaultFramebufferSurface(GL gl) => _gl = gl;
@@ -206,9 +299,105 @@ internal sealed class FrameScreenshotController
}
}
+ public uint DrawFramebufferBinding
+ {
+ get
+ {
+ _gl.GetInteger(GetPName.DrawFramebufferBinding, out int binding);
+ return (uint)binding;
+ }
+ }
+
+ public int DefaultFramebufferSamples
+ {
+ get
+ {
+ _gl.GetInteger(GetPName.Samples, out int samples);
+ return samples;
+ }
+ }
+
public void BindReadFramebuffer(uint framebuffer) =>
_gl.BindFramebuffer(FramebufferTarget.ReadFramebuffer, framebuffer);
+ public void BindDrawFramebuffer(uint framebuffer) =>
+ _gl.BindFramebuffer(FramebufferTarget.DrawFramebuffer, framebuffer);
+
+ public uint CreateResolveTarget(int width, int height)
+ {
+ // RGBA8 matches the default framebuffer's colour encoding — the app
+ // never enables GL_FRAMEBUFFER_SRGB — so the blit is a pure resolve
+ // with no encoding conversion.
+ uint renderbuffer = _gl.GenRenderbuffer();
+ _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, renderbuffer);
+ _gl.RenderbufferStorage(
+ RenderbufferTarget.Renderbuffer,
+ InternalFormat.Rgba8,
+ (uint)width,
+ (uint)height);
+ _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0u);
+
+ uint framebuffer = _gl.GenFramebuffer();
+ _gl.BindFramebuffer(FramebufferTarget.DrawFramebuffer, framebuffer);
+ _gl.FramebufferRenderbuffer(
+ FramebufferTarget.DrawFramebuffer,
+ FramebufferAttachment.ColorAttachment0,
+ RenderbufferTarget.Renderbuffer,
+ renderbuffer);
+ GLEnum status =
+ _gl.CheckFramebufferStatus(FramebufferTarget.DrawFramebuffer);
+ if (status != GLEnum.FramebufferComplete)
+ {
+ _gl.BindFramebuffer(FramebufferTarget.DrawFramebuffer, 0u);
+ _gl.DeleteFramebuffer(framebuffer);
+ _gl.DeleteRenderbuffer(renderbuffer);
+ throw new InvalidOperationException(
+ $"multisample resolve framebuffer {width}x{height} is "
+ + $"incomplete: {status}");
+ }
+
+ GLHelpers.ThrowOnResourceError(
+ _gl,
+ $"create screenshot resolve target {width}x{height}");
+ _resolveRenderbuffer = renderbuffer;
+ return framebuffer;
+ }
+
+ public void DeleteResolveTarget(uint framebuffer)
+ {
+ _gl.DeleteFramebuffer(framebuffer);
+ if (_resolveRenderbuffer != 0u)
+ _gl.DeleteRenderbuffer(_resolveRenderbuffer);
+ _resolveRenderbuffer = 0u;
+ }
+
+ public void BlitColorNearest(int width, int height)
+ {
+ // A blit is subject to the scissor test, so a frame that left a
+ // scissor rectangle armed would resolve only part of the image.
+ // This operation states the state it needs and restores it — the
+ // same self-contained-GL-state rule the render passes follow.
+ bool scissor = _gl.IsEnabled(EnableCap.ScissorTest);
+ if (scissor)
+ _gl.Disable(EnableCap.ScissorTest);
+ _gl.BlitFramebuffer(
+ 0,
+ 0,
+ width,
+ height,
+ 0,
+ 0,
+ width,
+ height,
+ ClearBufferMask.ColorBufferBit,
+ BlitFramebufferFilter.Nearest);
+ if (scissor)
+ _gl.Enable(EnableCap.ScissorTest);
+ GLHelpers.ThrowOnResourceError(
+ _gl,
+ $"resolve default framebuffer {width}x{height}");
+ }
+
public unsafe void ReadRgba(int width, int height, byte[] destination)
{
fixed (byte* pointer = destination)
diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs
index aeeafba4..3935e742 100644
--- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs
+++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs
@@ -500,20 +500,15 @@ internal sealed class GlGpuDevice : IGpuDevice
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
- byte[] pixels = new byte[checked(width * height * 4)];
- unsafe
- {
- fixed (byte* pointer = pixels)
- {
- _gl.ReadPixels(0, 0, (uint)width, (uint)height, PixelFormat.Rgba, PixelType.UnsignedByte, pointer);
- }
- }
+ // FrameScreenshotController owns the one backbuffer read in the process:
+ // it names framebuffer 0 rather than inheriting a binding, and it
+ // resolves the multisampled default framebuffer before reading, because
+ // glReadPixels against a multisampled read framebuffer is undefined.
+ // A second implementation here would be a second instrument to keep
+ // sound. FlipRows is the same top-left-origin flip the screenshot gates
+ // rely on, so this seam stays byte-for-byte compatible with them.
+ byte[] pixels = FrameScreenshotController.ReadDefaultFramebuffer(_gl, width, height);
GLHelpers.ThrowOnResourceError(_gl, $"capture backbuffer {width}x{height}");
-
- // FrameScreenshotController.FlipRows is the same top-left-origin flip
- // the existing screenshot gates already rely on — reusing it (rather
- // than reimplementing the loop) is what keeps this seam byte-for-byte
- // compatible with those gates.
return FrameScreenshotController.FlipRows(pixels, width, height);
}
diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs
index d5bd1509..e72f9907 100644
--- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs
+++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs
@@ -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(
() => 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)
+ ///
+ /// 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.
+ ///
+ [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(
+ () => 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 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);