feat(linux): gate graphical backends and capabilities

This commit is contained in:
Erik 2026-07-27 12:26:58 +02:00
parent 07bb1c5a74
commit 11501d52ca
13 changed files with 1924 additions and 32 deletions

View file

@ -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"

View file

@ -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<string> 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<string> Extensions,
GraphicalFunctionProbeResult FunctionProbe,
IReadOnlyList<string> SupportFailures)
{
internal bool IsSupported => SupportFailures.Count == 0;
}
internal static class GraphicalCapabilityRequirements
{
internal static IReadOnlyList<string> Evaluate(
GraphicalCapabilityRecord capabilities)
{
ArgumentNullException.ThrowIfNull(capabilities);
var failures = new List<string>();
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<string> 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<string> 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]<int>)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";
}
}

View file

@ -0,0 +1,513 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Platform;
/// <summary>
/// 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.
/// </summary>
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<string>();
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<string> 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");
}
}
}

View file

@ -18,6 +18,7 @@ internal sealed record GraphicalHostPlatformServices(
GraphicalHostOperatingSystem OperatingSystem,
Architecture ProcessArchitecture,
string RuntimeIdentifier,
GraphicalWindowBackendSelection WindowBackend,
ApplicationPathSet Paths,
IFramePacingWaiterFactory FramePacingWaiters,
IReadOnlyList<GraphicalNativeDependency> 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())

View file

@ -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,
}
/// <summary>
/// Immutable process-start decision for Silk's GLFW 3.4 backend. Linux users
/// may force <c>x11</c> or <c>wayland</c> with
/// <c>ACDREAM_DISPLAY_PROTOCOL</c>; otherwise GLFW selects from the available
/// native backends. The decision is applied once before any window exists.
/// </summary>
internal sealed record GraphicalWindowBackendSelection(
GraphicalDisplayProtocol RequestedProtocol,
string Reason)
{
internal const string EnvironmentVariable =
"ACDREAM_DISPLAY_PROTOCOL";
internal static GraphicalWindowBackendSelection Resolve(
GraphicalHostOperatingSystem operatingSystem,
Func<string, string?> 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");
}
}
/// <summary>
/// The only App-layer owner allowed to select a native GLFW platform. GLFW
/// requires the platform init hint before <c>glfwInit</c>; applying it after
/// <see cref="Silk.NET.Windowing.Window.Create"/> is too late.
/// </summary>
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<Func<string, IEnumerable<string>>> resolvers =
resolver.Resolvers;
resolvers.Remove(DefaultPathResolver.BaseDirectoryResolver);
resolvers.Insert(
0,
DefaultPathResolver.BaseDirectoryResolver);
}
}

View file

@ -8,6 +8,7 @@ using Serilog;
GraphicalHostPlatformServices graphicalPlatform =
GraphicalHostPlatformServices.Resolve();
graphicalPlatform.ConfigureWindowBackend();
ApplicationPathSet applicationPaths = graphicalPlatform.Paths;
IReadOnlyList<string> 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);
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); }
}
try
{
window.Run();
}
catch (NotSupportedException error)
{
Log.Error("{GraphicalStartupFailure}", error.Message);
return 4;
}
}
finally
{

View file

@ -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<GL, IInputContext> 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<GL, IInputContext>,

View file

@ -0,0 +1,30 @@
namespace AcDream.App.Studio;
/// <summary>
/// Defers a close requested by render work until the caller has completed the
/// active render-frame transaction. Silk dispatches <c>Closing</c>
/// synchronously, so closing directly from a render callback would otherwise
/// dispose GPU resources while submitted work is still owned by that frame.
/// </summary>
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();
}
}

View file

@ -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)
{
/// <summary>
/// Parse studio options from the args that come AFTER the <c>ui-studio</c> 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);
}
/// <summary>

View file

@ -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;
}
/// <summary>
@ -85,6 +112,7 @@ public sealed class StudioWindow : IDisposable
/// </summary>
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);
}
}

View file

@ -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: []);
}

View file

@ -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<InvalidOperationException>(
() => 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<string, string?> Environment(
params (string Name, string? Value)[] variables)
{
var values = variables.ToDictionary(
pair => pair.Name,
pair => pair.Value,
StringComparer.Ordinal);
return name => values.GetValueOrDefault(name);
}
}

View file

@ -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()
{