refactor(render): compose render frame orchestrator

Move the accepted draw transaction and failure recovery behind typed frame-phase owners so GameWindow only supplies immutable frame input. Preserve retail draw order, make ImGui/bootstrap shutdown ownership explicit, and restore exact text-render GL state on failures.\n\nCo-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 08:03:49 +02:00
parent 28e1cf8029
commit 9d7df1bfc5
25 changed files with 1675 additions and 279 deletions

View file

@ -19,7 +19,6 @@ public sealed class WorldLifecycleAutomationControllerTests
];
var logs = new List<string>();
var controller = new FrameScreenshotController(
() => (2, 2),
(_, _) => rgba,
directory,
logs.Add);
@ -29,7 +28,7 @@ public sealed class WorldLifecycleAutomationControllerTests
Assert.True(controller.TryRequest("login_stable", out string error), error);
Assert.False(controller.IsComplete("login_stable"));
Assert.True(controller.CapturePending());
Assert.True(controller.CapturePending(2, 2));
Assert.True(controller.IsComplete("login_stable"));
string path = Path.Combine(directory, "login_stable.png");
@ -53,15 +52,14 @@ public sealed class WorldLifecycleAutomationControllerTests
{
string directory = NewDirectory();
var controller = new FrameScreenshotController(
() => (0, 0),
(_, _) => [],
directory);
try
{
Assert.False(controller.CapturePending());
Assert.False(controller.CapturePending(0, 0));
Assert.True(controller.TryRequest("bad_frame", out string error), error);
Assert.False(controller.CapturePending());
Assert.False(controller.CapturePending(0, 0));
Assert.False(controller.IsComplete("bad_frame"));
}
finally
@ -109,7 +107,6 @@ public sealed class WorldLifecycleAutomationControllerTests
TrackedGpuBytes = 1234,
};
var screenshots = new FrameScreenshotController(
() => (1, 1),
(_, _) => [0, 0, 0, 255],
Path.Combine(directory, "screenshots"));
var controller = new WorldLifecycleAutomationController(

View file

@ -42,8 +42,10 @@ public sealed class DevToolsFramePresenterTests
var commands = new RecordingCommandSource { Current = first };
var presenter = Create(backend, commands: commands);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 800, 600);
commands.Current = second;
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600);
Assert.Same(first, backend.Contexts[0].Commands);
@ -60,6 +62,7 @@ public sealed class DevToolsFramePresenterTests
var panels = new RecordingPanels();
var presenter = Create(backend, panels: panels);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 1920, 1080);
Assert.Equal(
@ -93,6 +96,7 @@ public sealed class DevToolsFramePresenterTests
var camera = new RecordingCamera { IsFlyMode = true, CollideCamera = true };
var presenter = Create(backend, camera: camera);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 1280, 720);
Assert.Equal(1, camera.ToggleCount);
@ -113,6 +117,7 @@ public sealed class DevToolsFramePresenterTests
presenter.ToggleSettingsPanel();
presenter.ResetLayout(1280, 720, DevToolsPanelLayoutCondition.FirstUseEver);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 1280, 720);
Assert.DoesNotContain(DevToolsPanelKind.Settings, panels.Toggles);
@ -156,6 +161,68 @@ public sealed class DevToolsFramePresenterTests
call.WithoutCondition()));
}
[Fact]
public void AbortFrame_ClosesAnOpenBackendFrameAndAllowsTheNextFrame()
{
var calls = new List<string>();
var backend = new RecordingBackend(calls);
var presenter = Create(backend);
presenter.BeginFrame(0.1f);
presenter.AbortFrame();
presenter.AbortFrame();
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600);
Assert.Equal(1, calls.Count(call => call == "abort"));
Assert.Equal(2, calls.Count(call => call.StartsWith("begin:")));
Assert.Equal(1, calls.Count(call => call == "draw-data"));
}
[Fact]
public void RenderFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame()
{
var calls = new List<string>();
var backend = new RecordingBackend(calls)
{
DrawFailure = new InvalidOperationException("draw"),
};
var presenter = Create(backend);
presenter.BeginFrame(0.1f);
Assert.Throws<InvalidOperationException>(() =>
presenter.Render(0.1, 800, 600));
presenter.AbortFrame();
backend.DrawFailure = null;
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600);
Assert.Equal(0, calls.Count(call => call == "abort"));
Assert.Equal(2, calls.Count(call => call == "draw-data"));
}
[Fact]
public void BeginFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame()
{
var calls = new List<string>();
var backend = new RecordingBackend(calls)
{
BeginFailure = new InvalidOperationException("begin"),
};
var presenter = Create(backend);
Assert.Throws<InvalidOperationException>(() => presenter.BeginFrame(0.1f));
presenter.AbortFrame();
backend.BeginFailure = null;
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600);
Assert.Equal(1, calls.Count(call => call == "abort"));
Assert.Equal(2, calls.Count(call => call.StartsWith("begin:")));
}
[Fact]
public void InputFacingActionsDelegateToTheTypedPanelOwner()
{
@ -198,7 +265,7 @@ public sealed class DevToolsFramePresenterTests
field => field.FieldType == typeof(Silk.NET.Windowing.IWindow));
}
Assert.False(typeof(IDisposable).IsAssignableFrom(
Assert.True(typeof(IDisposable).IsAssignableFrom(
typeof(ImGuiDevToolsFrameBackend)));
}
@ -221,8 +288,29 @@ public sealed class DevToolsFramePresenterTests
public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = [];
public List<PanelContext> Contexts { get; } = [];
public List<LayoutCallWithCondition> Layouts { get; } = [];
public Exception? BeginFailure { get; set; }
public Exception? DrawFailure { get; set; }
public void BeginFrame(float deltaSeconds) => calls.Add($"begin:{deltaSeconds}");
public bool ControllerFrameOpen { get; private set; }
public void BeginFrame(float deltaSeconds)
{
calls.Add($"begin:{deltaSeconds}");
ControllerFrameOpen = true;
if (BeginFailure is not null)
throw BeginFailure;
}
public void AbortFrame()
{
// Silk ImGuiController.Render is idempotent. It closes an active
// controller frame, but is a no-op if Render already cleared its
// private _frameBegun flag before a GL upload failure.
if (!ControllerFrameOpen)
return;
ControllerFrameOpen = false;
calls.Add("abort");
}
public bool BeginMainMenuBar()
{
@ -254,7 +342,14 @@ public sealed class DevToolsFramePresenterTests
calls.Add($"panels:{context.DeltaSeconds}");
}
public void RenderDrawData() => calls.Add("draw-data");
public void RenderDrawData()
{
Assert.True(ControllerFrameOpen);
ControllerFrameOpen = false;
calls.Add("draw-data");
if (DrawFailure is not null)
throw DrawFailure;
}
public void SetWindowLayout(
string title,

View file

@ -7,32 +7,33 @@ public sealed class GameWindowRenderLeafCompositionTests
[Fact]
public void ProductionRender_PreservesPrivatePresentationAndCaptureOrder()
{
string source = GameWindowSource();
string presentation = Source("PrivatePresentationRenderer.cs");
string orchestrator = Source("RenderFrameOrchestrator.cs");
AssertAppearsInOrder(
source,
"_localPlayerTeleport?.DrawPortalViewport(",
"_paperdollFramePresenter?.Render();",
"_retailUiRuntime.Tick(deltaSeconds);",
"_devToolsFramePresenter?.Render(",
"_frameScreenshots?.CapturePending() == true;",
"_renderFrameDiagnostics?.Publish(");
presentation,
"_portal.Draw(",
"_paperdoll?.Render();",
"_gameplayUi?.Render(",
"_devTools?.Render(",
"_screenshots?.CapturePending(");
AssertAppearsInOrder(
orchestrator,
"_world.Render(input);",
"_presentation.Render(input, world);",
"_diagnostics.Publish(input, outcome);");
}
[Fact]
public void ProductionRender_Prepares_resources_then_devtools_weather_and_world()
{
string source = GameWindowSource();
string source = Source("RenderFramePreparationController.cs");
AssertAppearsInOrder(
source,
"_renderFrameResources!.Prepare(frameInput);",
"_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);",
"_renderWeatherFrame!.Tick(deltaSeconds);",
"_worldSceneRenderer!.Render(frameInput);");
Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source);
Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source);
Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source);
"_resources.Prepare(input);",
"_devTools?.BeginFrame((float)input.DeltaSeconds);",
"_weather.Tick(input.DeltaSeconds);");
}
[Fact]
@ -46,7 +47,9 @@ public sealed class GameWindowRenderLeafCompositionTests
"_portalTunnel = null;",
"_localPlayerTeleportSink.Bind(_localPlayerTeleport);",
"new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(",
"new AcDream.App.Rendering.RenderFrameResourceController(");
"new AcDream.App.Rendering.RenderFrameResourceController(",
"new AcDream.App.Rendering.PrivatePresentationRenderer(",
"new AcDream.App.Rendering.RenderFrameOrchestrator(");
}
[Fact]
@ -78,6 +81,13 @@ public sealed class GameWindowRenderLeafCompositionTests
"ApplyFramePacingPreference",
"RefreshActiveMonitorFramePacing",
"private void OnFrameRendered(",
"private AcDream.App.Rendering.WorldRenderDiagnostics? _worldRenderDiagnostics",
"private AcDream.App.Rendering.WorldRenderFrameBuilder?",
"private AcDream.App.Rendering.SkyPesFrameController?",
"private AcDream.App.Rendering.RetailPViewRenderer?",
"private AcDream.App.Rendering.RetailPViewPassExecutor?",
"private AcDream.App.Rendering.RetailPViewCellSource?",
"private AcDream.App.Rendering.TerrainDrawDiagnosticsController?",
];
foreach (string identifier in removed)
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
@ -86,6 +96,8 @@ public sealed class GameWindowRenderLeafCompositionTests
Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", source);
Assert.Contains("new AcDream.App.Rendering.RenderFrameResourceController(", source);
Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", source);
Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source);
Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source);
Assert.Contains("new DisplayFramePacingController(", source);
Assert.Contains("_inputCapture.WantCaptureMouse", source);
}
@ -99,40 +111,70 @@ public sealed class GameWindowRenderLeafCompositionTests
source,
"new ResourceShutdownStage(\"submitted GPU work\"",
"new ResourceShutdownStage(\"render frontends\"",
"new(\"frame composition\"",
"new(\"developer tools\"",
"_devToolsBackend?.Dispose()",
"new(\"portal tunnel\"",
"new(\"paperdoll viewport\"",
"new ResourceShutdownStage(\"OpenGL context\"");
Assert.Contains("_renderFrameResources = null;", source);
Assert.Contains("_renderFrameLivePreparation = null;", source);
Assert.Contains("_renderWeatherFrame = null;", source);
AssertAppearsInOrder(
source,
"new ResourceShutdownStage(\"frame borrowers\"",
"_retailPViewPassExecutor = null;",
"_retailPViewCells = null;",
"_terrainDrawDiagnostics = null;",
"_retailPViewRenderer = null;",
"_renderFrameOrchestrator = null;",
"new ResourceShutdownStage(\"session dependents\"");
AssertAppearsInOrder(
source,
"new(\"frame pacing\", _displayFramePacing.Dispose)",
"new(\"frame profiler\", _frameProfiler.Dispose)");
Assert.DoesNotContain("_devToolsBackend?.Dispose()", source);
AssertAppearsInOrder(
source,
"_renderFrameOrchestrator = null;",
"_devToolsBackend?.Dispose()",
"new ResourceShutdownStage(\"input\"",
"_input?.Dispose();",
"new ResourceShutdownStage(\"OpenGL context\"");
}
[Fact]
public void ProductionOutcome_UsesObservedWorldAndScreenshotFacts()
{
string source = GameWindowSource();
string presentation = Source("PrivatePresentationRenderer.cs");
string orchestrator = Source("RenderFrameOrchestrator.cs");
AssertAppearsInOrder(
source,
"_worldSceneRenderer!.Render(frameInput);",
"worldOutcome.NormalWorldDrawn;",
"bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;",
"normalWorldDrawn),",
"screenshotCaptured)));");
presentation,
"_foundation.Foundation.PortalViewportVisible;",
"_portal.Draw(",
"bool screenshotCaptured = _screenshots?.CapturePending(",
"portalViewportVisible,",
"screenshotCaptured);");
AssertAppearsInOrder(
orchestrator,
"WorldRenderFrameOutcome world = _world.Render(input);",
"_presentation.Render(input, world);",
"new RenderFrameOutcome(world, presentation);",
"_diagnostics.Publish(input, outcome);");
}
[Fact]
public void GameWindow_OnRenderIsOneImmutableOrchestratorHandoff()
{
string source = GameWindowSource();
int start = source.IndexOf(
"private void OnRender(double deltaSeconds)",
StringComparison.Ordinal);
int end = source.IndexOf(
"private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot",
start,
StringComparison.Ordinal);
Assert.True(start >= 0 && end > start);
string body = source[start..end];
Assert.Equal(1, CountOccurrences(body, "_renderFrameOrchestrator!.Render("));
Assert.DoesNotContain("_gpuFrameFlights", body);
Assert.DoesNotContain("_worldScene", body);
Assert.DoesNotContain("_devToolsFramePresenter", body);
Assert.DoesNotContain("_retailUiRuntime", body);
Assert.DoesNotContain("_frameScreenshots", body);
}
[Fact]
@ -168,6 +210,25 @@ public sealed class GameWindowRenderLeafCompositionTests
"Rendering",
"GameWindow.cs"));
private static string Source(string fileName) => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
fileName));
private static int CountOccurrences(string source, string value)
{
int count = 0;
int cursor = 0;
while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0)
{
count++;
cursor += value.Length;
}
return count;
}
private static void AssertAppearsInOrder(string source, params string[] needles)
{
int cursor = -1;

View file

@ -93,7 +93,7 @@ public sealed class PaperdollFramePresenterTests
}
[Fact]
public void PresenterAndProductionHelpersHaveNoWindowOrDelegateBackReference()
public void PresenterAndProductionHelpersHaveNoDirectWindowOrDelegateBackReference()
{
Type[] owners =
[

View file

@ -0,0 +1,255 @@
using System.Reflection;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class PrivatePresentationRendererTests
{
private static readonly RenderFrameInput Input = new(0.125d, 1600, 900);
private static readonly WorldRenderFrameOutcome World = new(3, 12, true);
[Theory]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(true, true)]
public void Render_PreservesPortalPaperdollGameplayDevToolsScreenshotOrder(
bool portalVisible,
bool screenshotCaptured)
{
List<string> calls = [];
var portal = new Portal(calls);
var paperdoll = new Paperdoll(calls);
var gameplay = new GameplayUi(calls);
var devTools = new DevTools(calls);
var screenshots = new Screenshots(calls, screenshotCaptured);
var presentation = new PrivatePresentationRenderer(
portal,
new FoundationSource(portalVisible),
paperdoll,
gameplay,
devTools,
screenshots);
PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World);
Assert.Equal(
["portal", "paperdoll", "gameplay-ui", "devtools-render", "screenshot"],
calls);
Assert.Equal(new PrivatePresentationFrameOutcome(
portalVisible,
screenshotCaptured), outcome);
Assert.Equal((Input.ViewportWidth, Input.ViewportHeight), portal.Size);
Assert.Equal((Input.ViewportWidth, Input.ViewportHeight), screenshots.Size);
Assert.Equal(Input, gameplay.Input);
Assert.Equal(Input, devTools.Input);
}
[Fact]
public void Render_OptionalLayersMayBeAbsentButPortalOutcomeRemainsObserved()
{
List<string> calls = [];
var presentation = new PrivatePresentationRenderer(
new Portal(calls),
new FoundationSource(portalVisible: true),
paperdoll: null,
gameplayUi: null,
devTools: null,
screenshots: null);
PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World);
Assert.Equal(["portal"], calls);
Assert.True(outcome.PortalViewportDrawn);
Assert.False(outcome.ScreenshotCaptured);
}
[Fact]
public void Render_ReportsThePreparedPortalSnapshotWhenDrawMutatesTheSource()
{
List<string> calls = [];
var foundation = new MutableFoundationSource(portalVisible: true);
var presentation = new PrivatePresentationRenderer(
new MutatingPortal(calls, () => foundation.PortalVisible = false),
foundation,
paperdoll: null,
gameplayUi: null,
devTools: null,
screenshots: null);
PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World);
Assert.Equal(["portal"], calls);
Assert.False(foundation.PortalVisible);
Assert.True(outcome.PortalViewportDrawn);
}
[Fact]
public void Constructor_RequiresTheCanonicalPortalOwner()
{
Assert.Throws<ArgumentNullException>(() =>
new PrivatePresentationRenderer(null!, new FoundationSource(false), null, null, null, null));
Assert.Throws<ArgumentNullException>(() =>
new PrivatePresentationRenderer(new Portal([]), null!, null, null, null, null));
}
[Fact]
public void ExtractedPresentationOwner_RetainsNoDirectWindowOrDelegate()
{
FieldInfo[] fields = typeof(PrivatePresentationRenderer).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
Assert.DoesNotContain(
fields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
}
[Fact]
public void RetainedUiAdapter_UsesTheDocumentedRuntimeBindingBoundary()
{
FieldInfo[] adapterFields = typeof(RetainedGameplayUiFrame).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.Equal(
[
typeof(AcDream.App.UI.RetailUiRuntime),
typeof(Silk.NET.Input.IInputContext),
],
adapterFields.Select(field => field.FieldType).OrderBy(type => type.FullName));
Assert.DoesNotContain(
adapterFields,
field => field.FieldType == typeof(GameWindow)
|| typeof(Delegate).IsAssignableFrom(field.FieldType));
FieldInfo bindings = typeof(AcDream.App.UI.RetailUiRuntime).GetField(
"_bindings",
BindingFlags.Instance | BindingFlags.NonPublic)!;
Assert.Equal(typeof(AcDream.App.UI.RetailUiRuntimeBindings), bindings.FieldType);
Assert.Contains(
typeof(AcDream.App.UI.RetailUiRuntimeBindings).GetProperties(),
property => property.PropertyType.Name.EndsWith("Bindings", StringComparison.Ordinal));
}
[Fact]
public void KnownSharedOwnerPaths_ArePinnedWithoutClaimingRecursivePurity()
{
static Type[] FieldTypes(Type owner) => owner.GetFields(
BindingFlags.Instance | BindingFlags.NonPublic)
.Select(field => field.FieldType)
.ToArray();
Assert.Contains(
typeof(AcDream.App.UI.RetailUiRuntime),
FieldTypes(typeof(RetainedGameplayUiFrame)));
Assert.Contains(
typeof(AcDream.App.UI.UiViewport),
FieldTypes(typeof(RetailPaperdollFrameView)));
Assert.Contains(
typeof(AcDream.App.UI.UiElement),
FieldTypes(typeof(PaperdollInventoryVisibility)));
Assert.Contains(
typeof(AcDream.UI.Abstractions.Panels.Debug.DebugVM),
FieldTypes(typeof(DevToolsPanelSet)));
Assert.Contains(
typeof(AcDream.UI.Abstractions.Panels.Settings.SettingsVM),
FieldTypes(typeof(RuntimeWorldFrameSettingsPreview)));
Assert.Contains(
typeof(AcDream.App.Streaming.LocalPlayerTeleportController),
FieldTypes(typeof(LocalPlayerPortalViewport)));
Assert.Contains(
typeof(AcDream.App.Streaming.ILocalPlayerTeleportInputLifetime),
FieldTypes(typeof(AcDream.App.Streaming.LocalPlayerTeleportController)));
Assert.DoesNotContain(
FieldTypes(typeof(PrivateFrameScreenshot)),
type => typeof(Delegate).IsAssignableFrom(type));
Assert.Null(typeof(AcDream.App.Diagnostics.FrameScreenshotController).GetField(
"_getSize",
BindingFlags.Instance | BindingFlags.NonPublic));
}
private sealed class Portal(List<string> calls) : IPrivatePortalViewport
{
public (int Width, int Height) Size { get; private set; }
public void Draw(int width, int height)
{
Size = (width, height);
calls.Add("portal");
}
}
private sealed class FoundationSource(bool portalVisible) : IRenderFrameFoundationSource
{
public RenderFrameFoundation Foundation { get; } = new(
portalVisible,
default,
default);
}
private sealed class MutableFoundationSource(bool portalVisible) :
IRenderFrameFoundationSource
{
public bool PortalVisible { get; set; } = portalVisible;
public RenderFrameFoundation Foundation => new(
PortalVisible,
default,
default);
}
private sealed class MutatingPortal(List<string> calls, Action mutate) :
IPrivatePortalViewport
{
public void Draw(int width, int height)
{
calls.Add("portal");
mutate();
}
}
private sealed class Paperdoll(List<string> calls) : IPrivatePaperdollFrame
{
public void Render() => calls.Add("paperdoll");
}
private sealed class GameplayUi(List<string> calls) : IRetainedGameplayUiFrame
{
public RenderFrameInput Input { get; private set; }
public void Render(double deltaSeconds, int width, int height)
{
Input = new RenderFrameInput(deltaSeconds, width, height);
calls.Add("gameplay-ui");
}
}
private sealed class DevTools(List<string> calls) : IDevToolsFrameLifecycle
{
public RenderFrameInput Input { get; private set; }
public void BeginFrame(float deltaSeconds) => calls.Add("devtools-begin");
public void AbortFrame() => calls.Add("devtools-abort");
public void Render(double deltaSeconds, int viewportWidth, int viewportHeight)
{
Input = new RenderFrameInput(deltaSeconds, viewportWidth, viewportHeight);
calls.Add("devtools-render");
}
}
private sealed class Screenshots(List<string> calls, bool result) :
IPrivateFrameScreenshot
{
public (int Width, int Height) Size { get; private set; }
public bool CapturePending(int width, int height)
{
Size = (width, height);
calls.Add("screenshot");
return result;
}
}
}

View file

@ -148,21 +148,77 @@ public sealed class RenderFrameOrchestratorTests
phases.ObservedInputs);
}
[Fact]
public void RenderAndRecoveryFailure_AreAggregatedBeforeGpuClose()
{
var calls = new List<string>();
var renderFailure = new InvalidOperationException("render");
var recoveryFailure = new InvalidOperationException("recovery");
var phases = new RecordingPhases(calls)
{
FailurePoint = "world",
Failure = renderFailure,
RecoveryFailure = recoveryFailure,
};
AggregateException actual = Assert.Throws<AggregateException>(
() => Create(phases).Render(Input));
Assert.Equal(2, actual.InnerExceptions.Count);
Assert.Same(renderFailure, actual.InnerExceptions[0]);
Assert.Same(recoveryFailure, actual.InnerExceptions[1]);
Assert.Equal(
["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"],
calls);
}
[Fact]
public void RenderRecoveryAndCloseFailures_AllRemainObservableInCausalOrder()
{
var calls = new List<string>();
var renderFailure = new InvalidOperationException("render");
var recoveryFailure = new InvalidOperationException("recovery");
var closeFailure = new InvalidOperationException("close");
var phases = new RecordingPhases(calls)
{
FailurePoint = "presentation",
Failure = renderFailure,
RecoveryFailure = recoveryFailure,
CloseFailure = closeFailure,
};
AggregateException actual = Assert.Throws<AggregateException>(
() => Create(phases).Render(Input));
Assert.Equal(3, actual.InnerExceptions.Count);
Assert.Same(renderFailure, actual.InnerExceptions[0]);
Assert.Same(recoveryFailure, actual.InnerExceptions[1]);
Assert.Same(closeFailure, actual.InnerExceptions[2]);
Assert.Equal(
[
"gpu-begin", "resources", "world", "presentation",
"recovery-abort", "gpu-end",
],
calls);
}
[Fact]
public void Constructor_RejectsEveryMissingRequiredOwner()
{
var phases = new RecordingPhases([]);
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
null!, phases, phases, phases, phases));
null!, phases, phases, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, null!, phases, phases, phases));
phases, null!, phases, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, null!, phases, phases));
phases, phases, null!, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, null!, phases));
phases, phases, phases, null!, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, phases, null!));
phases, phases, phases, phases, null!, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, phases, phases, null!));
}
[Fact]
@ -175,6 +231,7 @@ public sealed class RenderFrameOrchestratorTests
typeof(IWorldSceneFramePhase),
typeof(IPrivatePresentationFramePhase),
typeof(IRenderFrameDiagnosticsPhase),
typeof(IRenderFrameFailureRecovery),
];
FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
@ -239,14 +296,17 @@ public sealed class RenderFrameOrchestratorTests
private static string[] ExpectedFailureCalls(string failurePoint) => failurePoint switch
{
"resources" => ["gpu-begin", "resources", "gpu-end"],
"world" => ["gpu-begin", "resources", "world", "gpu-end"],
"resources" => ["gpu-begin", "resources", "recovery-abort", "gpu-end"],
"world" => ["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"],
"presentation" =>
["gpu-begin", "resources", "world", "presentation", "gpu-end"],
[
"gpu-begin", "resources", "world", "presentation",
"recovery-abort", "gpu-end",
],
"diagnostics" =>
[
"gpu-begin", "resources", "world", "presentation", "diagnostics",
"gpu-end",
"recovery-abort", "gpu-end",
],
_ => throw new ArgumentOutOfRangeException(nameof(failurePoint)),
};
@ -266,14 +326,15 @@ public sealed class RenderFrameOrchestratorTests
}
private static RenderFrameOrchestrator Create(RecordingPhases phases) =>
new(phases, phases, phases, phases, phases);
new(phases, phases, phases, phases, phases, phases);
private sealed class RecordingPhases :
IRenderFrameLifetime,
IRenderFrameResourcePhase,
IWorldSceneFramePhase,
IPrivatePresentationFramePhase,
IRenderFrameDiagnosticsPhase
IRenderFrameDiagnosticsPhase,
IRenderFrameFailureRecovery
{
private readonly List<string> _calls;
@ -285,6 +346,7 @@ public sealed class RenderFrameOrchestratorTests
public string? FailurePoint { get; init; }
public Exception? Failure { get; init; }
public Exception? CloseFailure { get; init; }
public Exception? RecoveryFailure { get; init; }
public WorldRenderFrameOutcome World { get; init; } = new(7, 19, true);
public PrivatePresentationFrameOutcome Presentation { get; init; } = new(false, false);
public List<(string Phase, RenderFrameInput Input)> ObservedInputs { get; } = [];
@ -300,6 +362,13 @@ public sealed class RenderFrameOrchestratorTests
throw CloseFailure;
}
public void AbortFrame()
{
_calls.Add("recovery-abort");
if (RecoveryFailure is not null)
throw RecoveryFailure;
}
public void Prepare(RenderFrameInput input)
{
ObservedInputs.Add(("resources", input));

View file

@ -0,0 +1,92 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class RenderFramePreparationControllerTests
{
private static readonly RenderFrameInput Input = new(0.25d, 1280, 720);
[Fact]
public void Prepare_PreservesResourcesThenDevToolsThenWeatherOrder()
{
List<string> calls = [];
var resources = new Resources(calls);
var devTools = new DevTools(calls);
var weather = new Weather(calls);
var preparation = new RenderFramePreparationController(
resources,
devTools,
weather);
preparation.Prepare(Input);
Assert.Equal(["resources", "devtools-begin", "weather"], calls);
Assert.Equal(Input, resources.Input);
Assert.Equal((float)Input.DeltaSeconds, devTools.DeltaSeconds);
Assert.Equal(Input.DeltaSeconds, weather.DeltaSeconds);
}
[Fact]
public void Prepare_DevToolsAreOptionalWithoutChangingRequiredOrder()
{
List<string> calls = [];
var preparation = new RenderFramePreparationController(
new Resources(calls),
devTools: null,
new Weather(calls));
preparation.Prepare(Input);
Assert.Equal(["resources", "weather"], calls);
}
[Fact]
public void Constructor_RejectsMissingRequiredOwners()
{
var resources = new Resources([]);
var weather = new Weather([]);
Assert.Throws<ArgumentNullException>(() =>
new RenderFramePreparationController(null!, null, weather));
Assert.Throws<ArgumentNullException>(() =>
new RenderFramePreparationController(resources, null, null!));
}
private sealed class Resources(List<string> calls) : IRenderFrameResourcePhase
{
public RenderFrameInput Input { get; private set; }
public void Prepare(RenderFrameInput input)
{
Input = input;
calls.Add("resources");
}
}
private sealed class DevTools(List<string> calls) : IDevToolsFrameLifecycle
{
public float DeltaSeconds { get; private set; }
public void BeginFrame(float deltaSeconds)
{
DeltaSeconds = deltaSeconds;
calls.Add("devtools-begin");
}
public void AbortFrame() => calls.Add("devtools-abort");
public void Render(double deltaSeconds, int viewportWidth, int viewportHeight) =>
calls.Add("devtools-render");
}
private sealed class Weather(List<string> calls) : IRenderWeatherFramePhase
{
public double DeltaSeconds { get; private set; }
public void Tick(double deltaSeconds)
{
DeltaSeconds = deltaSeconds;
calls.Add("weather");
}
}
}

View file

@ -0,0 +1,178 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class RenderFrameRecoveryIntegrationTests
{
private static readonly RenderFrameInput Input = new(1d / 60d, 1280, 720);
[Theory]
[InlineData("devtools-begin")]
[InlineData("weather")]
[InlineData("world")]
[InlineData("portal")]
[InlineData("paperdoll")]
[InlineData("gameplay-ui")]
[InlineData("devtools-render")]
public void DownstreamFailure_AbortsDevToolsBeforeGpuClose_ThenNextFrameSucceeds(
string failurePoint)
{
var failure = new FailureSwitch { Point = failurePoint };
var gpu = new GpuLifetime(failure);
var devTools = new DevTools(failure);
var preparation = new RenderFramePreparationController(
new Resources(),
devTools,
new Weather(failure));
var presentation = new PrivatePresentationRenderer(
new Portal(failure),
new FoundationSource(),
new Paperdoll(failure),
new GameplayUi(failure),
devTools,
new Screenshots());
var orchestrator = new RenderFrameOrchestrator(
gpu,
preparation,
new World(failure),
presentation,
new Diagnostics(),
devTools);
Assert.Throws<InvalidOperationException>(() => orchestrator.Render(Input));
Assert.False(devTools.IsOpen);
Assert.Equal(1, devTools.AbortCount);
Assert.Equal(1, gpu.EndCount);
Assert.True(devTools.AbortSequence < gpu.LastEndSequence);
failure.Point = null;
RenderFrameOutcome outcome = orchestrator.Render(Input);
Assert.False(devTools.IsOpen);
Assert.Equal(2, devTools.BeginCount);
Assert.Equal(2, gpu.EndCount);
Assert.True(outcome.World.NormalWorldDrawn);
}
private sealed class FailureSwitch
{
private int _sequence;
public string? Point { get; set; }
public int NextSequence() => ++_sequence;
public void ThrowIf(string point)
{
if (Point == point)
throw new InvalidOperationException(point);
}
}
private sealed class GpuLifetime(FailureSwitch failure) : IRenderFrameLifetime
{
public int EndCount { get; private set; }
public int LastEndSequence { get; private set; }
public void BeginFrame()
{
}
public void EndFrame()
{
EndCount++;
LastEndSequence = failure.NextSequence();
}
}
private sealed class DevTools(FailureSwitch failure) : IDevToolsFrameLifecycle
{
public bool IsOpen { get; private set; }
public int BeginCount { get; private set; }
public int AbortCount { get; private set; }
public int AbortSequence { get; private set; }
public void BeginFrame(float deltaSeconds)
{
Assert.False(IsOpen);
IsOpen = true;
BeginCount++;
failure.ThrowIf("devtools-begin");
}
public void Render(double deltaSeconds, int viewportWidth, int viewportHeight)
{
Assert.True(IsOpen);
failure.ThrowIf("devtools-render");
IsOpen = false;
}
public void AbortFrame()
{
if (!IsOpen)
return;
AbortCount++;
AbortSequence = failure.NextSequence();
IsOpen = false;
}
}
private sealed class Resources : IRenderFrameResourcePhase
{
public void Prepare(RenderFrameInput input)
{
}
}
private sealed class Weather(FailureSwitch failure) : IRenderWeatherFramePhase
{
public void Tick(double deltaSeconds) => failure.ThrowIf("weather");
}
private sealed class World(FailureSwitch failure) : IWorldSceneFramePhase
{
public WorldRenderFrameOutcome Render(RenderFrameInput input)
{
failure.ThrowIf("world");
return new WorldRenderFrameOutcome(1, 1, NormalWorldDrawn: true);
}
}
private sealed class Portal(FailureSwitch failure) : IPrivatePortalViewport
{
public void Draw(int width, int height) => failure.ThrowIf("portal");
}
private sealed class Paperdoll(FailureSwitch failure) : IPrivatePaperdollFrame
{
public void Render() => failure.ThrowIf("paperdoll");
}
private sealed class GameplayUi(FailureSwitch failure) : IRetainedGameplayUiFrame
{
public void Render(double deltaSeconds, int width, int height) =>
failure.ThrowIf("gameplay-ui");
}
private sealed class FoundationSource : IRenderFrameFoundationSource
{
public RenderFrameFoundation Foundation { get; } = new(
PortalViewportVisible: false,
default,
default);
}
private sealed class Screenshots : IPrivateFrameScreenshot
{
public bool CapturePending(int width, int height) => false;
}
private sealed class Diagnostics : IRenderFrameDiagnosticsPhase
{
public void Publish(RenderFrameInput input, RenderFrameOutcome outcome)
{
}
}
}

View file

@ -0,0 +1,217 @@
using System.Reflection;
using AcDream.App.Rendering;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering;
public sealed class TextRendererFailureSafetyTests
{
[Fact]
public void Flush_CompilesCompleteGlStateScopeAsFinallyAroundBothDrawLayers()
{
MethodInfo flush = typeof(TextRenderer).GetMethod(nameof(TextRenderer.Flush))!;
MethodBody body = flush.GetMethodBody()!;
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"TextRenderer.cs"));
Assert.Contains(
body.ExceptionHandlingClauses,
clause => clause.Flags == ExceptionHandlingClauseOptions.Finally);
AssertAppearsInOrder(
source,
"using var stateScope = new TextRenderGlStateScope(_glState);",
"_gl.Disable(EnableCap.Multisample);",
"DrawLayer(_spriteSegs,",
"DrawLayer(_overlaySpriteSegs,");
}
[Fact]
public void FailedDraw_RestoresEveryGlValueMutatedByTheTextPass()
{
var gl = new RecordingGlState
{
DepthWrite = false,
BlendSourceRgb = BlendingFactor.DstAlpha,
BlendDestinationRgb = BlendingFactor.OneMinusDstAlpha,
BlendSourceAlpha = BlendingFactor.One,
BlendDestinationAlpha = BlendingFactor.Zero,
Program = 17,
VertexArray = 23,
ArrayBuffer = 31,
ActiveUnit = TextureUnit.Texture2,
};
gl.SetCapability(EnableCap.DepthTest, enabled: true);
gl.SetCapability(EnableCap.Blend, enabled: false);
gl.SetCapability(EnableCap.CullFace, enabled: true);
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: true);
gl.SetCapability(EnableCap.Multisample, enabled: false);
gl.TextureBindings[TextureUnit.Texture0] = 41;
gl.TextureBindings[TextureUnit.Texture2] = 43;
StateSnapshot expected = gl.Capture();
Action failedDraw = () =>
{
using var stateScope = new TextRenderGlStateScope(gl);
gl.SetCapability(EnableCap.DepthTest, enabled: false);
gl.SetCapability(EnableCap.Blend, enabled: true);
gl.SetCapability(EnableCap.CullFace, enabled: false);
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false);
gl.SetCapability(EnableCap.Multisample, enabled: true);
gl.DepthMask(true);
gl.BlendFuncSeparate(
BlendingFactor.SrcAlpha,
BlendingFactor.OneMinusSrcAlpha,
BlendingFactor.SrcAlpha,
BlendingFactor.OneMinusSrcAlpha);
gl.UseProgram(101);
gl.BindVertexArray(103);
gl.BindBuffer(BufferTargetARB.ArrayBuffer, 107);
gl.ActiveTexture(TextureUnit.Texture0);
gl.BindTexture(TextureTarget.Texture2D, 109);
throw new InvalidOperationException("draw upload");
};
Assert.Throws<InvalidOperationException>(failedDraw);
Assert.Equal(expected, gl.Capture());
}
private readonly record struct StateSnapshot(
bool DepthTest,
bool Blend,
bool Cull,
bool AlphaToCoverage,
bool Multisample,
bool DepthWrite,
BlendingFactor BlendSourceRgb,
BlendingFactor BlendDestinationRgb,
BlendingFactor BlendSourceAlpha,
BlendingFactor BlendDestinationAlpha,
uint Program,
uint VertexArray,
uint ArrayBuffer,
TextureUnit ActiveUnit,
uint Texture0,
uint Texture2);
private sealed class RecordingGlState : ITextRenderGlStateApi
{
private readonly Dictionary<EnableCap, bool> _capabilities = [];
public bool DepthWrite { get; set; }
public BlendingFactor BlendSourceRgb { get; set; }
public BlendingFactor BlendDestinationRgb { get; set; }
public BlendingFactor BlendSourceAlpha { get; set; }
public BlendingFactor BlendDestinationAlpha { get; set; }
public uint Program { get; set; }
public uint VertexArray { get; set; }
public uint ArrayBuffer { get; set; }
public TextureUnit ActiveUnit { get; set; }
public Dictionary<TextureUnit, uint> TextureBindings { get; } = [];
public StateSnapshot Capture() => new(
IsEnabled(EnableCap.DepthTest),
IsEnabled(EnableCap.Blend),
IsEnabled(EnableCap.CullFace),
IsEnabled(EnableCap.SampleAlphaToCoverage),
IsEnabled(EnableCap.Multisample),
DepthWrite,
BlendSourceRgb,
BlendDestinationRgb,
BlendSourceAlpha,
BlendDestinationAlpha,
Program,
VertexArray,
ArrayBuffer,
ActiveUnit,
TextureBindings.GetValueOrDefault(TextureUnit.Texture0),
TextureBindings.GetValueOrDefault(TextureUnit.Texture2));
public bool IsEnabled(EnableCap capability) =>
_capabilities.GetValueOrDefault(capability);
public int GetInteger(GetPName parameter) => parameter switch
{
GetPName.BlendSrcRgb => (int)BlendSourceRgb,
GetPName.BlendDstRgb => (int)BlendDestinationRgb,
GetPName.BlendSrcAlpha => (int)BlendSourceAlpha,
GetPName.BlendDstAlpha => (int)BlendDestinationAlpha,
GetPName.CurrentProgram => (int)Program,
GetPName.VertexArrayBinding => (int)VertexArray,
GetPName.ArrayBufferBinding => (int)ArrayBuffer,
GetPName.ActiveTexture => (int)ActiveUnit,
GetPName.TextureBinding2D =>
(int)TextureBindings.GetValueOrDefault(ActiveUnit),
_ => throw new ArgumentOutOfRangeException(nameof(parameter)),
};
public bool GetBoolean(GetPName parameter) =>
parameter == GetPName.DepthWritemask
? DepthWrite
: throw new ArgumentOutOfRangeException(nameof(parameter));
public void SetCapability(EnableCap capability, bool enabled) =>
_capabilities[capability] = enabled;
public void DepthMask(bool enabled) => DepthWrite = enabled;
public void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha)
{
BlendSourceRgb = sourceRgb;
BlendDestinationRgb = destinationRgb;
BlendSourceAlpha = sourceAlpha;
BlendDestinationAlpha = destinationAlpha;
}
public void UseProgram(uint program) => Program = program;
public void BindVertexArray(uint vertexArray) => VertexArray = vertexArray;
public void BindBuffer(BufferTargetARB target, uint buffer)
{
Assert.Equal(BufferTargetARB.ArrayBuffer, target);
ArrayBuffer = buffer;
}
public void ActiveTexture(TextureUnit unit) => ActiveUnit = unit;
public void BindTexture(TextureTarget target, uint texture)
{
Assert.Equal(TextureTarget.Texture2D, target);
TextureBindings[ActiveUnit] = texture;
}
}
private static void AssertAppearsInOrder(string source, params string[] needles)
{
int cursor = -1;
foreach (string needle in needles)
{
int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal);
Assert.True(next >= 0, $"Missing expected source fragment: {needle}");
Assert.True(next > cursor, $"Out-of-order source fragment: {needle}");
cursor = next;
}
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -325,7 +325,7 @@ public sealed class WorldRenderFrameBuilderTests
}
[Fact]
public void World_scene_uses_the_typed_builder_and_game_window_releases_it_before_render_owners()
public void World_scene_uses_the_typed_builder_and_orchestrator_owns_its_local_composition()
{
string gameWindow = GameWindowSource();
string worldScene = File.ReadAllText(Path.Combine(
@ -341,6 +341,14 @@ public sealed class WorldRenderFrameBuilderTests
Assert.DoesNotContain("private void UpdateSunFromSky(", gameWindow, StringComparison.Ordinal);
Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal);
Assert.DoesNotContain("private static float ParseEnvFloat(", gameWindow, StringComparison.Ordinal);
Assert.DoesNotContain(
"private AcDream.App.Rendering.WorldRenderFrameBuilder?",
gameWindow,
StringComparison.Ordinal);
Assert.DoesNotContain(
"private AcDream.App.Rendering.SkyPesFrameController?",
gameWindow,
StringComparison.Ordinal);
AssertAppearsInOrder(
worldScene,
"_alpha.BeginFrame();",
@ -350,9 +358,10 @@ public sealed class WorldRenderFrameBuilderTests
"NormalWorldDrawn: true");
AssertAppearsInOrder(
gameWindow,
"_worldSceneRenderer = null;",
"_worldRenderFrameBuilder = null;",
"_skyPesFrame = null;",
"var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(",
"var worldRenderFrameBuilder =",
"new AcDream.App.Rendering.RenderFrameOrchestrator(",
"_renderFrameOrchestrator = null;",
"new(\"equipped children\"",
"new(\"effect network state\"",
"new(\"audio\"",

View file

@ -283,7 +283,8 @@ public sealed class WorldSceneRendererTests
"GameWindow.cs"));
Assert.Contains("new AcDream.App.Rendering.WorldSceneRenderer(", source);
Assert.Equal(1, CountOccurrences(source, "_worldSceneRenderer!.Render(frameInput);"));
Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source);
Assert.DoesNotContain("_worldSceneRenderer", source);
Assert.DoesNotContain("_retailPViewRenderer.DrawInside(", source);
Assert.DoesNotContain("_retailAlphaQueue.BeginFrame();", source);
Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source);