feat(linux): add graphical platform services
This commit is contained in:
parent
1628d9f587
commit
66f114b258
28 changed files with 1328 additions and 155 deletions
74
.github/workflows/headless-portability.yml
vendored
74
.github/workflows/headless-portability.yml
vendored
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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="{ "id": "acdream.smoke", "displayName": "Smoke Plugin", "version": "0.1.0", "entryDll": "AcDream.Plugins.Smoke.dll", "apiVersion": 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="{ "id": "acdream.smoke", "displayName": "Smoke Plugin", "version": "0.1.0", "entryDll": "AcDream.Plugins.Smoke.dll", "apiVersion": 1 }" />
|
Lines="{ "id": "acdream.smoke", "displayName": "Smoke Plugin", "version": "0.1.0", "entryDll": "AcDream.Plugins.Smoke.dll", "apiVersion": 1 }" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
102
src/AcDream.App/Platform/GraphicalHostPlatformServices.cs
Normal file
102
src/AcDream.App/Platform/GraphicalHostPlatformServices.cs
Normal 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
src/AcDream.App/Rendering/FramePacingWaiterFactory.cs
Normal file
38
src/AcDream.App/Rendering/FramePacingWaiterFactory.cs
Normal 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
151
src/AcDream.App/Rendering/LinuxMonotonicFramePacingWaiter.cs
Normal file
151
src/AcDream.App/Rendering/LinuxMonotonicFramePacingWaiter.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
171
src/AcDream.Runtime/Platform/ApplicationPathSet.cs
Normal file
171
src/AcDream.Runtime/Platform/ApplicationPathSet.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
123
tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs
Normal file
123
tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
162
tests/AcDream.Runtime.Tests/Platform/ApplicationPathSetTests.cs
Normal file
162
tests/AcDream.Runtime.Tests/Platform/ApplicationPathSetTests.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue