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:
|
with:
|
||||||
dotnet-version: "10.0.x"
|
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
|
- name: Build presentation-free closure
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -140,7 +150,7 @@ jobs:
|
||||||
dotnet test `
|
dotnet test `
|
||||||
tests/AcDream.App.Tests/AcDream.App.Tests.csproj `
|
tests/AcDream.App.Tests/AcDream.App.Tests.csproj `
|
||||||
-c Release `
|
-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 }
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
- name: Verify Linux package contract
|
- name: Verify Linux package contract
|
||||||
|
|
@ -169,3 +179,26 @@ jobs:
|
||||||
src/AcDream.App \
|
src/AcDream.App \
|
||||||
--exclude='FramePacingWaiterFactory.cs' \
|
--exclude='FramePacingWaiterFactory.cs' \
|
||||||
--exclude='WindowsHighResolutionFramePacingWaiter.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,
|
GraphicalHostOperatingSystem OperatingSystem,
|
||||||
Architecture ProcessArchitecture,
|
Architecture ProcessArchitecture,
|
||||||
string RuntimeIdentifier,
|
string RuntimeIdentifier,
|
||||||
|
GraphicalWindowBackendSelection WindowBackend,
|
||||||
ApplicationPathSet Paths,
|
ApplicationPathSet Paths,
|
||||||
IFramePacingWaiterFactory FramePacingWaiters,
|
IFramePacingWaiterFactory FramePacingWaiters,
|
||||||
IReadOnlyList<GraphicalNativeDependency> NativeDependencies)
|
IReadOnlyList<GraphicalNativeDependency> NativeDependencies)
|
||||||
|
|
@ -35,11 +36,17 @@ internal sealed record GraphicalHostPlatformServices(
|
||||||
operatingSystem,
|
operatingSystem,
|
||||||
architecture,
|
architecture,
|
||||||
runtimeIdentifier,
|
runtimeIdentifier,
|
||||||
|
GraphicalWindowBackendSelection.Resolve(
|
||||||
|
operatingSystem,
|
||||||
|
Environment.GetEnvironmentVariable),
|
||||||
ApplicationPathSet.Resolve(),
|
ApplicationPathSet.Resolve(),
|
||||||
new PlatformFramePacingWaiterFactory(operatingSystem),
|
new PlatformFramePacingWaiterFactory(operatingSystem),
|
||||||
ResolveNativeDependencies(operatingSystem));
|
ResolveNativeDependencies(operatingSystem));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void ConfigureWindowBackend() =>
|
||||||
|
GraphicalWindowBackendConfigurator.Configure(this);
|
||||||
|
|
||||||
internal static GraphicalHostOperatingSystem DetectOperatingSystem()
|
internal static GraphicalHostOperatingSystem DetectOperatingSystem()
|
||||||
{
|
{
|
||||||
if (System.OperatingSystem.IsWindows())
|
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 graphicalPlatform =
|
||||||
GraphicalHostPlatformServices.Resolve();
|
GraphicalHostPlatformServices.Resolve();
|
||||||
|
graphicalPlatform.ConfigureWindowBackend();
|
||||||
ApplicationPathSet applicationPaths = graphicalPlatform.Paths;
|
ApplicationPathSet applicationPaths = graphicalPlatform.Paths;
|
||||||
IReadOnlyList<string> migratedConfigurationFiles =
|
IReadOnlyList<string> migratedConfigurationFiles =
|
||||||
GraphicalLegacyConfigurationMigrator.Migrate(applicationPaths);
|
GraphicalLegacyConfigurationMigrator.Migrate(applicationPaths);
|
||||||
|
|
@ -18,9 +19,17 @@ if (args.Length >= 1 && args[0] == "ui-studio")
|
||||||
using var sw = new AcDream.App.Studio.StudioWindow(
|
using var sw = new AcDream.App.Studio.StudioWindow(
|
||||||
so,
|
so,
|
||||||
applicationPaths);
|
applicationPaths);
|
||||||
|
try
|
||||||
|
{
|
||||||
sw.Run();
|
sw.Run();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
catch (NotSupportedException error)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(error.Message);
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.MinimumLevel.Debug()
|
.MinimumLevel.Debug()
|
||||||
|
|
@ -138,8 +147,16 @@ try
|
||||||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
window.Run();
|
window.Run();
|
||||||
}
|
}
|
||||||
|
catch (NotSupportedException error)
|
||||||
|
{
|
||||||
|
Log.Error("{GraphicalStartupFailure}", error.Message);
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
foreach (var plugin in loaded)
|
foreach (var plugin in loaded)
|
||||||
|
|
|
||||||
|
|
@ -509,6 +509,7 @@ public sealed class GameWindow :
|
||||||
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings;
|
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings;
|
||||||
private readonly GraphicalHostPlatformServices _platformServices;
|
private readonly GraphicalHostPlatformServices _platformServices;
|
||||||
private readonly ApplicationPathSet _applicationPaths;
|
private readonly ApplicationPathSet _applicationPaths;
|
||||||
|
private GraphicalCapabilityRecord? _graphicalCapabilities;
|
||||||
|
|
||||||
private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings(
|
private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings(
|
||||||
string path)
|
string path)
|
||||||
|
|
@ -670,6 +671,7 @@ public sealed class GameWindow :
|
||||||
|
|
||||||
public void Run()
|
public void Run()
|
||||||
{
|
{
|
||||||
|
_platformServices.ConfigureWindowBackend();
|
||||||
// The runtime-settings owner was constructed before Window.Create and
|
// The runtime-settings owner was constructed before Window.Create and
|
||||||
// exposes the one immutable startup snapshot. MSAA is a context
|
// exposes the one immutable startup snapshot. MSAA is a context
|
||||||
// attribute, so it must come from this same snapshot rather than a
|
// 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.
|
// run narrow-phase BSP tests during FindObjCollisions.
|
||||||
|
|
||||||
GameWindowPlatformResult<GL, IInputContext> platform = AcquirePlatform();
|
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<
|
GameWindowCompositionPipeline.Run<
|
||||||
GameWindowPlatformResult<GL, IInputContext>,
|
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? DumpSlug = null,
|
||||||
string? DumpFile = null,
|
string? DumpFile = null,
|
||||||
string? ScreenshotPath = null,
|
string? ScreenshotPath = null,
|
||||||
bool Mockup = false)
|
bool Mockup = false,
|
||||||
|
string? CapabilityReportPath = null,
|
||||||
|
bool AudioSmoke = false)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse studio options from the args that come AFTER the <c>ui-studio</c> token.
|
/// 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? dumpSlug = null;
|
||||||
string? dumpFile = null;
|
string? dumpFile = null;
|
||||||
string? screenshotPath = null;
|
string? screenshotPath = null;
|
||||||
|
string? capabilityReportPath = null;
|
||||||
bool mockup = false;
|
bool mockup = false;
|
||||||
|
bool audioSmoke = false;
|
||||||
|
|
||||||
for (int i = 0; i < args.Length; i++)
|
for (int i = 0; i < args.Length; i++)
|
||||||
{
|
{
|
||||||
|
|
@ -69,6 +73,14 @@ public sealed record StudioOptions(
|
||||||
{
|
{
|
||||||
screenshotPath = args[++i];
|
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")
|
else if (args[i] == "--mockup")
|
||||||
{
|
{
|
||||||
mockup = true;
|
mockup = true;
|
||||||
|
|
@ -90,7 +102,16 @@ public sealed record StudioOptions(
|
||||||
if (!mockup && layoutId is null && markupPath is null && dumpSlug is null)
|
if (!mockup && layoutId is null && markupPath is null && dumpSlug is null)
|
||||||
layoutId = 0x2100006Cu;
|
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>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.Content;
|
using AcDream.Content;
|
||||||
|
using AcDream.App.Platform;
|
||||||
|
using AcDream.App.Audio;
|
||||||
|
using AcDream.Core.Audio;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.Runtime.Platform;
|
using AcDream.Runtime.Platform;
|
||||||
|
|
@ -38,9 +41,13 @@ public sealed class StudioWindow : IDisposable
|
||||||
{
|
{
|
||||||
private readonly StudioOptions _opts;
|
private readonly StudioOptions _opts;
|
||||||
private readonly ApplicationPathSet _applicationPaths;
|
private readonly ApplicationPathSet _applicationPaths;
|
||||||
|
private readonly GraphicalHostPlatformServices _platformServices;
|
||||||
|
|
||||||
// Created in OnLoad, released in OnClosing.
|
// Created in OnLoad, released in OnClosing.
|
||||||
private IWindow? _window;
|
private IWindow? _window;
|
||||||
|
private GL? _gl;
|
||||||
|
private IInputContext? _input;
|
||||||
|
private OpenAlAudioEngine? _audio;
|
||||||
private IDatReaderWriter? _dats;
|
private IDatReaderWriter? _dats;
|
||||||
private RenderStack? _stack;
|
private RenderStack? _stack;
|
||||||
private LayoutSource? _source;
|
private LayoutSource? _source;
|
||||||
|
|
@ -64,19 +71,39 @@ public sealed class StudioWindow : IDisposable
|
||||||
// Headless screenshot mode: set when --screenshot was passed.
|
// Headless screenshot mode: set when --screenshot was passed.
|
||||||
// True after the first OnRender fires the screenshot (guard against repeat).
|
// True after the first OnRender fires the screenshot (guard against repeat).
|
||||||
private bool _screenshotDone;
|
private bool _screenshotDone;
|
||||||
|
private readonly StudioFrameCloseGate _frameCloseGate = new();
|
||||||
|
private GraphicalCapabilityRecord? _capabilities;
|
||||||
|
private string? _capabilityReportPath;
|
||||||
|
|
||||||
public StudioWindow(StudioOptions opts)
|
public StudioWindow(StudioOptions opts)
|
||||||
: this(opts, ApplicationPathSet.Resolve())
|
: this(
|
||||||
|
opts,
|
||||||
|
GraphicalHostPlatformServices.Resolve())
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
internal StudioWindow(
|
internal StudioWindow(
|
||||||
StudioOptions opts,
|
StudioOptions opts,
|
||||||
ApplicationPathSet applicationPaths)
|
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));
|
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
|
||||||
_applicationPaths = applicationPaths
|
_platformServices = platformServices
|
||||||
?? throw new ArgumentNullException(nameof(applicationPaths));
|
?? throw new ArgumentNullException(nameof(platformServices));
|
||||||
|
_applicationPaths = _platformServices.Paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -85,6 +112,7 @@ public sealed class StudioWindow : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Run()
|
public void Run()
|
||||||
{
|
{
|
||||||
|
_platformServices.ConfigureWindowBackend();
|
||||||
// Resolve quality settings the same way GameWindow.Run() does
|
// Resolve quality settings the same way GameWindow.Run() does
|
||||||
// (SettingsStore → QualitySettings.From → WithEnvOverrides).
|
// (SettingsStore → QualitySettings.From → WithEnvOverrides).
|
||||||
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
|
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
|
||||||
|
|
@ -122,7 +150,48 @@ public sealed class StudioWindow : IDisposable
|
||||||
|
|
||||||
private void OnLoad()
|
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);
|
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_opts.DatDir);
|
||||||
|
|
||||||
|
|
@ -135,7 +204,7 @@ public sealed class StudioWindow : IDisposable
|
||||||
AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality));
|
AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality));
|
||||||
|
|
||||||
_stack = RenderBootstrap.Create(
|
_stack = RenderBootstrap.Create(
|
||||||
gl,
|
_gl,
|
||||||
_dats,
|
_dats,
|
||||||
new RenderBootstrapOptions(
|
new RenderBootstrapOptions(
|
||||||
quality,
|
quality,
|
||||||
|
|
@ -208,34 +277,63 @@ public sealed class StudioWindow : IDisposable
|
||||||
|
|
||||||
// Task 3: ImGui IDE — interactive mode only.
|
// Task 3: ImGui IDE — interactive mode only.
|
||||||
// Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
|
// Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
|
||||||
_panelFbo = new PanelFbo(gl);
|
_panelFbo = new PanelFbo(_gl);
|
||||||
if (_opts.ScreenshotPath is null)
|
if (_opts.ScreenshotPath is null)
|
||||||
{
|
{
|
||||||
var input = _window!.CreateInput();
|
|
||||||
// Mockup mode draws UiHost directly to the window, so raw Silk mouse coordinates
|
// 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
|
// already match UI pixels. Inspector mode draws UiHost inside an ImGui canvas, so
|
||||||
// mouse is forwarded manually from DrawCanvas with canvas-local remapping.
|
// mouse is forwarded manually from DrawCanvas with canvas-local remapping.
|
||||||
if (_opts.Mockup)
|
if (_opts.Mockup)
|
||||||
foreach (var mouse in input.Mice)
|
foreach (var mouse in _input.Mice)
|
||||||
_stack.UiHost.WireMouse(mouse);
|
_stack.UiHost.WireMouse(mouse);
|
||||||
foreach (var kb in input.Keyboards)
|
foreach (var kb in _input.Keyboards)
|
||||||
_stack.UiHost.WireKeyboard(kb);
|
_stack.UiHost.WireKeyboard(kb);
|
||||||
|
|
||||||
if (_opts.Mockup)
|
if (_opts.Mockup)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
|
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(
|
||||||
|
_gl,
|
||||||
|
_window!,
|
||||||
|
_input);
|
||||||
_inspector = new StudioInspector();
|
_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 OnUpdate(double dt) { }
|
||||||
|
|
||||||
private void OnRender(double dt)
|
private void OnRender(double dt)
|
||||||
{
|
{
|
||||||
if (_stack is null || _panelFbo is null) return;
|
if (_stack is null || _panelFbo is null) return;
|
||||||
_stack.BeginFrame();
|
RenderStack frameStack = _stack;
|
||||||
|
frameStack.BeginFrame();
|
||||||
Exception? renderFailure = null;
|
Exception? renderFailure = null;
|
||||||
|
bool frameClosed = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
@ -270,7 +368,7 @@ public sealed class StudioWindow : IDisposable
|
||||||
if (pixels.Length == 0)
|
if (pixels.Length == 0)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels.");
|
Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels.");
|
||||||
_window?.Close();
|
_frameCloseGate.Request();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -292,7 +390,7 @@ public sealed class StudioWindow : IDisposable
|
||||||
img.SaveAsPng(path);
|
img.SaveAsPng(path);
|
||||||
Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})");
|
Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})");
|
||||||
|
|
||||||
_window?.Close();
|
_frameCloseGate.Request();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,7 +543,8 @@ public sealed class StudioWindow : IDisposable
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_stack.EndFrame();
|
frameStack.EndFrame();
|
||||||
|
frameClosed = true;
|
||||||
}
|
}
|
||||||
catch (Exception closeFailure) when (renderFailure is not null)
|
catch (Exception closeFailure) when (renderFailure is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -454,6 +553,9 @@ public sealed class StudioWindow : IDisposable
|
||||||
renderFailure,
|
renderFailure,
|
||||||
closeFailure);
|
closeFailure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (frameClosed)
|
||||||
|
_frameCloseGate.CompleteFrame(() => _window?.Close());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -512,19 +614,18 @@ public sealed class StudioWindow : IDisposable
|
||||||
|
|
||||||
private void OnClosing()
|
private void OnClosing()
|
||||||
{
|
{
|
||||||
_fixtureController?.Dispose();
|
ReleaseContextResources();
|
||||||
_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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
{
|
||||||
|
ReleaseContextResources();
|
||||||
|
_window?.Dispose();
|
||||||
|
_window = null;
|
||||||
|
UpdateFinalCapabilityReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseContextResources()
|
||||||
{
|
{
|
||||||
_fixtureController?.Dispose();
|
_fixtureController?.Dispose();
|
||||||
_fixtureController = null;
|
_fixtureController = null;
|
||||||
|
|
@ -532,13 +633,43 @@ public sealed class StudioWindow : IDisposable
|
||||||
_panelFbo?.Dispose();
|
_panelFbo?.Dispose();
|
||||||
_imgui = null;
|
_imgui = null;
|
||||||
_panelFbo = null;
|
_panelFbo = null;
|
||||||
_window?.Dispose();
|
|
||||||
_window = null;
|
|
||||||
// If OnClosing wasn't called (e.g. an exception before Run() completed), dispose the FULL
|
// 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 anyway — the review flagged that disposing only UiHost here leaked the rest.
|
||||||
_stack?.Dispose();
|
_stack?.Dispose();
|
||||||
_dats?.Dispose();
|
_dats?.Dispose();
|
||||||
|
_audio?.Dispose();
|
||||||
|
_input?.Dispose();
|
||||||
|
_gl?.Dispose();
|
||||||
_dats = null;
|
_dats = null;
|
||||||
_stack = 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
|
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]
|
[Fact]
|
||||||
public void ParseMockup_doesNotDefaultToSinglePanelLayout()
|
public void ParseMockup_doesNotDefaultToSinglePanelLayout()
|
||||||
{
|
{
|
||||||
|
|
@ -16,6 +34,22 @@ public class StudioWindowTests
|
||||||
Assert.Null(opts.MarkupPath);
|
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]
|
[Fact]
|
||||||
public void NormalizeSinglePanelRoot_movesDatPanelToCanvasOrigin()
|
public void NormalizeSinglePanelRoot_movesDatPanelToCanvasOrigin()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue