fix(diag): make the frame capture name the framebuffer it reads

FrameScreenshotController.ReadDefaultFramebuffer called glReadPixels
without binding a read framebuffer, so it captured whatever was bound to
GL_READ_FRAMEBUFFER at that moment rather than the default framebuffer
its name promises. The capture runs at the end of
PrivatePresentationRenderer.Render, after PrivateEntityViewportRenderer
has drawn the paperdoll and appraisal views into its own FBO — an FBO it
clears to exactly RGBA(0,0,0,0). A capture that inherits that binding
writes a fully transparent PNG, which the repeat-run connected gate
scores as BLANK even though the backbuffer on screen was correct.

This was latent for as long as something rebound framebuffer 0 often
enough to mask it. Before Campaign V slice V4c, GL BeginPass bound
framebuffer 0 on every pass with a null colour target; V4c deliberately
stopped doing that (plan §5.4) so the offscreen viewport renderers could
keep their own target across a dispatcher draw. Removing the wide path
exposed the narrow bug underneath it — the same latent-bug-masked-by-a-
fallback class the project recorded for #98.

The read now binds framebuffer 0 to GL_READ_FRAMEBUFFER, reads, and
restores the caller's binding, so a diagnostic capture states its own
source and cannot perturb the frame it observes. The GL calls move behind
IDefaultFramebufferSurface so the bind/read/restore order is assertable
without a GL context; two tests cover the ordering and the restore on a
throwing read.

Gates: Release build green; App tests 3,864 passed / 3 skipped (3,862
baseline plus the two new tests), no #250 flakes; offline pixel gate
against 8dec163f PASS at a differing fraction of 4.26e-05 against the
0.001 threshold, inside the documented same-commit noise band.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 23:14:51 +02:00
parent 8dec163fd8
commit c090cd693d
2 changed files with 160 additions and 11 deletions

View file

@ -93,6 +93,73 @@ public sealed class WorldLifecycleAutomationControllerTests
Assert.NotEmpty(error);
}
/// <summary>
/// glReadPixels reads GL_READ_FRAMEBUFFER. A capture that does not name
/// framebuffer 0 captures whichever offscreen target the previous renderer
/// left bound - the paperdoll/appraisal FBOs clear to RGBA(0,0,0,0), so the
/// artifact is a fully transparent PNG that reads as a blank-world failure
/// while the backbuffer on screen was correct.
/// </summary>
[Fact]
public void DefaultFramebufferRead_BindsFramebufferZeroAndRestoresTheCallersBinding()
{
var surface = new RecordingFramebufferSurface(boundOnEntry: 7u, fill: 0x42);
byte[] pixels = FrameScreenshotController.ReadDefaultFramebuffer(surface, 2, 3);
Assert.Equal(
["bind 0", "read 2x3", "bind 7"],
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);
}
[Fact]
public void DefaultFramebufferRead_RestoresTheCallersBindingWhenTheReadThrows()
{
var surface = new RecordingFramebufferSurface(boundOnEntry: 9u, fill: 0)
{
ReadFailure = new InvalidOperationException("GL_OUT_OF_MEMORY"),
};
Assert.Throws<InvalidOperationException>(
() => FrameScreenshotController.ReadDefaultFramebuffer(surface, 1, 1));
Assert.Equal(["bind 0", "read 1x1", "bind 9"], surface.Calls);
Assert.Equal(9u, surface.ReadFramebufferBinding);
}
private sealed class RecordingFramebufferSurface(uint boundOnEntry, byte fill)
: FrameScreenshotController.IDefaultFramebufferSurface
{
private uint _binding = boundOnEntry;
public List<string> Calls { get; } = [];
public Exception? ReadFailure { get; init; }
public uint? ReadFramebufferBindingDuringRead { get; private set; }
public uint ReadFramebufferBinding => _binding;
public void BindReadFramebuffer(uint framebuffer)
{
_binding = framebuffer;
Calls.Add($"bind {framebuffer}");
}
public void ReadRgba(int width, int height, byte[] destination)
{
Calls.Add($"read {width}x{height}");
ReadFramebufferBindingDuringRead = _binding;
if (ReadFailure is not null)
throw ReadFailure;
Array.Fill(destination, fill);
}
}
[Fact]
public void Checkpoint_WritesCanonicalRevealAndResourceSnapshot()
{