feat(linux): add graphical platform services

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

View file

@ -11,11 +11,16 @@ on:
- "src/AcDream.Plugin.Abstractions/**" - "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**" - "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**" - "src/AcDream.Headless/**"
- "src/AcDream.App/**"
- "src/AcDream.UI.Abstractions/**"
- "src/AcDream.UI.ImGui/**"
- "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**" - "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**" - "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**" - "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
push: push:
paths: paths:
- ".github/workflows/headless-portability.yml" - ".github/workflows/headless-portability.yml"
@ -26,11 +31,16 @@ on:
- "src/AcDream.Plugin.Abstractions/**" - "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**" - "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**" - "src/AcDream.Headless/**"
- "src/AcDream.App/**"
- "src/AcDream.UI.Abstractions/**"
- "src/AcDream.UI.ImGui/**"
- "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**" - "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**" - "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**" - "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@ -95,3 +105,67 @@ jobs:
Set-Content -LiteralPath headless-k0.json -Value '{"version":1,"sessions":[]}' 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 dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } 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'

View file

@ -63,18 +63,42 @@
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties> <SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference> </ProjectReference>
</ItemGroup> </ItemGroup>
<Target Name="CopySmokePluginToPluginsDir" AfterTargets="Build"> <Target
Name="CopySmokePluginToBuildOutput"
AfterTargets="Build"
Condition="'$(IsCrossTargetingBuild)' != 'true'">
<PropertyGroup> <PropertyGroup>
<_SmokePluginSourceDir>..\AcDream.Plugins.Smoke\bin\$(Configuration)\net10.0</_SmokePluginSourceDir> <_SmokePluginSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework)</_SmokePluginSourceDir>
<_SmokePluginDestDir>$(OutputPath)plugins\AcDream.Plugins.Smoke</_SmokePluginDestDir> <_SmokePluginSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginSourceDir)/$(RuntimeIdentifier)</_SmokePluginSourceDir>
<_SmokePluginDestDir>$(OutputPath)plugins/AcDream.Plugins.Smoke</_SmokePluginDestDir>
</PropertyGroup> </PropertyGroup>
<MakeDir Directories="$(_SmokePluginDestDir)" /> <MakeDir Directories="$(_SmokePluginDestDir)" />
<Copy <Copy
SourceFiles="$(_SmokePluginSourceDir)\AcDream.Plugins.Smoke.dll" SourceFiles="$(_SmokePluginSourceDir)/AcDream.Plugins.Smoke.dll"
DestinationFolder="$(_SmokePluginDestDir)" DestinationFolder="$(_SmokePluginDestDir)"
SkipUnchangedFiles="true" /> SkipUnchangedFiles="true" />
<WriteLinesToFile <WriteLinesToFile
File="$(_SmokePluginDestDir)\plugin.json" File="$(_SmokePluginDestDir)/plugin.json"
Overwrite="true"
Lines="{ &quot;id&quot;: &quot;acdream.smoke&quot;, &quot;displayName&quot;: &quot;Smoke Plugin&quot;, &quot;version&quot;: &quot;0.1.0&quot;, &quot;entryDll&quot;: &quot;AcDream.Plugins.Smoke.dll&quot;, &quot;apiVersion&quot;: 1 }" />
</Target>
<Target
Name="CopySmokePluginToPublishOutput"
AfterTargets="Publish"
Condition="'$(IsCrossTargetingBuild)' != 'true'">
<PropertyGroup>
<_SmokePluginPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework)</_SmokePluginPublishSourceDir>
<_SmokePluginPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginPublishSourceDir)/$(RuntimeIdentifier)</_SmokePluginPublishSourceDir>
<_SmokePluginPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.Smoke</_SmokePluginPublishDestDir>
</PropertyGroup>
<MakeDir Directories="$(_SmokePluginPublishDestDir)" />
<Copy
SourceFiles="$(_SmokePluginPublishSourceDir)/AcDream.Plugins.Smoke.dll"
DestinationFolder="$(_SmokePluginPublishDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile
File="$(_SmokePluginPublishDestDir)/plugin.json"
Overwrite="true" Overwrite="true"
Lines="{ &quot;id&quot;: &quot;acdream.smoke&quot;, &quot;displayName&quot;: &quot;Smoke Plugin&quot;, &quot;version&quot;: &quot;0.1.0&quot;, &quot;entryDll&quot;: &quot;AcDream.Plugins.Smoke.dll&quot;, &quot;apiVersion&quot;: 1 }" /> Lines="{ &quot;id&quot;: &quot;acdream.smoke&quot;, &quot;displayName&quot;: &quot;Smoke Plugin&quot;, &quot;version&quot;: &quot;0.1.0&quot;, &quot;entryDll&quot;: &quot;AcDream.Plugins.Smoke.dll&quot;, &quot;apiVersion&quot;: 1 }" />
</Target> </Target>

View file

@ -54,6 +54,7 @@ internal sealed record WorldRenderDependencies(
IGpuResourceRetirementQueue ResourceRetirement, IGpuResourceRetirementQueue ResourceRetirement,
ResidencyBudgetOptions ResidencyBudgets, ResidencyBudgetOptions ResidencyBudgets,
uint InitialCenterLandblockId, uint InitialCenterLandblockId,
string DiagnosticsDirectory,
Action<string> Log); Action<string> Log);
internal interface IGameWindowWorldRenderPublication internal interface IGameWindowWorldRenderPublication
@ -114,6 +115,7 @@ internal interface IWorldRenderCompositionFactory
IDatReaderWriter dats, IDatReaderWriter dats,
BindlessSupport bindless, BindlessSupport bindless,
IGpuResourceRetirementQueue retirement, IGpuResourceRetirementQueue retirement,
string diagnosticsDirectory,
ResidencyBudgetOptions budgets); ResidencyBudgetOptions budgets);
void RegisterResidencySources( void RegisterResidencySources(
ResidencyManager manager, ResidencyManager manager,
@ -293,8 +295,15 @@ internal sealed class RetailWorldRenderCompositionFactory
IDatReaderWriter dats, IDatReaderWriter dats,
BindlessSupport bindless, BindlessSupport bindless,
IGpuResourceRetirementQueue retirement, IGpuResourceRetirementQueue retirement,
string diagnosticsDirectory,
ResidencyBudgetOptions budgets) => ResidencyBudgetOptions budgets) =>
new(gl, dats, bindless, retirement, budgets); new(
gl,
dats,
bindless,
retirement,
diagnosticsDirectory,
budgets);
public void RegisterResidencySources( public void RegisterResidencySources(
ResidencyManager manager, ResidencyManager manager,
@ -527,6 +536,7 @@ internal sealed class WorldRenderCompositionPhase
content.Dats, content.Dats,
bindless, bindless,
_dependencies.ResourceRetirement, _dependencies.ResourceRetirement,
_dependencies.DiagnosticsDirectory,
residency.Budgets), residency.Budgets),
_publication.PublishTextureCache, _publication.PublishTextureCache,
WorldRenderCompositionPoint.TextureCachePublished); WorldRenderCompositionPoint.TextureCachePublished);

View file

@ -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<GraphicalNativeDependency> 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<GraphicalNativeDependency>
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)),
};
}

View file

@ -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<string> Migrate(
ApplicationPathSet paths)
{
ArgumentNullException.ThrowIfNull(paths);
if (paths.LegacyConfigDirectory is not { } legacyDirectory
|| PathsEqual(legacyDirectory, paths.ConfigDirectory))
{
return [];
}
List<string>? 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);
}

View file

@ -1,13 +1,23 @@
using AcDream.App; using AcDream.App;
using AcDream.App.Plugins; using AcDream.App.Plugins;
using AcDream.App.Platform;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.Core.Plugins; using AcDream.Core.Plugins;
using AcDream.Runtime.Platform;
using Serilog; using Serilog;
GraphicalHostPlatformServices graphicalPlatform =
GraphicalHostPlatformServices.Resolve();
ApplicationPathSet applicationPaths = graphicalPlatform.Paths;
IReadOnlyList<string> migratedConfigurationFiles =
GraphicalLegacyConfigurationMigrator.Migrate(applicationPaths);
if (args.Length >= 1 && args[0] == "ui-studio") if (args.Length >= 1 && args[0] == "ui-studio")
{ {
var so = AcDream.App.Studio.StudioOptions.Parse(args[1..]); 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(); sw.Run();
return 0; return 0;
} }
@ -16,6 +26,20 @@ Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug() .MinimumLevel.Debug()
.WriteTo.Console() .WriteTo.Console()
.CreateLogger(); .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"); var datDir = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (string.IsNullOrWhiteSpace(datDir)) if (string.IsNullOrWhiteSpace(datDir))
@ -36,7 +60,8 @@ using var window = new GameWindow(
runtimeOptions, runtimeOptions,
worldGameState, worldGameState,
worldEvents, worldEvents,
uiRegistry); uiRegistry,
graphicalPlatform);
var host = new AppPluginHost( var host = new AppPluginHost(
new SerilogAdapter(Log.Logger), new SerilogAdapter(Log.Logger),
worldGameState, worldGameState,
@ -44,27 +69,65 @@ var host = new AppPluginHost(
window.Selection, window.Selection,
uiRegistry); uiRegistry);
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
var loaded = new List<LoadedPlugin>(); var loaded = new List<LoadedPlugin>();
foreach (var result in PluginDiscovery.Scan(pluginsDir)) var loadedPluginIds = new HashSet<string>(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)
{ {
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
foreach (var result in PluginDiscovery.Scan(pluginsDir))
{
if (!result.Success) if (!result.Success)
{ {
Log.Warning("plugin discovery failed for {Dir}: {Error}", result.PluginDirectory, result.Error); Log.Warning(
"plugin discovery failed for {Dir}: {Error}",
result.PluginDirectory,
result.Error);
continue; continue;
} }
var loadResult = PluginLoader.Load(result.PluginDirectory, result.Manifest!, host); if (loadedPluginIds.Contains(result.Manifest!.Id))
{
Log.Warning(
"skipping duplicate plugin id {Id} from {Dir}",
result.Manifest.Id,
result.PluginDirectory);
continue;
}
var loadResult = PluginLoader.Load(
result.PluginDirectory,
result.Manifest,
host);
if (!loadResult.Success) if (!loadResult.Success)
{ {
Log.Warning("plugin load failed for {Id}: {Error}", result.Manifest!.Id, loadResult.Error); Log.Warning(
"plugin load failed for {Id}: {Error}",
result.Manifest.Id,
loadResult.Error);
continue; continue;
} }
loadedPluginIds.Add(result.Manifest.Id);
loaded.Add(loadResult); loaded.Add(loadResult);
Log.Information("loaded plugin {Id} ({DisplayName})", result.Manifest!.Id, result.Manifest.DisplayName); Log.Information(
"loaded plugin {Id} ({DisplayName})",
result.Manifest.Id,
result.Manifest.DisplayName);
}
} }
try try

View file

@ -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( internal DisplayFramePacingController(
bool uncappedRendering, bool uncappedRendering,
FrameProfiler profiler, FrameProfiler profiler,

View file

@ -25,9 +25,17 @@ internal sealed class FramePacingController : IDisposable
private bool _disposed; private bool _disposed;
public FramePacingController() public FramePacingController()
: this(PlatformFramePacingWaiterFactory.ForCurrentProcess())
{
}
internal FramePacingController(
IFramePacingWaiterFactory waiterFactory)
: this( : this(
StopwatchFramePacingClock.Instance, StopwatchFramePacingClock.Instance,
WindowsHighResolutionFramePacingWaiter.Create(), (waiterFactory
?? throw new ArgumentNullException(nameof(waiterFactory)))
.Create(),
ownsWaiter: true) ownsWaiter: true)
{ {
} }

View file

@ -0,0 +1,38 @@
using AcDream.App.Platform;
namespace AcDream.App.Rendering;
internal interface IFramePacingWaiterFactory
{
IFramePacingWaiter Create();
}
/// <summary>
/// The sole startup-time platform selector for software frame waits.
/// Frame policy and deadline behavior remain platform-neutral.
/// </summary>
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)),
};
}

View file

@ -4,9 +4,11 @@ using AcDream.App.Physics;
using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.Settings; using AcDream.App.Settings;
using AcDream.App.Platform;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Content; using AcDream.Content;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Platform;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
@ -498,16 +500,19 @@ public sealed class GameWindow :
_runtimeDiagnosticCommands = new(); _runtimeDiagnosticCommands = new();
private readonly AcDream.App.Combat.LiveCombatModeCommandSlot private readonly AcDream.App.Combat.LiveCombatModeCommandSlot
_liveCombatModeCommands = new(); _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 // 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 // or corrupt. This is THE single source of truth for the keymap at
// startup — no other call to RetailDefaults() / AcdreamCurrentDefaults() // startup — no other call to RetailDefaults() / AcdreamCurrentDefaults()
// should land in the GameWindow construction path. // 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); var bindings = AcDream.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path);
Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}"); Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}");
return bindings; return bindings;
@ -571,8 +576,28 @@ public sealed class GameWindow :
WorldGameState worldGameState, WorldGameState worldGameState,
WorldEvents worldEvents, WorldEvents worldEvents,
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) 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)); _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( _runtime = new GameRuntime(new GameRuntimeDependencies(
_combatAttackOperations, _combatAttackOperations,
_combatTargetOperations, _combatTargetOperations,
@ -611,10 +636,11 @@ public sealed class GameWindow :
_worldEvents = worldEvents; _worldEvents = worldEvents;
_displayFramePacing = new DisplayFramePacingController( _displayFramePacing = new DisplayFramePacingController(
options.UncappedRendering, options.UncappedRendering,
_frameProfiler); _frameProfiler,
_platformServices.FramePacingWaiters);
_runtimeSettings = new RuntimeSettingsController( _runtimeSettings = new RuntimeSettingsController(
new JsonRuntimeSettingsStorage( new JsonRuntimeSettingsStorage(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()), _applicationPaths.SettingsFile),
log: Console.WriteLine); log: Console.WriteLine);
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry; _uiRegistry = uiRegistry;
@ -1209,7 +1235,9 @@ public sealed class GameWindow :
devToolsFrameDiagnostics); devToolsFrameDiagnostics);
IRuntimeKeyBindingTarget? keyBindingTarget = IRuntimeKeyBindingTarget? keyBindingTarget =
hostInputCamera.InputDispatcher is { } settingsDispatcher hostInputCamera.InputDispatcher is { } settingsDispatcher
? new RuntimeKeyBindingTarget(settingsDispatcher) ? new RuntimeKeyBindingTarget(
settingsDispatcher,
_applicationPaths.KeyBindingsFile)
: null; : null;
optionalDevTools = new SettingsDevToolsOptionalDependencies( optionalDevTools = new SettingsDevToolsOptionalDependencies(
devToolsFacts, devToolsFacts,
@ -1249,6 +1277,7 @@ public sealed class GameWindow :
_gpuFrameFlights!, _gpuFrameFlights!,
_options.ResidencyBudgets, _options.ResidencyBudgets,
initialCenterLandblockId, initialCenterLandblockId,
_applicationPaths.DiagnosticsDirectory,
Console.WriteLine), Console.WriteLine),
this).Compose(platformResult, contentEffectsAudio, settingsDevTools); this).Compose(platformResult, contentEffectsAudio, settingsDevTools);
Console.WriteLine( Console.WriteLine(

View file

@ -0,0 +1,151 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace AcDream.App.Rendering;
/// <summary>
/// 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.
/// </summary>
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);
}

View file

@ -124,7 +124,8 @@ public sealed record RenderStack(
/// <summary>Options for <see cref="RenderBootstrap.Create"/>.</summary> /// <summary>Options for <see cref="RenderBootstrap.Create"/>.</summary>
public sealed record RenderBootstrapOptions( public sealed record RenderBootstrapOptions(
AcDream.UI.Abstractions.Settings.QualitySettings Quality); AcDream.UI.Abstractions.Settings.QualitySettings Quality,
string DiagnosticsDirectory);
/// <summary> /// <summary>
/// Constructs the UI Studio's render stack from the production classes, /// Constructs the UI Studio's render stack from the production classes,
@ -164,7 +165,12 @@ public static class RenderBootstrap
// --- TextureCache (GameWindow ~1774) --- // --- TextureCache (GameWindow ~1774) ---
var frameFlights = new GpuFrameFlightController(gl); 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) --- // --- AnimLoader (GameWindow ~1240) ---
var animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(dats); var animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(dats);

View file

@ -18,6 +18,7 @@ public sealed unsafe class TextureCache
{ {
private readonly GL _gl; private readonly GL _gl;
private readonly IDatReaderWriter _dats; private readonly IDatReaderWriter _dats;
private readonly string _diagnosticsDirectory;
// Handle and decoded dimensions are one atomic cache entry. Keeping them // Handle and decoded dimensions are one atomic cache entry. Keeping them
// in separate dictionaries allowed GetOrUpload(surfaceId) followed by the // in separate dictionaries allowed GetOrUpload(surfaceId) followed by the
// sized overload to upload a second GL texture and orphan the first. // sized overload to upload a second GL texture and orphan the first.
@ -86,7 +87,15 @@ public sealed unsafe class TextureCache
private bool _surfaceHistogramAlreadyDumped; private bool _surfaceHistogramAlreadyDumped;
public TextureCache(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null) 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, IDatReaderWriter dats,
Wb.BindlessSupport? bindless, Wb.BindlessSupport? bindless,
IGpuResourceRetirementQueue retirementQueue, IGpuResourceRetirementQueue retirementQueue,
string diagnosticsDirectory,
ResidencyBudgetOptions? budgets = null) ResidencyBudgetOptions? budgets = null)
{ {
budgets ??= ResidencyBudgetOptions.Default; budgets ??= ResidencyBudgetOptions.Default;
_gl = gl; _gl = gl;
_dats = dats; _dats = dats;
_bindless = bindless; _bindless = bindless;
ArgumentException.ThrowIfNullOrWhiteSpace(diagnosticsDirectory);
_diagnosticsDirectory = diagnosticsDirectory;
ArgumentNullException.ThrowIfNull(retirementQueue); ArgumentNullException.ThrowIfNull(retirementQueue);
if (bindless is not null) 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 /// pulled in world content (not just sky/UI/font). The original
/// frame-only gate fired during the login/handshake phase where /// frame-only gate fired during the login/handshake phase where
/// OnRender ticks at GUI rates but no world has streamed in. /// 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 /// when off. See spec §5 in
/// docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md. /// docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md.
/// </summary> /// </summary>
@ -638,10 +651,10 @@ public sealed unsafe class TextureCache
private void DumpSurfaceHistogramCore() private void DumpSurfaceHistogramCore()
{ {
var localAppData = System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData); System.IO.Directory.CreateDirectory(_diagnosticsDirectory);
var outDir = System.IO.Path.Combine(localAppData, "acdream"); var outPath = System.IO.Path.Combine(
System.IO.Directory.CreateDirectory(outDir); _diagnosticsDirectory,
var outPath = System.IO.Path.Combine(outDir, "n6-surfaces.txt"); "n6-surfaces.txt");
var sb = new System.Text.StringBuilder(); var sb = new System.Text.StringBuilder();
sb.AppendLine($"# acdream surface-format histogram — generated {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}"); sb.AppendLine($"# acdream surface-format histogram — generated {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}");

View file

@ -20,11 +20,12 @@ internal sealed class RuntimeKeyBindingTarget : IRuntimeKeyBindingTarget
public RuntimeKeyBindingTarget( public RuntimeKeyBindingTarget(
InputDispatcher dispatcher, InputDispatcher dispatcher,
string? path = null, string path,
Action<string>? log = null) Action<string>? log = null)
{ {
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_path = path ?? KeyBindings.DefaultPath(); ArgumentException.ThrowIfNullOrWhiteSpace(path);
_path = path;
_log = log ?? Console.WriteLine; _log = log ?? Console.WriteLine;
} }

View file

@ -2,6 +2,7 @@ using System.Numerics;
using AcDream.Content; using AcDream.Content;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Runtime.Platform;
using DatReaderWriter; using DatReaderWriter;
using Silk.NET.Input; using Silk.NET.Input;
using Silk.NET.Maths; using Silk.NET.Maths;
@ -36,6 +37,7 @@ namespace AcDream.App.Studio;
public sealed class StudioWindow : IDisposable public sealed class StudioWindow : IDisposable
{ {
private readonly StudioOptions _opts; private readonly StudioOptions _opts;
private readonly ApplicationPathSet _applicationPaths;
// Created in OnLoad, released in OnClosing. // Created in OnLoad, released in OnClosing.
private IWindow? _window; private IWindow? _window;
@ -64,8 +66,17 @@ public sealed class StudioWindow : IDisposable
private bool _screenshotDone; private bool _screenshotDone;
public StudioWindow(StudioOptions opts) public StudioWindow(StudioOptions opts)
: this(opts, ApplicationPathSet.Resolve())
{
}
internal StudioWindow(
StudioOptions opts,
ApplicationPathSet applicationPaths)
{ {
_opts = opts ?? throw new ArgumentNullException(nameof(opts)); _opts = opts ?? throw new ArgumentNullException(nameof(opts));
_applicationPaths = applicationPaths
?? throw new ArgumentNullException(nameof(applicationPaths));
} }
/// <summary> /// <summary>
@ -77,7 +88,7 @@ public sealed class StudioWindow : IDisposable
// Resolve quality settings the same way GameWindow.Run() does // Resolve quality settings the same way GameWindow.Run() does
// (SettingsStore → QualitySettings.From → WithEnvOverrides). // (SettingsStore → QualitySettings.From → WithEnvOverrides).
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); _applicationPaths.SettingsFile);
var startupDisplay = startupStore.LoadDisplay(); var startupDisplay = startupStore.LoadDisplay();
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); 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 // Build QualitySettings for RenderBootstrap (same as Run() above — re-read
// after the GL context is confirmed, mirroring GameWindow.OnLoad). // after the GL context is confirmed, mirroring GameWindow.OnLoad).
var store = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( var store = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); _applicationPaths.SettingsFile);
var display = store.LoadDisplay(); var display = store.LoadDisplay();
var quality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides( var quality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(
AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality)); 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) if (_opts.Mockup)
MockupDesktop.Load(_dats, _stack); MockupDesktop.Load(_dats, _stack);

View file

@ -1,4 +1,5 @@
using AcDream.Headless.Configuration; using AcDream.Headless.Configuration;
using AcDream.Runtime.Platform;
namespace AcDream.Headless.Platform; namespace AcDream.Headless.Platform;
@ -14,86 +15,24 @@ internal sealed record HeadlessPathSet(
ArgumentNullException.ThrowIfNull(overrides); ArgumentNullException.ThrowIfNull(overrides);
platform ??= HeadlessPlatformEnvironment.Instance; platform ??= HeadlessPlatformEnvironment.Instance;
string config; try
string data;
string cache;
if (platform.IsWindows)
{ {
string roaming = RequireFolder( ApplicationPathSet paths = ApplicationPathSet.Resolve(
platform, overrides.ConfigDirectory,
Environment.SpecialFolder.ApplicationData); overrides.DataDirectory,
string local = RequireFolder( overrides.CacheDirectory,
platform, 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");
}
return new HeadlessPathSet( return new HeadlessPathSet(
Normalize(overrides.ConfigDirectory ?? config, platform), paths.ConfigDirectory,
Normalize(overrides.DataDirectory ?? data, platform), paths.DataDirectory,
Normalize(overrides.CacheDirectory ?? cache, platform)); paths.CacheDirectory);
} }
catch (Exception exception)
private static string ResolveXdg( when (exception is ArgumentException
IHeadlessPlatformEnvironment platform, or InvalidOperationException
string variable, or NotSupportedException)
string fallback,
string leaf)
{ {
string? configured = platform.GetEnvironmentVariable(variable); throw new HeadlessConfigurationException(exception.Message);
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));
} }
} }

View file

@ -1,11 +1,10 @@
using AcDream.Runtime.Platform;
namespace AcDream.Headless.Platform; namespace AcDream.Headless.Platform;
internal interface IHeadlessPlatformEnvironment internal interface IHeadlessPlatformEnvironment
: IApplicationPathEnvironment
{ {
bool IsWindows { get; }
string CurrentDirectory { get; }
string? GetEnvironmentVariable(string name);
string GetFolderPath(Environment.SpecialFolder folder);
} }
internal sealed class HeadlessPlatformEnvironment internal sealed class HeadlessPlatformEnvironment

View file

@ -0,0 +1,171 @@
namespace AcDream.Runtime.Platform;
/// <summary>
/// BCL-only view of the process environment used to resolve portable
/// application paths exactly once at a host composition root.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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));
}
}

View file

@ -18,7 +18,7 @@ namespace AcDream.UI.Abstractions.Input;
/// K.1c: <see cref="RetailDefaults"/> now returns the full retail-faithful /// K.1c: <see cref="RetailDefaults"/> now returns the full retail-faithful
/// preset (byte-precise to <c>retail-default.keymap.txt</c>); /// preset (byte-precise to <c>retail-default.keymap.txt</c>);
/// <see cref="LoadOrDefault"/> + <see cref="SaveToFile"/> persist the /// <see cref="LoadOrDefault"/> + <see cref="SaveToFile"/> persist the
/// table to JSON under <see cref="DefaultPath"/> with version-based /// table to JSON at a host-provided portable path with version-based
/// migration. <see cref="AcdreamCurrentDefaults"/> is preserved as a /// migration. <see cref="AcdreamCurrentDefaults"/> is preserved as a
/// reference for tests pinning the older WASD-only behavior, but is no /// reference for tests pinning the older WASD-only behavior, but is no
/// longer the GameWindow startup source. /// longer the GameWindow startup source.
@ -596,11 +596,4 @@ public sealed class KeyBindings
return result; return result;
} }
/// <summary>
/// Default path: <c>%LOCALAPPDATA%\acdream\keybinds.json</c>.
/// </summary>
public static string DefaultPath() => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"acdream",
"keybinds.json");
} }

View file

@ -13,7 +13,8 @@ public readonly record struct UiWindowPosition(float X, float Y);
/// <summary> /// <summary>
/// JSON-backed persistence for non-keybind settings (Display today; future /// JSON-backed persistence for non-keybind settings (Display today; future
/// tabs Audio / Gameplay / Chat / Character will be added to the same /// tabs Audio / Gameplay / Chat / Character will be added to the same
/// file). Path: <c>%LOCALAPPDATA%\acdream\settings.json</c>. Coexists /// file). The graphical host supplies a canonical portable configuration
/// path. Coexists
/// with <c>keybinds.json</c>, which retains its own /// with <c>keybinds.json</c>, which retains its own
/// <see cref="Input.KeyBindings.LoadOrDefault"/> path. /// <see cref="Input.KeyBindings.LoadOrDefault"/> path.
/// ///
@ -41,12 +42,6 @@ public sealed class SettingsStore
_path = path ?? throw new ArgumentNullException(nameof(path)); _path = path ?? throw new ArgumentNullException(nameof(path));
} }
/// <summary>Default path: <c>%LOCALAPPDATA%\acdream\settings.json</c>.</summary>
public static string DefaultPath() => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"acdream",
"settings.json");
/// <summary> /// <summary>
/// Load Display settings. Missing file → <see cref="DisplaySettings.Default"/>. /// Load Display settings. Missing file → <see cref="DisplaySettings.Default"/>.
/// Missing individual keys fall back to the corresponding default /// Missing individual keys fall back to the corresponding default

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, string> 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,
};
}
}

View file

@ -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);
}
} }

View file

@ -143,13 +143,6 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.Contains("1920x1080", raw); 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 ---------------------------------------- // -- Audio section round-trip ----------------------------------------
[Fact] [Fact]