diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index 250a92d4..bd039501 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -11,11 +11,16 @@ on: - "src/AcDream.Plugin.Abstractions/**" - "src/AcDream.Runtime/**" - "src/AcDream.Headless/**" + - "src/AcDream.App/**" + - "src/AcDream.UI.Abstractions/**" + - "src/AcDream.UI.ImGui/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" - "tests/AcDream.Runtime.Tests/**" - "tests/AcDream.Headless.Tests/**" + - "tests/AcDream.App.Tests/**" + - "tests/AcDream.UI.Abstractions.Tests/**" push: paths: - ".github/workflows/headless-portability.yml" @@ -26,11 +31,16 @@ on: - "src/AcDream.Plugin.Abstractions/**" - "src/AcDream.Runtime/**" - "src/AcDream.Headless/**" + - "src/AcDream.App/**" + - "src/AcDream.UI.Abstractions/**" + - "src/AcDream.UI.ImGui/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" - "tests/AcDream.Runtime.Tests/**" - "tests/AcDream.Headless.Tests/**" + - "tests/AcDream.App.Tests/**" + - "tests/AcDream.UI.Abstractions.Tests/**" workflow_dispatch: permissions: @@ -95,3 +105,67 @@ jobs: Set-Content -LiteralPath headless-k0.json -Value '{"version":1,"sessions":[]}' dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + linux-graphical: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Build and publish Linux graphical client + shell: pwsh + run: | + dotnet build src/AcDream.App/AcDream.App.csproj -c Release -r linux-x64 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet publish src/AcDream.App/AcDream.App.csproj ` + -c Release ` + -r linux-x64 ` + --self-contained false ` + -o artifacts/acdream-linux-x64 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Test portable graphical boundary + shell: pwsh + run: | + dotnet test ` + tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj ` + -c Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet test ` + tests/AcDream.App.Tests/AcDream.App.Tests.csproj ` + -c Release ` + --filter "FullyQualifiedName~LinuxMonotonicFramePacingWaiterTests|FullyQualifiedName~LinuxPlatformBoundaryTests|FullyQualifiedName~GraphicalHostPlatformServicesTests|FullyQualifiedName~GraphicalLegacyConfigurationMigratorTests" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify Linux package contract + shell: bash + run: | + set -euo pipefail + root=artifacts/acdream-linux-x64 + test -x "$root/AcDream.App" + test -f "$root/AcDream.App.dll" + test -f "$root/libglfw.so.3" + test -f "$root/libopenal.so" + test -f "$root/libcimgui.so" + test -f "$root/Rendering/Shaders/mesh_modern.vert" + test -f "$root/plugins/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.dll" + test -f "$root/plugins/AcDream.Plugins.Smoke/plugin.json" + + test "$(grep -RIl --include='*.cs' 'LibraryImport(\"kernel32.dll\"' \ + src/AcDream.App | wc -l)" -eq 1 + grep -q 'WindowsHighResolutionFramePacingWaiter.cs' < <( + grep -RIl --include='*.cs' 'LibraryImport(\"kernel32.dll\"' \ + src/AcDream.App) + ! grep -RIn --include='*.cs' 'LocalApplicationData' \ + src/AcDream.App src/AcDream.UI.Abstractions + ! grep -RIn --include='*.cs' \ + 'WindowsHighResolutionFramePacingWaiter.Create()' \ + src/AcDream.App \ + --exclude='FramePacingWaiterFactory.cs' \ + --exclude='WindowsHighResolutionFramePacingWaiter.cs' diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index 68338dd5..ffec2ecd 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -63,18 +63,42 @@ true - + - <_SmokePluginSourceDir>..\AcDream.Plugins.Smoke\bin\$(Configuration)\net10.0 - <_SmokePluginDestDir>$(OutputPath)plugins\AcDream.Plugins.Smoke + <_SmokePluginSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework) + <_SmokePluginSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginSourceDir)/$(RuntimeIdentifier) + <_SmokePluginDestDir>$(OutputPath)plugins/AcDream.Plugins.Smoke + + + + + <_SmokePluginPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework) + <_SmokePluginPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginPublishSourceDir)/$(RuntimeIdentifier) + <_SmokePluginPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.Smoke + + + + diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs index a0eb907b..21ad200d 100644 --- a/src/AcDream.App/Composition/WorldRenderComposition.cs +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -54,6 +54,7 @@ internal sealed record WorldRenderDependencies( IGpuResourceRetirementQueue ResourceRetirement, ResidencyBudgetOptions ResidencyBudgets, uint InitialCenterLandblockId, + string DiagnosticsDirectory, Action Log); internal interface IGameWindowWorldRenderPublication @@ -114,6 +115,7 @@ internal interface IWorldRenderCompositionFactory IDatReaderWriter dats, BindlessSupport bindless, IGpuResourceRetirementQueue retirement, + string diagnosticsDirectory, ResidencyBudgetOptions budgets); void RegisterResidencySources( ResidencyManager manager, @@ -293,8 +295,15 @@ internal sealed class RetailWorldRenderCompositionFactory IDatReaderWriter dats, BindlessSupport bindless, IGpuResourceRetirementQueue retirement, + string diagnosticsDirectory, ResidencyBudgetOptions budgets) => - new(gl, dats, bindless, retirement, budgets); + new( + gl, + dats, + bindless, + retirement, + diagnosticsDirectory, + budgets); public void RegisterResidencySources( ResidencyManager manager, @@ -527,6 +536,7 @@ internal sealed class WorldRenderCompositionPhase content.Dats, bindless, _dependencies.ResourceRetirement, + _dependencies.DiagnosticsDirectory, residency.Budgets), _publication.PublishTextureCache, WorldRenderCompositionPoint.TextureCachePublished); diff --git a/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs b/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs new file mode 100644 index 00000000..9a301cc5 --- /dev/null +++ b/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs @@ -0,0 +1,102 @@ +using System.Runtime.InteropServices; +using AcDream.App.Rendering; +using AcDream.Runtime.Platform; + +namespace AcDream.App.Platform; + +internal enum GraphicalHostOperatingSystem +{ + Windows, + Linux, +} + +internal sealed record GraphicalNativeDependency( + string Feature, + string PublishedFileName); + +internal sealed record GraphicalHostPlatformServices( + GraphicalHostOperatingSystem OperatingSystem, + Architecture ProcessArchitecture, + string RuntimeIdentifier, + ApplicationPathSet Paths, + IFramePacingWaiterFactory FramePacingWaiters, + IReadOnlyList NativeDependencies) +{ + internal static GraphicalHostPlatformServices Resolve() + { + GraphicalHostOperatingSystem operatingSystem = + DetectOperatingSystem(); + Architecture architecture = RuntimeInformation.ProcessArchitecture; + string runtimeIdentifier = ResolveRuntimeIdentifier( + operatingSystem, + architecture); + + return new GraphicalHostPlatformServices( + operatingSystem, + architecture, + runtimeIdentifier, + ApplicationPathSet.Resolve(), + new PlatformFramePacingWaiterFactory(operatingSystem), + ResolveNativeDependencies(operatingSystem)); + } + + internal static GraphicalHostOperatingSystem DetectOperatingSystem() + { + if (System.OperatingSystem.IsWindows()) + return GraphicalHostOperatingSystem.Windows; + if (System.OperatingSystem.IsLinux()) + return GraphicalHostOperatingSystem.Linux; + + throw new PlatformNotSupportedException( + "acdream graphical hosting supports Windows and Linux."); + } + + private static string ResolveRuntimeIdentifier( + GraphicalHostOperatingSystem operatingSystem, + Architecture architecture) + { + string architectureName = architecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + Architecture.X86 => "x86", + Architecture.Arm => "arm", + _ => throw new PlatformNotSupportedException( + $"Unsupported graphical process architecture {architecture}."), + }; + + if (operatingSystem == GraphicalHostOperatingSystem.Linux + && architecture != Architecture.X64) + { + throw new PlatformNotSupportedException( + "The first acdream Linux graphical package supports linux-x64 only."); + } + + string operatingSystemName = + operatingSystem == GraphicalHostOperatingSystem.Windows + ? "win" + : "linux"; + return $"{operatingSystemName}-{architectureName}"; + } + + private static IReadOnlyList + ResolveNativeDependencies( + GraphicalHostOperatingSystem operatingSystem) => + operatingSystem switch + { + GraphicalHostOperatingSystem.Windows => + [ + new("window/input", "glfw3.dll"), + new("developer UI", "cimgui.dll"), + new("audio", "soft_oal.dll"), + ], + GraphicalHostOperatingSystem.Linux => + [ + new("window/input", "libglfw.so.3"), + new("developer UI", "libcimgui.so"), + new("audio", "libopenal.so"), + ], + _ => throw new ArgumentOutOfRangeException( + nameof(operatingSystem)), + }; +} diff --git a/src/AcDream.App/Platform/GraphicalLegacyConfigurationMigrator.cs b/src/AcDream.App/Platform/GraphicalLegacyConfigurationMigrator.cs new file mode 100644 index 00000000..8a7e2fd9 --- /dev/null +++ b/src/AcDream.App/Platform/GraphicalLegacyConfigurationMigrator.cs @@ -0,0 +1,50 @@ +using AcDream.Runtime.Platform; + +namespace AcDream.App.Platform; + +internal static class GraphicalLegacyConfigurationMigrator +{ + private static readonly string[] FileNames = + [ + "settings.json", + "keybinds.json", + ]; + + internal static IReadOnlyList Migrate( + ApplicationPathSet paths) + { + ArgumentNullException.ThrowIfNull(paths); + if (paths.LegacyConfigDirectory is not { } legacyDirectory + || PathsEqual(legacyDirectory, paths.ConfigDirectory)) + { + return []; + } + + List? migrated = null; + foreach (string fileName in FileNames) + { + string source = Path.Combine(legacyDirectory, fileName); + string destination = Path.Combine( + paths.ConfigDirectory, + fileName); + if (!File.Exists(source) || File.Exists(destination)) + continue; + + Directory.CreateDirectory(paths.ConfigDirectory); + File.Copy(source, destination, overwrite: false); + (migrated ??= []).Add(destination); + } + + return migrated ?? []; + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator( + Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator( + Path.GetFullPath(right)), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index a6ef26ca..b8205bb7 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -1,13 +1,23 @@ using AcDream.App; using AcDream.App.Plugins; +using AcDream.App.Platform; using AcDream.App.Rendering; using AcDream.Core.Plugins; +using AcDream.Runtime.Platform; using Serilog; +GraphicalHostPlatformServices graphicalPlatform = + GraphicalHostPlatformServices.Resolve(); +ApplicationPathSet applicationPaths = graphicalPlatform.Paths; +IReadOnlyList migratedConfigurationFiles = + GraphicalLegacyConfigurationMigrator.Migrate(applicationPaths); + if (args.Length >= 1 && args[0] == "ui-studio") { var so = AcDream.App.Studio.StudioOptions.Parse(args[1..]); - using var sw = new AcDream.App.Studio.StudioWindow(so); + using var sw = new AcDream.App.Studio.StudioWindow( + so, + applicationPaths); sw.Run(); return 0; } @@ -16,6 +26,20 @@ Log.Logger = new LoggerConfiguration() .MinimumLevel.Debug() .WriteTo.Console() .CreateLogger(); +foreach (string migratedConfigurationFile in migratedConfigurationFiles) +{ + Log.Information( + "migrated legacy graphical configuration to {Path}", + migratedConfigurationFile); +} +Log.Information( + "graphical platform {RuntimeIdentifier}; native closure: {NativeDependencies}", + graphicalPlatform.RuntimeIdentifier, + string.Join( + ", ", + graphicalPlatform.NativeDependencies.Select( + dependency => + $"{dependency.Feature}={dependency.PublishedFileName}"))); var datDir = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); if (string.IsNullOrWhiteSpace(datDir)) @@ -36,7 +60,8 @@ using var window = new GameWindow( runtimeOptions, worldGameState, worldEvents, - uiRegistry); + uiRegistry, + graphicalPlatform); var host = new AppPluginHost( new SerilogAdapter(Log.Logger), worldGameState, @@ -44,27 +69,65 @@ var host = new AppPluginHost( window.Selection, uiRegistry); -var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins"); -Log.Information("scanning plugins in {PluginsDir}", pluginsDir); - var loaded = new List(); -foreach (var result in PluginDiscovery.Scan(pluginsDir)) +var loadedPluginIds = new HashSet(StringComparer.OrdinalIgnoreCase); +StringComparer pathComparer = + graphicalPlatform.OperatingSystem + == GraphicalHostOperatingSystem.Windows + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; +string[] pluginRoots = +[ + .. new[] + { + Path.Combine(AppContext.BaseDirectory, "plugins"), + applicationPaths.PluginsDirectory, + }.Distinct(pathComparer), +]; + +foreach (string pluginsDir in pluginRoots) { - if (!result.Success) + Log.Information("scanning plugins in {PluginsDir}", pluginsDir); + foreach (var result in PluginDiscovery.Scan(pluginsDir)) { - Log.Warning("plugin discovery failed for {Dir}: {Error}", result.PluginDirectory, result.Error); - continue; - } + if (!result.Success) + { + Log.Warning( + "plugin discovery failed for {Dir}: {Error}", + result.PluginDirectory, + result.Error); + continue; + } - var loadResult = PluginLoader.Load(result.PluginDirectory, result.Manifest!, host); - if (!loadResult.Success) - { - Log.Warning("plugin load failed for {Id}: {Error}", result.Manifest!.Id, loadResult.Error); - continue; - } + if (loadedPluginIds.Contains(result.Manifest!.Id)) + { + Log.Warning( + "skipping duplicate plugin id {Id} from {Dir}", + result.Manifest.Id, + result.PluginDirectory); + continue; + } - loaded.Add(loadResult); - Log.Information("loaded plugin {Id} ({DisplayName})", result.Manifest!.Id, result.Manifest.DisplayName); + var loadResult = PluginLoader.Load( + result.PluginDirectory, + result.Manifest, + host); + if (!loadResult.Success) + { + Log.Warning( + "plugin load failed for {Id}: {Error}", + result.Manifest.Id, + loadResult.Error); + continue; + } + + loadedPluginIds.Add(result.Manifest.Id); + loaded.Add(loadResult); + Log.Information( + "loaded plugin {Id} ({DisplayName})", + result.Manifest.Id, + result.Manifest.DisplayName); + } } try diff --git a/src/AcDream.App/Rendering/DisplayFramePacingController.cs b/src/AcDream.App/Rendering/DisplayFramePacingController.cs index 3d1fc0bb..65a7a4a7 100644 --- a/src/AcDream.App/Rendering/DisplayFramePacingController.cs +++ b/src/AcDream.App/Rendering/DisplayFramePacingController.cs @@ -72,6 +72,17 @@ internal sealed class DisplayFramePacingController : IDisposable { } + internal DisplayFramePacingController( + bool uncappedRendering, + FrameProfiler profiler, + IFramePacingWaiterFactory waiterFactory) + : this( + uncappedRendering, + profiler, + new FramePacingController(waiterFactory)) + { + } + internal DisplayFramePacingController( bool uncappedRendering, FrameProfiler profiler, diff --git a/src/AcDream.App/Rendering/FramePacingController.cs b/src/AcDream.App/Rendering/FramePacingController.cs index 4261bf66..a46bca40 100644 --- a/src/AcDream.App/Rendering/FramePacingController.cs +++ b/src/AcDream.App/Rendering/FramePacingController.cs @@ -25,9 +25,17 @@ internal sealed class FramePacingController : IDisposable private bool _disposed; public FramePacingController() + : this(PlatformFramePacingWaiterFactory.ForCurrentProcess()) + { + } + + internal FramePacingController( + IFramePacingWaiterFactory waiterFactory) : this( StopwatchFramePacingClock.Instance, - WindowsHighResolutionFramePacingWaiter.Create(), + (waiterFactory + ?? throw new ArgumentNullException(nameof(waiterFactory))) + .Create(), ownsWaiter: true) { } diff --git a/src/AcDream.App/Rendering/FramePacingWaiterFactory.cs b/src/AcDream.App/Rendering/FramePacingWaiterFactory.cs new file mode 100644 index 00000000..fcff2ca4 --- /dev/null +++ b/src/AcDream.App/Rendering/FramePacingWaiterFactory.cs @@ -0,0 +1,38 @@ +using AcDream.App.Platform; + +namespace AcDream.App.Rendering; + +internal interface IFramePacingWaiterFactory +{ + IFramePacingWaiter Create(); +} + +/// +/// The sole startup-time platform selector for software frame waits. +/// Frame policy and deadline behavior remain platform-neutral. +/// +internal sealed class PlatformFramePacingWaiterFactory + : IFramePacingWaiterFactory +{ + private readonly GraphicalHostOperatingSystem _operatingSystem; + + internal PlatformFramePacingWaiterFactory( + GraphicalHostOperatingSystem operatingSystem) + { + _operatingSystem = operatingSystem; + } + + internal static PlatformFramePacingWaiterFactory ForCurrentProcess() => + new(GraphicalHostPlatformServices.DetectOperatingSystem()); + + public IFramePacingWaiter Create() => + _operatingSystem switch + { + GraphicalHostOperatingSystem.Windows => + WindowsHighResolutionFramePacingWaiter.Create(), + GraphicalHostOperatingSystem.Linux => + LinuxMonotonicFramePacingWaiter.Create(), + _ => throw new ArgumentOutOfRangeException( + nameof(_operatingSystem)), + }; +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2b0cedc9..ae1922e0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4,9 +4,11 @@ using AcDream.App.Physics; using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Wb; using AcDream.App.Settings; +using AcDream.App.Platform; using AcDream.App.World; using AcDream.Content; using AcDream.Runtime; +using AcDream.Runtime.Platform; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; @@ -498,16 +500,19 @@ public sealed class GameWindow : _runtimeDiagnosticCommands = new(); private readonly AcDream.App.Combat.LiveCombatModeCommandSlot _liveCombatModeCommands = new(); - // K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json, + // K.1c/L0: load user-customized bindings from the canonical per-platform + // configuration path, // falling back to the retail-faithful defaults if the file is missing // or corrupt. This is THE single source of truth for the keymap at // startup — no other call to RetailDefaults() / AcdreamCurrentDefaults() // should land in the GameWindow construction path. - private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings = LoadStartupKeyBindings(); + private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings; + private readonly GraphicalHostPlatformServices _platformServices; + private readonly ApplicationPathSet _applicationPaths; - private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings() + private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings( + string path) { - var path = AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath(); var bindings = AcDream.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path); Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}"); return bindings; @@ -571,8 +576,28 @@ public sealed class GameWindow : WorldGameState worldGameState, WorldEvents worldEvents, AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) + : this( + options, + worldGameState, + worldEvents, + uiRegistry, + GraphicalHostPlatformServices.Resolve()) + { + } + + internal GameWindow( + AcDream.App.RuntimeOptions options, + WorldGameState worldGameState, + WorldEvents worldEvents, + AcDream.App.Plugins.BufferedUiRegistry? uiRegistry, + GraphicalHostPlatformServices platformServices) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); + _platformServices = platformServices + ?? throw new ArgumentNullException(nameof(platformServices)); + _applicationPaths = _platformServices.Paths; + _keyBindings = LoadStartupKeyBindings( + _applicationPaths.KeyBindingsFile); _runtime = new GameRuntime(new GameRuntimeDependencies( _combatAttackOperations, _combatTargetOperations, @@ -611,10 +636,11 @@ public sealed class GameWindow : _worldEvents = worldEvents; _displayFramePacing = new DisplayFramePacingController( options.UncappedRendering, - _frameProfiler); + _frameProfiler, + _platformServices.FramePacingWaiters); _runtimeSettings = new RuntimeSettingsController( new JsonRuntimeSettingsStorage( - AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()), + _applicationPaths.SettingsFile), log: Console.WriteLine); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _uiRegistry = uiRegistry; @@ -1209,7 +1235,9 @@ public sealed class GameWindow : devToolsFrameDiagnostics); IRuntimeKeyBindingTarget? keyBindingTarget = hostInputCamera.InputDispatcher is { } settingsDispatcher - ? new RuntimeKeyBindingTarget(settingsDispatcher) + ? new RuntimeKeyBindingTarget( + settingsDispatcher, + _applicationPaths.KeyBindingsFile) : null; optionalDevTools = new SettingsDevToolsOptionalDependencies( devToolsFacts, @@ -1249,6 +1277,7 @@ public sealed class GameWindow : _gpuFrameFlights!, _options.ResidencyBudgets, initialCenterLandblockId, + _applicationPaths.DiagnosticsDirectory, Console.WriteLine), this).Compose(platformResult, contentEffectsAudio, settingsDevTools); Console.WriteLine( diff --git a/src/AcDream.App/Rendering/LinuxMonotonicFramePacingWaiter.cs b/src/AcDream.App/Rendering/LinuxMonotonicFramePacingWaiter.cs new file mode 100644 index 00000000..d384ff3d --- /dev/null +++ b/src/AcDream.App/Rendering/LinuxMonotonicFramePacingWaiter.cs @@ -0,0 +1,151 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace AcDream.App.Rendering; + +/// +/// Low-CPU Linux frame wait using an absolute CLOCK_MONOTONIC deadline. +/// EINTR resumes against the original deadline, so signals cannot shorten or +/// cumulatively extend a normal presentation wait. +/// +internal sealed partial class LinuxMonotonicFramePacingWaiter + : IFramePacingWaiter, + IDisposable +{ + private const int ClockMonotonic = 1; + private const int TimerAbsolute = 1; + private const int Interrupted = 4; + private const long NanosecondsPerSecond = 1_000_000_000L; + private bool _disposed; + + private LinuxMonotonicFramePacingWaiter() + { + } + + internal static LinuxMonotonicFramePacingWaiter Create() + { + if (!OperatingSystem.IsLinux()) + { + throw new PlatformNotSupportedException( + "The monotonic Linux frame waiter requires Linux."); + } + + return new LinuxMonotonicFramePacingWaiter(); + } + + public void Wait(long durationTicks, long clockFrequency) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (durationTicks <= 0 || clockFrequency <= 0) + return; + + int result = ClockGetTime(ClockMonotonic, out Timespec now); + if (result != 0) + { + throw new Win32Exception( + Marshal.GetLastPInvokeError(), + "Could not read the Linux monotonic clock."); + } + + long durationNanoseconds = ConvertTicksToNanoseconds( + durationTicks, + clockFrequency); + Timespec deadline = AddNanoseconds(now, durationNanoseconds); + do + { + result = ClockNanosleep( + ClockMonotonic, + TimerAbsolute, + in deadline, + 0); + } + while (result == Interrupted); + + if (result != 0) + { + throw new Win32Exception( + result, + "Waiting on the Linux monotonic frame deadline failed."); + } + } + + internal static long ConvertTicksToNanoseconds( + long durationTicks, + long clockFrequency) + { + if (durationTicks <= 0) + throw new ArgumentOutOfRangeException(nameof(durationTicks)); + if (clockFrequency <= 0) + throw new ArgumentOutOfRangeException(nameof(clockFrequency)); + + long wholeSeconds = Math.DivRem( + durationTicks, + clockFrequency, + out long remainder); + if (wholeSeconds >= long.MaxValue / NanosecondsPerSecond) + return long.MaxValue; + + long wholeNanoseconds = wholeSeconds * NanosecondsPerSecond; + long fractionalNanoseconds = checked((long)Math.Ceiling( + remainder * (double)NanosecondsPerSecond / clockFrequency)); + if (wholeNanoseconds > long.MaxValue - fractionalNanoseconds) + return long.MaxValue; + + return Math.Max(1L, wholeNanoseconds + fractionalNanoseconds); + } + + private static Timespec AddNanoseconds( + Timespec timestamp, + long nanoseconds) + { + long seconds = nanoseconds / NanosecondsPerSecond; + long remainder = nanoseconds % NanosecondsPerSecond; + long resultSeconds = timestamp.Seconds > long.MaxValue - seconds + ? long.MaxValue + : timestamp.Seconds + seconds; + if (resultSeconds == long.MaxValue) + { + return new Timespec( + long.MaxValue, + NanosecondsPerSecond - 1L); + } + + long resultNanoseconds = timestamp.Nanoseconds + remainder; + if (resultNanoseconds >= NanosecondsPerSecond) + { + if (resultSeconds == long.MaxValue) + { + return new Timespec( + long.MaxValue, + NanosecondsPerSecond - 1L); + } + + resultSeconds++; + resultNanoseconds -= NanosecondsPerSecond; + } + + return new Timespec(resultSeconds, resultNanoseconds); + } + + public void Dispose() => _disposed = true; + + [StructLayout(LayoutKind.Sequential)] + private readonly record struct Timespec( + long Seconds, + long Nanoseconds); + + [LibraryImport( + "libc", + EntryPoint = "clock_gettime", + SetLastError = true)] + private static partial int ClockGetTime( + int clockId, + out Timespec timestamp); + + [LibraryImport("libc", EntryPoint = "clock_nanosleep")] + private static partial int ClockNanosleep( + int clockId, + int flags, + in Timespec request, + nint remainder); +} diff --git a/src/AcDream.App/Rendering/RenderBootstrap.cs b/src/AcDream.App/Rendering/RenderBootstrap.cs index 613b3e74..1b3949b2 100644 --- a/src/AcDream.App/Rendering/RenderBootstrap.cs +++ b/src/AcDream.App/Rendering/RenderBootstrap.cs @@ -124,7 +124,8 @@ public sealed record RenderStack( /// Options for . public sealed record RenderBootstrapOptions( - AcDream.UI.Abstractions.Settings.QualitySettings Quality); + AcDream.UI.Abstractions.Settings.QualitySettings Quality, + string DiagnosticsDirectory); /// /// Constructs the UI Studio's render stack from the production classes, @@ -164,7 +165,12 @@ public static class RenderBootstrap // --- TextureCache (GameWindow ~1774) --- var frameFlights = new GpuFrameFlightController(gl); - var textureCache = new TextureCache(gl, dats, bindless, frameFlights); + var textureCache = new TextureCache( + gl, + dats, + bindless, + frameFlights, + opts.DiagnosticsDirectory); // --- AnimLoader (GameWindow ~1240) --- var animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(dats); diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 7e5c3046..c4bfb1fd 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -18,6 +18,7 @@ public sealed unsafe class TextureCache { private readonly GL _gl; private readonly IDatReaderWriter _dats; + private readonly string _diagnosticsDirectory; // Handle and decoded dimensions are one atomic cache entry. Keeping them // in separate dictionaries allowed GetOrUpload(surfaceId) followed by the // sized overload to upload a second GL texture and orphan the first. @@ -86,7 +87,15 @@ public sealed unsafe class TextureCache private bool _surfaceHistogramAlreadyDumped; public TextureCache(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null) - : this(gl, dats, bindless, ImmediateGpuResourceRetirementQueue.Instance) + : this( + gl, + dats, + bindless, + ImmediateGpuResourceRetirementQueue.Instance, + Path.Combine( + Path.GetTempPath(), + "acdream", + "diagnostics")) { } @@ -95,12 +104,15 @@ public sealed unsafe class TextureCache IDatReaderWriter dats, Wb.BindlessSupport? bindless, IGpuResourceRetirementQueue retirementQueue, + string diagnosticsDirectory, ResidencyBudgetOptions? budgets = null) { budgets ??= ResidencyBudgetOptions.Default; _gl = gl; _dats = dats; _bindless = bindless; + ArgumentException.ThrowIfNullOrWhiteSpace(diagnosticsDirectory); + _diagnosticsDirectory = diagnosticsDirectory; ArgumentNullException.ThrowIfNull(retirementQueue); if (bindless is not null) { @@ -602,7 +614,8 @@ public sealed unsafe class TextureCache /// pulled in world content (not just sky/UI/font). The original /// frame-only gate fired during the login/handshake phase where /// OnRender ticks at GUI rates but no world has streamed in. - /// Output goes to %LOCALAPPDATA%\acdream\n6-surfaces.txt. Zero cost + /// Output goes to the host-provided portable diagnostics directory. + /// Zero cost /// when off. See spec §5 in /// docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md. /// @@ -638,10 +651,10 @@ public sealed unsafe class TextureCache private void DumpSurfaceHistogramCore() { - var localAppData = System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData); - var outDir = System.IO.Path.Combine(localAppData, "acdream"); - System.IO.Directory.CreateDirectory(outDir); - var outPath = System.IO.Path.Combine(outDir, "n6-surfaces.txt"); + System.IO.Directory.CreateDirectory(_diagnosticsDirectory); + var outPath = System.IO.Path.Combine( + _diagnosticsDirectory, + "n6-surfaces.txt"); var sb = new System.Text.StringBuilder(); sb.AppendLine($"# acdream surface-format histogram — generated {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}"); diff --git a/src/AcDream.App/Settings/RuntimeKeyBindingTarget.cs b/src/AcDream.App/Settings/RuntimeKeyBindingTarget.cs index 1fcef725..c06ec13a 100644 --- a/src/AcDream.App/Settings/RuntimeKeyBindingTarget.cs +++ b/src/AcDream.App/Settings/RuntimeKeyBindingTarget.cs @@ -20,11 +20,12 @@ internal sealed class RuntimeKeyBindingTarget : IRuntimeKeyBindingTarget public RuntimeKeyBindingTarget( InputDispatcher dispatcher, - string? path = null, + string path, Action? log = null) { _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - _path = path ?? KeyBindings.DefaultPath(); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _path = path; _log = log ?? Console.WriteLine; } diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs index 4df81786..de30c36f 100644 --- a/src/AcDream.App/Studio/StudioWindow.cs +++ b/src/AcDream.App/Studio/StudioWindow.cs @@ -2,6 +2,7 @@ using System.Numerics; using AcDream.Content; using AcDream.App.Rendering; using AcDream.App.UI; +using AcDream.Runtime.Platform; using DatReaderWriter; using Silk.NET.Input; using Silk.NET.Maths; @@ -36,6 +37,7 @@ namespace AcDream.App.Studio; public sealed class StudioWindow : IDisposable { private readonly StudioOptions _opts; + private readonly ApplicationPathSet _applicationPaths; // Created in OnLoad, released in OnClosing. private IWindow? _window; @@ -64,8 +66,17 @@ public sealed class StudioWindow : IDisposable private bool _screenshotDone; public StudioWindow(StudioOptions opts) + : this(opts, ApplicationPathSet.Resolve()) + { + } + + internal StudioWindow( + StudioOptions opts, + ApplicationPathSet applicationPaths) { _opts = opts ?? throw new ArgumentNullException(nameof(opts)); + _applicationPaths = applicationPaths + ?? throw new ArgumentNullException(nameof(applicationPaths)); } /// @@ -77,7 +88,7 @@ public sealed class StudioWindow : IDisposable // Resolve quality settings the same way GameWindow.Run() does // (SettingsStore → QualitySettings.From → WithEnvOverrides). var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( - AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + _applicationPaths.SettingsFile); var startupDisplay = startupStore.LoadDisplay(); var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); @@ -118,12 +129,17 @@ public sealed class StudioWindow : IDisposable // Build QualitySettings for RenderBootstrap (same as Run() above — re-read // after the GL context is confirmed, mirroring GameWindow.OnLoad). var store = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( - AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + _applicationPaths.SettingsFile); var display = store.LoadDisplay(); var quality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides( AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality)); - _stack = RenderBootstrap.Create(gl, _dats, new RenderBootstrapOptions(quality)); + _stack = RenderBootstrap.Create( + gl, + _dats, + new RenderBootstrapOptions( + quality, + _applicationPaths.DiagnosticsDirectory)); if (_opts.Mockup) MockupDesktop.Load(_dats, _stack); diff --git a/src/AcDream.Headless/Platform/HeadlessPathSet.cs b/src/AcDream.Headless/Platform/HeadlessPathSet.cs index a7a841eb..24059dae 100644 --- a/src/AcDream.Headless/Platform/HeadlessPathSet.cs +++ b/src/AcDream.Headless/Platform/HeadlessPathSet.cs @@ -1,4 +1,5 @@ using AcDream.Headless.Configuration; +using AcDream.Runtime.Platform; namespace AcDream.Headless.Platform; @@ -14,86 +15,24 @@ internal sealed record HeadlessPathSet( ArgumentNullException.ThrowIfNull(overrides); platform ??= HeadlessPlatformEnvironment.Instance; - string config; - string data; - string cache; - if (platform.IsWindows) + try { - string roaming = RequireFolder( - platform, - Environment.SpecialFolder.ApplicationData); - string local = RequireFolder( - platform, - Environment.SpecialFolder.LocalApplicationData); - config = Path.Combine(roaming, "acdream"); - data = Path.Combine(local, "acdream"); - cache = Path.Combine(local, "acdream", "cache"); + ApplicationPathSet paths = ApplicationPathSet.Resolve( + overrides.ConfigDirectory, + overrides.DataDirectory, + overrides.CacheDirectory, + platform); + return new HeadlessPathSet( + paths.ConfigDirectory, + paths.DataDirectory, + paths.CacheDirectory); } - else + catch (Exception exception) + when (exception is ArgumentException + or InvalidOperationException + or NotSupportedException) { - string home = RequireFolder( - platform, - Environment.SpecialFolder.UserProfile); - config = ResolveXdg( - platform, - "XDG_CONFIG_HOME", - Path.Combine(home, ".config"), - "acdream"); - data = ResolveXdg( - platform, - "XDG_DATA_HOME", - Path.Combine(home, ".local", "share"), - "acdream"); - cache = ResolveXdg( - platform, - "XDG_CACHE_HOME", - Path.Combine(home, ".cache"), - "acdream"); + throw new HeadlessConfigurationException(exception.Message); } - - return new HeadlessPathSet( - Normalize(overrides.ConfigDirectory ?? config, platform), - Normalize(overrides.DataDirectory ?? data, platform), - Normalize(overrides.CacheDirectory ?? cache, platform)); - } - - private static string ResolveXdg( - IHeadlessPlatformEnvironment platform, - string variable, - string fallback, - string leaf) - { - string? configured = platform.GetEnvironmentVariable(variable); - string root = string.IsNullOrWhiteSpace(configured) - ? fallback - : configured; - return Path.Combine(root, leaf); - } - - private static string RequireFolder( - IHeadlessPlatformEnvironment platform, - Environment.SpecialFolder folder) - { - string value = platform.GetFolderPath(folder); - if (string.IsNullOrWhiteSpace(value)) - { - throw new HeadlessConfigurationException( - $"The operating system did not provide {folder}."); - } - return value; - } - - private static string Normalize( - string path, - IHeadlessPlatformEnvironment platform) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new HeadlessConfigurationException( - "Headless directories cannot be empty."); - } - - return Path.TrimEndingDirectorySeparator( - Path.GetFullPath(path, platform.CurrentDirectory)); } } diff --git a/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs b/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs index 976e25d1..220f0571 100644 --- a/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs +++ b/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs @@ -1,11 +1,10 @@ +using AcDream.Runtime.Platform; + namespace AcDream.Headless.Platform; internal interface IHeadlessPlatformEnvironment + : IApplicationPathEnvironment { - bool IsWindows { get; } - string CurrentDirectory { get; } - string? GetEnvironmentVariable(string name); - string GetFolderPath(Environment.SpecialFolder folder); } internal sealed class HeadlessPlatformEnvironment diff --git a/src/AcDream.Runtime/Platform/ApplicationPathSet.cs b/src/AcDream.Runtime/Platform/ApplicationPathSet.cs new file mode 100644 index 00000000..6e4ee3d1 --- /dev/null +++ b/src/AcDream.Runtime/Platform/ApplicationPathSet.cs @@ -0,0 +1,171 @@ +namespace AcDream.Runtime.Platform; + +/// +/// BCL-only view of the process environment used to resolve portable +/// application paths exactly once at a host composition root. +/// +public interface IApplicationPathEnvironment +{ + bool IsWindows { get; } + + string CurrentDirectory { get; } + + string? GetEnvironmentVariable(string name); + + string GetFolderPath(Environment.SpecialFolder folder); +} + +internal sealed class ApplicationPathEnvironment + : IApplicationPathEnvironment +{ + internal static ApplicationPathEnvironment Instance { get; } = new(); + + private ApplicationPathEnvironment() + { + } + + public bool IsWindows => OperatingSystem.IsWindows(); + + public string CurrentDirectory => Environment.CurrentDirectory; + + public string? GetEnvironmentVariable(string name) => + Environment.GetEnvironmentVariable(name); + + public string GetFolderPath(Environment.SpecialFolder folder) => + Environment.GetFolderPath(folder); +} + +/// +/// Canonical portable per-user paths shared by graphical and no-window hosts. +/// Hosts may override the three roots, but feature code only receives the +/// resolved immutable paths and never reads operating-system state itself. +/// +public sealed record ApplicationPathSet( + string ConfigDirectory, + string DataDirectory, + string CacheDirectory, + string? LegacyConfigDirectory) +{ + public string SettingsFile => + Path.Combine(ConfigDirectory, "settings.json"); + + public string KeyBindingsFile => + Path.Combine(ConfigDirectory, "keybinds.json"); + + public string LogsDirectory => + Path.Combine(DataDirectory, "logs"); + + public string ScreenshotsDirectory => + Path.Combine(DataDirectory, "screenshots"); + + public string PluginsDirectory => + Path.Combine(DataDirectory, "plugins"); + + public string DiagnosticsDirectory => + Path.Combine(CacheDirectory, "diagnostics"); + + public static ApplicationPathSet Resolve( + string? configDirectory = null, + string? dataDirectory = null, + string? cacheDirectory = null, + IApplicationPathEnvironment? platform = null) + { + platform ??= ApplicationPathEnvironment.Instance; + + string config; + string data; + string cache; + if (platform.IsWindows) + { + string roaming = RequireFolder( + platform, + Environment.SpecialFolder.ApplicationData); + string local = RequireFolder( + platform, + Environment.SpecialFolder.LocalApplicationData); + config = Path.Combine(roaming, "acdream"); + data = Path.Combine(local, "acdream"); + cache = Path.Combine(local, "acdream", "cache"); + } + else + { + string home = RequireFolder( + platform, + Environment.SpecialFolder.UserProfile); + config = ResolveXdg( + platform, + "XDG_CONFIG_HOME", + Path.Combine(home, ".config"), + "acdream"); + data = ResolveXdg( + platform, + "XDG_DATA_HOME", + Path.Combine(home, ".local", "share"), + "acdream"); + cache = ResolveXdg( + platform, + "XDG_CACHE_HOME", + Path.Combine(home, ".cache"), + "acdream"); + } + + string? legacyConfigDirectory = + platform.IsWindows && configDirectory is null + ? Path.Combine( + RequireFolder( + platform, + Environment.SpecialFolder.LocalApplicationData), + "acdream") + : null; + + return new ApplicationPathSet( + Normalize(configDirectory ?? config, platform), + Normalize(dataDirectory ?? data, platform), + Normalize(cacheDirectory ?? cache, platform), + legacyConfigDirectory is null + ? null + : Normalize(legacyConfigDirectory, platform)); + } + + private static string ResolveXdg( + IApplicationPathEnvironment platform, + string variable, + string fallback, + string leaf) + { + string? configured = platform.GetEnvironmentVariable(variable); + string root = string.IsNullOrWhiteSpace(configured) + ? fallback + : configured; + return Path.Combine(root, leaf); + } + + private static string RequireFolder( + IApplicationPathEnvironment platform, + Environment.SpecialFolder folder) + { + string value = platform.GetFolderPath(folder); + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"The operating system did not provide {folder}."); + } + + return value; + } + + private static string Normalize( + string path, + IApplicationPathEnvironment platform) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException( + "Application directories cannot be empty.", + nameof(path)); + } + + return Path.TrimEndingDirectorySeparator( + Path.GetFullPath(path, platform.CurrentDirectory)); + } +} diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs index 68ae52f4..b3c7a622 100644 --- a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs +++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs @@ -18,7 +18,7 @@ namespace AcDream.UI.Abstractions.Input; /// K.1c: now returns the full retail-faithful /// preset (byte-precise to retail-default.keymap.txt); /// + persist the -/// table to JSON under with version-based +/// table to JSON at a host-provided portable path with version-based /// migration. is preserved as a /// reference for tests pinning the older WASD-only behavior, but is no /// longer the GameWindow startup source. @@ -596,11 +596,4 @@ public sealed class KeyBindings return result; } - /// - /// Default path: %LOCALAPPDATA%\acdream\keybinds.json. - /// - public static string DefaultPath() => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "acdream", - "keybinds.json"); } diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index aaee6b81..0c238961 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -13,7 +13,8 @@ public readonly record struct UiWindowPosition(float X, float Y); /// /// JSON-backed persistence for non-keybind settings (Display today; future /// tabs Audio / Gameplay / Chat / Character will be added to the same -/// file). Path: %LOCALAPPDATA%\acdream\settings.json. Coexists +/// file). The graphical host supplies a canonical portable configuration +/// path. Coexists /// with keybinds.json, which retains its own /// path. /// @@ -41,12 +42,6 @@ public sealed class SettingsStore _path = path ?? throw new ArgumentNullException(nameof(path)); } - /// Default path: %LOCALAPPDATA%\acdream\settings.json. - public static string DefaultPath() => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "acdream", - "settings.json"); - /// /// Load Display settings. Missing file → . /// Missing individual keys fall back to the corresponding default diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs index 4fbc73a5..4a1aab29 100644 --- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs @@ -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; diff --git a/tests/AcDream.App.Tests/Platform/GraphicalHostPlatformServicesTests.cs b/tests/AcDream.App.Tests/Platform/GraphicalHostPlatformServicesTests.cs new file mode 100644 index 00000000..8e80f3a8 --- /dev/null +++ b/tests/AcDream.App.Tests/Platform/GraphicalHostPlatformServicesTests.cs @@ -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(); + } +} diff --git a/tests/AcDream.App.Tests/Platform/GraphicalLegacyConfigurationMigratorTests.cs b/tests/AcDream.App.Tests/Platform/GraphicalLegacyConfigurationMigratorTests.cs new file mode 100644 index 00000000..1cccf33c --- /dev/null +++ b/tests/AcDream.App.Tests/Platform/GraphicalLegacyConfigurationMigratorTests.cs @@ -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 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); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/LinuxMonotonicFramePacingWaiterTests.cs b/tests/AcDream.App.Tests/Rendering/LinuxMonotonicFramePacingWaiterTests.cs new file mode 100644 index 00000000..53876839 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/LinuxMonotonicFramePacingWaiterTests.cs @@ -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( + waiter); + ((IDisposable)waiter).Dispose(); + return; + } + + if (OperatingSystem.IsLinux()) + { + Assert.IsType(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( + () => waiter.Wait(1, Stopwatch.Frequency)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs new file mode 100644 index 00000000..bb53f82b --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs @@ -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."); + } +} diff --git a/tests/AcDream.Runtime.Tests/Platform/ApplicationPathSetTests.cs b/tests/AcDream.Runtime.Tests/Platform/ApplicationPathSetTests.cs new file mode 100644 index 00000000..bbc595c3 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Platform/ApplicationPathSetTests.cs @@ -0,0 +1,162 @@ +using AcDream.Runtime.Platform; + +namespace AcDream.Runtime.Tests.Platform; + +public sealed class ApplicationPathSetTests +{ + [Fact] + public void LinuxUsesXdgRootsAndPublishesFeaturePaths() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream-runtime-xdg")); + var platform = new FixtureEnvironment(isWindows: false) + { + CurrentDirectoryValue = Path.Combine(root, "work"), + UserProfile = Path.Combine(root, "home"), + Variables = + { + ["XDG_CONFIG_HOME"] = Path.Combine(root, "cfg"), + ["XDG_DATA_HOME"] = Path.Combine(root, "data"), + ["XDG_CACHE_HOME"] = Path.Combine(root, "cache"), + }, + }; + + ApplicationPathSet paths = ApplicationPathSet.Resolve( + platform: platform); + + Assert.Equal( + Path.Combine(root, "cfg", "acdream"), + paths.ConfigDirectory); + Assert.Equal( + Path.Combine(root, "data", "acdream"), + paths.DataDirectory); + Assert.Equal( + Path.Combine(root, "cache", "acdream"), + paths.CacheDirectory); + Assert.Equal( + Path.Combine(paths.ConfigDirectory, "settings.json"), + paths.SettingsFile); + Assert.Equal( + Path.Combine(paths.ConfigDirectory, "keybinds.json"), + paths.KeyBindingsFile); + Assert.Equal( + Path.Combine(paths.DataDirectory, "plugins"), + paths.PluginsDirectory); + Assert.Equal( + Path.Combine(paths.CacheDirectory, "diagnostics"), + paths.DiagnosticsDirectory); + Assert.Null(paths.LegacyConfigDirectory); + } + + [Fact] + public void LinuxFallsBackToHomeAndNormalizesOverrides() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream runtime paths")); + var platform = new FixtureEnvironment(isWindows: false) + { + CurrentDirectoryValue = Path.Combine(root, "work"), + UserProfile = Path.Combine(root, "home"), + }; + + ApplicationPathSet paths = ApplicationPathSet.Resolve( + "relative config", + "dåta", + "cache", + platform); + + Assert.Equal( + Path.GetFullPath( + "relative config", + platform.CurrentDirectoryValue), + paths.ConfigDirectory); + Assert.Equal( + Path.GetFullPath("dåta", platform.CurrentDirectoryValue), + paths.DataDirectory); + Assert.Equal( + Path.GetFullPath("cache", platform.CurrentDirectoryValue), + paths.CacheDirectory); + } + + [Fact] + public void WindowsUsesRoamingConfigAndLocalData() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream-runtime-windows")); + var platform = new FixtureEnvironment(isWindows: true) + { + CurrentDirectoryValue = Path.Combine(root, "work"), + ApplicationData = Path.Combine(root, "AppData", "Roaming"), + LocalApplicationData = + Path.Combine(root, "AppData", "Local"), + }; + + ApplicationPathSet paths = ApplicationPathSet.Resolve( + platform: platform); + + Assert.Equal( + Path.Combine( + platform.ApplicationData, + "acdream"), + paths.ConfigDirectory); + Assert.Equal( + Path.Combine( + platform.LocalApplicationData, + "acdream"), + paths.DataDirectory); + Assert.Equal( + Path.Combine( + platform.LocalApplicationData, + "acdream", + "cache"), + paths.CacheDirectory); + Assert.Equal( + Path.Combine( + platform.LocalApplicationData, + "acdream"), + paths.LegacyConfigDirectory); + } + + private sealed class FixtureEnvironment(bool isWindows) + : IApplicationPathEnvironment + { + public bool IsWindows { get; } = isWindows; + + public string CurrentDirectoryValue { get; init; } = + Environment.CurrentDirectory; + + public string CurrentDirectory => CurrentDirectoryValue; + + public string UserProfile { get; init; } = + Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile); + + public string ApplicationData { get; init; } = + Environment.GetFolderPath( + Environment.SpecialFolder.ApplicationData); + + public string LocalApplicationData { get; init; } = + Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData); + + public Dictionary Variables { get; } = + new(StringComparer.Ordinal); + + public string? GetEnvironmentVariable(string name) => + Variables.TryGetValue(name, out string? value) + ? value + : null; + + public string GetFolderPath( + Environment.SpecialFolder folder) => + folder switch + { + Environment.SpecialFolder.UserProfile => UserProfile, + Environment.SpecialFolder.ApplicationData => + ApplicationData, + Environment.SpecialFolder.LocalApplicationData => + LocalApplicationData, + _ => string.Empty, + }; + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs index d37de98b..17189277 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs @@ -322,11 +322,4 @@ public class KeyBindingsJsonTests } } - [Fact] - public void DefaultPath_lives_under_LocalAppData_acdream() - { - var path = KeyBindings.DefaultPath(); - Assert.Contains("acdream", path); - Assert.EndsWith("keybinds.json", path); - } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index 4f467db6..b6012b6c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -143,13 +143,6 @@ public sealed class SettingsStoreTests : System.IDisposable Assert.Contains("1920x1080", raw); } - [Fact] - public void DefaultPath_is_under_LocalAppData_acdream() - { - var path = SettingsStore.DefaultPath(); - Assert.EndsWith("acdream" + Path.DirectorySeparatorChar + "settings.json", path); - } - // -- Audio section round-trip ---------------------------------------- [Fact]