From 11501d52cad942850d88f83490710faf1d5cdf28 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Jul 2026 12:26:58 +0200 Subject: [PATCH] feat(linux): gate graphical backends and capabilities --- .github/workflows/headless-portability.yml | 35 +- .../Platform/GraphicalCapabilityRecord.cs | 525 ++++++++++++++++++ .../Platform/GraphicalGlFunctionProbe.cs | 513 +++++++++++++++++ .../Platform/GraphicalHostPlatformServices.cs | 7 + .../GraphicalWindowBackendSelection.cs | 189 +++++++ src/AcDream.App/Program.cs | 23 +- src/AcDream.App/Rendering/GameWindow.cs | 22 + .../Studio/StudioFrameCloseGate.cs | 30 + src/AcDream.App/Studio/StudioOptions.cs | 25 +- src/AcDream.App/Studio/StudioWindow.cs | 183 +++++- .../GraphicalCapabilityRequirementsTests.cs | 261 +++++++++ .../GraphicalWindowBackendSelectionTests.cs | 109 ++++ .../Studio/StudioWindowTests.cs | 34 ++ 13 files changed, 1924 insertions(+), 32 deletions(-) create mode 100644 src/AcDream.App/Platform/GraphicalCapabilityRecord.cs create mode 100644 src/AcDream.App/Platform/GraphicalGlFunctionProbe.cs create mode 100644 src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs create mode 100644 src/AcDream.App/Studio/StudioFrameCloseGate.cs create mode 100644 tests/AcDream.App.Tests/Platform/GraphicalCapabilityRequirementsTests.cs create mode 100644 tests/AcDream.App.Tests/Platform/GraphicalWindowBackendSelectionTests.cs diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index bd039501..6d3d2b0a 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -63,6 +63,16 @@ jobs: with: dotnet-version: "10.0.x" + - name: Install Linux graphical smoke dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libgl1-mesa-dri \ + mesa-utils \ + xauth \ + xvfb + - name: Build presentation-free closure shell: pwsh run: | @@ -140,7 +150,7 @@ jobs: dotnet test ` tests/AcDream.App.Tests/AcDream.App.Tests.csproj ` -c Release ` - --filter "FullyQualifiedName~LinuxMonotonicFramePacingWaiterTests|FullyQualifiedName~LinuxPlatformBoundaryTests|FullyQualifiedName~GraphicalHostPlatformServicesTests|FullyQualifiedName~GraphicalLegacyConfigurationMigratorTests" + --filter "FullyQualifiedName~LinuxMonotonicFramePacingWaiterTests|FullyQualifiedName~LinuxPlatformBoundaryTests|FullyQualifiedName~GraphicalHostPlatformServicesTests|FullyQualifiedName~GraphicalLegacyConfigurationMigratorTests|FullyQualifiedName~GraphicalWindowBackendSelectionTests|FullyQualifiedName~GraphicalCapabilityRequirementsTests|FullyQualifiedName~StudioWindowTests" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Verify Linux package contract @@ -169,3 +179,26 @@ jobs: src/AcDream.App \ --exclude='FramePacingWaiterFactory.cs' \ --exclude='WindowsHighResolutionFramePacingWaiter.cs' + + - name: Verify actionable unsupported-driver gate + shell: bash + run: | + set -euo pipefail + root=artifacts/acdream-linux-x64 + report=artifacts/linux-x11-capabilities.json + set +e + ACDREAM_DISPLAY_PROTOCOL=x11 \ + xvfb-run -a "$root/AcDream.App" \ + ui-studio /tmp/not-needed \ + --mockup \ + --capability-report "$report" + code=$? + set -e + + test "$code" -eq 4 + test "$(jq -r '.ActiveDisplayProtocol' "$report")" = X11 + test "$(jq -r '.Lifecycle.ShutdownComplete' "$report")" = true + test "$(jq -r '.Lifecycle.OwnedWindowCount' "$report")" -eq 0 + test "$(jq -r '.Lifecycle.OwnedGlApiCount' "$report")" -eq 0 + test "$(jq -r '.Lifecycle.OwnedInputContextCount' "$report")" -eq 0 + jq -e '.SupportFailures | length > 0' "$report" diff --git a/src/AcDream.App/Platform/GraphicalCapabilityRecord.cs b/src/AcDream.App/Platform/GraphicalCapabilityRecord.cs new file mode 100644 index 00000000..7a5e3f2d --- /dev/null +++ b/src/AcDream.App/Platform/GraphicalCapabilityRecord.cs @@ -0,0 +1,525 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.App.Rendering; +using Silk.NET.Input; +using Silk.NET.GLFW; +using Silk.NET.OpenGL; +using Silk.NET.Windowing; + +namespace AcDream.App.Platform; + +internal sealed record GraphicalFramebufferCapabilities( + int RedBits, + int GreenBits, + int BlueBits, + int AlphaBits, + int DepthBits, + int StencilBits, + int SampleBuffers, + int Samples, + bool FramebufferSrgbApi); + +internal sealed record GraphicalInputCapabilities( + int KeyboardCount, + int MouseCount, + int GamepadCount, + int JoystickCount); + +internal sealed record GraphicalWindowCapabilities( + int LogicalWidth, + int LogicalHeight, + int FramebufferWidth, + int FramebufferHeight, + string MonitorName, + double RefreshRateHz, + bool VSync); + +internal sealed record GraphicalAudioCapabilities( + bool Requested, + bool Available, + bool PlaybackSubmitted, + bool DisposalComplete, + string Backend); + +internal sealed record GraphicalSmokeLifecycleCapabilities( + int OwnedWindowCount, + int OwnedGlApiCount, + int OwnedInputContextCount, + int OwnedAudioEngineCount, + bool ShutdownComplete); + +internal sealed record GraphicalFunctionProbeResult( + bool BindlessTexture, + bool ShaderDrawParameters, + bool MultiDrawIndirect, + bool ShaderStorageBuffer, + bool TimerQuery, + bool SrgbFramebuffer, + bool? PersistentBufferStorage, + IReadOnlyList Failures) +{ + internal static GraphicalFunctionProbeResult NotRun { get; } = new( + false, + false, + false, + false, + false, + false, + null, + ["active OpenGL function probe did not run"]); +} + +internal sealed record GraphicalCapabilityRecord( + DateTimeOffset CapturedAtUtc, + string RuntimeIdentifier, + GraphicalHostOperatingSystem OperatingSystem, + GraphicalDisplayProtocol RequestedDisplayProtocol, + GraphicalDisplayProtocol ActiveDisplayProtocol, + string DisplaySelectionReason, + string WindowBackend, + string WindowBackendVersion, + string GlVendor, + string GlRenderer, + string GlVersion, + string GlslVersion, + int GlMajorVersion, + int GlMinorVersion, + int ContextProfileMask, + int ContextFlags, + bool HasBindlessTexture, + bool HasShaderDrawParameters, + bool HasMultiDrawIndirect, + bool HasShaderStorageBuffer, + bool HasBufferStorage, + bool HasTimerQuery, + int MaximumShaderStorageBufferBindings, + int MaximumUniformBufferBindings, + int MaximumTextureSize, + int MaximumArrayTextureLayers, + int MaximumCombinedTextureImageUnits, + GraphicalFramebufferCapabilities Framebuffer, + GraphicalInputCapabilities Input, + GraphicalWindowCapabilities Window, + GraphicalAudioCapabilities Audio, + GraphicalSmokeLifecycleCapabilities Lifecycle, + IReadOnlyList Extensions, + GraphicalFunctionProbeResult FunctionProbe, + IReadOnlyList SupportFailures) +{ + internal bool IsSupported => SupportFailures.Count == 0; +} + +internal static class GraphicalCapabilityRequirements +{ + internal static IReadOnlyList Evaluate( + GraphicalCapabilityRecord capabilities) + { + ArgumentNullException.ThrowIfNull(capabilities); + var failures = new List(); + + if (capabilities.GlMajorVersion < 4 + || capabilities.GlMajorVersion == 4 + && capabilities.GlMinorVersion < 3) + { + failures.Add( + "OpenGL 4.3 core is required; the active context reports " + + $"{capabilities.GlMajorVersion}.{capabilities.GlMinorVersion}."); + } + if (!capabilities.HasBindlessTexture) + failures.Add("GL_ARB_bindless_texture is required."); + if (!capabilities.HasShaderDrawParameters) + failures.Add("GL_ARB_shader_draw_parameters is required."); + if (!capabilities.HasMultiDrawIndirect) + failures.Add("multi-draw indirect support is required."); + if (!capabilities.HasShaderStorageBuffer) + failures.Add("shader-storage buffers are required."); + if (!capabilities.HasTimerQuery) + failures.Add("OpenGL timer queries are required."); + if (capabilities.Framebuffer.DepthBits < 24) + { + failures.Add( + "the default framebuffer must provide at least 24 depth bits."); + } + if (capabilities.Framebuffer.StencilBits < 8) + { + failures.Add( + "the default framebuffer must provide at least 8 stencil bits."); + } + if (!capabilities.Framebuffer.FramebufferSrgbApi) + failures.Add("framebuffer sRGB support is required."); + if (capabilities.Input.KeyboardCount < 1) + failures.Add("the graphical input backend exposed no keyboard."); + if (capabilities.Input.MouseCount < 1) + failures.Add("the graphical input backend exposed no mouse."); + + if (capabilities.FunctionProbe.Failures.Count != 0) + { + failures.AddRange( + capabilities.FunctionProbe.Failures.Select( + failure => $"OpenGL function probe: {failure}")); + } + else + { + if (!capabilities.FunctionProbe.BindlessTexture) + failures.Add("the bindless texture call probe did not pass."); + if (!capabilities.FunctionProbe.ShaderDrawParameters) + { + failures.Add( + "the shader draw-parameters compile probe did not pass."); + } + if (!capabilities.FunctionProbe.MultiDrawIndirect) + failures.Add("the multi-draw indirect call probe did not pass."); + if (!capabilities.FunctionProbe.ShaderStorageBuffer) + failures.Add("the shader-storage buffer call probe did not pass."); + if (!capabilities.FunctionProbe.TimerQuery) + failures.Add("the timer-query call probe did not pass."); + if (!capabilities.FunctionProbe.SrgbFramebuffer) + failures.Add("the sRGB framebuffer call probe did not pass."); + if (capabilities.HasBufferStorage + && capabilities.FunctionProbe.PersistentBufferStorage != true) + { + failures.Add( + "GL_ARB_buffer_storage was advertised but the persistent " + + "mapping call probe did not pass."); + } + } + + return failures; + } +} + +internal static class GraphicalCapabilityProbe +{ + private const int GlSampleBuffers = 0x80A8; + private const int GlSamples = 0x80A9; + private const int GlMaxShaderStorageBufferBindings = 0x90DD; + private const int GlMaxUniformBufferBindings = 0x8A2F; + private const int GlMaxCombinedTextureImageUnits = 0x8B4D; + + internal static GraphicalCapabilityRecord Capture( + GL gl, + IWindow window, + IInputContext input, + GraphicalHostPlatformServices platform) + { + ArgumentNullException.ThrowIfNull(gl); + ArgumentNullException.ThrowIfNull(window); + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(platform); + + int major = gl.GetInteger(GetPName.MajorVersion); + int minor = gl.GetInteger(GetPName.MinorVersion); + bool openGl43 = major > 4 || major == 4 && minor >= 3; + bool timerQuery = + major > 3 + || major == 3 && minor >= 3 + || gl.IsExtensionPresent("GL_ARB_timer_query"); + bool framebufferSrgb = + major > 3 + || major == 3 && minor >= 0 + || gl.IsExtensionPresent("GL_ARB_framebuffer_sRGB") + || gl.IsExtensionPresent("GL_EXT_framebuffer_sRGB"); + + IReadOnlyList extensions = ReadExtensions(gl); + IMonitor? monitor = window.Monitor; + Silk.NET.Windowing.VideoMode mode = + monitor?.VideoMode ?? Silk.NET.Windowing.VideoMode.Default; + var framebuffer = window.FramebufferSize; + var logical = window.Size; + + var captured = new GraphicalCapabilityRecord( + DateTimeOffset.UtcNow, + platform.RuntimeIdentifier, + platform.OperatingSystem, + platform.WindowBackend.RequestedProtocol, + GlfwNativePlatformProbe.GetActiveProtocol( + platform.OperatingSystem), + platform.WindowBackend.Reason, + "Silk.NET.Windowing.Glfw", + GlfwNativePlatformProbe.GetVersion( + platform.OperatingSystem), + gl.GetStringS(GLEnum.Vendor), + gl.GetStringS(GLEnum.Renderer), + gl.GetStringS(GLEnum.Version), + gl.GetStringS(GLEnum.ShadingLanguageVersion), + major, + minor, + GetInteger(gl, (GetPName)GLEnum.ContextProfileMask), + GetInteger(gl, (GetPName)GLEnum.ContextFlags), + gl.IsExtensionPresent("GL_ARB_bindless_texture"), + gl.IsExtensionPresent("GL_ARB_shader_draw_parameters"), + openGl43 + || gl.IsExtensionPresent("GL_ARB_multi_draw_indirect"), + openGl43 + || gl.IsExtensionPresent("GL_ARB_shader_storage_buffer_object"), + major > 4 + || major == 4 && minor >= 4 + || gl.IsExtensionPresent("GL_ARB_buffer_storage"), + timerQuery, + GetInteger( + gl, + (GetPName)GlMaxShaderStorageBufferBindings), + GetInteger(gl, (GetPName)GlMaxUniformBufferBindings), + GetInteger(gl, GetPName.MaxTextureSize), + GetInteger(gl, GetPName.MaxArrayTextureLayers), + GetInteger( + gl, + (GetPName)GlMaxCombinedTextureImageUnits), + new GraphicalFramebufferCapabilities( + GetFramebufferAttachmentInteger( + gl, + (FramebufferAttachment)GLEnum.BackLeft, + FramebufferAttachmentParameterName.RedSize), + GetFramebufferAttachmentInteger( + gl, + (FramebufferAttachment)GLEnum.BackLeft, + FramebufferAttachmentParameterName.GreenSize), + GetFramebufferAttachmentInteger( + gl, + (FramebufferAttachment)GLEnum.BackLeft, + FramebufferAttachmentParameterName.BlueSize), + GetFramebufferAttachmentInteger( + gl, + (FramebufferAttachment)GLEnum.BackLeft, + FramebufferAttachmentParameterName.AlphaSize), + GetFramebufferAttachmentInteger( + gl, + (FramebufferAttachment)GLEnum.Depth, + FramebufferAttachmentParameterName.DepthSize), + GetFramebufferAttachmentInteger( + gl, + (FramebufferAttachment)GLEnum.Stencil, + FramebufferAttachmentParameterName.StencilSize), + GetInteger(gl, (GetPName)GlSampleBuffers), + GetInteger(gl, (GetPName)GlSamples), + framebufferSrgb), + new GraphicalInputCapabilities( + input.Keyboards.Count, + input.Mice.Count, + input.Gamepads.Count, + input.Joysticks.Count), + new GraphicalWindowCapabilities( + logical.X, + logical.Y, + framebuffer.X, + framebuffer.Y, + monitor?.Name ?? "unknown", + mode.RefreshRate ?? 0, + window.VSync), + new GraphicalAudioCapabilities( + Requested: false, + Available: false, + PlaybackSubmitted: false, + DisposalComplete: true, + Backend: "not requested"), + new GraphicalSmokeLifecycleCapabilities( + OwnedWindowCount: 1, + OwnedGlApiCount: 1, + OwnedInputContextCount: 1, + OwnedAudioEngineCount: 0, + ShutdownComplete: false), + extensions, + GraphicalFunctionProbeResult.NotRun, + []); + return captured with + { + SupportFailures = + GraphicalCapabilityRequirements.Evaluate(captured), + }; + } + + internal static GraphicalCapabilityRecord WithFunctionProbe( + GraphicalCapabilityRecord capabilities, + GraphicalFunctionProbeResult functionProbe) + { + ArgumentNullException.ThrowIfNull(capabilities); + ArgumentNullException.ThrowIfNull(functionProbe); + GraphicalCapabilityRecord updated = capabilities with + { + FunctionProbe = functionProbe, + SupportFailures = [], + }; + return updated with + { + SupportFailures = + GraphicalCapabilityRequirements.Evaluate(updated), + }; + } + + private static int GetInteger(GL gl, GetPName name) + { + return GlResourceCommand.Execute( + gl, + $"query OpenGL integer 0x{(uint)name:X}", + () => + { + gl.GetInteger(name, out int value); + return value; + }); + } + + private static int GetFramebufferAttachmentInteger( + GL gl, + FramebufferAttachment attachment, + FramebufferAttachmentParameterName name) + { + return GlResourceCommand.Execute( + gl, + $"query default framebuffer attachment {attachment}/{name}", + () => + { + gl.GetFramebufferAttachmentParameter( + FramebufferTarget.Framebuffer, + attachment, + name, + out int value); + return value; + }); + } + + private static IReadOnlyList ReadExtensions(GL gl) + { + int count = GetInteger(gl, GetPName.NumExtensions); + var extensions = new string[Math.Max(0, count)]; + for (uint index = 0; index < extensions.Length; index++) + extensions[index] = gl.GetStringS(GLEnum.Extensions, index); + Array.Sort(extensions, StringComparer.Ordinal); + return extensions; + } +} + +internal static class GraphicalCapabilityGuard +{ + internal static GraphicalCapabilityRecord CaptureVerifyAndWrite( + GL gl, + IWindow window, + IInputContext input, + GraphicalHostPlatformServices platform, + string reportPath) + { + GraphicalCapabilityRecord passive = + GraphicalCapabilityProbe.Capture( + gl, + window, + input, + platform); + GraphicalFunctionProbeResult functions = + GraphicalGlFunctionProbe.Run(gl, passive); + GraphicalCapabilityRecord verified = + GraphicalCapabilityProbe.WithFunctionProbe( + passive, + functions); + GraphicalCapabilityReportWriter.Write(reportPath, verified); + + return verified; + } + + internal static void ThrowIfUnsupported( + GraphicalCapabilityRecord capabilities, + string reportPath) + { + ArgumentNullException.ThrowIfNull(capabilities); + if (!capabilities.IsSupported) + { + throw new NotSupportedException( + FormatUnsupportedMessage(capabilities, reportPath)); + } + } + + internal static string FormatUnsupportedMessage( + GraphicalCapabilityRecord capabilities, + string reportPath) + { + ArgumentNullException.ThrowIfNull(capabilities); + ArgumentException.ThrowIfNullOrWhiteSpace(reportPath); + return + "acdream's mandatory modern renderer is unsupported by the " + + "active graphical backend.\n" + + $"Platform: {capabilities.RuntimeIdentifier}, " + + $"{capabilities.ActiveDisplayProtocol}, " + + $"{capabilities.GlVendor} / {capabilities.GlRenderer}, " + + $"{capabilities.GlVersion}\n" + + string.Join( + "\n", + capabilities.SupportFailures.Select( + failure => $" - {failure}")) + + $"\nFull capability report: {Path.GetFullPath(reportPath)}"; + } +} + +internal static class GraphicalCapabilityReportWriter +{ + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + Converters = + { + new JsonStringEnumConverter(), + }, + }; + + internal static void Write( + string path, + GraphicalCapabilityRecord capabilities) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(capabilities); + string fullPath = Path.GetFullPath(path); + string? directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + string temporaryPath = fullPath + ".tmp"; + File.WriteAllText( + temporaryPath, + JsonSerializer.Serialize(capabilities, Options)); + File.Move(temporaryPath, fullPath, overwrite: true); + } +} + +internal static unsafe class GlfwNativePlatformProbe +{ + private const int GlfwWin32Platform = 0x00060001; + private const int GlfwWaylandPlatform = 0x00060003; + private const int GlfwX11Platform = 0x00060004; + + internal static GraphicalDisplayProtocol GetActiveProtocol( + GraphicalHostOperatingSystem operatingSystem) + { + if (operatingSystem == GraphicalHostOperatingSystem.Windows) + return GraphicalDisplayProtocol.Windows; + + if (!GraphicalWindowBackendConfigurator.TryGetConfiguredApi( + out Glfw? glfw) + || glfw is null + || !glfw.Context.TryGetProcAddress( + "glfwGetPlatform", + out nint export)) + { + return GraphicalDisplayProtocol.Unknown; + } + + int platform = + ((delegate* unmanaged[Cdecl])export)(); + return platform switch + { + GlfwX11Platform => GraphicalDisplayProtocol.X11, + GlfwWaylandPlatform => GraphicalDisplayProtocol.Wayland, + GlfwWin32Platform => GraphicalDisplayProtocol.Windows, + _ => GraphicalDisplayProtocol.Unknown, + }; + } + + internal static string GetVersion( + GraphicalHostOperatingSystem operatingSystem) + { + if (!GraphicalWindowBackendConfigurator.TryGetConfiguredApi( + out Glfw? glfw) + || glfw is null) + { + return "unknown"; + } + + return glfw.GetVersionString() ?? "unknown"; + } +} diff --git a/src/AcDream.App/Platform/GraphicalGlFunctionProbe.cs b/src/AcDream.App/Platform/GraphicalGlFunctionProbe.cs new file mode 100644 index 00000000..c9f5a95a --- /dev/null +++ b/src/AcDream.App/Platform/GraphicalGlFunctionProbe.cs @@ -0,0 +1,513 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Platform; + +/// +/// Executes one minimal, ownership-balanced call path for every OpenGL feature +/// the mandatory renderer relies upon. Advertisement alone is insufficient: +/// several Linux driver failures present an extension string while returning +/// a missing entry point, invalid bindless handle, or unusable framebuffer. +/// +internal static unsafe class GraphicalGlFunctionProbe +{ + private const string VertexSource = """ + #version 430 core + #extension GL_ARB_shader_draw_parameters : require + void main() + { + float identity = + float(gl_DrawIDARB + gl_BaseInstanceARB) * 0.0; + gl_Position = vec4(identity, 0.0, 0.0, 1.0); + } + """; + + private const string FragmentSource = """ + #version 430 core + layout(location = 0) out vec4 outColor; + void main() + { + outColor = vec4(1.0); + } + """; + + internal static GraphicalFunctionProbeResult Run( + GL gl, + GraphicalCapabilityRecord capabilities) + { + ArgumentNullException.ThrowIfNull(gl); + ArgumentNullException.ThrowIfNull(capabilities); + var failures = new List(); + + bool bindless = capabilities.HasBindlessTexture + && Attempt( + "bindless texture handle/residency", + () => ProbeBindless(gl), + failures); + + bool shaderDraw = capabilities.HasShaderDrawParameters + && Attempt( + "shader draw parameters and multi-draw indirect", + () => ProbeShaderDrawAndMultiDraw(gl), + failures); + bool multiDraw = shaderDraw + && capabilities.HasMultiDrawIndirect; + + bool shaderStorage = capabilities.HasShaderStorageBuffer + && Attempt( + "shader-storage buffer", + () => ProbeShaderStorageBuffer(gl), + failures); + + bool timer = capabilities.HasTimerQuery + && Attempt( + "timer query", + () => ProbeTimerQuery(gl), + failures); + + bool srgb = capabilities.Framebuffer.FramebufferSrgbApi + && Attempt( + "sRGB depth/stencil framebuffer", + () => ProbeSrgbFramebuffer(gl), + failures); + + bool? persistent = capabilities.HasBufferStorage + ? Attempt( + "persistent coherent buffer storage", + () => ProbePersistentBufferStorage(gl), + failures) + : null; + + if (!capabilities.HasBindlessTexture) + failures.Add("GL_ARB_bindless_texture was not advertised."); + if (!capabilities.HasShaderDrawParameters) + failures.Add("GL_ARB_shader_draw_parameters was not advertised."); + if (!capabilities.HasMultiDrawIndirect) + failures.Add("multi-draw indirect was not advertised."); + if (!capabilities.HasShaderStorageBuffer) + failures.Add("shader-storage buffers were not advertised."); + if (!capabilities.HasTimerQuery) + failures.Add("timer queries were not advertised."); + if (!capabilities.Framebuffer.FramebufferSrgbApi) + failures.Add("framebuffer sRGB was not advertised."); + + return new GraphicalFunctionProbeResult( + bindless, + shaderDraw, + multiDraw, + shaderStorage, + timer, + srgb, + persistent, + failures); + } + + private static bool Attempt( + string name, + Action action, + List failures) + { + try + { + action(); + return true; + } + catch (Exception error) + { + failures.Add($"{name}: {error.GetType().Name}: {error.Message}"); + return false; + } + } + + private static void ProbeBindless(GL gl) + { + if (!BindlessSupport.TryCreate(gl, out BindlessSupport? bindless) + || bindless is null) + { + throw new NotSupportedException( + "the ARB bindless extension object could not be loaded."); + } + + uint texture = GlResourceCommand.CreateTexture( + gl, + "graphical capability bindless texture"); + ulong handle = 0; + try + { + byte* pixel = stackalloc byte[4] { 255, 255, 255, 255 }; + GlResourceCommand.Execute( + gl, + "initialize graphical capability bindless texture", + () => + { + gl.BindTexture(TextureTarget.Texture2D, texture); + gl.TexImage2D( + TextureTarget.Texture2D, + 0, + InternalFormat.Rgba8, + 1, + 1, + 0, + PixelFormat.Rgba, + PixelType.UnsignedByte, + pixel); + gl.TexParameter( + TextureTarget.Texture2D, + TextureParameterName.TextureMinFilter, + (int)TextureMinFilter.Nearest); + gl.TexParameter( + TextureTarget.Texture2D, + TextureParameterName.TextureMagFilter, + (int)TextureMagFilter.Nearest); + }); + handle = bindless.GetResidentHandle(texture); + bindless.MakeNonResident(handle); + handle = 0; + } + finally + { + if (handle != 0) + bindless.MakeNonResident(handle); + gl.BindTexture(TextureTarget.Texture2D, 0); + GlResourceCommand.DeleteTexture( + gl, + texture, + "delete graphical capability bindless texture"); + } + } + + private static void ProbeShaderDrawAndMultiDraw(GL gl) + { + uint program = ShaderProgramConstruction.Build( + new GlShaderProgramBuildApi(gl), + VertexSource, + FragmentSource); + uint vertexArray = 0; + uint elementBuffer = 0; + uint indirectBuffer = 0; + try + { + vertexArray = GlResourceCommand.CreateName( + gl, + "graphical capability vertex array", + gl.GenVertexArray, + gl.DeleteVertexArray); + elementBuffer = GlResourceCommand.CreateName( + gl, + "graphical capability element buffer", + gl.GenBuffer, + gl.DeleteBuffer); + indirectBuffer = GlResourceCommand.CreateName( + gl, + "graphical capability indirect buffer", + gl.GenBuffer, + gl.DeleteBuffer); + + uint* index = stackalloc uint[1] { 0 }; + uint* command = stackalloc uint[5] + { + 0, + 1, + 0, + 0, + 0, + }; + GLHelpers.ThrowOnResourceError( + gl, + "execute graphical capability multi-draw indirect call " + + "(precondition)"); + gl.UseProgram(program); + gl.BindVertexArray(vertexArray); + gl.BindBuffer( + BufferTargetARB.ElementArrayBuffer, + elementBuffer); + gl.BufferData( + BufferTargetARB.ElementArrayBuffer, + (nuint)sizeof(uint), + index, + BufferUsageARB.StaticDraw); + gl.BindBuffer( + BufferTargetARB.DrawIndirectBuffer, + indirectBuffer); + gl.BufferData( + BufferTargetARB.DrawIndirectBuffer, + (nuint)(sizeof(uint) * 5), + command, + BufferUsageARB.StaticDraw); + gl.MultiDrawElementsIndirect( + PrimitiveType.Triangles, + DrawElementsType.UnsignedInt, + null, + 1, + 0); + GLHelpers.ThrowOnResourceError( + gl, + "execute graphical capability multi-draw indirect call"); + } + finally + { + gl.UseProgram(0); + gl.BindVertexArray(0); + gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, 0); + if (indirectBuffer != 0) + { + GlResourceCommand.DeleteBuffer( + gl, + indirectBuffer, + "delete graphical capability indirect buffer"); + } + if (elementBuffer != 0) + { + GlResourceCommand.DeleteBuffer( + gl, + elementBuffer, + "delete graphical capability element buffer"); + } + if (vertexArray != 0) + { + GlResourceCommand.DeleteVertexArray( + gl, + vertexArray, + "delete graphical capability vertex array"); + } + GlResourceCommand.DeleteProgram( + gl, + program, + "delete graphical capability shader program"); + } + } + + private static void ProbeShaderStorageBuffer(GL gl) + { + uint buffer = GlResourceCommand.CreateName( + gl, + "graphical capability shader-storage buffer", + gl.GenBuffer, + gl.DeleteBuffer); + try + { + uint* value = stackalloc uint[1] { 0xACD0_0001u }; + GLHelpers.ThrowOnResourceError( + gl, + "bind graphical capability shader-storage buffer " + + "(precondition)"); + gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, buffer); + gl.BufferData( + BufferTargetARB.ShaderStorageBuffer, + (nuint)sizeof(uint), + value, + BufferUsageARB.StaticDraw); + gl.BindBufferBase( + BufferTargetARB.ShaderStorageBuffer, + 0, + buffer); + gl.BindBufferBase( + BufferTargetARB.ShaderStorageBuffer, + 0, + 0); + GLHelpers.ThrowOnResourceError( + gl, + "bind graphical capability shader-storage buffer"); + } + finally + { + gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, 0); + GlResourceCommand.DeleteBuffer( + gl, + buffer, + "delete graphical capability shader-storage buffer"); + } + } + + private static void ProbeTimerQuery(GL gl) + { + uint query = GlResourceCommand.CreateName( + gl, + "graphical capability timer query", + gl.GenQuery, + gl.DeleteQuery); + try + { + GlResourceCommand.Execute( + gl, + "execute graphical capability timer query", + () => + { + gl.BeginQuery(QueryTarget.TimeElapsed, query); + gl.EndQuery(QueryTarget.TimeElapsed); + }); + } + finally + { + GlResourceCommand.Execute( + gl, + "delete graphical capability timer query", + () => gl.DeleteQuery(query)); + } + } + + private static void ProbeSrgbFramebuffer(GL gl) + { + uint texture = 0; + uint depthStencil = 0; + uint framebuffer = 0; + try + { + texture = GlResourceCommand.CreateTexture( + gl, + "graphical capability sRGB color texture"); + depthStencil = GlResourceCommand.CreateName( + gl, + "graphical capability depth/stencil renderbuffer", + gl.GenRenderbuffer, + gl.DeleteRenderbuffer); + framebuffer = GlResourceCommand.CreateName( + gl, + "graphical capability framebuffer", + gl.GenFramebuffer, + gl.DeleteFramebuffer); + + GlResourceCommand.Execute( + gl, + "configure graphical capability sRGB framebuffer", + () => + { + gl.BindTexture(TextureTarget.Texture2D, texture); + gl.TexImage2D( + TextureTarget.Texture2D, + 0, + InternalFormat.Srgb8Alpha8, + 2, + 2, + 0, + PixelFormat.Rgba, + PixelType.UnsignedByte, + null); + + gl.BindRenderbuffer( + RenderbufferTarget.Renderbuffer, + depthStencil); + gl.RenderbufferStorage( + RenderbufferTarget.Renderbuffer, + InternalFormat.Depth24Stencil8, + 2, + 2); + + gl.BindFramebuffer( + FramebufferTarget.Framebuffer, + framebuffer); + gl.FramebufferTexture2D( + FramebufferTarget.Framebuffer, + FramebufferAttachment.ColorAttachment0, + TextureTarget.Texture2D, + texture, + 0); + gl.FramebufferRenderbuffer( + FramebufferTarget.Framebuffer, + FramebufferAttachment.DepthStencilAttachment, + RenderbufferTarget.Renderbuffer, + depthStencil); + if (gl.CheckFramebufferStatus( + FramebufferTarget.Framebuffer) + != GLEnum.FramebufferComplete) + { + throw new InvalidOperationException( + "the sRGB depth/stencil framebuffer is incomplete."); + } + + gl.Enable(EnableCap.FramebufferSrgb); + gl.Clear( + ClearBufferMask.ColorBufferBit + | ClearBufferMask.DepthBufferBit + | ClearBufferMask.StencilBufferBit); + gl.Disable(EnableCap.FramebufferSrgb); + }); + } + finally + { + gl.Disable(EnableCap.FramebufferSrgb); + gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0); + gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0); + gl.BindTexture(TextureTarget.Texture2D, 0); + if (framebuffer != 0) + { + GlResourceCommand.Execute( + gl, + "delete graphical capability framebuffer", + () => gl.DeleteFramebuffer(framebuffer)); + } + if (depthStencil != 0) + { + GlResourceCommand.Execute( + gl, + "delete graphical capability depth/stencil renderbuffer", + () => gl.DeleteRenderbuffer(depthStencil)); + } + if (texture != 0) + { + GlResourceCommand.DeleteTexture( + gl, + texture, + "delete graphical capability sRGB texture"); + } + } + } + + private static void ProbePersistentBufferStorage(GL gl) + { + uint buffer = GlResourceCommand.CreateName( + gl, + "graphical capability persistent buffer", + gl.GenBuffer, + gl.DeleteBuffer); + void* mapped = null; + try + { + GlResourceCommand.Execute( + gl, + "allocate graphical capability persistent buffer", + () => + { + gl.BindBuffer(BufferTargetARB.ArrayBuffer, buffer); + gl.BufferStorage( + GLEnum.ArrayBuffer, + 64, + null, + (uint)( + BufferStorageMask.MapWriteBit + | BufferStorageMask.MapPersistentBit + | BufferStorageMask.MapCoherentBit + | BufferStorageMask.DynamicStorageBit)); + mapped = gl.MapBufferRange( + BufferTargetARB.ArrayBuffer, + 0, + 64, + MapBufferAccessMask.WriteBit + | MapBufferAccessMask.PersistentBit + | MapBufferAccessMask.CoherentBit); + if (mapped is null) + { + throw new InvalidOperationException( + "persistent mapping returned a null pointer."); + } + *((byte*)mapped) = 0x5A; + }); + } + finally + { + if (mapped is not null) + { + GlResourceCommand.Execute( + gl, + "unmap graphical capability persistent buffer", + () => gl.UnmapBuffer(BufferTargetARB.ArrayBuffer)); + } + gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + GlResourceCommand.DeleteBuffer( + gl, + buffer, + "delete graphical capability persistent buffer"); + } + } +} diff --git a/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs b/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs index 9a301cc5..f498debf 100644 --- a/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs +++ b/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs @@ -18,6 +18,7 @@ internal sealed record GraphicalHostPlatformServices( GraphicalHostOperatingSystem OperatingSystem, Architecture ProcessArchitecture, string RuntimeIdentifier, + GraphicalWindowBackendSelection WindowBackend, ApplicationPathSet Paths, IFramePacingWaiterFactory FramePacingWaiters, IReadOnlyList NativeDependencies) @@ -35,11 +36,17 @@ internal sealed record GraphicalHostPlatformServices( operatingSystem, architecture, runtimeIdentifier, + GraphicalWindowBackendSelection.Resolve( + operatingSystem, + Environment.GetEnvironmentVariable), ApplicationPathSet.Resolve(), new PlatformFramePacingWaiterFactory(operatingSystem), ResolveNativeDependencies(operatingSystem)); } + internal void ConfigureWindowBackend() => + GraphicalWindowBackendConfigurator.Configure(this); + internal static GraphicalHostOperatingSystem DetectOperatingSystem() { if (System.OperatingSystem.IsWindows()) diff --git a/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs b/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs new file mode 100644 index 00000000..55931dce --- /dev/null +++ b/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs @@ -0,0 +1,189 @@ +using Silk.NET.GLFW; +using Silk.NET.Core.Loader; +using Silk.NET.Windowing.Glfw; + +namespace AcDream.App.Platform; + +internal enum GraphicalDisplayProtocol +{ + Unknown, + Windows, + X11, + Wayland, + Automatic, +} + +/// +/// Immutable process-start decision for Silk's GLFW 3.4 backend. Linux users +/// may force x11 or wayland with +/// ACDREAM_DISPLAY_PROTOCOL; otherwise GLFW selects from the available +/// native backends. The decision is applied once before any window exists. +/// +internal sealed record GraphicalWindowBackendSelection( + GraphicalDisplayProtocol RequestedProtocol, + string Reason) +{ + internal const string EnvironmentVariable = + "ACDREAM_DISPLAY_PROTOCOL"; + + internal static GraphicalWindowBackendSelection Resolve( + GraphicalHostOperatingSystem operatingSystem, + Func environment) + { + ArgumentNullException.ThrowIfNull(environment); + if (operatingSystem == GraphicalHostOperatingSystem.Windows) + { + return new( + GraphicalDisplayProtocol.Windows, + "Windows graphical host"); + } + + string? configured = environment(EnvironmentVariable); + if (!string.IsNullOrWhiteSpace(configured)) + { + return configured.Trim().ToLowerInvariant() switch + { + "auto" => new( + GraphicalDisplayProtocol.Automatic, + $"{EnvironmentVariable}=auto"), + "x11" => new( + GraphicalDisplayProtocol.X11, + $"{EnvironmentVariable}=x11"), + "wayland" => new( + GraphicalDisplayProtocol.Wayland, + $"{EnvironmentVariable}=wayland"), + _ => throw new InvalidOperationException( + $"{EnvironmentVariable} must be auto, x11, or wayland; " + + $"received '{configured}'."), + }; + } + + string? sessionType = environment("XDG_SESSION_TYPE"); + string? waylandDisplay = environment("WAYLAND_DISPLAY"); + if (string.Equals( + sessionType, + "wayland", + StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(waylandDisplay)) + { + return new( + GraphicalDisplayProtocol.Wayland, + "XDG_SESSION_TYPE=wayland and WAYLAND_DISPLAY is set"); + } + + string? xDisplay = environment("DISPLAY"); + if (!string.IsNullOrWhiteSpace(xDisplay)) + { + return new( + GraphicalDisplayProtocol.X11, + "DISPLAY is set"); + } + + if (!string.IsNullOrWhiteSpace(waylandDisplay)) + { + return new( + GraphicalDisplayProtocol.Wayland, + "WAYLAND_DISPLAY is set"); + } + + return new( + GraphicalDisplayProtocol.Automatic, + "no X11 or Wayland environment was detected"); + } +} + +/// +/// The only App-layer owner allowed to select a native GLFW platform. GLFW +/// requires the platform init hint before glfwInit; applying it after +/// is too late. +/// +internal static class GraphicalWindowBackendConfigurator +{ + private const int GlfwPlatformInitHint = 0x00050003; + private const int GlfwAnyPlatform = 0x00060000; + private const int GlfwWin32Platform = 0x00060001; + private const int GlfwWaylandPlatform = 0x00060003; + private const int GlfwX11Platform = 0x00060004; + + private static readonly object Gate = new(); + private static GraphicalDisplayProtocol? _configuredProtocol; + private static Glfw? _glfw; + + internal static void Configure( + GraphicalHostPlatformServices platform) + { + ArgumentNullException.ThrowIfNull(platform); + GraphicalDisplayProtocol requested = + platform.WindowBackend.RequestedProtocol; + + lock (Gate) + { + if (_configuredProtocol is { } existing) + { + if (existing != requested) + { + throw new InvalidOperationException( + "The GLFW platform is already configured as " + + $"{existing}; it cannot change to {requested} in " + + "the same process."); + } + return; + } + + PreferPublishedNativeLibraries(); + GlfwWindowing.Use(); + // Silk's windowing backend initializes this exact singleton. + // A separate Glfw.GetApi() instance would receive the hint but + // would not own the window backend's process-global GLFW state. + Glfw glfw = GlfwProvider.UninitializedGLFW.Value; + glfw.InitHint( + (InitHint)GlfwPlatformInitHint, + requested switch + { + GraphicalDisplayProtocol.Windows => + GlfwWin32Platform, + GraphicalDisplayProtocol.X11 => + GlfwX11Platform, + GraphicalDisplayProtocol.Wayland => + GlfwWaylandPlatform, + GraphicalDisplayProtocol.Automatic => + GlfwAnyPlatform, + _ => throw new ArgumentOutOfRangeException( + nameof(requested)), + }); + _glfw = glfw; + _configuredProtocol = requested; + } + } + + internal static bool TryGetConfiguredApi(out Glfw? glfw) + { + lock (Gate) + { + glfw = _glfw; + return glfw is not null; + } + } + + private static void PreferPublishedNativeLibraries() + { + if (PathResolver.Default is not DefaultPathResolver resolver) + { + throw new InvalidOperationException( + "Silk.NET's default native path resolver is unavailable."); + } + + // Silk 2.23 names its Linux GLFW ABI libglfw.so.3.3, while the + // packaged multi-backend GLFW 3.4 asset is libglfw.so.3. Move the + // application directory ahead of the system passthrough so Silk's + // version resolver reaches the packaged .so.3 before a distribution + // GLFW 3.3. This also makes every packaged native closure deterministic + // instead of silently preferring a machine-global library. + List>> resolvers = + resolver.Resolvers; + resolvers.Remove(DefaultPathResolver.BaseDirectoryResolver); + resolvers.Insert( + 0, + DefaultPathResolver.BaseDirectoryResolver); + } +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index b8205bb7..2c77e4d4 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -8,6 +8,7 @@ using Serilog; GraphicalHostPlatformServices graphicalPlatform = GraphicalHostPlatformServices.Resolve(); +graphicalPlatform.ConfigureWindowBackend(); ApplicationPathSet applicationPaths = graphicalPlatform.Paths; IReadOnlyList migratedConfigurationFiles = GraphicalLegacyConfigurationMigrator.Migrate(applicationPaths); @@ -18,8 +19,16 @@ if (args.Length >= 1 && args[0] == "ui-studio") using var sw = new AcDream.App.Studio.StudioWindow( so, applicationPaths); - sw.Run(); - return 0; + try + { + sw.Run(); + return 0; + } + catch (NotSupportedException error) + { + Console.Error.WriteLine(error.Message); + return 4; + } } Log.Logger = new LoggerConfiguration() @@ -138,7 +147,15 @@ try catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } } - window.Run(); + try + { + window.Run(); + } + catch (NotSupportedException error) + { + Log.Error("{GraphicalStartupFailure}", error.Message); + return 4; + } } finally { diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ae1922e0..70030b40 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -509,6 +509,7 @@ public sealed class GameWindow : private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings; private readonly GraphicalHostPlatformServices _platformServices; private readonly ApplicationPathSet _applicationPaths; + private GraphicalCapabilityRecord? _graphicalCapabilities; private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings( string path) @@ -670,6 +671,7 @@ public sealed class GameWindow : public void Run() { + _platformServices.ConfigureWindowBackend(); // The runtime-settings owner was constructed before Window.Create and // exposes the one immutable startup snapshot. MSAA is a context // attribute, so it must come from this same snapshot rather than a @@ -1163,6 +1165,26 @@ public sealed class GameWindow : // run narrow-phase BSP tests during FindObjCollisions. GameWindowPlatformResult platform = AcquirePlatform(); + string capabilityReportPath = Path.Combine( + _applicationPaths.DiagnosticsDirectory, + "graphical-capabilities.json"); + _graphicalCapabilities = + GraphicalCapabilityGuard.CaptureVerifyAndWrite( + platform.Graphics, + _window!, + platform.Input, + _platformServices, + capabilityReportPath); + GraphicalCapabilityGuard.ThrowIfUnsupported( + _graphicalCapabilities, + capabilityReportPath); + Console.WriteLine( + "graphics: capability gate passed " + + $"({_graphicalCapabilities.ActiveDisplayProtocol}, " + + $"{_graphicalCapabilities.GlVendor}, " + + $"{_graphicalCapabilities.GlRenderer}, " + + $"{_graphicalCapabilities.GlVersion}); " + + $"report={capabilityReportPath}"); GameWindowCompositionPipeline.Run< GameWindowPlatformResult, diff --git a/src/AcDream.App/Studio/StudioFrameCloseGate.cs b/src/AcDream.App/Studio/StudioFrameCloseGate.cs new file mode 100644 index 00000000..4450d3f4 --- /dev/null +++ b/src/AcDream.App/Studio/StudioFrameCloseGate.cs @@ -0,0 +1,30 @@ +namespace AcDream.App.Studio; + +/// +/// Defers a close requested by render work until the caller has completed the +/// active render-frame transaction. Silk dispatches Closing +/// synchronously, so closing directly from a render callback would otherwise +/// dispose GPU resources while submitted work is still owned by that frame. +/// +internal sealed class StudioFrameCloseGate +{ + private bool _requested; + + public bool IsRequested => _requested; + + public void Request() + { + _requested = true; + } + + public void CompleteFrame(Action close) + { + ArgumentNullException.ThrowIfNull(close); + + if (!_requested) + return; + + _requested = false; + close(); + } +} diff --git a/src/AcDream.App/Studio/StudioOptions.cs b/src/AcDream.App/Studio/StudioOptions.cs index f60e7fb4..8e2a3c2e 100644 --- a/src/AcDream.App/Studio/StudioOptions.cs +++ b/src/AcDream.App/Studio/StudioOptions.cs @@ -12,7 +12,9 @@ public sealed record StudioOptions( string? DumpSlug = null, string? DumpFile = null, string? ScreenshotPath = null, - bool Mockup = false) + bool Mockup = false, + string? CapabilityReportPath = null, + bool AudioSmoke = false) { /// /// Parse studio options from the args that come AFTER the ui-studio token. @@ -40,7 +42,9 @@ public sealed record StudioOptions( string? dumpSlug = null; string? dumpFile = null; string? screenshotPath = null; + string? capabilityReportPath = null; bool mockup = false; + bool audioSmoke = false; for (int i = 0; i < args.Length; i++) { @@ -69,6 +73,14 @@ public sealed record StudioOptions( { screenshotPath = args[++i]; } + else if (args[i] == "--capability-report" && i + 1 < args.Length) + { + capabilityReportPath = args[++i]; + } + else if (args[i] == "--audio-smoke") + { + audioSmoke = true; + } else if (args[i] == "--mockup") { mockup = true; @@ -90,7 +102,16 @@ public sealed record StudioOptions( if (!mockup && layoutId is null && markupPath is null && dumpSlug is null) layoutId = 0x2100006Cu; - return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile, screenshotPath, mockup); + return new StudioOptions( + datDir, + layoutId, + markupPath, + dumpSlug, + dumpFile, + screenshotPath, + mockup, + capabilityReportPath, + audioSmoke); } /// diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs index de30c36f..be43d853 100644 --- a/src/AcDream.App/Studio/StudioWindow.cs +++ b/src/AcDream.App/Studio/StudioWindow.cs @@ -1,5 +1,8 @@ using System.Numerics; using AcDream.Content; +using AcDream.App.Platform; +using AcDream.App.Audio; +using AcDream.Core.Audio; using AcDream.App.Rendering; using AcDream.App.UI; using AcDream.Runtime.Platform; @@ -38,9 +41,13 @@ public sealed class StudioWindow : IDisposable { private readonly StudioOptions _opts; private readonly ApplicationPathSet _applicationPaths; + private readonly GraphicalHostPlatformServices _platformServices; // Created in OnLoad, released in OnClosing. private IWindow? _window; + private GL? _gl; + private IInputContext? _input; + private OpenAlAudioEngine? _audio; private IDatReaderWriter? _dats; private RenderStack? _stack; private LayoutSource? _source; @@ -64,19 +71,39 @@ public sealed class StudioWindow : IDisposable // Headless screenshot mode: set when --screenshot was passed. // True after the first OnRender fires the screenshot (guard against repeat). private bool _screenshotDone; + private readonly StudioFrameCloseGate _frameCloseGate = new(); + private GraphicalCapabilityRecord? _capabilities; + private string? _capabilityReportPath; public StudioWindow(StudioOptions opts) - : this(opts, ApplicationPathSet.Resolve()) + : this( + opts, + GraphicalHostPlatformServices.Resolve()) { } internal StudioWindow( StudioOptions opts, ApplicationPathSet applicationPaths) + : this( + opts, + GraphicalHostPlatformServices.Resolve() with + { + Paths = applicationPaths + ?? throw new ArgumentNullException( + nameof(applicationPaths)), + }) + { + } + + internal StudioWindow( + StudioOptions opts, + GraphicalHostPlatformServices platformServices) { _opts = opts ?? throw new ArgumentNullException(nameof(opts)); - _applicationPaths = applicationPaths - ?? throw new ArgumentNullException(nameof(applicationPaths)); + _platformServices = platformServices + ?? throw new ArgumentNullException(nameof(platformServices)); + _applicationPaths = _platformServices.Paths; } /// @@ -85,6 +112,7 @@ public sealed class StudioWindow : IDisposable /// public void Run() { + _platformServices.ConfigureWindowBackend(); // Resolve quality settings the same way GameWindow.Run() does // (SettingsStore → QualitySettings.From → WithEnvOverrides). var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( @@ -122,7 +150,48 @@ public sealed class StudioWindow : IDisposable private void OnLoad() { - var gl = GL.GetApi(_window!); + _gl = GL.GetApi(_window!); + _input = _window!.CreateInput(); + _capabilityReportPath = Path.GetFullPath( + _opts.CapabilityReportPath + ?? Path.Combine( + _applicationPaths.DiagnosticsDirectory, + "graphical-capabilities.json")); + _capabilities = GraphicalCapabilityGuard.CaptureVerifyAndWrite( + _gl, + _window!, + _input, + _platformServices, + _capabilityReportPath); + GraphicalCapabilityGuard.ThrowIfUnsupported( + _capabilities, + _capabilityReportPath); + + if (_opts.AudioSmoke) + { + _audio = new OpenAlAudioEngine(); + bool playback = _audio.IsAvailable + && _audio.PlayUiWave( + 0xFFFF_FF01u, + CreateAudioSmokeWave(), + volume: 0.05f); + _capabilities = _capabilities with + { + Audio = new GraphicalAudioCapabilities( + Requested: true, + Available: _audio.IsAvailable, + PlaybackSubmitted: playback, + DisposalComplete: _audio.IsDisposalComplete, + Backend: "OpenAL Soft default device"), + Lifecycle = _capabilities.Lifecycle with + { + OwnedAudioEngineCount = 1, + }, + }; + GraphicalCapabilityReportWriter.Write( + _capabilityReportPath, + _capabilities); + } _dats = RuntimeDatCollectionFactory.OpenReadOnly(_opts.DatDir); @@ -135,7 +204,7 @@ public sealed class StudioWindow : IDisposable AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality)); _stack = RenderBootstrap.Create( - gl, + _gl, _dats, new RenderBootstrapOptions( quality, @@ -208,34 +277,63 @@ public sealed class StudioWindow : IDisposable // Task 3: ImGui IDE — interactive mode only. // Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped. - _panelFbo = new PanelFbo(gl); + _panelFbo = new PanelFbo(_gl); if (_opts.ScreenshotPath is null) { - var input = _window!.CreateInput(); // Mockup mode draws UiHost directly to the window, so raw Silk mouse coordinates // already match UI pixels. Inspector mode draws UiHost inside an ImGui canvas, so // mouse is forwarded manually from DrawCanvas with canvas-local remapping. if (_opts.Mockup) - foreach (var mouse in input.Mice) + foreach (var mouse in _input.Mice) _stack.UiHost.WireMouse(mouse); - foreach (var kb in input.Keyboards) + foreach (var kb in _input.Keyboards) _stack.UiHost.WireKeyboard(kb); if (_opts.Mockup) return; - _imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input); + _imgui = new AcDream.UI.ImGui.ImGuiBootstrapper( + _gl, + _window!, + _input); _inspector = new StudioInspector(); } } + private static WaveData CreateAudioSmokeWave() + { + const int sampleRate = 8_000; + const int sampleCount = 400; + var pcm = new byte[sampleCount * sizeof(short)]; + for (int i = 0; i < sampleCount; i++) + { + double phase = i * (2.0 * Math.PI * 440.0 / sampleRate); + short sample = (short)(Math.Sin(phase) * short.MaxValue * 0.08); + BitConverter.TryWriteBytes( + pcm.AsSpan(i * sizeof(short), sizeof(short)), + sample); + } + + return new WaveData + { + ChannelCount = 1, + SampleRate = sampleRate, + BitsPerSample = 16, + PcmBytes = pcm, + Duration = TimeSpan.FromSeconds( + sampleCount / (double)sampleRate), + }; + } + private void OnUpdate(double dt) { } private void OnRender(double dt) { if (_stack is null || _panelFbo is null) return; - _stack.BeginFrame(); + RenderStack frameStack = _stack; + frameStack.BeginFrame(); Exception? renderFailure = null; + bool frameClosed = false; try { @@ -270,7 +368,7 @@ public sealed class StudioWindow : IDisposable if (pixels.Length == 0) { Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels."); - _window?.Close(); + _frameCloseGate.Request(); return; } @@ -292,7 +390,7 @@ public sealed class StudioWindow : IDisposable img.SaveAsPng(path); Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})"); - _window?.Close(); + _frameCloseGate.Request(); return; } @@ -445,7 +543,8 @@ public sealed class StudioWindow : IDisposable { try { - _stack.EndFrame(); + frameStack.EndFrame(); + frameClosed = true; } catch (Exception closeFailure) when (renderFailure is not null) { @@ -454,6 +553,9 @@ public sealed class StudioWindow : IDisposable renderFailure, closeFailure); } + + if (frameClosed) + _frameCloseGate.CompleteFrame(() => _window?.Close()); } } @@ -512,19 +614,18 @@ public sealed class StudioWindow : IDisposable private void OnClosing() { - _fixtureController?.Dispose(); - _fixtureController = null; - _imgui?.Dispose(); - _panelFbo?.Dispose(); - _imgui = null; - _panelFbo = null; - _stack?.Dispose(); // whole render stack: dispatcher/mesh/textures/shader/ubo/uihost - _dats?.Dispose(); - _dats = null; - _stack = null; + ReleaseContextResources(); } public void Dispose() + { + ReleaseContextResources(); + _window?.Dispose(); + _window = null; + UpdateFinalCapabilityReport(); + } + + private void ReleaseContextResources() { _fixtureController?.Dispose(); _fixtureController = null; @@ -532,13 +633,43 @@ public sealed class StudioWindow : IDisposable _panelFbo?.Dispose(); _imgui = null; _panelFbo = null; - _window?.Dispose(); - _window = null; // If OnClosing wasn't called (e.g. an exception before Run() completed), dispose the FULL // stack anyway — the review flagged that disposing only UiHost here leaked the rest. _stack?.Dispose(); _dats?.Dispose(); + _audio?.Dispose(); + _input?.Dispose(); + _gl?.Dispose(); _dats = null; _stack = null; + _audio = null; + _input = null; + _gl = null; + } + + private void UpdateFinalCapabilityReport() + { + if (_capabilities is null + || string.IsNullOrWhiteSpace(_capabilityReportPath)) + { + return; + } + + _capabilities = _capabilities with + { + Audio = _capabilities.Audio with + { + DisposalComplete = true, + }, + Lifecycle = new GraphicalSmokeLifecycleCapabilities( + OwnedWindowCount: 0, + OwnedGlApiCount: 0, + OwnedInputContextCount: 0, + OwnedAudioEngineCount: 0, + ShutdownComplete: true), + }; + GraphicalCapabilityReportWriter.Write( + _capabilityReportPath, + _capabilities); } } diff --git a/tests/AcDream.App.Tests/Platform/GraphicalCapabilityRequirementsTests.cs b/tests/AcDream.App.Tests/Platform/GraphicalCapabilityRequirementsTests.cs new file mode 100644 index 00000000..741686e1 --- /dev/null +++ b/tests/AcDream.App.Tests/Platform/GraphicalCapabilityRequirementsTests.cs @@ -0,0 +1,261 @@ +using System.Text.Json; +using AcDream.App.Platform; + +namespace AcDream.App.Tests.Platform; + +public sealed class GraphicalCapabilityRequirementsTests +{ + [Fact] + public void SupportedModernContextPasses() + { + GraphicalCapabilityRecord capabilities = CreateSupported(); + + Assert.Empty(GraphicalCapabilityRequirements.Evaluate(capabilities)); + } + + [Theory] + [InlineData("bindless")] + [InlineData("draw-parameters")] + [InlineData("mdi")] + [InlineData("ssbo")] + [InlineData("timer")] + [InlineData("depth")] + [InlineData("stencil")] + [InlineData("srgb")] + [InlineData("keyboard")] + [InlineData("mouse")] + public void MissingMandatoryCapabilityIsRejected(string missing) + { + GraphicalCapabilityRecord capabilities = CreateSupported(); + capabilities = missing switch + { + "bindless" => capabilities with + { + HasBindlessTexture = false, + }, + "draw-parameters" => capabilities with + { + HasShaderDrawParameters = false, + }, + "mdi" => capabilities with + { + HasMultiDrawIndirect = false, + }, + "ssbo" => capabilities with + { + HasShaderStorageBuffer = false, + }, + "timer" => capabilities with + { + HasTimerQuery = false, + }, + "depth" => capabilities with + { + Framebuffer = capabilities.Framebuffer with + { + DepthBits = 16, + }, + }, + "stencil" => capabilities with + { + Framebuffer = capabilities.Framebuffer with + { + StencilBits = 0, + }, + }, + "srgb" => capabilities with + { + Framebuffer = capabilities.Framebuffer with + { + FramebufferSrgbApi = false, + }, + }, + "keyboard" => capabilities with + { + Input = capabilities.Input with + { + KeyboardCount = 0, + }, + }, + "mouse" => capabilities with + { + Input = capabilities.Input with + { + MouseCount = 0, + }, + }, + _ => throw new ArgumentOutOfRangeException(nameof(missing)), + }; + + Assert.NotEmpty(GraphicalCapabilityRequirements.Evaluate(capabilities)); + } + + [Fact] + public void AdvertisedBufferStorageRequiresSuccessfulPersistentProbe() + { + GraphicalCapabilityRecord capabilities = CreateSupported() with + { + FunctionProbe = CreateSupported().FunctionProbe with + { + PersistentBufferStorage = false, + }, + }; + + Assert.Contains( + GraphicalCapabilityRequirements.Evaluate(capabilities), + failure => failure.Contains( + "persistent mapping", + StringComparison.Ordinal)); + } + + [Fact] + public void MissingOptionalBufferStorageDoesNotRequireProbe() + { + GraphicalCapabilityRecord capabilities = CreateSupported() with + { + HasBufferStorage = false, + FunctionProbe = CreateSupported().FunctionProbe with + { + PersistentBufferStorage = null, + }, + }; + + Assert.Empty(GraphicalCapabilityRequirements.Evaluate(capabilities)); + } + + [Fact] + public void UnsupportedMessageNamesDriverProtocolFailureAndReport() + { + GraphicalCapabilityRecord capabilities = CreateSupported() with + { + SupportFailures = ["GL_ARB_bindless_texture is required."], + }; + + string message = GraphicalCapabilityGuard.FormatUnsupportedMessage( + capabilities, + "capabilities.json"); + + Assert.Contains("Mesa", message); + Assert.Contains("RadeonSI", message); + Assert.Contains("Wayland", message); + Assert.Contains("GL_ARB_bindless_texture", message); + Assert.Contains( + Path.GetFullPath("capabilities.json"), + message); + } + + [Fact] + public void ReportWriterAtomicallyOverwritesJson() + { + string directory = Path.Combine( + Path.GetTempPath(), + "acdream-capability-tests", + Guid.NewGuid().ToString("N")); + string path = Path.Combine(directory, "capabilities.json"); + try + { + GraphicalCapabilityReportWriter.Write(path, CreateSupported()); + GraphicalCapabilityReportWriter.Write( + path, + CreateSupported() with + { + GlRenderer = "second renderer", + }); + + using JsonDocument report = JsonDocument.Parse( + File.ReadAllText(path)); + Assert.Equal( + "second renderer", + report.RootElement + .GetProperty(nameof(GraphicalCapabilityRecord.GlRenderer)) + .GetString()); + Assert.Equal( + "Wayland", + report.RootElement + .GetProperty(nameof( + GraphicalCapabilityRecord.ActiveDisplayProtocol)) + .GetString()); + Assert.False(File.Exists(path + ".tmp")); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + } + + private static GraphicalCapabilityRecord CreateSupported() => new( + DateTimeOffset.UnixEpoch, + "linux-x64", + GraphicalHostOperatingSystem.Linux, + GraphicalDisplayProtocol.Wayland, + GraphicalDisplayProtocol.Wayland, + "test", + "Silk.NET.Windowing.Glfw", + "3.4.0", + "Mesa", + "RadeonSI", + "4.6", + "4.60", + 4, + 6, + 1, + 1, + HasBindlessTexture: true, + HasShaderDrawParameters: true, + HasMultiDrawIndirect: true, + HasShaderStorageBuffer: true, + HasBufferStorage: true, + HasTimerQuery: true, + MaximumShaderStorageBufferBindings: 16, + MaximumUniformBufferBindings: 72, + MaximumTextureSize: 16_384, + MaximumArrayTextureLayers: 2_048, + MaximumCombinedTextureImageUnits: 192, + new GraphicalFramebufferCapabilities( + 8, + 8, + 8, + 8, + 24, + 8, + 1, + 4, + FramebufferSrgbApi: true), + new GraphicalInputCapabilities( + KeyboardCount: 1, + MouseCount: 1, + GamepadCount: 0, + JoystickCount: 0), + new GraphicalWindowCapabilities( + 1280, + 720, + 2560, + 1440, + "test monitor", + 144, + VSync: false), + new GraphicalAudioCapabilities( + Requested: false, + Available: false, + PlaybackSubmitted: false, + DisposalComplete: true, + Backend: "not requested"), + new GraphicalSmokeLifecycleCapabilities( + OwnedWindowCount: 1, + OwnedGlApiCount: 1, + OwnedInputContextCount: 1, + OwnedAudioEngineCount: 0, + ShutdownComplete: false), + [], + new GraphicalFunctionProbeResult( + BindlessTexture: true, + ShaderDrawParameters: true, + MultiDrawIndirect: true, + ShaderStorageBuffer: true, + TimerQuery: true, + SrgbFramebuffer: true, + PersistentBufferStorage: true, + Failures: []), + SupportFailures: []); +} diff --git a/tests/AcDream.App.Tests/Platform/GraphicalWindowBackendSelectionTests.cs b/tests/AcDream.App.Tests/Platform/GraphicalWindowBackendSelectionTests.cs new file mode 100644 index 00000000..5d71378f --- /dev/null +++ b/tests/AcDream.App.Tests/Platform/GraphicalWindowBackendSelectionTests.cs @@ -0,0 +1,109 @@ +using AcDream.App.Platform; + +namespace AcDream.App.Tests.Platform; + +public sealed class GraphicalWindowBackendSelectionTests +{ + [Fact] + public void WindowsAlwaysSelectsWin32() + { + GraphicalWindowBackendSelection selection = + GraphicalWindowBackendSelection.Resolve( + GraphicalHostOperatingSystem.Windows, + _ => "wayland"); + + Assert.Equal( + GraphicalDisplayProtocol.Windows, + selection.RequestedProtocol); + } + + [Theory] + [InlineData("auto", "Automatic")] + [InlineData("x11", "X11")] + [InlineData("wayland", "Wayland")] + [InlineData(" WAYLAND ", "Wayland")] + public void ExplicitLinuxProtocolWins( + string configured, + string expected) + { + var environment = Environment( + (GraphicalWindowBackendSelection.EnvironmentVariable, configured), + ("XDG_SESSION_TYPE", "x11"), + ("DISPLAY", ":0")); + + GraphicalWindowBackendSelection selection = + GraphicalWindowBackendSelection.Resolve( + GraphicalHostOperatingSystem.Linux, + environment); + + Assert.Equal(expected, selection.RequestedProtocol.ToString()); + } + + [Fact] + public void InvalidExplicitLinuxProtocolIsActionable() + { + var error = Assert.Throws( + () => GraphicalWindowBackendSelection.Resolve( + GraphicalHostOperatingSystem.Linux, + Environment( + (GraphicalWindowBackendSelection.EnvironmentVariable, + "mir")))); + + Assert.Contains("auto, x11, or wayland", error.Message); + Assert.Contains("mir", error.Message); + } + + [Theory] + [MemberData(nameof(AutomaticLinuxCases))] + public void LinuxEnvironmentSelectsExpectedProtocol( + (string Name, string? Value)[] variables, + string expected) + { + GraphicalWindowBackendSelection selection = + GraphicalWindowBackendSelection.Resolve( + GraphicalHostOperatingSystem.Linux, + Environment(variables)); + + Assert.Equal(expected, selection.RequestedProtocol.ToString()); + } + + public static TheoryData< + (string Name, string? Value)[], + string> AutomaticLinuxCases => new() + { + { + [ + ("XDG_SESSION_TYPE", "wayland"), + ("WAYLAND_DISPLAY", "wayland-0"), + ("DISPLAY", ":0"), + ], + "Wayland" + }, + { + [ + ("XDG_SESSION_TYPE", "x11"), + ("WAYLAND_DISPLAY", "wayland-0"), + ("DISPLAY", ":0"), + ], + "X11" + }, + { + [("WAYLAND_DISPLAY", "wayland-0")], + "Wayland" + }, + { + [], + "Automatic" + }, + }; + + private static Func Environment( + params (string Name, string? Value)[] variables) + { + var values = variables.ToDictionary( + pair => pair.Name, + pair => pair.Value, + StringComparer.Ordinal); + return name => values.GetValueOrDefault(name); + } +} diff --git a/tests/AcDream.App.Tests/Studio/StudioWindowTests.cs b/tests/AcDream.App.Tests/Studio/StudioWindowTests.cs index ba39748c..9f10682b 100644 --- a/tests/AcDream.App.Tests/Studio/StudioWindowTests.cs +++ b/tests/AcDream.App.Tests/Studio/StudioWindowTests.cs @@ -5,6 +5,24 @@ namespace AcDream.App.Tests.Studio; public class StudioWindowTests { + [Fact] + public void FrameCloseGate_defersCloseUntilFrameCompletion() + { + var gate = new StudioFrameCloseGate(); + int closeCount = 0; + + gate.Request(); + + Assert.True(gate.IsRequested); + Assert.Equal(0, closeCount); + + gate.CompleteFrame(() => closeCount++); + gate.CompleteFrame(() => closeCount++); + + Assert.False(gate.IsRequested); + Assert.Equal(1, closeCount); + } + [Fact] public void ParseMockup_doesNotDefaultToSinglePanelLayout() { @@ -16,6 +34,22 @@ public class StudioWindowTests Assert.Null(opts.MarkupPath); } + [Fact] + public void ParseCapabilitySmokeOptions() + { + var opts = StudioOptions.Parse( + [ + @"C:\fake-dats", + "--mockup", + "--capability-report", + "report.json", + "--audio-smoke", + ]); + + Assert.Equal("report.json", opts.CapabilityReportPath); + Assert.True(opts.AudioSmoke); + } + [Fact] public void NormalizeSinglePanelRoot_movesDatPanelToCanvasOrigin() {