using System.Reflection; using AcDream.App.Rendering; using AcDream.App.Rendering.Gpu.Gl; using Silk.NET.OpenGL; namespace AcDream.App.Tests.Rendering; /// /// The retained UI's whole draw is one RHI pass, and a failure anywhere inside /// it must not leave GL state behind for the raw-GL world renderers that run in /// the next frame. Until Campaign V slice V6d, TextRenderer owned a private /// state scope for that; V6d made the renderer backend-neutral and moved the /// guarantee onto , which every RHI pass /// gets. These tests follow it there. /// public sealed class TextRendererFailureSafetyTests { [Fact] public void Flush_CompilesThePassEncoderAsFinallyAroundBothDrawLayers() { 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")); // The encoder's `using` is the finally: disposing it closes the pass, // which is what restores the ambient capability state the pass changed. Assert.Contains( body.ExceptionHandlingClauses, clause => clause.Flags == ExceptionHandlingClauseOptions.Finally); AssertAppearsInOrder( source, "using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription", "encoder.BindPipeline(_pipeline);", "DrawLayer(_spriteSegs,", "DrawLayer(_overlaySpriteSegs,"); // And no raw GL of its own is left: the renderer draws on both backends. Assert.DoesNotContain("Silk.NET.OpenGL", source, StringComparison.Ordinal); Assert.DoesNotContain("_gl.", source, StringComparison.Ordinal); } [Fact] public void FailedDraw_RestoresEveryGlValueMutatedByThePass() { var gl = new RecordingGlState { DepthWrite = false, DepthFuncValue = DepthFunction.Greater, BlendSourceRgb = BlendingFactor.DstAlpha, BlendDestinationRgb = BlendingFactor.OneMinusDstAlpha, BlendSourceAlpha = BlendingFactor.One, BlendDestinationAlpha = BlendingFactor.Zero, CullFaceMode = TriangleFace.Front, FrontFaceDirection = FrontFaceDirection.CW, 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); // Enabled on entry — the world's MSAA. The UI pass turns it off and the // restore has to put it back, which is the exact dimension the V4a // revert lost and which nothing covered before this test. gl.SetCapability(EnableCap.Multisample, enabled: true); gl.TextureBindings[TextureUnit.Texture0] = 41; gl.TextureBindings[TextureUnit.Texture2] = 43; StateSnapshot expected = gl.Capture(); Action failedDraw = () => { GlAmbientCapabilityState ambient = GlAmbientCapabilityState.Capture(gl); try { 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: false); gl.DepthMask(true); gl.DepthFunc(DepthFunction.Lequal); gl.BlendFuncSeparate( BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); gl.CullFace(TriangleFace.Back); gl.FrontFace(FrontFaceDirection.Ccw); 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"); } finally { ambient.Restore(gl); } }; Assert.Throws(failedDraw); Assert.Equal(expected, gl.Capture()); } private readonly record struct StateSnapshot( bool DepthTest, bool Blend, bool Cull, bool AlphaToCoverage, bool Multisample, bool DepthWrite, DepthFunction DepthFunc, BlendingFactor BlendSourceRgb, BlendingFactor BlendDestinationRgb, BlendingFactor BlendSourceAlpha, BlendingFactor BlendDestinationAlpha, TriangleFace CullFaceMode, FrontFaceDirection FrontFaceDirection, uint Program, uint VertexArray, uint ArrayBuffer, TextureUnit ActiveUnit, uint Texture0, uint Texture2); private sealed class RecordingGlState : IGlAmbientStateApi { private readonly Dictionary _capabilities = []; public bool DepthWrite { get; set; } public DepthFunction DepthFuncValue { get; set; } public BlendingFactor BlendSourceRgb { get; set; } public BlendingFactor BlendDestinationRgb { get; set; } public BlendingFactor BlendSourceAlpha { get; set; } public BlendingFactor BlendDestinationAlpha { get; set; } public TriangleFace CullFaceMode { get; set; } public FrontFaceDirection FrontFaceDirection { get; set; } public uint Program { get; set; } public uint VertexArray { get; set; } public uint ArrayBuffer { get; set; } public TextureUnit ActiveUnit { get; set; } public Dictionary TextureBindings { get; } = []; public StateSnapshot Capture() => new( IsEnabled(EnableCap.DepthTest), IsEnabled(EnableCap.Blend), IsEnabled(EnableCap.CullFace), IsEnabled(EnableCap.SampleAlphaToCoverage), IsEnabled(EnableCap.Multisample), DepthWrite, DepthFuncValue, BlendSourceRgb, BlendDestinationRgb, BlendSourceAlpha, BlendDestinationAlpha, CullFaceMode, FrontFaceDirection, 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.DepthFunc => (int)DepthFuncValue, GetPName.CullFaceMode => (int)CullFaceMode, GetPName.FrontFace => (int)FrontFaceDirection, 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 DepthFunc(DepthFunction function) => DepthFuncValue = function; public void BlendFuncSeparate( BlendingFactor sourceRgb, BlendingFactor destinationRgb, BlendingFactor sourceAlpha, BlendingFactor destinationAlpha) { BlendSourceRgb = sourceRgb; BlendDestinationRgb = destinationRgb; BlendSourceAlpha = sourceAlpha; BlendDestinationAlpha = destinationAlpha; } public void CullFace(TriangleFace face) => CullFaceMode = face; public void FrontFace(FrontFaceDirection direction) => FrontFaceDirection = direction; 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."); } }