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);