feat(linux): gate graphical backends and capabilities
This commit is contained in:
parent
07bb1c5a74
commit
11501d52ca
13 changed files with 1924 additions and 32 deletions
35
.github/workflows/headless-portability.yml
vendored
35
.github/workflows/headless-portability.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
525
src/AcDream.App/Platform/GraphicalCapabilityRecord.cs
Normal file
525
src/AcDream.App/Platform/GraphicalCapabilityRecord.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
513
src/AcDream.App/Platform/GraphicalGlFunctionProbe.cs
Normal file
513
src/AcDream.App/Platform/GraphicalGlFunctionProbe.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
189
src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs
Normal file
189
src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ using Serilog;
|
|||
|
||||
GraphicalHostPlatformServices graphicalPlatform =
|
||||
GraphicalHostPlatformServices.Resolve();
|
||||
graphicalPlatform.ConfigureWindowBackend();
|
||||
ApplicationPathSet applicationPaths = graphicalPlatform.Paths;
|
||||
IReadOnlyList<string> migratedConfigurationFiles =
|
||||
GraphicalLegacyConfigurationMigrator.Migrate(applicationPaths);
|
||||
|
|
@ -18,9 +19,17 @@ 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()
|
||||
.MinimumLevel.Debug()
|
||||
|
|
@ -138,8 +147,16 @@ 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
|
||||
{
|
||||
foreach (var plugin in loaded)
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
30
src/AcDream.App/Studio/StudioFrameCloseGate.cs
Normal file
30
src/AcDream.App/Studio/StudioFrameCloseGate.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: []);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue