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

@ -72,6 +72,17 @@ internal sealed class DisplayFramePacingController : IDisposable
{
}
internal DisplayFramePacingController(
bool uncappedRendering,
FrameProfiler profiler,
IFramePacingWaiterFactory waiterFactory)
: this(
uncappedRendering,
profiler,
new FramePacingController(waiterFactory))
{
}
internal DisplayFramePacingController(
bool uncappedRendering,
FrameProfiler profiler,

View file

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

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.Wb;
using AcDream.App.Settings;
using AcDream.App.Platform;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Runtime;
using AcDream.Runtime.Platform;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
@ -498,16 +500,19 @@ public sealed class GameWindow :
_runtimeDiagnosticCommands = new();
private readonly AcDream.App.Combat.LiveCombatModeCommandSlot
_liveCombatModeCommands = new();
// K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json,
// K.1c/L0: load user-customized bindings from the canonical per-platform
// configuration path,
// falling back to the retail-faithful defaults if the file is missing
// or corrupt. This is THE single source of truth for the keymap at
// startup — no other call to RetailDefaults() / AcdreamCurrentDefaults()
// should land in the GameWindow construction path.
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings = LoadStartupKeyBindings();
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings;
private readonly GraphicalHostPlatformServices _platformServices;
private readonly ApplicationPathSet _applicationPaths;
private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings()
private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings(
string path)
{
var path = AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath();
var bindings = AcDream.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path);
Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}");
return bindings;
@ -571,8 +576,28 @@ public sealed class GameWindow :
WorldGameState worldGameState,
WorldEvents worldEvents,
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
: this(
options,
worldGameState,
worldEvents,
uiRegistry,
GraphicalHostPlatformServices.Resolve())
{
}
internal GameWindow(
AcDream.App.RuntimeOptions options,
WorldGameState worldGameState,
WorldEvents worldEvents,
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry,
GraphicalHostPlatformServices platformServices)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_platformServices = platformServices
?? throw new ArgumentNullException(nameof(platformServices));
_applicationPaths = _platformServices.Paths;
_keyBindings = LoadStartupKeyBindings(
_applicationPaths.KeyBindingsFile);
_runtime = new GameRuntime(new GameRuntimeDependencies(
_combatAttackOperations,
_combatTargetOperations,
@ -611,10 +636,11 @@ public sealed class GameWindow :
_worldEvents = worldEvents;
_displayFramePacing = new DisplayFramePacingController(
options.UncappedRendering,
_frameProfiler);
_frameProfiler,
_platformServices.FramePacingWaiters);
_runtimeSettings = new RuntimeSettingsController(
new JsonRuntimeSettingsStorage(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()),
_applicationPaths.SettingsFile),
log: Console.WriteLine);
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry;
@ -1209,7 +1235,9 @@ public sealed class GameWindow :
devToolsFrameDiagnostics);
IRuntimeKeyBindingTarget? keyBindingTarget =
hostInputCamera.InputDispatcher is { } settingsDispatcher
? new RuntimeKeyBindingTarget(settingsDispatcher)
? new RuntimeKeyBindingTarget(
settingsDispatcher,
_applicationPaths.KeyBindingsFile)
: null;
optionalDevTools = new SettingsDevToolsOptionalDependencies(
devToolsFacts,
@ -1249,6 +1277,7 @@ public sealed class GameWindow :
_gpuFrameFlights!,
_options.ResidencyBudgets,
initialCenterLandblockId,
_applicationPaths.DiagnosticsDirectory,
Console.WriteLine),
this).Compose(platformResult, contentEffectsAudio, settingsDevTools);
Console.WriteLine(

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

View file

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