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:
parent
28e1cf8029
commit
9d7df1bfc5
25 changed files with 1675 additions and 279 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ public sealed class PaperdollFramePresenterTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void PresenterAndProductionHelpersHaveNoWindowOrDelegateBackReference()
|
||||
public void PresenterAndProductionHelpersHaveNoDirectWindowOrDelegateBackReference()
|
||||
{
|
||||
Type[] owners =
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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\"",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue