feat(linux): gate graphical backends and capabilities

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

View file

@ -0,0 +1,30 @@
namespace AcDream.App.Studio;
/// <summary>
/// Defers a close requested by render work until the caller has completed the
/// active render-frame transaction. Silk dispatches <c>Closing</c>
/// synchronously, so closing directly from a render callback would otherwise
/// dispose GPU resources while submitted work is still owned by that frame.
/// </summary>
internal sealed class StudioFrameCloseGate
{
private bool _requested;
public bool IsRequested => _requested;
public void Request()
{
_requested = true;
}
public void CompleteFrame(Action close)
{
ArgumentNullException.ThrowIfNull(close);
if (!_requested)
return;
_requested = false;
close();
}
}

View file

@ -12,7 +12,9 @@ public sealed record StudioOptions(
string? DumpSlug = null,
string? DumpFile = null,
string? ScreenshotPath = null,
bool Mockup = false)
bool Mockup = false,
string? CapabilityReportPath = null,
bool AudioSmoke = false)
{
/// <summary>
/// Parse studio options from the args that come AFTER the <c>ui-studio</c> token.
@ -40,7 +42,9 @@ public sealed record StudioOptions(
string? dumpSlug = null;
string? dumpFile = null;
string? screenshotPath = null;
string? capabilityReportPath = null;
bool mockup = false;
bool audioSmoke = false;
for (int i = 0; i < args.Length; i++)
{
@ -69,6 +73,14 @@ public sealed record StudioOptions(
{
screenshotPath = args[++i];
}
else if (args[i] == "--capability-report" && i + 1 < args.Length)
{
capabilityReportPath = args[++i];
}
else if (args[i] == "--audio-smoke")
{
audioSmoke = true;
}
else if (args[i] == "--mockup")
{
mockup = true;
@ -90,7 +102,16 @@ public sealed record StudioOptions(
if (!mockup && layoutId is null && markupPath is null && dumpSlug is null)
layoutId = 0x2100006Cu;
return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile, screenshotPath, mockup);
return new StudioOptions(
datDir,
layoutId,
markupPath,
dumpSlug,
dumpFile,
screenshotPath,
mockup,
capabilityReportPath,
audioSmoke);
}
/// <summary>

View file

@ -1,5 +1,8 @@
using System.Numerics;
using AcDream.Content;
using AcDream.App.Platform;
using AcDream.App.Audio;
using AcDream.Core.Audio;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Runtime.Platform;
@ -38,9 +41,13 @@ public sealed class StudioWindow : IDisposable
{
private readonly StudioOptions _opts;
private readonly ApplicationPathSet _applicationPaths;
private readonly GraphicalHostPlatformServices _platformServices;
// Created in OnLoad, released in OnClosing.
private IWindow? _window;
private GL? _gl;
private IInputContext? _input;
private OpenAlAudioEngine? _audio;
private IDatReaderWriter? _dats;
private RenderStack? _stack;
private LayoutSource? _source;
@ -64,19 +71,39 @@ public sealed class StudioWindow : IDisposable
// Headless screenshot mode: set when --screenshot was passed.
// True after the first OnRender fires the screenshot (guard against repeat).
private bool _screenshotDone;
private readonly StudioFrameCloseGate _frameCloseGate = new();
private GraphicalCapabilityRecord? _capabilities;
private string? _capabilityReportPath;
public StudioWindow(StudioOptions opts)
: this(opts, ApplicationPathSet.Resolve())
: this(
opts,
GraphicalHostPlatformServices.Resolve())
{
}
internal StudioWindow(
StudioOptions opts,
ApplicationPathSet applicationPaths)
: this(
opts,
GraphicalHostPlatformServices.Resolve() with
{
Paths = applicationPaths
?? throw new ArgumentNullException(
nameof(applicationPaths)),
})
{
}
internal StudioWindow(
StudioOptions opts,
GraphicalHostPlatformServices platformServices)
{
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
_applicationPaths = applicationPaths
?? throw new ArgumentNullException(nameof(applicationPaths));
_platformServices = platformServices
?? throw new ArgumentNullException(nameof(platformServices));
_applicationPaths = _platformServices.Paths;
}
/// <summary>
@ -85,6 +112,7 @@ public sealed class StudioWindow : IDisposable
/// </summary>
public void Run()
{
_platformServices.ConfigureWindowBackend();
// Resolve quality settings the same way GameWindow.Run() does
// (SettingsStore → QualitySettings.From → WithEnvOverrides).
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
@ -122,7 +150,48 @@ public sealed class StudioWindow : IDisposable
private void OnLoad()
{
var gl = GL.GetApi(_window!);
_gl = GL.GetApi(_window!);
_input = _window!.CreateInput();
_capabilityReportPath = Path.GetFullPath(
_opts.CapabilityReportPath
?? Path.Combine(
_applicationPaths.DiagnosticsDirectory,
"graphical-capabilities.json"));
_capabilities = GraphicalCapabilityGuard.CaptureVerifyAndWrite(
_gl,
_window!,
_input,
_platformServices,
_capabilityReportPath);
GraphicalCapabilityGuard.ThrowIfUnsupported(
_capabilities,
_capabilityReportPath);
if (_opts.AudioSmoke)
{
_audio = new OpenAlAudioEngine();
bool playback = _audio.IsAvailable
&& _audio.PlayUiWave(
0xFFFF_FF01u,
CreateAudioSmokeWave(),
volume: 0.05f);
_capabilities = _capabilities with
{
Audio = new GraphicalAudioCapabilities(
Requested: true,
Available: _audio.IsAvailable,
PlaybackSubmitted: playback,
DisposalComplete: _audio.IsDisposalComplete,
Backend: "OpenAL Soft default device"),
Lifecycle = _capabilities.Lifecycle with
{
OwnedAudioEngineCount = 1,
},
};
GraphicalCapabilityReportWriter.Write(
_capabilityReportPath,
_capabilities);
}
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_opts.DatDir);
@ -135,7 +204,7 @@ public sealed class StudioWindow : IDisposable
AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality));
_stack = RenderBootstrap.Create(
gl,
_gl,
_dats,
new RenderBootstrapOptions(
quality,
@ -208,34 +277,63 @@ public sealed class StudioWindow : IDisposable
// Task 3: ImGui IDE — interactive mode only.
// Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
_panelFbo = new PanelFbo(gl);
_panelFbo = new PanelFbo(_gl);
if (_opts.ScreenshotPath is null)
{
var input = _window!.CreateInput();
// Mockup mode draws UiHost directly to the window, so raw Silk mouse coordinates
// already match UI pixels. Inspector mode draws UiHost inside an ImGui canvas, so
// mouse is forwarded manually from DrawCanvas with canvas-local remapping.
if (_opts.Mockup)
foreach (var mouse in input.Mice)
foreach (var mouse in _input.Mice)
_stack.UiHost.WireMouse(mouse);
foreach (var kb in input.Keyboards)
foreach (var kb in _input.Keyboards)
_stack.UiHost.WireKeyboard(kb);
if (_opts.Mockup)
return;
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(
_gl,
_window!,
_input);
_inspector = new StudioInspector();
}
}
private static WaveData CreateAudioSmokeWave()
{
const int sampleRate = 8_000;
const int sampleCount = 400;
var pcm = new byte[sampleCount * sizeof(short)];
for (int i = 0; i < sampleCount; i++)
{
double phase = i * (2.0 * Math.PI * 440.0 / sampleRate);
short sample = (short)(Math.Sin(phase) * short.MaxValue * 0.08);
BitConverter.TryWriteBytes(
pcm.AsSpan(i * sizeof(short), sizeof(short)),
sample);
}
return new WaveData
{
ChannelCount = 1,
SampleRate = sampleRate,
BitsPerSample = 16,
PcmBytes = pcm,
Duration = TimeSpan.FromSeconds(
sampleCount / (double)sampleRate),
};
}
private void OnUpdate(double dt) { }
private void OnRender(double dt)
{
if (_stack is null || _panelFbo is null) return;
_stack.BeginFrame();
RenderStack frameStack = _stack;
frameStack.BeginFrame();
Exception? renderFailure = null;
bool frameClosed = false;
try
{
@ -270,7 +368,7 @@ public sealed class StudioWindow : IDisposable
if (pixels.Length == 0)
{
Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels.");
_window?.Close();
_frameCloseGate.Request();
return;
}
@ -292,7 +390,7 @@ public sealed class StudioWindow : IDisposable
img.SaveAsPng(path);
Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})");
_window?.Close();
_frameCloseGate.Request();
return;
}
@ -445,7 +543,8 @@ public sealed class StudioWindow : IDisposable
{
try
{
_stack.EndFrame();
frameStack.EndFrame();
frameClosed = true;
}
catch (Exception closeFailure) when (renderFailure is not null)
{
@ -454,6 +553,9 @@ public sealed class StudioWindow : IDisposable
renderFailure,
closeFailure);
}
if (frameClosed)
_frameCloseGate.CompleteFrame(() => _window?.Close());
}
}
@ -512,19 +614,18 @@ public sealed class StudioWindow : IDisposable
private void OnClosing()
{
_fixtureController?.Dispose();
_fixtureController = null;
_imgui?.Dispose();
_panelFbo?.Dispose();
_imgui = null;
_panelFbo = null;
_stack?.Dispose(); // whole render stack: dispatcher/mesh/textures/shader/ubo/uihost
_dats?.Dispose();
_dats = null;
_stack = null;
ReleaseContextResources();
}
public void Dispose()
{
ReleaseContextResources();
_window?.Dispose();
_window = null;
UpdateFinalCapabilityReport();
}
private void ReleaseContextResources()
{
_fixtureController?.Dispose();
_fixtureController = null;
@ -532,13 +633,43 @@ public sealed class StudioWindow : IDisposable
_panelFbo?.Dispose();
_imgui = null;
_panelFbo = null;
_window?.Dispose();
_window = null;
// If OnClosing wasn't called (e.g. an exception before Run() completed), dispose the FULL
// stack anyway — the review flagged that disposing only UiHost here leaked the rest.
_stack?.Dispose();
_dats?.Dispose();
_audio?.Dispose();
_input?.Dispose();
_gl?.Dispose();
_dats = null;
_stack = null;
_audio = null;
_input = null;
_gl = null;
}
private void UpdateFinalCapabilityReport()
{
if (_capabilities is null
|| string.IsNullOrWhiteSpace(_capabilityReportPath))
{
return;
}
_capabilities = _capabilities with
{
Audio = _capabilities.Audio with
{
DisposalComplete = true,
},
Lifecycle = new GraphicalSmokeLifecycleCapabilities(
OwnedWindowCount: 0,
OwnedGlApiCount: 0,
OwnedInputContextCount: 0,
OwnedAudioEngineCount: 0,
ShutdownComplete: true),
};
GraphicalCapabilityReportWriter.Write(
_capabilityReportPath,
_capabilities);
}
}