feat(linux): add graphical platform services

This commit is contained in:
Erik 2026-07-27 11:54:59 +02:00
parent 1628d9f587
commit 66f114b258
28 changed files with 1328 additions and 155 deletions

View file

@ -178,6 +178,7 @@ public sealed class WorldRenderCompositionTests
ImmediateGpuResourceRetirementQueue.Instance,
_budgets,
0xA9B4FFFFu,
Path.Combine(Path.GetTempPath(), "acdream-tests"),
_ => { }),
Publication,
Factory,
@ -297,6 +298,7 @@ public sealed class WorldRenderCompositionTests
IDatReaderWriter dats,
BindlessSupport bindless,
IGpuResourceRetirementQueue retirement,
string diagnosticsDirectory,
ResidencyBudgetOptions budgets)
{
TextureBudgets = budgets;

View file

@ -0,0 +1,56 @@
using AcDream.App.Platform;
namespace AcDream.App.Tests.Platform;
public sealed class GraphicalHostPlatformServicesTests
{
[Fact]
public void CurrentPlatformOwnsPathsPacingAndNativeClosure()
{
GraphicalHostPlatformServices platform =
GraphicalHostPlatformServices.Resolve();
Assert.NotNull(platform.Paths);
Assert.NotNull(platform.FramePacingWaiters);
Assert.Equal(3, platform.NativeDependencies.Count);
Assert.All(
platform.NativeDependencies,
dependency =>
{
Assert.False(string.IsNullOrWhiteSpace(dependency.Feature));
Assert.False(string.IsNullOrWhiteSpace(
dependency.PublishedFileName));
});
if (OperatingSystem.IsWindows())
{
Assert.Equal(
GraphicalHostOperatingSystem.Windows,
platform.OperatingSystem);
Assert.StartsWith(
"win-",
platform.RuntimeIdentifier,
StringComparison.Ordinal);
Assert.Contains(
platform.NativeDependencies,
dependency =>
dependency.PublishedFileName == "glfw3.dll");
return;
}
if (OperatingSystem.IsLinux())
{
Assert.Equal(
GraphicalHostOperatingSystem.Linux,
platform.OperatingSystem);
Assert.Equal("linux-x64", platform.RuntimeIdentifier);
Assert.Contains(
platform.NativeDependencies,
dependency =>
dependency.PublishedFileName == "libglfw.so.3");
return;
}
throw new PlatformNotSupportedException();
}
}

View file

@ -0,0 +1,66 @@
using AcDream.App.Platform;
using AcDream.Runtime.Platform;
namespace AcDream.App.Tests.Platform;
public sealed class GraphicalLegacyConfigurationMigratorTests
: IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-l0-migration-" + Guid.NewGuid().ToString("N"));
[Fact]
public void MissingCanonicalFilesAreCopiedWithoutOverwriting()
{
string legacy = Path.Combine(_root, "legacy");
string canonical = Path.Combine(_root, "canonical");
Directory.CreateDirectory(legacy);
Directory.CreateDirectory(canonical);
File.WriteAllText(
Path.Combine(legacy, "settings.json"),
"legacy-settings");
File.WriteAllText(
Path.Combine(legacy, "keybinds.json"),
"legacy-keybinds");
File.WriteAllText(
Path.Combine(canonical, "keybinds.json"),
"canonical-keybinds");
var paths = new ApplicationPathSet(
canonical,
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
legacy);
IReadOnlyList<string> migrated =
GraphicalLegacyConfigurationMigrator.Migrate(paths);
Assert.Single(migrated);
Assert.Equal(
"legacy-settings",
File.ReadAllText(paths.SettingsFile));
Assert.Equal(
"canonical-keybinds",
File.ReadAllText(paths.KeyBindingsFile));
}
[Fact]
public void NoLegacyDirectoryProducesNoWrites()
{
var paths = new ApplicationPathSet(
Path.Combine(_root, "canonical"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
LegacyConfigDirectory: null);
Assert.Empty(
GraphicalLegacyConfigurationMigrator.Migrate(paths));
Assert.False(Directory.Exists(paths.ConfigDirectory));
}
public void Dispose()
{
if (Directory.Exists(_root))
Directory.Delete(_root, recursive: true);
}
}

View file

@ -0,0 +1,85 @@
using System.Diagnostics;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class LinuxMonotonicFramePacingWaiterTests
{
[Theory]
[InlineData(1L, 1_000_000_000L, 1L)]
[InlineData(1L, 3L, 333_333_334L)]
[InlineData(15L, 1_000L, 15_000_000L)]
[InlineData(6_060L, 1_000_000L, 6_060_000L)]
public void TickConversionRoundsUpWithoutReturningZero(
long ticks,
long frequency,
long expected)
{
Assert.Equal(
expected,
LinuxMonotonicFramePacingWaiter
.ConvertTicksToNanoseconds(ticks, frequency));
}
[Fact]
public void PlatformFactorySelectsCurrentOperatingSystem()
{
IFramePacingWaiter waiter =
PlatformFramePacingWaiterFactory
.ForCurrentProcess()
.Create();
if (OperatingSystem.IsWindows())
{
Assert.IsType<WindowsHighResolutionFramePacingWaiter>(
waiter);
((IDisposable)waiter).Dispose();
return;
}
if (OperatingSystem.IsLinux())
{
Assert.IsType<LinuxMonotonicFramePacingWaiter>(waiter);
((IDisposable)waiter).Dispose();
return;
}
throw new PlatformNotSupportedException();
}
[Fact]
public void LinuxWaitBlocksUntilRequestedMonotonicDeadline()
{
if (!OperatingSystem.IsLinux())
return;
LinuxMonotonicFramePacingWaiter waiter =
LinuxMonotonicFramePacingWaiter.Create();
long start = Stopwatch.GetTimestamp();
waiter.Wait(
durationTicks: Stopwatch.Frequency / 200L,
clockFrequency: Stopwatch.Frequency);
TimeSpan elapsed = Stopwatch.GetElapsedTime(start);
Assert.True(
elapsed >= TimeSpan.FromMilliseconds(4),
$"Linux monotonic wait returned after {elapsed.TotalMilliseconds:F3} ms.");
Assert.True(
elapsed < TimeSpan.FromSeconds(1),
$"Linux monotonic wait stalled for {elapsed.TotalMilliseconds:F3} ms.");
}
[Fact]
public void LinuxWaiterRejectsUseAfterDisposal()
{
if (!OperatingSystem.IsLinux())
return;
LinuxMonotonicFramePacingWaiter waiter =
LinuxMonotonicFramePacingWaiter.Create();
waiter.Dispose();
Assert.Throws<ObjectDisposedException>(
() => waiter.Wait(1, Stopwatch.Frequency));
}
}

View file

@ -0,0 +1,123 @@
namespace AcDream.App.Tests.Rendering;
public sealed class LinuxPlatformBoundaryTests
{
[Fact]
public void FramePacingSelectsPlatformWaiterOnlyAtFactoryBoundary()
{
string app = AppSourceRoot();
string controller = File.ReadAllText(Path.Combine(
app,
"Rendering",
"FramePacingController.cs"));
Assert.Contains(
"PlatformFramePacingWaiterFactory.ForCurrentProcess()",
controller,
StringComparison.Ordinal);
Assert.DoesNotContain(
"WindowsHighResolutionFramePacingWaiter.Create()",
controller,
StringComparison.Ordinal);
string[] windowsImports = Directory
.EnumerateFiles(app, "*.cs", SearchOption.AllDirectories)
.Where(path => File.ReadAllText(path).Contains(
"LibraryImport(\"kernel32.dll\"",
StringComparison.Ordinal))
.Select(path => Path.GetFileName(path)!)
.ToArray();
Assert.Equal(
["WindowsHighResolutionFramePacingWaiter.cs"],
windowsImports);
}
[Fact]
public void GraphicalFeatureCodeDoesNotResolveWindowsLocalAppData()
{
string root = RepositoryRoot();
string[] productionFiles =
[
.. Directory.EnumerateFiles(
Path.Combine(root, "src", "AcDream.App"),
"*.cs",
SearchOption.AllDirectories),
.. Directory.EnumerateFiles(
Path.Combine(root, "src", "AcDream.UI.Abstractions"),
"*.cs",
SearchOption.AllDirectories),
];
string[] offenders = productionFiles
.Where(path => File.ReadAllText(path).Contains(
"LocalApplicationData",
StringComparison.Ordinal))
.Select(path => Path.GetRelativePath(root, path))
.ToArray();
Assert.Empty(offenders);
}
[Fact]
public void OperatingSystemChecksRemainInsidePlatformOwners()
{
string app = AppSourceRoot();
string[] offenders = Directory
.EnumerateFiles(app, "*.cs", SearchOption.AllDirectories)
.Where(path => File.ReadAllText(path).Contains(
"OperatingSystem.Is",
StringComparison.Ordinal))
.Where(path =>
{
string relative = Path.GetRelativePath(app, path)
.Replace('\\', '/');
return !relative.StartsWith(
"Platform/",
StringComparison.Ordinal)
&& relative is not
"Rendering/FramePacingWaiterFactory.cs"
&& relative is not
"Rendering/LinuxMonotonicFramePacingWaiter.cs"
&& relative is not
"Rendering/WindowsHighResolutionFramePacingWaiter.cs";
})
.Select(path => Path.GetRelativePath(app, path))
.ToArray();
Assert.Empty(offenders);
}
[Fact]
public void SmokePluginCopyUsesRidAwarePortableBuildAndPublishPaths()
{
string project = File.ReadAllText(Path.Combine(
AppSourceRoot(),
"AcDream.App.csproj"));
Assert.Contains("$(TargetFramework)", project, StringComparison.Ordinal);
Assert.Contains("$(RuntimeIdentifier)", project, StringComparison.Ordinal);
Assert.Contains("$(OutputPath)plugins/", project, StringComparison.Ordinal);
Assert.Contains("$(PublishDir)plugins/", project, StringComparison.Ordinal);
Assert.DoesNotContain(
@"bin\$(Configuration)\net10.0",
project,
StringComparison.Ordinal);
}
private static string AppSourceRoot() =>
Path.Combine(RepositoryRoot(), "src", "AcDream.App");
private static string RepositoryRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException(
"Could not locate repository root from test output.");
}
}