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>
490 lines
19 KiB
C#
490 lines
19 KiB
C#
using System.Text.Json;
|
|
using AcDream.App.Diagnostics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Residency;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.UI.Testing;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.World;
|
|
using SixLabors.ImageSharp;
|
|
|
|
namespace AcDream.App.Tests.Diagnostics;
|
|
|
|
public sealed class WorldLifecycleAutomationControllerTests
|
|
{
|
|
private static readonly RenderFrameInput FrameInput = new(1d / 60d, 1280, 720);
|
|
private static readonly RenderFrameOutcome FrameOutcome = new(
|
|
new WorldRenderFrameOutcome(4, 17, NormalWorldDrawn: true),
|
|
new PrivatePresentationFrameOutcome(
|
|
PortalViewportDrawn: false,
|
|
ScreenshotCaptured: true));
|
|
|
|
[Fact]
|
|
public void ScreenshotCapture_FlipsGlRowsAndWritesACompletePng()
|
|
{
|
|
string directory = NewDirectory();
|
|
// GL order: bottom row red/green, top row blue/white.
|
|
byte[] rgba =
|
|
[
|
|
255, 0, 0, 255, 0, 255, 0, 255,
|
|
0, 0, 255, 255, 255, 255, 255, 255,
|
|
];
|
|
var logs = new List<string>();
|
|
var controller = new FrameScreenshotController(
|
|
(_, _) => rgba,
|
|
directory,
|
|
logs.Add);
|
|
|
|
try
|
|
{
|
|
Assert.True(controller.TryRequest("login_stable", out string error), error);
|
|
Assert.False(controller.IsComplete("login_stable"));
|
|
|
|
Assert.True(controller.CapturePending(2, 2));
|
|
|
|
Assert.True(controller.IsComplete("login_stable"));
|
|
string path = Path.Combine(directory, "login_stable.png");
|
|
using Image image = Image.Load(path);
|
|
Assert.Equal(2, image.Width);
|
|
Assert.Equal(2, image.Height);
|
|
Assert.Contains(logs, line => line.Contains("screenshot-complete", StringComparison.Ordinal));
|
|
|
|
byte[] flipped = FrameScreenshotController.FlipRows(rgba, 2, 2);
|
|
Assert.Equal(rgba.AsSpan(8, 8).ToArray(), flipped.AsSpan(0, 8).ToArray());
|
|
Assert.Equal(rgba.AsSpan(0, 8).ToArray(), flipped.AsSpan(8, 8).ToArray());
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ScreenshotCapture_ReportsNoWorkAndFailedCapture()
|
|
{
|
|
string directory = NewDirectory();
|
|
var controller = new FrameScreenshotController(
|
|
(_, _) => [],
|
|
directory);
|
|
|
|
try
|
|
{
|
|
Assert.False(controller.CapturePending(0, 0));
|
|
Assert.True(controller.TryRequest("bad_frame", out string error), error);
|
|
Assert.False(controller.CapturePending(0, 0));
|
|
Assert.False(controller.IsComplete("bad_frame"));
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(directory))
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("../escape")]
|
|
[InlineData("has spaces")]
|
|
[InlineData("name.png")]
|
|
public void ArtifactNames_RejectPathsAndAmbiguousCharacters(string name)
|
|
{
|
|
Assert.False(AutomationArtifactName.TryValidate(name, out string error));
|
|
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()
|
|
{
|
|
string directory = NewDirectory();
|
|
var reveal = new RuntimePortalSnapshot(
|
|
Generation: 7,
|
|
Kind: RuntimePortalKind.Portal,
|
|
Readiness: new RuntimeDestinationReadiness(
|
|
Generation: 7,
|
|
0x8A020164u,
|
|
IsIndoor: true,
|
|
IsUnhydratable: false,
|
|
RequiredRenderRadius: 0,
|
|
IsRenderNeighborhoodReady: true,
|
|
AreCompositeTexturesReady: true,
|
|
IsCollisionReady: true),
|
|
Materialized: true,
|
|
Completed: true,
|
|
Cancelled: false,
|
|
WorldViewportObserved: true,
|
|
WorldSimulationAvailable: true,
|
|
InvariantFailureCount: 0,
|
|
WaitCueShown: false,
|
|
PortalMaterializationCount: 3);
|
|
var resources = EmptyResources() with
|
|
{
|
|
LoadedLandblocks = 1,
|
|
WorldEntities = 42,
|
|
RenderSceneOracle = new CurrentRenderSceneOracleSnapshot(
|
|
Enabled: true,
|
|
CompletedFrameSequence: 19,
|
|
AbortedFrames: 1,
|
|
ProjectionCount: 73,
|
|
OutdoorStaticCount: 41,
|
|
CellStaticCount: 20,
|
|
DynamicCount: 12,
|
|
CellBucketCount: 4,
|
|
Digest: new RenderSceneHash128(
|
|
Low: 0x0123456789ABCDEF,
|
|
High: 0xFEDCBA9876543210),
|
|
CompletedPViewFrameSequence: 19,
|
|
AbortedPViewFrames: 0,
|
|
PViewCandidateCount: 61,
|
|
PViewDigest: new RenderSceneHash128(11, 12),
|
|
DispatcherFrameSequence: 19,
|
|
AbortedDispatcherFrames: 0,
|
|
DispatcherDrawCount: 3,
|
|
DispatcherEntityCount: 52,
|
|
DispatcherMeshRefCount: 80,
|
|
DispatcherInstanceCount: 96,
|
|
DispatcherOpaqueGroupCount: 11,
|
|
DispatcherTransparentGroupCount: 4,
|
|
DispatcherDigest: new RenderSceneHash128(13, 14),
|
|
CompletedSelectionFrameSequence: 19,
|
|
AbortedSelectionFrames: 0,
|
|
SelectionPartCount: 7,
|
|
SelectionDigest: new RenderSceneHash128(15, 16)),
|
|
RenderSceneShadow =
|
|
RenderSceneShadowComparisonSnapshot.Disabled with
|
|
{
|
|
Enabled = true,
|
|
ComparisonCount = 19,
|
|
SuccessfulComparisonCount = 19,
|
|
ComparedOracleFrameSequence = 19,
|
|
MatchedProjectionCount = 73,
|
|
},
|
|
TrackedGpuBytes = 1234,
|
|
Residency = new ResidencySnapshot(
|
|
[
|
|
new ResidencyDomainSnapshot(
|
|
ResidencyDomain.Animations,
|
|
EntryCount: 2,
|
|
OwnerCount: 0,
|
|
Charges: new ResidencyCharges(
|
|
DecodedBytes: 1024),
|
|
BudgetBytes: 4096,
|
|
Hits: 8,
|
|
Misses: 3,
|
|
Evictions: 1),
|
|
],
|
|
new ResidencyCharges(DecodedBytes: 1024)),
|
|
};
|
|
var screenshots = new FrameScreenshotController(
|
|
(_, _) => [0, 0, 0, 255],
|
|
Path.Combine(directory, "screenshots"));
|
|
var controller = new WorldLifecycleAutomationController(
|
|
() => reveal,
|
|
() => new RuntimeWorldEnvironmentOwnershipSnapshot(
|
|
IsInitialized: true,
|
|
DayGroupDefinitionCount: 4,
|
|
ActiveDayGroupCount: 1),
|
|
() => new RuntimeWorldTransitOwnershipSnapshot(
|
|
BufferedTeleportDestinationCount: 0,
|
|
PendingTeleportStartCount: 0,
|
|
ActiveTeleportCount: 0,
|
|
AcceptedTeleportDestinationCount: 0,
|
|
ActiveRevealCount: 0,
|
|
PendingDestinationReadinessCount: 0,
|
|
HostProjectionCount: 0,
|
|
PendingHostAcknowledgementCount: 0),
|
|
() => 3,
|
|
_ => resources,
|
|
screenshots,
|
|
directory);
|
|
|
|
try
|
|
{
|
|
Assert.True(controller.IsWorldReady);
|
|
Assert.True(controller.IsWorldViewportVisible);
|
|
Assert.Equal(3, controller.PortalMaterializationCount);
|
|
Assert.True(controller.TryRequestCheckpoint(
|
|
"dungeon",
|
|
out IRetailUiAutomationCheckpoint? request,
|
|
out string error), error);
|
|
Assert.NotNull(request);
|
|
Assert.Equal(RetailUiAutomationCheckpointStatus.Pending, request.Status);
|
|
|
|
controller.Process(FrameInput, FrameOutcome);
|
|
|
|
Assert.Equal(RetailUiAutomationCheckpointStatus.Succeeded, request.Status);
|
|
|
|
string line = Assert.Single(File.ReadAllLines(
|
|
Path.Combine(directory, "world-lifecycle.checkpoints.jsonl")));
|
|
using JsonDocument json = JsonDocument.Parse(line);
|
|
Assert.Equal("dungeon", json.RootElement.GetProperty("name").GetString());
|
|
Assert.Equal(7, json.RootElement.GetProperty("reveal").GetProperty("generation").GetInt64());
|
|
Assert.Equal(
|
|
4,
|
|
json.RootElement.GetProperty("environmentOwnership")
|
|
.GetProperty("dayGroupDefinitionCount").GetInt32());
|
|
Assert.Equal(
|
|
0,
|
|
json.RootElement.GetProperty("transitOwnership")
|
|
.GetProperty("pendingHostAcknowledgementCount")
|
|
.GetInt32());
|
|
Assert.Equal(4, json.RootElement.GetProperty("render").GetProperty("world")
|
|
.GetProperty("visibleLandblocks").GetInt32());
|
|
Assert.True(json.RootElement.GetProperty("render").GetProperty("presentation")
|
|
.GetProperty("screenshotCaptured").GetBoolean());
|
|
Assert.Equal(42, json.RootElement.GetProperty("resources").GetProperty("worldEntities").GetInt32());
|
|
JsonElement renderSceneOracle = json.RootElement
|
|
.GetProperty("resources")
|
|
.GetProperty("renderSceneOracle");
|
|
Assert.True(renderSceneOracle.GetProperty("enabled").GetBoolean());
|
|
Assert.Equal(
|
|
19,
|
|
renderSceneOracle.GetProperty("completedFrameSequence").GetInt64());
|
|
Assert.Equal(
|
|
73,
|
|
renderSceneOracle.GetProperty("projectionCount").GetInt32());
|
|
Assert.Equal(
|
|
0x0123456789ABCDEFuL,
|
|
renderSceneOracle.GetProperty("digest").GetProperty("low")
|
|
.GetUInt64());
|
|
Assert.Equal(
|
|
0xFEDCBA9876543210uL,
|
|
renderSceneOracle.GetProperty("digest").GetProperty("high")
|
|
.GetUInt64());
|
|
JsonElement renderSceneShadow = json.RootElement
|
|
.GetProperty("resources")
|
|
.GetProperty("renderSceneShadow");
|
|
Assert.True(renderSceneShadow.GetProperty("enabled").GetBoolean());
|
|
Assert.Equal(
|
|
19,
|
|
renderSceneShadow.GetProperty("comparisonCount").GetInt64());
|
|
Assert.Equal(
|
|
73,
|
|
renderSceneShadow.GetProperty("matchedProjectionCount")
|
|
.GetInt32());
|
|
Assert.Equal(
|
|
0,
|
|
renderSceneShadow.GetProperty("mismatchCount").GetInt64());
|
|
JsonElement residency = json.RootElement
|
|
.GetProperty("resources")
|
|
.GetProperty("residency");
|
|
Assert.Equal(
|
|
4096,
|
|
residency.GetProperty("totalBudgetBytes").GetInt64());
|
|
Assert.Equal(8, residency.GetProperty("totalHits").GetInt64());
|
|
Assert.Equal(
|
|
1024,
|
|
residency.GetProperty("totalCharges")
|
|
.GetProperty("decodedBytes")
|
|
.GetInt64());
|
|
Assert.True(File.Exists(Path.Combine(directory, "checkpoint-dungeon.json")));
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckpointRequests_DrainInFifoOrderOnce()
|
|
{
|
|
string directory = NewDirectory();
|
|
int captures = 0;
|
|
var controller = CreateController(
|
|
directory,
|
|
_ =>
|
|
{
|
|
captures++;
|
|
return EmptyResources();
|
|
});
|
|
|
|
try
|
|
{
|
|
Assert.True(controller.TryRequestCheckpoint(
|
|
"first", out IRetailUiAutomationCheckpoint? first, out _));
|
|
Assert.True(controller.TryRequestCheckpoint(
|
|
"second", out IRetailUiAutomationCheckpoint? second, out _));
|
|
|
|
controller.Process(FrameInput, FrameOutcome);
|
|
controller.Process(FrameInput, FrameOutcome);
|
|
|
|
Assert.NotNull(first);
|
|
Assert.NotNull(second);
|
|
Assert.Equal(1, first.Sequence);
|
|
Assert.Equal(2, second.Sequence);
|
|
Assert.Equal(RetailUiAutomationCheckpointStatus.Succeeded, first.Status);
|
|
Assert.Equal(RetailUiAutomationCheckpointStatus.Succeeded, second.Status);
|
|
Assert.Equal(2, captures);
|
|
string[] lines = File.ReadAllLines(
|
|
Path.Combine(directory, "world-lifecycle.checkpoints.jsonl"));
|
|
Assert.Equal(2, lines.Length);
|
|
Assert.Contains("\"name\":\"first\"", lines[0], StringComparison.Ordinal);
|
|
Assert.Contains("\"name\":\"second\"", lines[1], StringComparison.Ordinal);
|
|
}
|
|
finally
|
|
{
|
|
controller.Dispose();
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckpointWriteFailure_IsReportedOnTokenWithoutThrowingFromFrame()
|
|
{
|
|
string directory = NewDirectory();
|
|
string blockedPath = Path.Combine(directory, "not-a-directory");
|
|
File.WriteAllText(blockedPath, "occupied");
|
|
var controller = CreateController(blockedPath, _ => EmptyResources());
|
|
|
|
try
|
|
{
|
|
Assert.True(controller.TryRequestCheckpoint(
|
|
"failed", out IRetailUiAutomationCheckpoint? request, out _));
|
|
|
|
controller.Process(FrameInput, FrameOutcome);
|
|
|
|
Assert.NotNull(request);
|
|
Assert.Equal(RetailUiAutomationCheckpointStatus.Failed, request.Status);
|
|
Assert.Contains("checkpoint 'failed' sequence 1 failed", request.Error);
|
|
}
|
|
finally
|
|
{
|
|
controller.Dispose();
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckpointCancellationAndShutdown_AreExplicitAndNeverWritten()
|
|
{
|
|
string directory = NewDirectory();
|
|
var controller = CreateController(directory, _ => EmptyResources());
|
|
|
|
try
|
|
{
|
|
Assert.True(controller.TryRequestCheckpoint(
|
|
"caller", out IRetailUiAutomationCheckpoint? caller, out _));
|
|
Assert.True(controller.TryRequestCheckpoint(
|
|
"shutdown", out IRetailUiAutomationCheckpoint? shutdown, out _));
|
|
Assert.NotNull(caller);
|
|
Assert.NotNull(shutdown);
|
|
|
|
controller.CancelCheckpoint(caller);
|
|
controller.Dispose();
|
|
controller.Process(FrameInput, FrameOutcome);
|
|
|
|
Assert.Equal(RetailUiAutomationCheckpointStatus.Cancelled, caller.Status);
|
|
Assert.Contains("cancelled before render-frame capture", caller.Error);
|
|
Assert.Equal(RetailUiAutomationCheckpointStatus.Cancelled, shutdown.Status);
|
|
Assert.Contains("cancelled during world lifecycle automation shutdown", shutdown.Error);
|
|
Assert.False(controller.TryRequestCheckpoint(
|
|
"late", out IRetailUiAutomationCheckpoint? late, out string error));
|
|
Assert.Null(late);
|
|
Assert.Contains("shutting down", error);
|
|
Assert.False(File.Exists(Path.Combine(
|
|
directory,
|
|
"world-lifecycle.checkpoints.jsonl")));
|
|
}
|
|
finally
|
|
{
|
|
controller.Dispose();
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
private static WorldLifecycleAutomationController CreateController(
|
|
string directory,
|
|
Func<RenderFrameOutcome, WorldLifecycleResourceSnapshot> capture) =>
|
|
new(
|
|
() => default,
|
|
() => default,
|
|
() => default,
|
|
() => 0,
|
|
capture,
|
|
new FrameScreenshotController((_, _) => [], directory),
|
|
directory);
|
|
|
|
private static WorldLifecycleResourceSnapshot EmptyResources() => new(
|
|
0, 0, 0, 0, 0, 0, 0, CurrentRenderSceneOracleSnapshot.Disabled,
|
|
RenderSceneShadowComparisonSnapshot.Disabled,
|
|
RenderFrameProductComparisonSnapshot.Disabled,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L,
|
|
0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L,
|
|
0, 0, 0, 0, 0, 0, 0, default,
|
|
default, default, 0d, 0d, null);
|
|
|
|
private static string NewDirectory()
|
|
{
|
|
string path = Path.Combine(Path.GetTempPath(), "acdream-world-gate-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(path);
|
|
return path;
|
|
}
|
|
}
|