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]