From 2b6bd5a532da1af7287069a41d87d7a30bc57c31 Mon Sep 17 00:00:00 2001
From: Berk <12572227+par274@users.noreply.github.com>
Date: Tue, 28 Jul 2026 03:33:26 +0300
Subject: [PATCH] Sdl backend (#670)
* [audio] added sdl audio backend and in-tree atrac9 decoder
* [input] replaced per-platform pad readers with sdl gamepad input
* [video] added sdl window and host display plumbing
* [gui] added host display options and per-game render settings
* [bink] synced host movie playback to the guest audio clock
* [cpu] hooked windows write faults into guest image tracking
* [perf] added guest and render profiling, reserved host cpu lanes
* [kernel] fixed stale pthread mutex handle alias
* [host] wired the sdl session, save-data paths and project references
* [audio] hoisted ajm trace stackalloc out of its loop
* [video] Add guest image sync setting
* [build] Strip native symbols
* reuse
---
Directory.Packages.props | 3 +-
REUSE.toml | 1 +
SharpEmu.slnx | 1 +
src/SharpEmu.CLI/Program.cs | 368 +++-
src/SharpEmu.CLI/SharpEmu.CLI.csproj | 46 +-
.../DirectExecutionBackend.Diagnostics.cs | 70 +-
.../DirectExecutionBackend.Exceptions.cs | 9 +
.../DirectExecutionBackend.GuestSampler.cs | 322 +++
.../Native/DirectExecutionBackend.Imports.cs | 58 +-
.../Cpu/Native/DirectExecutionBackend.cs | 41 +-
src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs | 1 +
src/SharpEmu.GUI/GameSurfaceHost.cs | 620 ------
src/SharpEmu.GUI/GuiSettings.cs | 34 +
src/SharpEmu.GUI/HostDisplayOptions.cs | 138 ++
src/SharpEmu.GUI/Languages/en.json | 20 +
src/SharpEmu.GUI/Languages/tr.json | 20 +
src/SharpEmu.GUI/MainWindow.axaml | 102 +-
src/SharpEmu.GUI/MainWindow.axaml.cs | 551 +++--
src/SharpEmu.GUI/PerGameSettings.cs | 35 +
src/SharpEmu.GUI/PerGameSettingsDialog.cs | 248 ++-
src/SharpEmu.GUI/SharpEmu.GUI.csproj | 7 +-
src/SharpEmu.HLE/CpuContext.cs | 9 +
src/SharpEmu.HLE/GuestImageWriteTracker.cs | 17 +-
src/SharpEmu.HLE/Host/GuestAudioClock.cs | 69 +
src/SharpEmu.HLE/Host/HostGamepadState.cs | 91 +-
src/SharpEmu.HLE/Host/IHostAudioStream.cs | 13 +
src/SharpEmu.HLE/Host/IHostInput.cs | 5 +
src/SharpEmu.HLE/Host/IHostPcmAudioOutput.cs | 19 +
.../Host/IHostWindowInputSource.cs | 42 +
src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs | 186 --
.../Host/Posix/PosixHostPlatform.cs | 6 +-
src/SharpEmu.HLE/Host/Sdl/SdlHostAudio.cs | 325 +++
src/SharpEmu.HLE/Host/WindowHostInput.cs | 43 +
.../Host/Windows/WindowsDualSenseReader.cs | 439 ----
.../Host/Windows/WindowsHidNative.cs | 141 --
.../Host/Windows/WindowsHostInput.cs | 101 -
.../Host/Windows/WindowsHostPlatform.cs | 6 +-
.../Host/Windows/WindowsXInputReader.cs | 277 ---
src/SharpEmu.HLE/HostMainThread.cs | 4 +-
src/SharpEmu.HLE/HostSessionControl.cs | 17 -
src/SharpEmu.HLE/SharpEmu.HLE.csproj | 4 +
.../Atrac9Config.cs | 1 +
.../Atrac9Decoder.cs | 1 +
.../Atrac9Rng.cs | 1 +
.../BandExtension.cs | 1 +
.../BitAllocation.cs | 1 +
.../Atrac9 => SharpEmu.LibAtrac9}/Block.cs | 1 +
.../Atrac9 => SharpEmu.LibAtrac9}/Channel.cs | 1 +
.../ChannelConfig.cs | 1 +
.../Atrac9 => SharpEmu.LibAtrac9}/Frame.cs | 1 +
.../HuffmanCodebook.cs | 1 +
.../HuffmanCodebooks.cs | 3 +-
.../Atrac9 => SharpEmu.LibAtrac9}/LICENSE.txt | 0
.../Quantization.cs | 1 +
.../ScaleFactors.cs | 1 +
.../SharpEmu.LibAtrac9.csproj | 12 +
.../Atrac9 => SharpEmu.LibAtrac9}/Stereo.cs | 1 +
.../Atrac9 => SharpEmu.LibAtrac9}/Tables.cs | 1 +
.../Atrac9 => SharpEmu.LibAtrac9}/Unpack.cs | 1 +
.../Utilities/Bit.cs | 1 +
.../Utilities/BitReader.cs | 1 +
.../Utilities/Helpers.cs | 1 +
.../Utilities/Mdct.cs | 1 +
src/SharpEmu.Libs/Acm/AcmExports.cs | 212 +-
src/SharpEmu.Libs/Agc/AgcExports.cs | 11 +
src/SharpEmu.Libs/Agc/GpuWaitProfile.cs | 160 ++
src/SharpEmu.Libs/Audio/AjmExports.cs | 985 ++++++++-
src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs | 409 ++++
src/SharpEmu.Libs/Audio/AudioOutExports.cs | 63 +-
src/SharpEmu.Libs/Audio/AudioPcmConversion.cs | 40 +
src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs | 4 +-
src/SharpEmu.Libs/Bink/BinkFramePlayback.cs | 136 +-
.../Bink/FfmpegNativeBinkFrameSource.cs | 515 ++++-
src/SharpEmu.Libs/Codec/CodecExports.cs | 624 +++++-
src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs | 16 +-
.../Gpu/Metal/MetalGuestGpuBackend.cs | 2 +-
src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs | 182 --
src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs | 91 +-
.../Gpu/Metal/MetalVideoPresenter.cs | 470 +----
.../Gpu/Vulkan/VulkanGuestGpuBackend.cs | 6 +-
.../Kernel/KernelMemoryCompatExports.cs | 30 +-
.../Kernel/KernelPthreadCompatExports.cs | 106 +-
src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs | 55 +-
src/SharpEmu.Libs/Pad/HostWindowInput.cs | 366 ++--
src/SharpEmu.Libs/Pad/PadExports.cs | 234 +-
src/SharpEmu.Libs/Pad/PadState.cs | 9 +-
.../Pad/SdlGamepadStateReader.cs | 121 ++
src/SharpEmu.Libs/Pad/SdlLauncherGamepad.cs | 85 +
src/SharpEmu.Libs/SaveData/SaveDataExports.cs | 22 +-
src/SharpEmu.Libs/SaveData/SaveDataStorage.cs | 67 +-
src/SharpEmu.Libs/SharpEmu.Libs.csproj | 5 +-
.../VideoOut/HostDisplayCatalog.cs | 129 ++
.../VideoOut/HostVideoOptions.cs | 68 +
.../VideoOut/RenderPhaseProfile.cs | 252 +++
src/SharpEmu.Libs/VideoOut/SdlHostWindow.cs | 832 ++++++++
src/SharpEmu.Libs/VideoOut/VideoOutExports.cs | 78 +-
.../VideoOut/VulkanHostSurface.cs | 270 ---
.../VideoOut/VulkanPipelineCacheStorage.cs | 96 +
.../VideoOut/VulkanVideoPresenter.cs | 1879 ++++++++++-------
.../SpirvFixedShaders.cs | 161 +-
.../Audio/AcmExportsTests.cs | 130 ++
.../Audio/AjmExportsTests.cs | 231 ++
.../GUI/GuiSettingsTests.cs | 79 +
.../GUI/HostDisplayOptionsTests.cs | 51 +
.../Kernel/KernelGameLogPathTests.cs | 96 +
.../Pad/HostWindowInputTests.cs | 159 +-
.../Pthread/PthreadMutexSemanticsTests.cs | 83 +
.../SaveDataStorageTests.cs | 41 +-
.../VideoOut/HostVideoOptionsTests.cs | 29 +
.../VideoOut/VideoOutPixelFormatTests.cs | 18 +
.../VulkanPipelineCacheStorageTests.cs | 74 +
111 files changed, 9846 insertions(+), 4479 deletions(-)
create mode 100644 src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.GuestSampler.cs
delete mode 100644 src/SharpEmu.GUI/GameSurfaceHost.cs
create mode 100644 src/SharpEmu.GUI/HostDisplayOptions.cs
create mode 100644 src/SharpEmu.HLE/Host/GuestAudioClock.cs
create mode 100644 src/SharpEmu.HLE/Host/IHostPcmAudioOutput.cs
create mode 100644 src/SharpEmu.HLE/Host/IHostWindowInputSource.cs
delete mode 100644 src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs
create mode 100644 src/SharpEmu.HLE/Host/Sdl/SdlHostAudio.cs
create mode 100644 src/SharpEmu.HLE/Host/WindowHostInput.cs
delete mode 100644 src/SharpEmu.HLE/Host/Windows/WindowsDualSenseReader.cs
delete mode 100644 src/SharpEmu.HLE/Host/Windows/WindowsHidNative.cs
delete mode 100644 src/SharpEmu.HLE/Host/Windows/WindowsHostInput.cs
delete mode 100644 src/SharpEmu.HLE/Host/Windows/WindowsXInputReader.cs
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Atrac9Config.cs (99%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Atrac9Decoder.cs (99%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Atrac9Rng.cs (96%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/BandExtension.cs (99%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/BitAllocation.cs (99%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Block.cs (98%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Channel.cs (98%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/ChannelConfig.cs (96%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Frame.cs (94%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/HuffmanCodebook.cs (98%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/HuffmanCodebooks.cs (99%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/LICENSE.txt (100%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Quantization.cs (98%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/ScaleFactors.cs (99%)
create mode 100644 src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Stereo.cs (97%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Tables.cs (99%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Unpack.cs (99%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Utilities/Bit.cs (96%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Utilities/BitReader.cs (99%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Utilities/Helpers.cs (98%)
rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Utilities/Mdct.cs (99%)
create mode 100644 src/SharpEmu.Libs/Agc/GpuWaitProfile.cs
create mode 100644 src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs
delete mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs
create mode 100644 src/SharpEmu.Libs/Pad/SdlGamepadStateReader.cs
create mode 100644 src/SharpEmu.Libs/Pad/SdlLauncherGamepad.cs
create mode 100644 src/SharpEmu.Libs/VideoOut/HostDisplayCatalog.cs
create mode 100644 src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs
create mode 100644 src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs
create mode 100644 src/SharpEmu.Libs/VideoOut/SdlHostWindow.cs
delete mode 100644 src/SharpEmu.Libs/VideoOut/VulkanHostSurface.cs
create mode 100644 src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs
create mode 100644 tests/SharpEmu.Libs.Tests/Audio/AcmExportsTests.cs
create mode 100644 tests/SharpEmu.Libs.Tests/GUI/HostDisplayOptionsTests.cs
create mode 100644 tests/SharpEmu.Libs.Tests/Kernel/KernelGameLogPathTests.cs
create mode 100644 tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs
create mode 100644 tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineCacheStorageTests.cs
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 267a07aa..56deb2ea 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -18,11 +18,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
+
-
diff --git a/REUSE.toml b/REUSE.toml
index b7162dd2..b789e0c2 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -7,6 +7,7 @@ path = [
"global.json",
"**/packages.lock.json",
"scripts/ps5_names.txt",
+ "src/SharpEmu.LibAtrac9/**",
"src/SharpEmu.GUI/Languages/**",
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
diff --git a/SharpEmu.slnx b/SharpEmu.slnx
index a59b4889..f7eafb5a 100644
--- a/SharpEmu.slnx
+++ b/SharpEmu.slnx
@@ -5,6 +5,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs
index 868a7cb3..d673e43f 100644
--- a/src/SharpEmu.CLI/Program.cs
+++ b/src/SharpEmu.CLI/Program.cs
@@ -8,6 +8,7 @@ using SharpEmu.HLE;
using SharpEmu.Libs.VideoOut;
using SharpEmu.Logging;
using System.Runtime.InteropServices;
+using System.Runtime.Loader;
using System.Text;
using System.Text.Json;
@@ -45,6 +46,8 @@ internal static partial class Program
[STAThread]
private static int Main(string[] args)
{
+ ConfigureManagedPluginResolution();
+
try
{
return Run(args);
@@ -56,6 +59,25 @@ internal static partial class Program
}
}
+ private static void ConfigureManagedPluginResolution()
+ {
+ AssemblyLoadContext.Default.Resolving += static (loadContext, assemblyName) =>
+ {
+ if (string.IsNullOrWhiteSpace(assemblyName.Name))
+ {
+ return null;
+ }
+
+ var assemblyPath = Path.Combine(
+ AppContext.BaseDirectory,
+ "plugins",
+ assemblyName.Name + ".dll");
+ return File.Exists(assemblyPath)
+ ? loadContext.LoadFromAssemblyPath(assemblyPath)
+ : null;
+ };
+ }
+
private static int Run(string[] args)
{
if (Updater.TryApply(args, out var updateExitCode))
@@ -64,7 +86,6 @@ internal static partial class Program
}
args = NormalizeInternalArguments(args, out var isMitigatedChild);
- PreloadGlfw();
if (args.Length == 0)
{
@@ -93,14 +114,9 @@ internal static partial class Program
PreloadMacVulkanLoader();
}
- // GLFW requires window creation and event processing on the
- // process main thread: AppKit demands it on macOS, and X11 has a
- // single event queue that must be serviced from the main thread
- // (a window created and polled off it may never map, which showed
- // as a running game with no visible window on Linux). Emulation
- // moves to a worker thread and the main thread services the window
- // work the video presenter posts. Windows keeps a per-thread event
- // queue, so its window stays on the presenter's own thread.
+ // SDL/AppKit window work belongs on the process main thread on
+ // macOS. Linux uses the same model for consistent X11/Wayland
+ // event ownership. Emulation remains on a worker thread.
var exitCode = 0;
HostMainThread.Enable();
var emulation = new Thread(() =>
@@ -131,10 +147,9 @@ internal static partial class Program
/// starts: the CPU backend executes guest x86-64 code natively, so the
/// host process must be x86-64 — win-x64/linux-x64 on x64 hardware, or
/// osx-x64 under Rosetta 2 on Apple Silicon (Rosetta translates the
- /// whole process, so it still reports as X64 here). An arm64 process
- /// (e.g. the osx-arm64 build) can browse the GUI but cannot run games;
- /// failing up front distinguishes that from MoltenVK, signal-handler,
- /// or guest-memory startup problems.
+ /// whole process, so it still reports as X64 here). Failing up front on
+ /// any other process architecture distinguishes that from MoltenVK,
+ /// signal-handler, or guest-memory startup problems.
///
private static bool CheckHostArchitecture()
{
@@ -178,11 +193,11 @@ internal static partial class Program
}
///
- /// Makes a Vulkan loader visible to GLFW's dlopen("libvulkan.1.dylib").
+ /// Makes a Vulkan loader visible before SDL creates its Vulkan surface.
/// Homebrew's Vulkan libraries are arm64-only and cannot load into this
/// x86-64 (Rosetta 2) process, so a universal libMoltenVK.dylib placed
/// next to the executable (named libvulkan.1.dylib) is preloaded here;
- /// dyld then resolves GLFW's bare-name dlopen to the loaded image.
+ /// dyld can then resolve the loader for SDL and Silk.NET.
///
private static void PreloadMacVulkanLoader()
{
@@ -214,27 +229,6 @@ internal static partial class Program
"as libvulkan.1.dylib.");
}
- ///
- /// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
- /// than flat next to the executable, which falls outside the default OS
- /// DLL/dlopen search path. Preloading it here by full path first means
- /// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
- /// library) finds it already loaded in the process and reuses it -- the
- /// same technique already relies on
- /// for the Vulkan loader.
- ///
- private static void PreloadGlfw()
- {
- var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
- : OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
- : "libglfw.so.3";
- var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
- if (File.Exists(candidate))
- {
- NativeLibrary.TryLoad(candidate, out _);
- }
- }
-
private static int RunEmulator(string[] args, bool isMitigatedChild)
{
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
@@ -244,17 +238,13 @@ internal static partial class Program
return childExitCode;
}
- if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError))
- {
- Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}");
- return 1;
- }
-
- HostSessionControl.SetEmbeddedHostSurface(
- hostSurface?.WindowHandle ?? 0,
- hostSurface?.DisplayHandle ?? 0);
-
- if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
+ if (!TryParseArguments(
+ args,
+ out var ebootPath,
+ out var runtimeOptions,
+ out var videoOptions,
+ out var logLevel,
+ out var logFilePath))
{
PrintUsage();
return 1;
@@ -266,6 +256,11 @@ internal static partial class Program
}
SharpEmuLog.MinimumLevel = logLevel;
+ if (!HostVideoHost.TryConfigureVideo(videoOptions))
+ {
+ Console.Error.WriteLine("[LOADER][ERROR] Video options cannot change while a presenter is active.");
+ return 3;
+ }
Log.Info(BuildInfo.Banner);
Log.Info(HostSystemInfo.Summary);
@@ -309,12 +304,6 @@ internal static partial class Program
try
{
- if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface))
- {
- Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active.");
- return 3;
- }
-
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
OrbisGen2Result result;
@@ -384,53 +373,9 @@ internal static partial class Program
debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
- HostSessionControl.SetEmbeddedHostSurface(0);
- if (hostSurface is not null)
- {
- VulkanVideoHost.RequestClose();
- VulkanVideoHost.DetachSurface(hostSurface);
- hostSurface.Dispose();
- }
}
}
- private static bool TryExtractHostSurfaceArgument(
- IReadOnlyList args,
- out string[] emulatorArgs,
- out VulkanHostSurface? hostSurface,
- out string? error)
- {
- const string hostSurfacePrefix = "--host-surface=";
- var remaining = new List(args.Count);
- hostSurface = null;
- error = null;
- foreach (var argument in args)
- {
- if (!argument.StartsWith(hostSurfacePrefix, StringComparison.OrdinalIgnoreCase))
- {
- remaining.Add(argument);
- continue;
- }
-
- if (hostSurface is not null)
- {
- emulatorArgs = [];
- error = "more than one GUI host surface was specified";
- return false;
- }
-
- var descriptor = argument[hostSurfacePrefix.Length..];
- if (!VulkanHostSurface.TryCreateChildProcessSurface(descriptor, out hostSurface, out error))
- {
- emulatorArgs = [];
- return false;
- }
- }
-
- emulatorArgs = remaining.ToArray();
- return true;
- }
-
private static void EnsureCliConsole()
{
if (!OperatingSystem.IsWindows())
@@ -1042,7 +987,7 @@ internal static partial class Program
private static void PrintUsage()
{
- Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--log-level=] [--log-file[=]] [--debug-server[=host:port]] ");
+ Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--log-level=] [--log-file[=]] [--window-mode=] [--resolution=] [--display=] [--refresh-rate=] [--scaling=] [--vsync=] [--hdr=] [--debug-server[=host:port]] ");
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin""");
Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient.");
}
@@ -1087,6 +1032,7 @@ internal static partial class Program
string[] args,
out string ebootPath,
out SharpEmuRuntimeOptions runtimeOptions,
+ out HostVideoOptions videoOptions,
out LogLevel logLevel,
out string? logFilePath)
{
@@ -1094,6 +1040,7 @@ internal static partial class Program
{
ebootPath = string.Empty;
runtimeOptions = default;
+ videoOptions = HostVideoOptions.Default;
logLevel = SharpEmuLog.MinimumLevel;
logFilePath = null;
return false;
@@ -1102,12 +1049,99 @@ internal static partial class Program
var strictDynlibResolution = false;
var importTraceLimit = 0;
var cpuEngine = CpuExecutionEngine.NativeOnly;
+ HostWindowMode? windowModeOverride = null;
+ HostScalingMode? scalingModeOverride = null;
+ int? windowWidthOverride = null;
+ int? windowHeightOverride = null;
+ int? displayIndexOverride = null;
+ int? refreshRateOverride = null;
+ bool? vsyncOverride = null;
+ HostHdrMode? hdrModeOverride = null;
+ videoOptions = HostVideoOptions.Default;
logFilePath = null;
logLevel = SharpEmuLog.MinimumLevel;
var pathTokens = new List(args.Length);
for (var i = 0; i < args.Length; i++)
{
var argument = args[i];
+ if (TrySplitOption(argument, "--window-mode", out var windowModeText))
+ {
+ if (!TryParseWindowMode(windowModeText, out var windowMode))
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ windowModeOverride = windowMode;
+ continue;
+ }
+ if (TrySplitOption(argument, "--resolution", out var resolutionText))
+ {
+ if (!TryParseResolution(resolutionText, out var windowWidth, out var windowHeight))
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ windowWidthOverride = windowWidth;
+ windowHeightOverride = windowHeight;
+ continue;
+ }
+ if (TrySplitOption(argument, "--display", out var displayText))
+ {
+ if (!int.TryParse(displayText, out var displayIndex) || displayIndex < 0)
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ displayIndexOverride = displayIndex;
+ continue;
+ }
+ if (TrySplitOption(argument, "--refresh-rate", out var refreshText))
+ {
+ if (!int.TryParse(refreshText, out var refreshRate) || refreshRate < 0)
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ refreshRateOverride = refreshRate;
+ continue;
+ }
+ if (TrySplitOption(argument, "--scaling", out var scalingText))
+ {
+ if (!TryParseScalingMode(scalingText, out var scalingMode))
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ scalingModeOverride = scalingMode;
+ continue;
+ }
+ if (TrySplitOption(argument, "--vsync", out var vsyncText))
+ {
+ if (!TryParseSwitch(vsyncText, out var vsync))
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ vsyncOverride = vsync;
+ continue;
+ }
+ if (TrySplitOption(argument, "--hdr", out var hdrText))
+ {
+ if (!TryParseHdrMode(hdrText, out var hdrMode))
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ hdrModeOverride = hdrMode;
+ continue;
+ }
if (string.Equals(argument, "--strict", StringComparison.OrdinalIgnoreCase))
{
strictDynlibResolution = true;
@@ -1269,9 +1303,147 @@ internal static partial class Program
StrictDynlibResolution = strictDynlibResolution,
ImportTraceLimit = importTraceLimit,
};
+ var configuredVideoOptions = LoadConfiguredVideoOptions(ebootPath);
+ videoOptions = (configuredVideoOptions with
+ {
+ WindowMode = windowModeOverride ?? configuredVideoOptions.WindowMode,
+ ScalingMode = scalingModeOverride ?? configuredVideoOptions.ScalingMode,
+ Width = windowWidthOverride ?? configuredVideoOptions.Width,
+ Height = windowHeightOverride ?? configuredVideoOptions.Height,
+ DisplayIndex = displayIndexOverride ?? configuredVideoOptions.DisplayIndex,
+ RefreshRate = refreshRateOverride ?? configuredVideoOptions.RefreshRate,
+ VSync = vsyncOverride ?? configuredVideoOptions.VSync,
+ HdrMode = hdrModeOverride ?? configuredVideoOptions.HdrMode,
+ }).Normalize();
return true;
}
+ private static HostVideoOptions LoadConfiguredVideoOptions(string ebootPath)
+ {
+ var defaults = HostVideoOptions.Default;
+ try
+ {
+ var effective = EffectiveLaunchSettings.Resolve(
+ GuiSettings.Load(),
+ PerGameSettings.Load(TryReadTitleId(ebootPath)));
+
+ var windowMode = TryParseWindowMode(effective.WindowMode, out var parsedWindowMode)
+ ? parsedWindowMode
+ : defaults.WindowMode;
+ var scalingMode = TryParseScalingMode(effective.ScalingMode, out var parsedScalingMode)
+ ? parsedScalingMode
+ : defaults.ScalingMode;
+ var hasResolution = TryParseResolution(
+ effective.Resolution,
+ out var configuredWidth,
+ out var configuredHeight);
+ var hdrMode = TryParseHdrMode(effective.HdrMode, out var parsedHdrMode)
+ ? parsedHdrMode
+ : defaults.HdrMode;
+
+ return new HostVideoOptions
+ {
+ WindowMode = windowMode,
+ ScalingMode = scalingMode,
+ Width = hasResolution ? configuredWidth : defaults.Width,
+ Height = hasResolution ? configuredHeight : defaults.Height,
+ DisplayIndex = effective.DisplayIndex,
+ RefreshRate = effective.RefreshRate,
+ VSync = effective.VSync,
+ HdrMode = hdrMode,
+ }.Normalize();
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] GUI video settings could not be loaded; using defaults: {exception.Message}");
+ return defaults;
+ }
+ }
+
+ private static bool TrySplitOption(string argument, string name, out string value)
+ {
+ var prefix = name + "=";
+ if (argument.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ value = argument[prefix.Length..];
+ return true;
+ }
+
+ value = string.Empty;
+ return false;
+ }
+
+ private static bool TryParseWindowMode(string value, out HostWindowMode mode)
+ {
+ mode = value.ToLowerInvariant() switch
+ {
+ "windowed" => HostWindowMode.Windowed,
+ "borderless" => HostWindowMode.Borderless,
+ "exclusive" or "fullscreen" => HostWindowMode.ExclusiveFullscreen,
+ _ => (HostWindowMode)(-1),
+ };
+ return Enum.IsDefined(mode);
+ }
+
+ private static bool TryParseScalingMode(string value, out HostScalingMode mode)
+ {
+ mode = value.ToLowerInvariant() switch
+ {
+ "fit" => HostScalingMode.Fit,
+ "cover" => HostScalingMode.Cover,
+ "stretch" => HostScalingMode.Stretch,
+ "integer" => HostScalingMode.Integer,
+ _ => (HostScalingMode)(-1),
+ };
+ return Enum.IsDefined(mode);
+ }
+
+ private static bool TryParseHdrMode(string value, out HostHdrMode mode)
+ {
+ mode = value.ToLowerInvariant() switch
+ {
+ "auto" => HostHdrMode.Auto,
+ "on" or "true" or "1" => HostHdrMode.On,
+ "off" or "false" or "0" => HostHdrMode.Off,
+ _ => (HostHdrMode)(-1),
+ };
+ return Enum.IsDefined(mode);
+ }
+
+ private static bool TryParseResolution(string value, out int width, out int height)
+ {
+ var parts = value.Split('x', 'X');
+ if (parts.Length == 2 && int.TryParse(parts[0], out width) && int.TryParse(parts[1], out height) &&
+ width >= 640 && height >= 360)
+ {
+ return true;
+ }
+
+ width = 0;
+ height = 0;
+ return false;
+ }
+
+ private static bool TryParseSwitch(string value, out bool enabled)
+ {
+ if (value is "1" || value.Equals("on", StringComparison.OrdinalIgnoreCase) ||
+ value.Equals("true", StringComparison.OrdinalIgnoreCase))
+ {
+ enabled = true;
+ return true;
+ }
+ if (value is "0" || value.Equals("off", StringComparison.OrdinalIgnoreCase) ||
+ value.Equals("false", StringComparison.OrdinalIgnoreCase))
+ {
+ enabled = false;
+ return true;
+ }
+
+ enabled = false;
+ return false;
+ }
+
private static bool TryParseCpuEngine(string valueText, out CpuExecutionEngine engine)
{
if (string.Equals(valueText, "native", StringComparison.OrdinalIgnoreCase) ||
diff --git a/src/SharpEmu.CLI/SharpEmu.CLI.csproj b/src/SharpEmu.CLI/SharpEmu.CLI.csproj
index d4ef9e8b..2714e437 100644
--- a/src/SharpEmu.CLI/SharpEmu.CLI.csproj
+++ b/src/SharpEmu.CLI/SharpEmu.CLI.csproj
@@ -77,35 +77,47 @@ SPDX-License-Identifier: GPL-2.0-or-later
Languages\%(Filename)%(Extension)
False
+
+ Always
+ licenses\LibAtrac9.txt
+ true
+ False
+
-
+
+
+ <_NativeDebugSymbols Include="$(PublishDir)**\*.pdb" />
+
+
+
+
+
+ FfmpegNativeBinkFrameSource uses the same literal "plugins" folder
+ name. -->
plugins
-
-
-
- <_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
- Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
-
-
- true
- $(NativeLibraryFolderName)/%(Filename)%(Extension)
-
-
-
-
2c92585
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Diagnostics.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Diagnostics.cs
index c741472d..f226f3ba 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Diagnostics.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Diagnostics.cs
@@ -23,14 +23,71 @@ public sealed partial class DirectExecutionBackend
private static long _perfHleTotal;
private static long _perfHleDispatchTicks;
+ private sealed class PerfHleExportCost
+ {
+ public long Calls;
+ public long Ticks;
+ }
+
+ private static readonly System.Collections.Concurrent.ConcurrentDictionary _perfHleCosts = new();
+
+ ///
+ /// Name of the export currently being dispatched on this thread, so the
+ /// gateway can attribute its elapsed time once the call returns. Answering
+ /// "which export is worth optimising" needs cost per export, not just call
+ /// counts — a rare expensive call and a hot cheap one look identical in a
+ /// frequency histogram.
+ ///
+ [System.ThreadStatic]
+ private static string? _perfHleCurrentExport;
+
+ private static long _perfHleFirstTimestamp;
+
private static void RecordPerfHleDispatchTime(long ticks)
{
var total = System.Threading.Interlocked.Add(ref _perfHleDispatchTicks, ticks);
var calls = System.Threading.Interlocked.Read(ref _perfHleTotal);
+
+ var name = _perfHleCurrentExport;
+ if (name is not null)
+ {
+ var cost = _perfHleCosts.GetOrAdd(name, static _ => new PerfHleExportCost());
+ System.Threading.Interlocked.Increment(ref cost.Calls);
+ System.Threading.Interlocked.Add(ref cost.Ticks, ticks);
+ }
+
if (calls > 0 && calls % 500000 == 0)
{
- var avgUs = (double)total / System.Diagnostics.Stopwatch.Frequency * 1_000_000.0 / calls;
- System.Console.Error.WriteLine($"[PERF][HLE] managed_dispatch_avg={avgUs:F3}us total_managed_s={(double)total / System.Diagnostics.Stopwatch.Frequency:F2}");
+ var frequency = (double)System.Diagnostics.Stopwatch.Frequency;
+ var avgUs = (double)total / frequency * 1_000_000.0 / calls;
+ var first = System.Threading.Interlocked.CompareExchange(ref _perfHleFirstTimestamp, 0, 0);
+ var wallSeconds = first == 0
+ ? 0
+ : (double)(System.Diagnostics.Stopwatch.GetTimestamp() - first) / frequency;
+ System.Console.Error.WriteLine(
+ $"[PERF][HLE] managed_dispatch_avg={avgUs:F3}us " +
+ $"total_managed_s={(double)total / frequency:F2} " +
+ $"wall_s={wallSeconds:F2} " +
+ $"cores={(wallSeconds > 0 ? total / frequency / wallSeconds : 0):F2}");
+
+ var snapshot = new System.Collections.Generic.List>(_perfHleCosts.Count + 16);
+ foreach (var kvp in _perfHleCosts)
+ {
+ snapshot.Add(kvp);
+ }
+
+ var top = snapshot
+ .OrderByDescending(kvp => System.Threading.Interlocked.Read(ref kvp.Value.Ticks))
+ .Take(12)
+ .Select(kvp =>
+ {
+ var seconds = System.Threading.Interlocked.Read(ref kvp.Value.Ticks) / frequency;
+ var callCount = System.Threading.Interlocked.Read(ref kvp.Value.Calls);
+ var cores = wallSeconds > 0 ? seconds / wallSeconds : 0;
+ var perCallUs = callCount > 0 ? seconds * 1_000_000.0 / callCount : 0;
+ return $"{kvp.Key}: {cores:F2}cores {seconds:F1}s n={callCount} {perCallUs:F2}us/call";
+ });
+ System.Console.Error.WriteLine($"[PERF][HLE] cost: {string.Join(" | ", top)}");
}
}
@@ -39,7 +96,16 @@ public sealed partial class DirectExecutionBackend
private static void RecordPerfHleCall(string name)
{
+ _perfHleCurrentExport = name;
var total = System.Threading.Interlocked.Increment(ref _perfHleTotal);
+ if (total == 1)
+ {
+ System.Threading.Interlocked.CompareExchange(
+ ref _perfHleFirstTimestamp,
+ System.Diagnostics.Stopwatch.GetTimestamp(),
+ 0);
+ }
+
if (!_perfHleNoDict)
{
_perfHleCounts.AddOrUpdate(name, 1, static (_, v) => v + 1);
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
index b76069ee..fb3812e2 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs
@@ -40,6 +40,15 @@ public sealed partial class DirectExecutionBackend
}
_rawExceptionHandler = (nint)AddVectoredExceptionHandler(1u, _rawExceptionHandlerStub);
Console.Error.WriteLine($"[LOADER][INFO] Raw exception handler installed: 0x{_rawExceptionHandler:X16}");
+
+ // The raw handler carries the guest-image write-fault bridge, so the
+ // path must be compiled before the first protected-page store can
+ // reach it. Guest code has not started yet, so warming here cannot
+ // race a real fault.
+ SharpEmu.HLE.GuestImageWriteTracker.WarmUp();
+ Console.Error.WriteLine(
+ "[LOADER][INFO] Guest image CPU write tracking: " +
+ $"{(SharpEmu.HLE.GuestImageWriteTracker.Enabled ? "enabled" : "disabled")}");
}
else
{
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.GuestSampler.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.GuestSampler.cs
new file mode 100644
index 00000000..93b17445
--- /dev/null
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.GuestSampler.cs
@@ -0,0 +1,322 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+
+namespace SharpEmu.Core.Cpu.Native;
+
+///
+/// Sampling profiler for guest code. Managed profilers only see the emulator's
+/// own frames — once a guest thread is running translated code it is opaque to
+/// them, so a title that burns its cores inside its own spin loops looks like
+/// unattributed native time. This walks the guest thread registry and samples
+/// each thread's host RIP, which lands directly on the guest instruction being
+/// executed.
+///
+public sealed partial class DirectExecutionBackend
+{
+ private static readonly bool _profileGuestRip =
+ string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP"),
+ "1",
+ StringComparison.Ordinal);
+
+ private static readonly int _profileGuestRipIntervalMs =
+ int.TryParse(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP_INTERVAL_MS"),
+ out var interval) && interval > 0
+ ? interval
+ : 2;
+
+ private static readonly int _profileGuestRipReportSeconds =
+ int.TryParse(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP_REPORT_S"),
+ out var report) && report > 0
+ ? report
+ : 15;
+
+ private const ulong GuestImageBase = 0x0000_0008_0000_0000UL;
+ private const ulong GuestImageLimit = 0x0000_0009_0000_0000UL;
+
+ private int _guestRipSamplerStarted;
+ private readonly ConcurrentDictionary _guestRipSamples = new();
+ private readonly ConcurrentDictionary _guestRipThreadSamples = new();
+ private readonly ConcurrentDictionary _guestWaitSamples = new();
+ private readonly ConcurrentDictionary _guestThreadWaitSamples = new();
+ private long _guestRipTotalSamples;
+ private long _guestWaitTotalSamples;
+ private long _guestRipCaptureFailures;
+ private long _guestRipSamplerErrors;
+ private int _guestRipSampleCursor;
+
+ ///
+ /// Names the HLE call a thread is parked in, using the guest RIP the import
+ /// dispatcher left on its context.
+ ///
+ private string ResolveWaitLabel(GuestThreadState thread)
+ {
+ var context = thread.Context;
+ if (context is null)
+ {
+ return "";
+ }
+
+ var importIndex = context.ActiveImportIndex;
+ if ((uint)importIndex >= (uint)_importEntries.Length)
+ {
+ // Host code with no import in flight: the thread is parked by the
+ // emulator's own scheduler. The cooperative block records why, which
+ // is the part that actually identifies what the frame is waiting on.
+ var blockReason = thread.BlockReason;
+ return string.IsNullOrEmpty(blockReason)
+ ? ""
+ : $"blocked:{blockReason}";
+ }
+
+ var entry = _importEntries[importIndex];
+ return entry.Export?.Name ?? entry.Nid;
+ }
+
+ internal void ClearActiveImportIndex()
+ {
+ if (!_profileGuestRip)
+ {
+ return;
+ }
+
+ var context = ActiveCpuContext;
+ if (context is not null)
+ {
+ context.ActiveImportIndex = -1;
+ }
+ }
+
+ private void EnsureGuestRipSampler()
+ {
+ if (!_profileGuestRip ||
+ !OperatingSystem.IsWindows() ||
+ Interlocked.Exchange(ref _guestRipSamplerStarted, 1) != 0)
+ {
+ return;
+ }
+
+ var sampler = new Thread(GuestRipSampleLoop)
+ {
+ IsBackground = true,
+ Name = "SharpEmu guest RIP sampler",
+ // Sampling suspends guest threads briefly. Keep this diagnostic below
+ // the title workers so it observes them without becoming the bottleneck.
+ Priority = ThreadPriority.BelowNormal,
+ };
+ sampler.Start();
+ Console.Error.WriteLine(
+ $"[PERF][GUEST] RIP sampler started: interval={_profileGuestRipIntervalMs}ms " +
+ $"report={_profileGuestRipReportSeconds}s");
+ }
+
+ private void GuestRipSampleLoop()
+ {
+ var clock = Stopwatch.StartNew();
+ var lastReportMs = 0L;
+ var lastReportSamples = 0L;
+
+ while (true)
+ {
+ try
+ {
+ var guestThreads = SnapshotGuestThreads();
+ var sampleIndex = guestThreads.Length == 0
+ ? 0
+ : (int)((uint)Interlocked.Increment(ref _guestRipSampleCursor) % (uint)guestThreads.Length);
+ foreach (var thread in guestThreads.Skip(sampleIndex).Take(1))
+ {
+ var hostThreadId = Volatile.Read(ref thread.HostThreadId);
+ if (hostThreadId == 0)
+ {
+ continue;
+ }
+
+ if (!TryCaptureHostThreadContext(hostThreadId, out var snapshot) ||
+ !snapshot.IsValid)
+ {
+ Interlocked.Increment(ref _guestRipCaptureFailures);
+ continue;
+ }
+
+ _guestRipSamples.AddOrUpdate(snapshot.Rip, 1, static (_, value) => value + 1);
+ _guestRipThreadSamples.AddOrUpdate(
+ string.IsNullOrEmpty(thread.Name) ? "" : thread.Name,
+ 1,
+ static (_, value) => value + 1);
+ Interlocked.Increment(ref _guestRipTotalSamples);
+
+ // A host RIP means the thread is inside the emulator rather
+ // than running translated code. DispatchImport parks the
+ // guest RIP on the import stub for the call being serviced,
+ // so the stub address names what the thread is waiting on —
+ // no hot-path bookkeeping needed to find out.
+ if (snapshot.Rip >= GuestImageBase && snapshot.Rip < GuestImageLimit)
+ {
+ continue;
+ }
+
+ _guestWaitSamples.AddOrUpdate(
+ ResolveWaitLabel(thread),
+ 1,
+ static (_, value) => value + 1);
+ _guestThreadWaitSamples.AddOrUpdate(
+ string.IsNullOrEmpty(thread.Name) ? "" : thread.Name,
+ 1,
+ static (_, value) => value + 1);
+ Interlocked.Increment(ref _guestWaitTotalSamples);
+ }
+
+ Thread.Sleep(_profileGuestRipIntervalMs);
+
+ var elapsedMs = clock.ElapsedMilliseconds;
+ if (elapsedMs - lastReportMs < _profileGuestRipReportSeconds * 1000L)
+ {
+ continue;
+ }
+
+ var samples = Interlocked.Read(ref _guestRipTotalSamples);
+ ReportGuestRipSamples(samples - lastReportSamples, (elapsedMs - lastReportMs) / 1000.0);
+ lastReportMs = elapsedMs;
+ lastReportSamples = samples;
+ }
+ catch (Exception exception)
+ {
+ // A title can tear down a thread or its context during a capture.
+ // The profiler must never silently die or affect guest execution.
+ if (Interlocked.Increment(ref _guestRipSamplerErrors) == 1)
+ {
+ Console.Error.WriteLine($"[PERF][GUEST] sampler recovery: {exception.GetType().Name}: {exception.Message}");
+ }
+ }
+ }
+ }
+
+ private void ReportGuestRipSamples(long windowSamples, double windowSeconds)
+ {
+ var total = Interlocked.Read(ref _guestRipTotalSamples);
+ if (total == 0)
+ {
+ return;
+ }
+
+ var byRip = new List>(_guestRipSamples.Count + 16);
+ foreach (var pair in _guestRipSamples)
+ {
+ byRip.Add(pair);
+ }
+
+ // A tight spin lands on a handful of instructions; grouping by 4 KB page
+ // as well shows which routine those instructions belong to.
+ var byPage = new Dictionary();
+ foreach (var pair in byRip)
+ {
+ var page = pair.Key & ~0xFFFUL;
+ byPage[page] = byPage.TryGetValue(page, out var existing)
+ ? existing + pair.Value
+ : pair.Value;
+ }
+
+ var byThread = new List>(_guestRipThreadSamples.Count + 16);
+ foreach (var pair in _guestRipThreadSamples)
+ {
+ byThread.Add(pair);
+ }
+
+ Console.Error.WriteLine(
+ $"[PERF][GUEST] samples={total} window={windowSamples} in {windowSeconds:F1}s " +
+ $"capture_failures={Interlocked.Read(ref _guestRipCaptureFailures)}");
+
+ Console.Error.WriteLine(
+ "[PERF][GUEST] top_rip: " +
+ string.Join(
+ " | ",
+ byRip.OrderByDescending(pair => pair.Value)
+ .Take(12)
+ .Select(pair =>
+ $"0x{pair.Key:X}{DescribeGuestAddress(pair.Key)}={pair.Value * 100.0 / total:F1}%")));
+
+ Console.Error.WriteLine(
+ "[PERF][GUEST] top_page: " +
+ string.Join(
+ " | ",
+ byPage.OrderByDescending(pair => pair.Value)
+ .Take(8)
+ .Select(pair =>
+ $"0x{pair.Key:X}{DescribeGuestAddress(pair.Key)}={pair.Value * 100.0 / total:F1}%")));
+
+ var byWait = new List>(_guestWaitSamples.Count + 16);
+ foreach (var pair in _guestWaitSamples)
+ {
+ byWait.Add(pair);
+ }
+
+ var waitTotal = Interlocked.Read(ref _guestWaitTotalSamples);
+ Console.Error.WriteLine(
+ $"[PERF][GUEST] waiting={waitTotal * 100.0 / total:F1}% of guest thread-time; top_wait: " +
+ string.Join(
+ " | ",
+ byWait.OrderByDescending(pair => pair.Value)
+ .Take(12)
+ .Select(pair => $"{pair.Key}={pair.Value * 100.0 / total:F1}%")));
+
+ // Per-thread spin/park split. The global wait share mixes the job pool in
+ // with a dozen dormant threads, which hides the number that matters:
+ // how much of a core each worker actually burns.
+ Console.Error.WriteLine(
+ "[PERF][GUEST] thread_split (running/parked): " +
+ string.Join(
+ " | ",
+ byThread.OrderByDescending(pair => pair.Value)
+ .Take(10)
+ .Select(pair =>
+ {
+ var parked = _guestThreadWaitSamples.TryGetValue(pair.Key, out var wait) ? wait : 0;
+ var running = pair.Value - parked;
+ return $"{pair.Key}={running * 100.0 / pair.Value:F0}%/{parked * 100.0 / pair.Value:F0}%";
+ })));
+
+ Console.Error.WriteLine(
+ "[PERF][GUEST] top_thread: " +
+ string.Join(
+ " | ",
+ byThread.OrderByDescending(pair => pair.Value)
+ .Take(10)
+ .Select(pair => $"{pair.Key}={pair.Value * 100.0 / total:F1}%")));
+ }
+
+ ///
+ /// Tags a sampled address with the region it belongs to. Guest module code
+ /// lives above the image base; anything else is emulator or system code that
+ /// the managed profiler already covers.
+ ///
+ private string DescribeGuestAddress(ulong address)
+ {
+
+
+
+ if (address >= GuestImageBase && address < GuestImageLimit)
+ {
+ return $"(app+0x{address - GuestImageBase:X})";
+ }
+
+ for (var index = 0; index < _importEntries.Length; index++)
+ {
+ if (_importEntries[index].Address == (address & ~0xFUL))
+ {
+ return $"(stub:{_importEntries[index].Nid})";
+ }
+ }
+
+ return "(host)";
+ }
+}
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
index c352b2c4..f6e5dd94 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs
@@ -54,10 +54,13 @@ public sealed partial class DirectExecutionBackend
var startTicks = System.Diagnostics.Stopwatch.GetTimestamp();
var r = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
RecordPerfHleDispatchTime(System.Diagnostics.Stopwatch.GetTimestamp() - startTicks);
+ directExecutionBackend.ClearActiveImportIndex();
return r;
}
- return directExecutionBackend.DispatchImport(importIndex, argPackPtr);
+ var result = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
+ directExecutionBackend.ClearActiveImportIndex();
+ return result;
}
catch (Exception ex)
{
@@ -69,11 +72,7 @@ public sealed partial class DirectExecutionBackend
private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo)
{
- EXCEPTION_RECORD* exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord;
- if (exceptionRecord->ExceptionCode == 3221225477u &&
- exceptionRecord->NumberParameters >= 2 &&
- SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
- exceptionRecord->ExceptionInformation[1]))
+ if (TryHandleGuestImageWriteFault(exceptionInfo))
{
return -1;
}
@@ -81,6 +80,37 @@ public sealed partial class DirectExecutionBackend
return TryRecoverUnresolvedSentinel(exceptionInfo);
}
+ ///
+ /// Windows counterpart of the POSIX SIGSEGV bridge into
+ /// . Guest code runs natively,
+ /// so a store into a surface the GPU backend has cached is an ordinary CPU
+ /// write with nothing to intercept — the page is write-protected instead and
+ /// the resulting fault is what tells the backend to re-upload. Without this
+ /// the cache serves the first upload forever, and anything the guest CPU
+ /// draws (a software-decoded movie frame, a memset fog layer) never reaches
+ /// the screen.
+ ///
+ private unsafe static bool TryHandleGuestImageWriteFault(void* exceptionInfo)
+ {
+ if (!SharpEmu.HLE.GuestImageWriteTracker.Enabled)
+ {
+ return false;
+ }
+
+ var exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord;
+ // STATUS_ACCESS_VIOLATION, and only the write flavour: ExceptionInformation
+ // is [accessKind, address] with 0=read, 1=write, 8=DEP execute.
+ if (exceptionRecord->ExceptionCode != 3221225477u ||
+ exceptionRecord->NumberParameters < 2 ||
+ exceptionRecord->ExceptionInformation[0] != 1uL)
+ {
+ return false;
+ }
+
+ return SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
+ exceptionRecord->ExceptionInformation[1]);
+ }
+
private unsafe static int RawUnhandledFilterManaged(void* exceptionInfo)
{
return TryRecoverUnresolvedSentinel(exceptionInfo);
@@ -174,6 +204,10 @@ public sealed partial class DirectExecutionBackend
{
RecordPerfHleCall(importStubEntry.Export?.Name ?? importStubEntry.Nid);
}
+ if (_profileGuestRip)
+ {
+ EnsureGuestRipSampler();
+ }
int num2 = Volatile.Read(in _rawSentinelRecoveries);
if (num2 != _lastReportedRawSentinelRecoveries)
{
@@ -187,6 +221,10 @@ public sealed partial class DirectExecutionBackend
}
cpuContext.Rip = importStubEntry.Address;
+ if (_profileGuestRip)
+ {
+ cpuContext.ActiveImportIndex = importIndex;
+ }
LoadImportVolatileArguments(cpuContext, argPackPtr);
cpuContext[CpuRegister.Rdi] = *(ulong*)argPackPtr;
cpuContext[CpuRegister.Rsi] = *(ulong*)(argPackPtr + 8);
@@ -1453,7 +1491,7 @@ public sealed partial class DirectExecutionBackend
string.Equals(nid, "fzyMKs9kim0", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
var expectedMutexTrylockBusy =
- string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
+ (nid is "K-jXhbt2gn4" or "upoVrzMHFeE") &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
var expectedSemaphoreTrywaitAgain =
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
@@ -1470,6 +1508,9 @@ public sealed partial class DirectExecutionBackend
var expectedPrivacyInvalidParameter =
string.Equals(nid, "D-CzAxQL0XI", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80960009);
+ var expectedPlayGoChunkEnumerationEnd =
+ string.Equals(nid, "uWIYLFkkwqk", StringComparison.Ordinal) &&
+ resultValue == unchecked((int)0x80B2000C);
if (!expectedFileProbeMiss &&
!expectedTimedWaitTimeout &&
!expectedEqueueTimeout &&
@@ -1478,7 +1519,8 @@ public sealed partial class DirectExecutionBackend
!expectedPollSemaBusy &&
!expectedNetAcceptWouldBlock &&
!expectedUserServiceNoEvent &&
- !expectedPrivacyInvalidParameter)
+ !expectedPrivacyInvalidParameter &&
+ !expectedPlayGoChunkEnumerationEnd)
{
return true;
}
diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs
index 54c7fb06..1309410d 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs
@@ -5314,7 +5314,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
var hostCpu = processorCount < 8
? guestCpu % processorCount
: processorCount >= 16
- ? guestCpu * 2
+ ? MapGuestCpuAcrossSmtLanes(guestCpu, processorCount)
: guestCpu;
if (hostCpu < processorCount)
{
@@ -5325,6 +5325,45 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return hostAffinityMask;
}
+ ///
+ /// Places guest CPUs on distinct physical cores first, then wraps onto the
+ /// SMT siblings. Doubling the index alone only works while the title stays
+ /// inside the first half of the guest CPU set: beyond that every mapped lane
+ /// lands past the host's processor count and gets dropped, which silently
+ /// leaves those threads unpinned. Demon's Souls asks for CPUs 0-12 and keeps
+ /// its renderer on 9 and 11, so dropping the overflow un-pinned both the
+ /// renderer and a third of its job pool onto every core at once.
+ ///
+ private static int MapGuestCpuAcrossSmtLanes(int guestCpu, int processorCount)
+ {
+ // Reserve the top lanes for the emulator itself. A title sized for a
+ // console's dedicated cores will happily keep a worker per guest CPU
+ // spinning on an empty queue — Demon's Souls' job pool runs ~90% busy
+ // doing nothing — and spreading those across every host lane leaves the
+ // GPU translation and present threads fighting them for a slice. Packing
+ // near-idle spinners tighter costs them almost nothing and buys back
+ // whole cores for the work that actually produces frames.
+ var usableLanes = Math.Max(processorCount - EmulatorReservedLanes, 2);
+ var physicalCores = usableLanes / 2;
+ var lane = guestCpu % usableLanes;
+ return lane < physicalCores
+ ? lane * 2
+ : ((lane - physicalCores) * 2) + 1;
+ }
+
+ ///
+ /// Host lanes kept away from guest threads. Measured on a 16-lane host with
+ /// Demon's Souls: reserving 0/4/6/8 lanes gave 6.08/6.78/7.20/5.62 fps, so
+ /// the useful range is a bit over a third of the machine — too few and the
+ /// emulator is crowded out, too many and the guest cannot make progress.
+ ///
+ private static readonly int EmulatorReservedLanes =
+ int.TryParse(
+ Environment.GetEnvironmentVariable("SHARPEMU_RESERVED_HOST_LANES"),
+ out var reserved) && reserved >= 0
+ ? reserved
+ : Math.Max(2, Environment.ProcessorCount * 3 / 8);
+
public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority)
{
lock (_guestThreadGate)
diff --git a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs
index e1c853e2..671f3d57 100644
--- a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs
+++ b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs
@@ -143,6 +143,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
KernelModuleRegistry.Reset();
var image = LoadImage(normalizedEbootPath);
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
+ KernelMemoryCompatExports.ConfigureApplicationInfo(image.TitleId);
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
SystemServiceExports.ConfigureApplicationInfo(image.TitleId);
_ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
diff --git a/src/SharpEmu.GUI/GameSurfaceHost.cs b/src/SharpEmu.GUI/GameSurfaceHost.cs
deleted file mode 100644
index 7e3471f5..00000000
--- a/src/SharpEmu.GUI/GameSurfaceHost.cs
+++ /dev/null
@@ -1,620 +0,0 @@
-// Copyright (C) 2026 SharpEmu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-using Avalonia;
-using Avalonia.Controls;
-using Avalonia.Platform;
-using Avalonia.Threading;
-using SharpEmu.Libs.VideoOut;
-using System.Runtime.InteropServices;
-
-namespace SharpEmu.GUI;
-
-///
-/// Native child surface owned by Avalonia. The isolated emulator process uses
-/// its platform handle to create the Vulkan presentation surface, keeping the
-/// guest address space out of the GUI process.
-///
-public sealed class GameSurfaceHost : NativeControlHost
-{
- private const uint SwpNoSize = 0x0001;
- private const uint SwpNoMove = 0x0002;
- private const uint SwpNoZOrder = 0x0004;
- private const uint SwpNoActivate = 0x0010;
- private const uint SwpShowWindow = 0x0040;
- private const uint SwpHideWindow = 0x0080;
- private const uint WsChild = 0x40000000;
- private const uint WsVisible = 0x10000000;
- private const uint WsClipSiblings = 0x04000000;
- private const uint WsClipChildren = 0x02000000;
- private const uint CsOwnDc = 0x0020;
- private const uint WmSetCursor = 0x0020;
- private const uint WmMouseMove = 0x0200;
- private const int IdcArrow = 32512;
- private const int CursorHideDelayMs = 2500;
-
- private VulkanHostSurface? _surface;
- private nint _windowHandle;
- private nint _x11Display;
- private string? _win32ClassName;
- private WindowProcedure? _windowProcedure;
- private nint _metalLayer;
- private bool _presentationVisible = true;
- private DispatcherTimer? _cursorIdleTimer;
- private bool _cursorAutoHide;
- private bool _cursorHidden;
- private long _lastPointerActivity;
-
- public GameSurfaceHost()
- {
- PropertyChanged += (_, change) =>
- {
- if (change.Property == BoundsProperty)
- {
- UpdateSurfaceSize();
- }
- };
- LayoutUpdated += (_, _) =>
- {
- // Fullscreen can change a monitor's DPI scale without changing
- // the logical Bounds. Refresh the native child from physical size.
- UpdateSurfaceSize();
-
- // NativeControlHost may make its HWND visible again as part of a
- // later arrange pass. Keep a loading surface hidden until its
- // child process reports a real first frame.
- if (!_presentationVisible)
- {
- ApplyPresentationVisibility();
- }
- };
- }
-
- public event EventHandler? SurfaceAvailable;
-
- public event EventHandler? SurfaceDestroyed;
-
- public VulkanHostSurface? Surface => _surface;
-
- public void RefreshSurfaceSize() => UpdateSurfaceSize();
-
- ///
- /// Hides the platform child without detaching the Vulkan surface. This
- /// allows the launcher to return to its library while guest teardown is
- /// still finishing on the render thread.
- ///
- public void SetPresentationVisible(bool visible)
- {
- _presentationVisible = visible;
- ApplyPresentationVisibility();
- }
-
- ///
- /// Auto-hides the mouse cursor over the game surface after a short idle
- /// period; any pointer movement brings it back. Enabling (again) restarts
- /// the idle countdown, so both "first frame presented" and "entered
- /// fullscreen" can arm it. Windows-only; a no-op elsewhere.
- ///
- public void SetCursorAutoHide(bool enabled)
- {
- if (!OperatingSystem.IsWindows())
- {
- return;
- }
-
- _cursorAutoHide = enabled;
- _lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
- if (enabled)
- {
- _cursorIdleTimer ??= CreateCursorIdleTimer();
- _cursorIdleTimer.Start();
- return;
- }
-
- _cursorIdleTimer?.Stop();
- ShowCursorNow();
- }
-
- private DispatcherTimer CreateCursorIdleTimer()
- {
- var timer = new DispatcherTimer
- {
- Interval = TimeSpan.FromMilliseconds(250),
- };
- timer.Tick += (_, _) => HideCursorWhenIdle();
- return timer;
- }
-
- private void HideCursorWhenIdle()
- {
- if (!_cursorAutoHide || _cursorHidden || _windowHandle == 0)
- {
- return;
- }
-
- var idleMs = (System.Diagnostics.Stopwatch.GetTimestamp() - _lastPointerActivity) *
- 1000 / System.Diagnostics.Stopwatch.Frequency;
- if (idleMs < CursorHideDelayMs)
- {
- return;
- }
-
- // Only swallow the cursor while it is actually over the game surface;
- // hovering launcher chrome (console, toolbar) must keep the arrow.
- if (!GetCursorPos(out var point) || WindowFromPoint(point) != _windowHandle)
- {
- return;
- }
-
- _cursorHidden = true;
- _ = SetCursor(0);
- }
-
- private void ShowCursorNow()
- {
- if (!_cursorHidden)
- {
- return;
- }
-
- _cursorHidden = false;
- _ = SetCursor(LoadCursorW(0, IdcArrow));
- }
-
- private void ApplyPresentationVisibility()
- {
- if (_windowHandle == 0)
- {
- return;
- }
-
- var visible = _presentationVisible;
- if (OperatingSystem.IsWindows())
- {
- // SW_HIDE can be ignored for a window's initial show state. Force
- // the state through SetWindowPos so an old child swapchain cannot
- // remain composed while the next game is loading.
- var flags = SwpNoSize | SwpNoMove | SwpNoZOrder | SwpNoActivate |
- (visible ? SwpShowWindow : SwpHideWindow);
- _ = SetWindowPos(_windowHandle, 0, 0, 0, 0, 0, flags);
- }
- else if (OperatingSystem.IsLinux() && _x11Display != 0)
- {
- _ = visible
- ? XMapWindow(_x11Display, _windowHandle)
- : XUnmapWindow(_x11Display, _windowHandle);
- _ = XFlush(_x11Display);
- }
- else if (OperatingSystem.IsMacOS())
- {
- SendBool(_windowHandle, "setHidden:", !visible);
- }
- }
-
- protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle control)
- {
- PlatformHandle handle;
- if (OperatingSystem.IsWindows())
- {
- handle = CreateWin32(control);
- }
- else if (OperatingSystem.IsLinux())
- {
- handle = CreateX11(control);
- }
- else if (OperatingSystem.IsMacOS())
- {
- handle = CreateMacOS();
- }
- else
- {
- throw new PlatformNotSupportedException("SharpEmu's embedded Vulkan surface is unsupported on this platform.");
- }
-
- UpdateSurfaceSize();
- if (_surface is { } surface)
- {
- SurfaceAvailable?.Invoke(this, surface);
- }
-
- return handle;
- }
-
- protected override void DestroyNativeControlCore(IPlatformHandle control)
- {
- if (OperatingSystem.IsWindows())
- {
- SetCursorAutoHide(false);
- }
-
- var surface = _surface;
- _surface = null;
-
- if (OperatingSystem.IsWindows())
- {
- DestroyWin32();
- }
- else if (OperatingSystem.IsLinux())
- {
- DestroyX11();
- }
- else if (OperatingSystem.IsMacOS())
- {
- DestroyMacOS();
- }
-
- if (surface is not null)
- {
- SurfaceDestroyed?.Invoke(this, surface);
- }
- }
-
- private PlatformHandle CreateWin32(IPlatformHandle control)
- {
- _win32ClassName = $"SharpEmuGameSurface-{Guid.NewGuid():N}";
- _windowProcedure = WindowProcedureImpl;
- var classInfo = new WndClassEx
- {
- Size = (uint)Marshal.SizeOf(),
- Style = CsOwnDc,
- WindowProcedure = Marshal.GetFunctionPointerForDelegate(_windowProcedure),
- Instance = GetModuleHandleW(null),
- ClassName = _win32ClassName,
- };
-
- if (RegisterClassExW(ref classInfo) == 0)
- {
- throw new InvalidOperationException($"Could not register the embedded game window class (Win32 error {Marshal.GetLastWin32Error()}).");
- }
-
- _windowHandle = CreateWindowExW(
- 0,
- _win32ClassName,
- "SharpEmu Game Surface",
- WsChild | (_presentationVisible ? WsVisible : 0) | WsClipSiblings | WsClipChildren,
- 0,
- 0,
- 1,
- 1,
- control.Handle,
- 0,
- classInfo.Instance,
- 0);
- if (_windowHandle == 0)
- {
- var error = Marshal.GetLastWin32Error();
- _ = UnregisterClassW(_win32ClassName, classInfo.Instance);
- throw new InvalidOperationException($"Could not create the embedded game window (Win32 error {error}).");
- }
-
- _surface = new VulkanHostSurface(
- VulkanHostSurfaceKind.Win32,
- _windowHandle,
- classInfo.Instance);
- return new PlatformHandle(_windowHandle, "HWND");
- }
-
- private PlatformHandle CreateX11(IPlatformHandle control)
- {
- _x11Display = XOpenDisplay(0);
- if (_x11Display == 0)
- {
- throw new InvalidOperationException("Could not connect to the X11 server for the embedded game surface.");
- }
-
- _windowHandle = XCreateSimpleWindow(
- _x11Display,
- control.Handle,
- 0,
- 0,
- 1,
- 1,
- 0,
- 0,
- 0);
- if (_windowHandle == 0)
- {
- XCloseDisplay(_x11Display);
- _x11Display = 0;
- throw new InvalidOperationException("Could not create the X11 embedded game surface.");
- }
-
- if (_presentationVisible)
- {
- _ = XMapWindow(_x11Display, _windowHandle);
- }
- _ = XFlush(_x11Display);
- _surface = new VulkanHostSurface(VulkanHostSurfaceKind.Xlib, _windowHandle, _x11Display);
- return new PlatformHandle(_windowHandle, "X11");
- }
-
- private PlatformHandle CreateMacOS()
- {
- _metalLayer = CreateObjectiveCObject("CAMetalLayer");
- _windowHandle = CreateObjectiveCObject("NSView");
- SendBool(_windowHandle, "setWantsLayer:", true);
- SendPointer(_windowHandle, "setLayer:", _metalLayer);
- SendBool(_windowHandle, "setHidden:", !_presentationVisible);
-
- _surface = new VulkanHostSurface(VulkanHostSurfaceKind.Metal, _windowHandle, metalLayerHandle: _metalLayer);
- return new PlatformHandle(_windowHandle, "NSView");
- }
-
- private void UpdateSurfaceSize()
- {
- if (_surface is null)
- {
- return;
- }
-
- var renderScale = (VisualRoot as TopLevel)?.RenderScaling ?? 1.0;
- var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
- var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
- var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
- if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
- {
- Console.Error.WriteLine(
- $"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
- $"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
- $"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
- }
- _surface.UpdatePixelSize(width, height);
-
- if (!sizeChanged)
- {
- return;
- }
-
- if (OperatingSystem.IsWindows() && _windowHandle != 0)
- {
- _ = SetWindowPos(
- _windowHandle,
- 0,
- 0,
- 0,
- width,
- height,
- SwpNoMove | SwpNoZOrder | SwpNoActivate);
- }
- else if (OperatingSystem.IsLinux() && _x11Display != 0 && _windowHandle != 0)
- {
- _ = XResizeWindow(_x11Display, _windowHandle, (uint)width, (uint)height);
- _ = XFlush(_x11Display);
- }
- else if (OperatingSystem.IsMacOS() && _metalLayer != 0)
- {
- SendDouble(_metalLayer, "setContentsScale:", renderScale);
- }
- }
-
- private void DestroyWin32()
- {
- if (_windowHandle != 0)
- {
- _ = DestroyWindow(_windowHandle);
- _windowHandle = 0;
- }
-
- if (!string.IsNullOrWhiteSpace(_win32ClassName))
- {
- _ = UnregisterClassW(_win32ClassName, GetModuleHandleW(null));
- _win32ClassName = null;
- }
-
- _windowProcedure = null;
- }
-
- private void DestroyX11()
- {
- if (_x11Display != 0 && _windowHandle != 0)
- {
- _ = XDestroyWindow(_x11Display, _windowHandle);
- }
- if (_x11Display != 0)
- {
- _ = XCloseDisplay(_x11Display);
- }
-
- _windowHandle = 0;
- _x11Display = 0;
- }
-
- private void DestroyMacOS()
- {
- if (_windowHandle != 0)
- {
- SendVoid(_windowHandle, "release");
- }
- if (_metalLayer != 0)
- {
- SendVoid(_metalLayer, "release");
- }
-
- _windowHandle = 0;
- _metalLayer = 0;
- }
-
- private nint WindowProcedureImpl(nint window, uint message, nint wParam, nint lParam)
- {
- if (message == WmMouseMove)
- {
- _lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
- ShowCursorNow();
- }
- else if (message == WmSetCursor && _cursorHidden)
- {
- // Win32 re-resolves the cursor on every mouse message; returning
- // TRUE here keeps the parent chain from restoring the arrow.
- _ = SetCursor(0);
- return 1;
- }
-
- return DefWindowProcW(window, message, wParam, lParam);
- }
-
- private static nint CreateObjectiveCObject(string className)
- {
- var classHandle = objc_getClass(className);
- if (classHandle == 0)
- {
- throw new InvalidOperationException($"Objective-C class '{className}' is unavailable.");
- }
-
- var instance = objc_msgSend_id(classHandle, sel_registerName("alloc"));
- instance = objc_msgSend_id(instance, sel_registerName("init"));
- if (instance == 0)
- {
- throw new InvalidOperationException($"Could not create Objective-C '{className}'.");
- }
-
- return instance;
- }
-
- private static void SendVoid(nint receiver, string selector) =>
- objc_msgSend_void(receiver, sel_registerName(selector));
-
- private static void SendBool(nint receiver, string selector, bool value) =>
- objc_msgSend_bool(receiver, sel_registerName(selector), value ? (byte)1 : (byte)0);
-
- private static void SendPointer(nint receiver, string selector, nint value) =>
- objc_msgSend_pointer(receiver, sel_registerName(selector), value);
-
- private static void SendDouble(nint receiver, string selector, double value) =>
- objc_msgSend_double(receiver, sel_registerName(selector), value);
-
- private delegate nint WindowProcedure(nint window, uint message, nint wParam, nint lParam);
-
- [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
- private struct WndClassEx
- {
- public uint Size;
- public uint Style;
- public nint WindowProcedure;
- public int ClassExtra;
- public int WindowExtra;
- public nint Instance;
- public nint Icon;
- public nint Cursor;
- public nint Background;
- public string? MenuName;
- public string? ClassName;
- public nint IconSmall;
- }
-
- [DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
- private static extern nint GetModuleHandleW(string? moduleName);
-
- [DllImport("user32.dll", EntryPoint = "RegisterClassExW", SetLastError = true, CharSet = CharSet.Unicode)]
- private static extern ushort RegisterClassExW(ref WndClassEx classInfo);
-
- [DllImport("user32.dll", EntryPoint = "UnregisterClassW", SetLastError = true, CharSet = CharSet.Unicode)]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool UnregisterClassW(string className, nint instance);
-
- [DllImport("user32.dll", EntryPoint = "CreateWindowExW", SetLastError = true, CharSet = CharSet.Unicode)]
- private static extern nint CreateWindowExW(
- uint extendedStyle,
- string className,
- string windowName,
- uint style,
- int x,
- int y,
- int width,
- int height,
- nint parent,
- nint menu,
- nint instance,
- nint parameter);
-
- [DllImport("user32.dll", EntryPoint = "DestroyWindow", SetLastError = true)]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool DestroyWindow(nint window);
-
- [DllImport("user32.dll", EntryPoint = "SetWindowPos", SetLastError = true)]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool SetWindowPos(
- nint window,
- nint insertAfter,
- int x,
- int y,
- int width,
- int height,
- uint flags);
-
- [DllImport("user32.dll", EntryPoint = "DefWindowProcW", CharSet = CharSet.Unicode)]
- private static extern nint DefWindowProcW(nint window, uint message, nint wParam, nint lParam);
-
- [DllImport("user32.dll", EntryPoint = "SetCursor")]
- private static extern nint SetCursor(nint cursor);
-
- [DllImport("user32.dll", EntryPoint = "LoadCursorW", CharSet = CharSet.Unicode)]
- private static extern nint LoadCursorW(nint instance, nint cursorName);
-
- [DllImport("user32.dll", EntryPoint = "GetCursorPos")]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool GetCursorPos(out NativePoint point);
-
- [DllImport("user32.dll", EntryPoint = "WindowFromPoint")]
- private static extern nint WindowFromPoint(NativePoint point);
-
- [StructLayout(LayoutKind.Sequential)]
- private struct NativePoint
- {
- public int X;
- public int Y;
- }
-
- [DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
- private static extern nint XOpenDisplay(nint displayName);
-
- [DllImport("libX11.so.6", EntryPoint = "XCreateSimpleWindow")]
- private static extern nint XCreateSimpleWindow(
- nint display,
- nint parent,
- int x,
- int y,
- uint width,
- uint height,
- uint borderWidth,
- ulong border,
- ulong background);
-
- [DllImport("libX11.so.6", EntryPoint = "XMapWindow")]
- private static extern int XMapWindow(nint display, nint window);
-
- [DllImport("libX11.so.6", EntryPoint = "XUnmapWindow")]
- private static extern int XUnmapWindow(nint display, nint window);
-
- [DllImport("libX11.so.6", EntryPoint = "XResizeWindow")]
- private static extern int XResizeWindow(nint display, nint window, uint width, uint height);
-
- [DllImport("libX11.so.6", EntryPoint = "XDestroyWindow")]
- private static extern int XDestroyWindow(nint display, nint window);
-
- [DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
- private static extern int XCloseDisplay(nint display);
-
- [DllImport("libX11.so.6", EntryPoint = "XFlush")]
- private static extern int XFlush(nint display);
-
- [DllImport("/usr/lib/libobjc.A.dylib")]
- private static extern nint objc_getClass(string name);
-
- [DllImport("/usr/lib/libobjc.A.dylib")]
- private static extern nint sel_registerName(string name);
-
- [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
- private static extern nint objc_msgSend_id(nint receiver, nint selector);
-
- [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
- private static extern void objc_msgSend_void(nint receiver, nint selector);
-
- [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
- private static extern void objc_msgSend_bool(nint receiver, nint selector, byte value);
-
- [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
- private static extern void objc_msgSend_pointer(nint receiver, nint selector, nint value);
-
- [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
- private static extern void objc_msgSend_double(nint receiver, nint selector, double value);
-}
diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs
index a6510036..73e98be2 100644
--- a/src/SharpEmu.GUI/GuiSettings.cs
+++ b/src/SharpEmu.GUI/GuiSettings.cs
@@ -50,6 +50,20 @@ public sealed class GuiSettings
public bool CheckForUpdatesOnStartup { get; set; } = true;
+ public string WindowMode { get; set; } = "Windowed";
+
+ public string Resolution { get; set; } = "1920x1080";
+
+ public int DisplayIndex { get; set; }
+
+ public int RefreshRate { get; set; }
+
+ public string ScalingMode { get; set; } = "Fit";
+
+ public bool VSync { get; set; } = true;
+
+ public string HdrMode { get; set; } = "Auto";
+
/// Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.
public List EnvironmentToggles { get; set; } = new();
@@ -103,6 +117,12 @@ public sealed class GuiSettings
{
settings.RenderResolutionScale = 1.0;
}
+ settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
+ settings.Resolution = NormalizeResolution(settings.Resolution);
+ settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
+ settings.HdrMode = NormalizeChoice(settings.HdrMode, "Auto", "On", "Off");
+ settings.DisplayIndex = Math.Max(0, settings.DisplayIndex);
+ settings.RefreshRate = Math.Clamp(settings.RefreshRate, 0, 1000);
return settings;
}
@@ -118,6 +138,20 @@ public sealed class GuiSettings
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
}
+ private static string NormalizeChoice(string? value, string fallback, params string[] choices) =>
+ choices.Prepend(fallback).FirstOrDefault(
+ choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
+
+ private static string NormalizeResolution(string? value)
+ {
+ if (!HostDisplayOptions.TryParseResolution(value, out var width, out var height))
+ {
+ return "1920x1080";
+ }
+
+ return $"{width}x{height}";
+ }
+
public void Save()
{
try
diff --git a/src/SharpEmu.GUI/HostDisplayOptions.cs b/src/SharpEmu.GUI/HostDisplayOptions.cs
new file mode 100644
index 00000000..ee3d1143
--- /dev/null
+++ b/src/SharpEmu.GUI/HostDisplayOptions.cs
@@ -0,0 +1,138 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.Libs.VideoOut;
+
+namespace SharpEmu.GUI;
+
+internal sealed record HostDisplayOption(HostDisplayInfo Display)
+{
+ public int Index => Display.Index;
+
+ public IReadOnlyList Modes => Display.Modes;
+
+ public override string ToString() => $"{Index + 1}: {Display.Name}";
+}
+
+internal sealed record HostRefreshRateOption(int Value, string Label)
+{
+ public override string ToString() => Label;
+}
+
+internal static class HostDisplayOptions
+{
+ public static IReadOnlyList BuildDisplays(
+ IReadOnlyList detected,
+ int selectedIndex)
+ {
+ selectedIndex = Math.Max(0, selectedIndex);
+ var options = detected
+ .Select(display => new HostDisplayOption(display))
+ .ToList();
+ if (options.Count == 0)
+ {
+ options.Add(new HostDisplayOption(new HostDisplayInfo(
+ 0,
+ "Display 1",
+ CreateFallbackModes())));
+ }
+
+ if (options.All(display => display.Index != selectedIndex))
+ {
+ options.Add(new HostDisplayOption(new HostDisplayInfo(
+ selectedIndex,
+ $"Display {selectedIndex + 1}",
+ options[0].Modes)));
+ }
+
+ return options.OrderBy(display => display.Index).ToArray();
+ }
+
+ public static HostDisplayOption SelectDisplay(
+ IReadOnlyList displays,
+ int selectedIndex) =>
+ displays.FirstOrDefault(display => display.Index == selectedIndex) ?? displays[0];
+
+ public static IReadOnlyList BuildResolutions(
+ HostDisplayOption display,
+ string? selectedResolution)
+ {
+ var resolutions = display.Modes
+ .Where(mode => mode.Width > 0 && mode.Height > 0)
+ .Select(mode => $"{mode.Width}x{mode.Height}")
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ if (TryParseResolution(selectedResolution, out var selectedWidth, out var selectedHeight))
+ {
+ var selected = $"{selectedWidth}x{selectedHeight}";
+ if (!resolutions.Contains(selected, StringComparer.OrdinalIgnoreCase))
+ {
+ resolutions.Add(selected);
+ }
+ }
+
+ if (resolutions.Count == 0)
+ {
+ resolutions.Add("1920x1080");
+ }
+
+ return resolutions
+ .OrderByDescending(resolution => ResolutionArea(resolution))
+ .ThenByDescending(resolution => resolution, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
+ public static IReadOnlyList BuildRefreshRates(
+ HostDisplayOption display,
+ string? resolution,
+ int selectedRefreshRate,
+ string automaticLabel)
+ {
+ TryParseResolution(resolution, out var width, out var height);
+ var rates = display.Modes
+ .Where(mode => mode.Width == width && mode.Height == height && mode.RefreshRate > 0)
+ .Select(mode => mode.RefreshRate)
+ .Distinct()
+ .OrderByDescending(rate => rate)
+ .ToList();
+ if (selectedRefreshRate > 0 && !rates.Contains(selectedRefreshRate))
+ {
+ rates.Add(selectedRefreshRate);
+ rates.Sort((left, right) => right.CompareTo(left));
+ }
+
+ return new[] { new HostRefreshRateOption(0, automaticLabel) }
+ .Concat(rates.Select(rate => new HostRefreshRateOption(rate, $"{rate} Hz")))
+ .ToArray();
+ }
+
+ public static bool TryParseResolution(string? value, out int width, out int height)
+ {
+ width = 0;
+ height = 0;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ var separator = value.IndexOf('x', StringComparison.OrdinalIgnoreCase);
+ return separator > 0 &&
+ int.TryParse(value.AsSpan(0, separator), out width) &&
+ int.TryParse(value.AsSpan(separator + 1), out height) &&
+ width > 0 &&
+ height > 0;
+ }
+
+ private static long ResolutionArea(string resolution) =>
+ TryParseResolution(resolution, out var width, out var height)
+ ? (long)width * height
+ : 0;
+
+ private static IReadOnlyList CreateFallbackModes() =>
+ [
+ new HostDisplayMode(3840, 2160, 60),
+ new HostDisplayMode(2560, 1440, 60),
+ new HostDisplayMode(1920, 1080, 60),
+ new HostDisplayMode(1280, 720, 60),
+ ];
+}
diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json
index 9d4e7ab4..ce7d8a46 100644
--- a/src/SharpEmu.GUI/Languages/en.json
+++ b/src/SharpEmu.GUI/Languages/en.json
@@ -41,6 +41,24 @@
"Options.Section.Emulation": "EMULATION",
"Options.Section.Logging": "LOGGING",
"Options.Section.Launcher": "LAUNCHER",
+ "Options.Section.Display": "DISPLAY",
+ "Options.Graphics": "Graphics",
+
+ "Options.WindowMode.Label": "Window mode",
+ "Options.WindowMode.Desc": "Regular window, desktop borderless, or exclusive fullscreen.",
+ "Options.Resolution.Label": "Resolution",
+ "Options.Resolution.Desc": "Initial window size or exclusive fullscreen resolution.",
+ "Options.Display.Label": "Display",
+ "Options.Display.Desc": "Monitor used for centering and fullscreen.",
+ "Options.RefreshRate.Label": "Refresh rate",
+ "Options.RefreshRate.Desc": "Exclusive fullscreen refresh rate. Automatic selects the closest mode.",
+ "Options.RefreshRate.Automatic": "Automatic",
+ "Options.Scaling.Label": "Scaling",
+ "Options.Scaling.Desc": "Scale the native guest image without changing its internal resolution.",
+ "Options.VSync.Label": "VSync",
+ "Options.VSync.Desc": "Use FIFO presentation for tear-free output.",
+ "Options.Hdr.Label": "HDR output",
+ "Options.Hdr.Desc": "Use HDR when the selected display and graphics backend support it. Auto falls back to SDR.",
"Options.CpuEngine.Label": "CPU engine",
"Options.CpuEngine.Desc": "Execution engine used to run game code.",
@@ -87,6 +105,8 @@
"PerGame.Title": "Per-game settings — {0} ({1})",
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
+ "PerGame.Tab.General": "General",
+ "PerGame.Tab.Graphics": "Graphics",
"PerGame.EnvToggles.Label": "Environment toggles",
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
diff --git a/src/SharpEmu.GUI/Languages/tr.json b/src/SharpEmu.GUI/Languages/tr.json
index 89fa5ff7..5132220c 100644
--- a/src/SharpEmu.GUI/Languages/tr.json
+++ b/src/SharpEmu.GUI/Languages/tr.json
@@ -29,6 +29,24 @@
"Options.Section.Emulation": "EMÜLASYON",
"Options.Section.Logging": "GÜNLÜKLEME",
"Options.Section.Launcher": "BAŞLATICI",
+ "Options.Section.Display": "GÖRÜNTÜ",
+ "Options.Graphics": "Grafik",
+
+ "Options.WindowMode.Label": "Pencere modu",
+ "Options.WindowMode.Desc": "Normal pencere, kenarlıksız masaüstü veya özel tam ekran.",
+ "Options.Resolution.Label": "Çözünürlük",
+ "Options.Resolution.Desc": "Başlangıç pencere boyutu veya özel tam ekran çözünürlüğü.",
+ "Options.Display.Label": "Ekran",
+ "Options.Display.Desc": "Ortalama ve tam ekran için kullanılan monitör.",
+ "Options.RefreshRate.Label": "Yenileme hızı",
+ "Options.RefreshRate.Desc": "Özel tam ekran yenileme hızı. Otomatik, en yakın modu seçer.",
+ "Options.RefreshRate.Automatic": "Otomatik",
+ "Options.Scaling.Label": "Ölçekleme",
+ "Options.Scaling.Desc": "Dahili çözünürlüğü değiştirmeden oyun görüntüsünü ölçekle.",
+ "Options.VSync.Label": "VSync",
+ "Options.VSync.Desc": "Yırtılmasız görüntü için FIFO sunumunu kullan.",
+ "Options.Hdr.Label": "HDR çıkışı",
+ "Options.Hdr.Desc": "Seçili ekran ve grafik backend'i destekliyorsa HDR kullan. Otomatik mod SDR'ye geri döner.",
"Options.CpuEngine.Label": "CPU motoru",
"Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
@@ -159,6 +177,8 @@
"Common.Cancel": "İptal",
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
+ "PerGame.Tab.General": "Genel",
+ "PerGame.Tab.Graphics": "Grafik",
"PerGame.EnvToggles.Label": "Ortam anahtarları",
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
"Options.About": "Hakkında",
diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml
index 9e59d23f..31169e47 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml
+++ b/src/SharpEmu.GUI/MainWindow.axaml
@@ -60,12 +60,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs
index a89b752f..8e7432d0 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml.cs
+++ b/src/SharpEmu.GUI/MainWindow.axaml.cs
@@ -16,7 +16,7 @@ using Avalonia.VisualTree;
using SharpEmu.Core.Cpu;
using SharpEmu.Core.Runtime;
using SharpEmu.HLE.Host;
-using SharpEmu.HLE.Host.Windows;
+using SharpEmu.Libs.Pad;
using SharpEmu.Libs.VideoOut;
using SharpEmu.Logging;
using System.Collections.Concurrent;
@@ -62,18 +62,17 @@ public partial class MainWindow : Window
private bool _clearLibraryBlurWhenComplete;
private GuiSettings _settings = new();
+ private IReadOnlyList _hostDisplays = [];
+ private bool _updatingHostDisplayOptions;
private EmulatorProcess? _emulator;
- private GameSurfaceHost? _gameSurfaceHost;
private ConsoleWindow? _consoleWindow;
private GuiConsoleMirror? _consoleMirror;
private StreamWriter? _fileLog;
private readonly SndPreviewPlayer _sndPreview = new();
private string? _emulatorExePath;
private PendingLaunch? _pendingLaunch;
- private bool _gameFullscreen;
private bool _isRunning;
private bool _isStopping;
- private bool _awaitingFirstFrame;
private int _autoScrollTicks;
private int _activePageIndex;
private Updater.UpdateInfo? _availableUpdate;
@@ -114,7 +113,7 @@ public partial class MainWindow : Window
string EbootPath,
string DisplayName,
string? TitleId,
- string LogLevel,
+ EffectiveLaunchSettings Settings,
SharpEmuRuntimeOptions RuntimeOptions);
public MainWindow()
@@ -160,12 +159,10 @@ public partial class MainWindow : Window
// follow the launcher into the background or a minimized state.
Activated += (_, _) =>
{
- UpdateSessionBarVisibility();
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
};
Deactivated += (_, _) =>
{
- SessionBarPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = false;
};
@@ -181,8 +178,6 @@ public partial class MainWindow : Window
LaunchButton.Click += (_, _) => LaunchSelected();
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
StopButton.Click += (_, _) => StopEmulator();
- SessionStopButton.Click += (_, _) => StopEmulator();
- SessionConsoleButton.Click += (_, _) => ShowConsoleWindow();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
LibraryTabButton.Click += (_, _) => SetActivePage(0);
@@ -221,6 +216,13 @@ public partial class MainWindow : Window
};
AutoUpdateToggle.IsCheckedChanged += (_, _) =>
_settings.CheckForUpdatesOnStartup = AutoUpdateToggle.IsChecked == true;
+ WindowModeBox.SelectionChanged += (_, _) => _settings.WindowMode = SelectedComboText(WindowModeBox, "Windowed");
+ DisplayBox.SelectionChanged += (_, _) => OnHostDisplayChanged();
+ ResolutionBox.SelectionChanged += (_, _) => OnHostResolutionChanged();
+ RefreshRateBox.SelectionChanged += (_, _) => OnHostRefreshRateChanged();
+ ScalingModeBox.SelectionChanged += (_, _) => _settings.ScalingMode = SelectedComboText(ScalingModeBox, "Fit");
+ VSyncToggle.IsCheckedChanged += (_, _) => _settings.VSync = VSyncToggle.IsChecked == true;
+ HdrModeBox.SelectionChanged += (_, _) => _settings.HdrMode = SelectedComboText(HdrModeBox, "Auto");
UpdateButton.Click += async (_, _) => await OnUpdateButtonAsync();
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
EnvBthidToggle.IsCheckedChanged += (_, _) =>
@@ -239,6 +241,8 @@ public partial class MainWindow : Window
SetEnvironmentToggle("SHARPEMU_LOG_IO", EnvLogIoToggle.IsChecked == true);
EnvLogNpToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_LOG_NP", EnvLogNpToggle.IsChecked == true);
+ EnvGuestImageCpuSyncToggle.IsCheckedChanged += (_, _) =>
+ SetGuestImageCpuSync(EnvGuestImageCpuSyncToggle.IsChecked == true);
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
@@ -255,8 +259,7 @@ public partial class MainWindow : Window
Opened += async (_, _) => await OnOpenedAsync();
Closing += (_, _) => OnWindowClosing();
- WindowsDualSenseReader.EnsureStarted();
- WindowsXInputReader.EnsureStarted();
+ SdlLauncherGamepad.EnsureStarted();
_gamepadTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50),
@@ -427,8 +430,7 @@ public partial class MainWindow : Window
private void PollGamepad()
{
- // DualSense wins when both are connected; XInput covers Xbox pads.
- if (!WindowsDualSenseReader.TryGetState(out var pad) && !WindowsXInputReader.TryGetState(out pad))
+ if (!SdlLauncherGamepad.TryGetState(out var pad))
{
_previousPadButtons = HostGamepadButtons.None;
return;
@@ -444,9 +446,8 @@ public partial class MainWindow : Window
if (_isRunning || _isStopping)
{
- // The game renders inside the launcher window, so the launcher
- // stays active while playing. The controller belongs to the game
- // then: no navigation, and Circle/B must never stop the session.
+ // The controller belongs to the separate game window while a
+ // session is active; Circle/B must never stop the session.
_previousPadButtons = pad.Buttons;
return;
}
@@ -685,7 +686,25 @@ public partial class MainWindow : Window
AutoUpdateRow.Label = loc.Get("Updater.Auto.Label");
AutoUpdateRow.Description = loc.Get("Updater.Auto.Desc");
- foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle })
+ GraphicsTabItem.Header = loc.Get("Options.Graphics");
+ DisplaySectionTitle.Text = loc.Get("Options.Section.Display");
+ WindowModeRow.Label = loc.Get("Options.WindowMode.Label");
+ WindowModeRow.Description = loc.Get("Options.WindowMode.Desc");
+ ResolutionRow.Label = loc.Get("Options.Resolution.Label");
+ ResolutionRow.Description = loc.Get("Options.Resolution.Desc");
+ DisplayRow.Label = loc.Get("Options.Display.Label");
+ DisplayRow.Description = loc.Get("Options.Display.Desc");
+ RefreshRateRow.Label = loc.Get("Options.RefreshRate.Label");
+ RefreshRateRow.Description = loc.Get("Options.RefreshRate.Desc");
+ ScalingRow.Label = loc.Get("Options.Scaling.Label");
+ ScalingRow.Description = loc.Get("Options.Scaling.Desc");
+ VSyncRow.Label = loc.Get("Options.VSync.Label");
+ VSyncRow.Description = loc.Get("Options.VSync.Desc");
+ HdrRow.Label = loc.Get("Options.Hdr.Label");
+ HdrRow.Description = loc.Get("Options.Hdr.Desc");
+ RefreshHostRefreshRates(_settings.RefreshRate);
+
+ foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle, VSyncToggle })
{
toggle.OnContent = loc.Get("Common.On");
toggle.OffContent = loc.Get("Common.Off");
@@ -758,91 +777,21 @@ public partial class MainWindow : Window
private void OnKeyDown(object sender, KeyEventArgs args)
{
- args.Handled = true;
- switch (args.Key)
+ if (args.Key == Key.F11 && !_isRunning)
{
- case Key.F11:
- OnWindowFullScreen(this, new RoutedEventArgs());
- break;
- default:
- args.Handled = false;
- break;
+ WindowState = WindowState == WindowState.FullScreen
+ ? WindowState.Maximized
+ : WindowState.FullScreen;
+ args.Handled = true;
}
}
private void OnPreviewKeyDown(object? sender, KeyEventArgs args)
{
- // While a session is on screen, Enter and Space are game input
- // (Cross button). Keyboard focus stays on the launcher window, so a
- // previously clicked, still-focused button (console toggle, session
- // bar) would also activate and reshape the game view. Swallow the
- // keys before button activation; the emulator process reads raw key
- // state and is unaffected. Fullscreen hides those buttons, which is
- // why this only manifested in windowed sessions.
- if (_isRunning && GameView.IsVisible &&
- args.Key is Key.Enter or Key.Space)
- {
- args.Handled = true;
- }
- }
-
- private void OnWindowFullScreen(object sender, RoutedEventArgs args)
- {
- if (WindowState == WindowState.FullScreen)
- {
- // Leaving F11 should restore a monitor-sized window with the
- // launcher chrome, not fall back to the design-time window size.
- WindowState = WindowState.Maximized;
- WindowDecorations = WindowDecorations.Full;
- TitleBar.IsVisible = true;
- StatusBar.IsVisible = true;
- if (_gameFullscreen)
- {
- _gameFullscreen = false;
- Grid.SetRow(MainContent, 1);
- Grid.SetRowSpan(MainContent, 1);
- MainContent.Margin = _isRunning
- ? new Thickness(0)
- : new Thickness(32, 24, 32, 20);
- ContentToolbar.IsVisible = !_isRunning;
- ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
- LaunchBar.IsVisible = true;
- QueueGameSurfaceResize();
- UpdateSessionBarVisibility();
- }
- }
- else
- {
- WindowState = WindowState.FullScreen;
- WindowDecorations = WindowDecorations.None;
- TitleBar.IsVisible = false;
- StatusBar.IsVisible = false;
- if (_isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible)
- {
- // The native child receives its new physical Bounds as soon
- // as this grid spans the monitor. The presenter recreates its
- // swapchain from that size, rather than stretching 720p.
- _gameFullscreen = true;
- // Re-arming restarts the idle countdown, so the cursor also
- // hides a moment after F11 even without further mouse motion.
- _gameSurfaceHost?.SetCursorAutoHide(true);
- Grid.SetRow(MainContent, 0);
- Grid.SetRowSpan(MainContent, 3);
- MainContent.Margin = new Thickness(0);
- ContentToolbar.IsVisible = false;
- ConsolePanel.IsVisible = false;
- LaunchBar.IsVisible = false;
- QueueGameSurfaceResize();
- UpdateSessionBarVisibility();
- }
- }
- }
-
- private void QueueGameSurfaceResize()
- {
- Dispatcher.UIThread.Post(
- () => _gameSurfaceHost?.RefreshSurfaceSize(),
- DispatcherPriority.Render);
+ // The session runs in its own SDL window and takes keyboard focus with
+ // it, so launcher buttons no longer see game input and nothing has to
+ // be swallowed here. Kept as the wired handler because the launcher
+ // still needs a preview hook for its own shortcuts.
}
private void OnWindowClosing()
@@ -851,6 +800,7 @@ public partial class MainWindow : Window
_consoleFlushTimer.Stop();
_libraryBlurTimer.Stop();
_gamepadTimer.Stop();
+ SdlLauncherGamepad.Shutdown();
_sndPreview.Stop();
_discord?.Dispose();
_consoleWindow?.Close();
@@ -903,9 +853,143 @@ public partial class MainWindow : Window
EnvLogDirectMemoryToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_DIRECT_MEMORY");
EnvLogIoToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_IO");
EnvLogNpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_NP");
+ EnvGuestImageCpuSyncToggle.IsChecked = IsEnvironmentEnabled(
+ _settings.EnvironmentToggles,
+ "SHARPEMU_GUEST_IMAGE_CPU_SYNC",
+ defaultValue: true);
+ WindowModeBox.SelectedIndex = ChoiceIndex(_settings.WindowMode, "Windowed", "Borderless", "Exclusive");
+ LoadHostDisplayOptions();
+ ScalingModeBox.SelectedIndex = ChoiceIndex(_settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
+ VSyncToggle.IsChecked = _settings.VSync;
+ HdrModeBox.SelectedIndex = ChoiceIndex(_settings.HdrMode, "Auto", "On", "Off");
UpdateLogFilePathText();
}
+ private static string SelectedComboText(ComboBox comboBox, string fallback) =>
+ comboBox.SelectedItem switch
+ {
+ ComboBoxItem item => item.Content?.ToString() ?? fallback,
+ string value => value,
+ _ => fallback,
+ };
+
+ private void LoadHostDisplayOptions()
+ {
+ _updatingHostDisplayOptions = true;
+ try
+ {
+ _hostDisplays = HostDisplayOptions.BuildDisplays(
+ HostDisplayCatalog.Query(),
+ _settings.DisplayIndex);
+ DisplayBox.ItemsSource = _hostDisplays;
+ var display = HostDisplayOptions.SelectDisplay(_hostDisplays, _settings.DisplayIndex);
+ DisplayBox.SelectedItem = display;
+ PopulateHostModes(display, _settings.Resolution, _settings.RefreshRate);
+ }
+ finally
+ {
+ _updatingHostDisplayOptions = false;
+ }
+
+ SyncHostVideoSettings();
+ }
+
+ private void OnHostDisplayChanged()
+ {
+ if (_updatingHostDisplayOptions || DisplayBox.SelectedItem is not HostDisplayOption display)
+ {
+ return;
+ }
+
+ _updatingHostDisplayOptions = true;
+ try
+ {
+ PopulateHostModes(display, _settings.Resolution, _settings.RefreshRate);
+ }
+ finally
+ {
+ _updatingHostDisplayOptions = false;
+ }
+
+ SyncHostVideoSettings();
+ }
+
+ private void OnHostResolutionChanged()
+ {
+ if (_updatingHostDisplayOptions || DisplayBox.SelectedItem is not HostDisplayOption)
+ {
+ return;
+ }
+
+ _settings.Resolution = SelectedComboText(ResolutionBox, "1920x1080");
+ RefreshHostRefreshRates(_settings.RefreshRate);
+ OnHostRefreshRateChanged();
+ }
+
+ private void OnHostRefreshRateChanged()
+ {
+ if (!_updatingHostDisplayOptions && RefreshRateBox.SelectedItem is HostRefreshRateOption refreshRate)
+ {
+ _settings.RefreshRate = refreshRate.Value;
+ }
+ }
+
+ private void PopulateHostModes(
+ HostDisplayOption display,
+ string selectedResolution,
+ int selectedRefreshRate)
+ {
+ var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
+ ResolutionBox.ItemsSource = resolutions;
+ ResolutionBox.SelectedItem = resolutions.FirstOrDefault(resolution =>
+ string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
+ RefreshHostRefreshRates(selectedRefreshRate);
+ }
+
+ private void RefreshHostRefreshRates(int selectedRefreshRate)
+ {
+ if (DisplayBox.SelectedItem is not HostDisplayOption display)
+ {
+ return;
+ }
+
+ var wasUpdating = _updatingHostDisplayOptions;
+ _updatingHostDisplayOptions = true;
+ try
+ {
+ var rates = HostDisplayOptions.BuildRefreshRates(
+ display,
+ SelectedComboText(ResolutionBox, _settings.Resolution),
+ selectedRefreshRate,
+ Localization.Instance.Get("Options.RefreshRate.Automatic"));
+ RefreshRateBox.ItemsSource = rates;
+ RefreshRateBox.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
+ }
+ finally
+ {
+ _updatingHostDisplayOptions = wasUpdating;
+ }
+ }
+
+ private void SyncHostVideoSettings()
+ {
+ if (DisplayBox.SelectedItem is HostDisplayOption display)
+ {
+ _settings.DisplayIndex = display.Index;
+ }
+
+ _settings.Resolution = SelectedComboText(ResolutionBox, "1920x1080");
+ _settings.RefreshRate = RefreshRateBox.SelectedItem is HostRefreshRateOption refreshRate
+ ? refreshRate.Value
+ : 0;
+ }
+
+ private static int ChoiceIndex(string value, params string[] choices)
+ {
+ var index = Array.FindIndex(choices, choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase));
+ return index < 0 ? 0 : index;
+ }
+
private async Task OnUpdateButtonAsync()
{
if (_availableUpdate is null)
@@ -999,6 +1083,40 @@ public partial class MainWindow : Window
}
}
+ private void SetGuestImageCpuSync(bool enabled)
+ {
+ const string name = "SHARPEMU_GUEST_IMAGE_CPU_SYNC";
+ _settings.EnvironmentToggles.RemoveAll(entry =>
+ string.Equals(entry, name, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(entry, name + "=0", StringComparison.OrdinalIgnoreCase));
+ if (!enabled)
+ {
+ _settings.EnvironmentToggles.Add(name + "=0");
+ }
+ }
+
+ private static bool IsEnvironmentEnabled(
+ IEnumerable entries,
+ string name,
+ bool defaultValue)
+ {
+ foreach (var entry in entries)
+ {
+ if (string.Equals(entry, name + "=0", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (string.Equals(entry, name, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(entry, name + "=1", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return defaultValue;
+ }
+
private string SelectedLogLevel()
{
return LogLevelBox.SelectedIndex switch
@@ -1795,16 +1913,23 @@ public partial class MainWindow : Window
// launcher process so every platform receives the same launch options.
foreach (var staleName in _appliedEnvironmentVariables)
{
- if (!effective.EnvironmentToggles.Contains(staleName))
+ if (!effective.EnvironmentToggles.Any(entry =>
+ TryParseEnvironmentEntry(entry, out var name, out _) &&
+ string.Equals(name, staleName, StringComparison.OrdinalIgnoreCase)))
{
Environment.SetEnvironmentVariable(staleName, null);
}
}
_appliedEnvironmentVariables.Clear();
- foreach (var name in effective.EnvironmentToggles)
+ foreach (var entry in effective.EnvironmentToggles)
{
- Environment.SetEnvironmentVariable(name, "1");
+ if (!TryParseEnvironmentEntry(entry, out var name, out var value))
+ {
+ continue;
+ }
+
+ Environment.SetEnvironmentVariable(name, value);
_appliedEnvironmentVariables.Add(name);
}
@@ -1828,7 +1953,6 @@ public partial class MainWindow : Window
_isRunning = true;
_runningGameName = displayName;
- SessionGameTitle.Text = displayName;
_runningGameTitleId = resolvedTitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush;
@@ -1837,18 +1961,25 @@ public partial class MainWindow : Window
UpdateRunButtons();
UpdateDiscordPresence();
- ShowGameView();
+ BeginSessionUi();
_pendingLaunch = new PendingLaunch(
Path.GetFullPath(ebootPath),
displayName,
_runningGameTitleId,
- effective.LogLevel,
+ effective,
runtimeOptions);
- if (_gameSurfaceHost?.Surface is { } surface)
- {
- StartPendingSession(surface);
- }
+ StartPendingSession();
+ }
+
+ private static bool TryParseEnvironmentEntry(string entry, out string name, out string value)
+ {
+ var separator = entry.IndexOf('=');
+ name = separator >= 0 ? entry[..separator] : entry;
+ value = separator >= 0 ? entry[(separator + 1)..] : "1";
+ return name.StartsWith("SHARPEMU_", StringComparison.OrdinalIgnoreCase) &&
+ name.Length > "SHARPEMU_".Length &&
+ value.Length != 0;
}
///
@@ -1877,9 +2008,6 @@ public partial class MainWindow : Window
_isStopping = true;
StopButton.IsEnabled = false;
- SessionStopButton.IsEnabled = false;
- SessionHintText.Text = Localization.Instance.Get("Launch.Stopping");
- SessionF11Badge.IsVisible = false;
ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
_emulator.Stop();
_runningGameName = null;
@@ -1887,7 +2015,6 @@ public partial class MainWindow : Window
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
UpdateDiscordPresence();
- UpdateSessionBarVisibility();
ReturnToLibraryWhileStopping();
}
@@ -1930,8 +2057,7 @@ public partial class MainWindow : Window
_emulator?.Dispose();
_emulator = null;
_pendingLaunch = null;
- DisposeGameSurfaceHost();
- HideGameView();
+ EndSessionUi();
var meaningKey = exitCode switch
{
@@ -1964,7 +2090,7 @@ public partial class MainWindow : Window
UpdateDiscordPresence();
}
- private void StartPendingSession(VulkanHostSurface surface)
+ private void StartPendingSession()
{
if (_pendingLaunch is not { } launch || _emulator is not null)
{
@@ -1984,7 +2110,7 @@ public partial class MainWindow : Window
try
{
- var arguments = BuildEmulatorArguments(launch, surface);
+ var arguments = BuildEmulatorArguments(launch);
_emulator = process;
_pendingLaunch = null;
process.Start(
@@ -2006,12 +2132,12 @@ public partial class MainWindow : Window
}
}
- private List BuildEmulatorArguments(PendingLaunch launch, VulkanHostSurface surface)
+ private List BuildEmulatorArguments(PendingLaunch launch)
{
var arguments = new List
{
"--cpu-engine=native",
- $"--log-level={launch.LogLevel}",
+ $"--log-level={launch.Settings.LogLevel}",
};
if (launch.RuntimeOptions.StrictDynlibResolution)
{
@@ -2022,16 +2148,13 @@ public partial class MainWindow : Window
arguments.Add($"--trace-imports={launch.RuntimeOptions.ImportTraceLimit}");
}
- if (surface.TryGetChildProcessDescriptor(out var descriptor))
- {
- arguments.Add($"--host-surface={descriptor}");
- }
- else
- {
- AppendConsoleLine(
- "[GUI][WARN] Embedded child surfaces are unavailable on this platform; opening a game window instead.",
- WarningLineBrush);
- }
+ arguments.Add($"--window-mode={launch.Settings.WindowMode.ToLowerInvariant()}");
+ arguments.Add($"--resolution={launch.Settings.Resolution}");
+ arguments.Add($"--display={launch.Settings.DisplayIndex}");
+ arguments.Add($"--refresh-rate={launch.Settings.RefreshRate}");
+ arguments.Add($"--scaling={launch.Settings.ScalingMode.ToLowerInvariant()}");
+ arguments.Add($"--vsync={(launch.Settings.VSync ? "on" : "off")}");
+ arguments.Add($"--hdr={launch.Settings.HdrMode.ToLowerInvariant()}");
arguments.Add(launch.EbootPath);
return arguments;
@@ -2040,8 +2163,8 @@ public partial class MainWindow : Window
private void OnEmulatorOutput(string line, bool isError)
{
_pendingLines.Enqueue((line, isError));
- if (!line.Contains("[VIDEOOUT][INFO] Hosted splash ready.", StringComparison.Ordinal) &&
- !line.Contains("[VIDEOOUT][INFO] Hosted first frame presented.", StringComparison.Ordinal))
+ if (!line.Contains("Vulkan VideoOut presented first frame:", StringComparison.Ordinal) &&
+ !line.Contains("Vulkan VideoOut ready:", StringComparison.Ordinal))
{
return;
}
@@ -2050,143 +2173,25 @@ public partial class MainWindow : Window
{
if (_isRunning && !_isStopping)
{
- _awaitingFirstFrame = false;
- ClearLibraryBlur();
- MainContent.Margin = new Thickness(0);
- RestoreGameViewToFull();
- GameView.Background = Brushes.Black;
- GameView.IsHitTestVisible = true;
- LibraryPage.IsVisible = false;
- OptionsPage.IsVisible = false;
- LibraryToolbar.IsVisible = false;
- ContentToolbar.IsVisible = false;
- ConsolePanel.IsVisible = false;
- LaunchBar.IsVisible = false;
- HideSessionLoading();
- UpdateSessionBarVisibility();
-
- // Defer so the layout pass from the margin change above settles first.
- Dispatcher.UIThread.Post(() =>
- {
- if (!_isRunning || _isStopping)
- {
- return;
- }
-
- _gameSurfaceHost?.RefreshSurfaceSize();
- _gameSurfaceHost?.SetPresentationVisible(true);
- _gameSurfaceHost?.SetCursorAutoHide(true);
- });
+ ShowSessionStatus("Game is running");
}
});
}
- private GameSurfaceHost EnsureGameSurfaceHost()
- {
- if (_gameSurfaceHost is not null)
- {
- return _gameSurfaceHost;
- }
-
- var host = new GameSurfaceHost();
- // Configure this before attaching it to Avalonia so its first native
- // HWND is hidden while the child process starts.
- host.SetPresentationVisible(false);
- host.SurfaceAvailable += (_, surface) =>
- {
- if (ReferenceEquals(_gameSurfaceHost, host))
- {
- StartPendingSession(surface);
- }
- };
- host.SurfaceDestroyed += (_, surface) => OnGameSurfaceDestroyed(host, surface);
- _gameSurfaceHost = host;
- GameSurfaceContainer.Children.Add(host);
- return host;
- }
-
- private void DisposeGameSurfaceHost()
- {
- var host = _gameSurfaceHost;
- if (host is null)
- {
- return;
- }
-
- _gameSurfaceHost = null;
- host.SetPresentationVisible(false);
- GameSurfaceContainer.Children.Remove(host);
- }
-
- private void OnGameSurfaceDestroyed(GameSurfaceHost host, VulkanHostSurface surface)
- {
- if (ReferenceEquals(_gameSurfaceHost, host) && _isRunning)
- {
- StopEmulator();
- }
- }
-
- ///
- /// The native host attachment is a real child window: it sits above every
- /// Avalonia control it covers and swallows their mouse input regardless of
- /// hit-test settings. While the library must stay interactive (loading,
- /// closing), the surface is parked offscreen AT FULL SIZE via a negative
- /// margin. It must not be shrunk instead: the emulator child polls the
- /// HWND client size and its presenter defers swapchain creation while the
- /// surface is 1px, which would deadlock the loading handshake.
- ///
- private void ParkGameViewOffscreen()
- {
- GameView.Margin = new Thickness(-20000, 0, 20000, 0);
- }
-
- private void RestoreGameViewToFull()
- {
- GameView.Margin = new Thickness(0);
- }
-
- private void ShowGameView()
+ private void BeginSessionUi()
{
_isStopping = false;
- _awaitingFirstFrame = true;
- var host = EnsureGameSurfaceHost();
- ParkGameViewOffscreen();
- GameView.IsVisible = true;
- GameView.Background = Brushes.Transparent;
- GameView.IsHitTestVisible = false;
- host.SetPresentationVisible(false);
AnimateLibraryBlur(LaunchBlurRadius);
- SessionHintText.Text = "Fullscreen";
- SessionF11Badge.IsVisible = true;
- UpdateSessionBarVisibility();
ShowSessionLoading("Loading game", "Preparing the emulation session...");
+ LaunchBar.IsVisible = true;
}
- private void HideGameView()
+ private void EndSessionUi()
{
- if (_gameFullscreen && WindowState == WindowState.FullScreen)
- {
- OnWindowFullScreen(this, new RoutedEventArgs());
- }
-
- _gameSurfaceHost?.SetCursorAutoHide(false);
- _gameSurfaceHost?.SetPresentationVisible(false);
- _awaitingFirstFrame = false;
- GameView.IsVisible = false;
- GameView.IsHitTestVisible = true;
- SessionBarPopup.IsOpen = false;
HideSessionLoading();
AnimateLibraryBlur(0, clearWhenComplete: true);
- MainContent.Margin = new Thickness(32, 24, 32, 20);
- ContentToolbar.IsVisible = true;
- ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true;
- LibraryPage.IsVisible = _activePageIndex == 0;
- LibraryToolbar.IsVisible = _activePageIndex == 0;
- OptionsPage.IsVisible = _activePageIndex == 1;
- // Game art when the source still holds it, otherwise the bundled
- // default; a bare color only when neither is available.
- BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
+ ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
}
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
@@ -2258,7 +2263,20 @@ public partial class MainWindow : Window
private void ShowSessionLoading(string title, string detail)
{
SessionLoadingTitle.Text = title;
+ SessionLoadingTitle.IsVisible = true;
SessionLoadingDetail.Text = detail;
+ SessionLoadingDetail.IsVisible = true;
+ SessionLoadingProgress.IsVisible = true;
+ _sessionLoadingActive = true;
+ SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
+ }
+
+ private void ShowSessionStatus(string message)
+ {
+ SessionLoadingTitle.Text = message;
+ SessionLoadingTitle.IsVisible = true;
+ SessionLoadingDetail.IsVisible = false;
+ SessionLoadingProgress.IsVisible = false;
_sessionLoadingActive = true;
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
}
@@ -2271,33 +2289,11 @@ public partial class MainWindow : Window
private void ReturnToLibraryWhileStopping()
{
- if (_gameFullscreen && WindowState == WindowState.FullScreen)
- {
- OnWindowFullScreen(this, new RoutedEventArgs());
- }
-
- // Keep the native child alive until the session exits, but hide it
- // immediately. Destroying it while Vulkan still owns the surface can
- // crash the GUI; parking it in the 1x1 corner lets the library
- // recover — and stay clickable — while the native closing popup
- // reports teardown progress.
- _gameSurfaceHost?.SetPresentationVisible(false);
- _awaitingFirstFrame = false;
- ParkGameViewOffscreen();
- GameView.Background = Brushes.Transparent;
- GameView.IsHitTestVisible = false;
- SessionBarPopup.IsOpen = false;
AnimateLibraryBlur(LaunchBlurRadius);
- MainContent.Margin = new Thickness(32, 24, 32, 20);
- ContentToolbar.IsVisible = true;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true;
- LibraryPage.IsVisible = _activePageIndex == 0;
- LibraryToolbar.IsVisible = _activePageIndex == 0;
- OptionsPage.IsVisible = _activePageIndex == 1;
- BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
UpdateRunButtons();
- Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing.");
+ Console.Error.WriteLine("[GUI][INFO] Waiting for the SDL game process to exit.");
}
private void OpenFileLog(string? titleId)
@@ -2356,16 +2352,9 @@ public partial class MainWindow : Window
{
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
StopButton.IsEnabled = _isRunning && !_isStopping;
- SessionStopButton.IsEnabled = _isRunning && !_isStopping;
OpenFileButton.IsEnabled = !_isRunning;
}
- private void UpdateSessionBarVisibility()
- {
- SessionBarPopup.IsOpen = _isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible &&
- !_gameFullscreen && WindowState != WindowState.FullScreen;
- }
-
// ---- Console ----
private void FlushPendingConsoleLines()
diff --git a/src/SharpEmu.GUI/PerGameSettings.cs b/src/SharpEmu.GUI/PerGameSettings.cs
index 7023d778..ca932e3c 100644
--- a/src/SharpEmu.GUI/PerGameSettings.cs
+++ b/src/SharpEmu.GUI/PerGameSettings.cs
@@ -21,6 +21,20 @@ public sealed class PerGameSettings
public bool? LogToFile { get; set; }
+ public string? WindowMode { get; set; }
+
+ public string? Resolution { get; set; }
+
+ public int? DisplayIndex { get; set; }
+
+ public int? RefreshRate { get; set; }
+
+ public string? ScalingMode { get; set; }
+
+ public bool? VSync { get; set; }
+
+ public string? HdrMode { get; set; }
+
public List? EnvironmentToggles { get; set; }
[JsonIgnore]
@@ -29,6 +43,13 @@ public sealed class PerGameSettings
ImportTraceLimit is null &&
StrictDynlibResolution is null &&
LogToFile is null &&
+ WindowMode is null &&
+ Resolution is null &&
+ DisplayIndex is null &&
+ RefreshRate is null &&
+ ScalingMode is null &&
+ VSync is null &&
+ HdrMode is null &&
EnvironmentToggles is null;
public static string DirectoryPath =>
@@ -116,6 +137,13 @@ public sealed record EffectiveLaunchSettings(
int ImportTraceLimit,
bool StrictDynlibResolution,
bool LogToFile,
+ string WindowMode,
+ string Resolution,
+ int DisplayIndex,
+ int RefreshRate,
+ string ScalingMode,
+ bool VSync,
+ string HdrMode,
IReadOnlyList EnvironmentToggles)
{
public static EffectiveLaunchSettings Resolve(GuiSettings global, PerGameSettings? perGame) => new(
@@ -123,5 +151,12 @@ public sealed record EffectiveLaunchSettings(
perGame?.ImportTraceLimit ?? global.ImportTraceLimit,
perGame?.StrictDynlibResolution ?? global.StrictDynlibResolution,
perGame?.LogToFile ?? global.LogToFile,
+ perGame?.WindowMode ?? global.WindowMode,
+ perGame?.Resolution ?? global.Resolution,
+ Math.Max(0, perGame?.DisplayIndex ?? global.DisplayIndex),
+ Math.Clamp(perGame?.RefreshRate ?? global.RefreshRate, 0, 1000),
+ perGame?.ScalingMode ?? global.ScalingMode,
+ perGame?.VSync ?? global.VSync,
+ perGame?.HdrMode ?? global.HdrMode,
perGame?.EnvironmentToggles ?? global.EnvironmentToggles);
}
diff --git a/src/SharpEmu.GUI/PerGameSettingsDialog.cs b/src/SharpEmu.GUI/PerGameSettingsDialog.cs
index fa6bde4c..6be8a823 100644
--- a/src/SharpEmu.GUI/PerGameSettingsDialog.cs
+++ b/src/SharpEmu.GUI/PerGameSettingsDialog.cs
@@ -5,6 +5,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
+using SharpEmu.Libs.VideoOut;
namespace SharpEmu.GUI;
@@ -12,6 +13,9 @@ public sealed class PerGameSettingsDialog : Window
{
private static readonly string[] LogLevels =
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" };
+ private static readonly string[] WindowModes = { "Windowed", "Borderless", "Exclusive" };
+ private static readonly string[] ScalingModes = { "Fit", "Cover", "Stretch", "Integer" };
+ private static readonly string[] HdrModes = { "Auto", "On", "Off" };
private static readonly string[] EnvToggles =
{
@@ -23,9 +27,12 @@ public sealed class PerGameSettingsDialog : Window
"SHARPEMU_LOG_DIRECT_MEMORY",
"SHARPEMU_LOG_IO",
"SHARPEMU_LOG_NP",
+ "SHARPEMU_GUEST_IMAGE_CPU_SYNC",
};
private readonly string _titleId;
+ private IReadOnlyList _hostDisplays = [];
+ private bool _updatingHostDisplayOptions;
private readonly SettingRow _logLevelRow;
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
@@ -42,6 +49,27 @@ public sealed class PerGameSettingsDialog : Window
private readonly SettingRow _logToFileRow;
private readonly ToggleSwitch _logToFile = new();
+ private readonly SettingRow _windowModeRow;
+ private readonly ComboBox _windowMode = new() { ItemsSource = WindowModes, Width = 160 };
+
+ private readonly SettingRow _resolutionRow;
+ private readonly ComboBox _resolution = new() { Width = 160 };
+
+ private readonly SettingRow _displayIndexRow;
+ private readonly ComboBox _displayIndex = new() { Width = 240 };
+
+ private readonly SettingRow _refreshRateRow;
+ private readonly ComboBox _refreshRate = new() { Width = 160 };
+
+ private readonly SettingRow _scalingModeRow;
+ private readonly ComboBox _scalingMode = new() { ItemsSource = ScalingModes, Width = 160 };
+
+ private readonly SettingRow _vsyncRow;
+ private readonly ToggleSwitch _vsync = new();
+
+ private readonly SettingRow _hdrModeRow;
+ private readonly ComboBox _hdrMode = new() { ItemsSource = HdrModes, Width = 160 };
+
private readonly SettingRow _envRow;
private readonly StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
@@ -60,13 +88,20 @@ public sealed class PerGameSettingsDialog : Window
Background = new SolidColorBrush(Color.Parse("#0D1017"));
- _strict.OnContent = _logToFile.OnContent = loc.Get("Common.On");
- _strict.OffContent = _logToFile.OffContent = loc.Get("Common.Off");
+ _strict.OnContent = _logToFile.OnContent = _vsync.OnContent = loc.Get("Common.On");
+ _strict.OffContent = _logToFile.OffContent = _vsync.OffContent = loc.Get("Common.Off");
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel);
_traceRow = Row(loc.Get("Options.TraceImports.Label"), loc.Get("Options.TraceImports.Desc"), _trace);
_strictRow = Row(loc.Get("Options.Strict.Label"), loc.Get("Options.Strict.Desc"), _strict);
_logToFileRow = Row(loc.Get("Options.LogToFile.Label"), loc.Get("Options.LogToFile.Desc"), _logToFile);
+ _windowModeRow = Row(loc.Get("Options.WindowMode.Label"), loc.Get("Options.WindowMode.Desc"), _windowMode);
+ _resolutionRow = Row(loc.Get("Options.Resolution.Label"), loc.Get("Options.Resolution.Desc"), _resolution);
+ _displayIndexRow = Row(loc.Get("Options.Display.Label"), loc.Get("Options.Display.Desc"), _displayIndex);
+ _refreshRateRow = Row(loc.Get("Options.RefreshRate.Label"), loc.Get("Options.RefreshRate.Desc"), _refreshRate);
+ _scalingModeRow = Row(loc.Get("Options.Scaling.Label"), loc.Get("Options.Scaling.Desc"), _scalingMode);
+ _vsyncRow = Row(loc.Get("Options.VSync.Label"), loc.Get("Options.VSync.Desc"), _vsync);
+ _hdrModeRow = Row(loc.Get("Options.Hdr.Label"), loc.Get("Options.Hdr.Desc"), _hdrMode);
_envRow = new SettingRow
{
Label = loc.Get("PerGame.EnvToggles.Label"),
@@ -81,6 +116,22 @@ public sealed class PerGameSettingsDialog : Window
_envList.Children.Add(box);
}
+ var general = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
+ general.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
+ general.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
+ general.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
+
+ var graphics = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
+ graphics.Children.Add(Card(
+ loc.Get("Options.Section.Display"),
+ _windowModeRow,
+ _resolutionRow,
+ _displayIndexRow,
+ _refreshRateRow,
+ _scalingModeRow,
+ _vsyncRow,
+ _hdrModeRow));
+
var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
content.Children.Add(new TextBlock
{
@@ -88,9 +139,14 @@ public sealed class PerGameSettingsDialog : Window
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
FontSize = 12,
});
- content.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
- content.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
- content.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
+ content.Children.Add(new TabControl
+ {
+ ItemsSource = new[]
+ {
+ new TabItem { Header = loc.Get("PerGame.Tab.General"), Content = general },
+ new TabItem { Header = loc.Get("PerGame.Tab.Graphics"), Content = graphics },
+ },
+ });
var save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
@@ -119,6 +175,8 @@ public sealed class PerGameSettingsDialog : Window
root.Children.Add(buttonBar);
Content = root;
+ _displayIndex.SelectionChanged += (_, _) => OnHostDisplayChanged();
+ _resolution.SelectionChanged += (_, _) => OnHostResolutionChanged();
LoadValues(global);
_envRow.PropertyChanged += (_, e) =>
{
@@ -154,16 +212,38 @@ public sealed class PerGameSettingsDialog : Window
private void LoadValues(GuiSettings global)
{
+ var existing = PerGameSettings.Load(_titleId);
+ var displayIndex = Math.Max(0, existing?.DisplayIndex ?? global.DisplayIndex);
+ var resolution = existing?.Resolution ?? global.Resolution;
+ var refreshRate = Math.Clamp(existing?.RefreshRate ?? global.RefreshRate, 0, 1000);
+
+ _updatingHostDisplayOptions = true;
+ try
+ {
+ _hostDisplays = HostDisplayOptions.BuildDisplays(HostDisplayCatalog.Query(), displayIndex);
+ _displayIndex.ItemsSource = _hostDisplays;
+ var display = HostDisplayOptions.SelectDisplay(_hostDisplays, displayIndex);
+ _displayIndex.SelectedItem = display;
+ PopulateHostModes(display, resolution, refreshRate);
+ }
+ finally
+ {
+ _updatingHostDisplayOptions = false;
+ }
+
_logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
_trace.Value = global.ImportTraceLimit;
_strict.IsChecked = global.StrictDynlibResolution;
_logToFile.IsChecked = global.LogToFile;
+ _windowMode.SelectedItem = ChoiceOrDefault(WindowModes, global.WindowMode, "Windowed");
+ _scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, global.ScalingMode, "Fit");
+ _vsync.IsChecked = global.VSync;
+ _hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, global.HdrMode, "Auto");
foreach (var (name, box) in _envBoxes)
{
- box.IsChecked = global.EnvironmentToggles.Contains(name);
+ box.IsChecked = IsEnvironmentEnabled(global.EnvironmentToggles, name, defaultValue: name == "SHARPEMU_GUEST_IMAGE_CPU_SYNC");
}
- var existing = PerGameSettings.Load(_titleId);
if (existing is null)
{
return;
@@ -178,16 +258,120 @@ public sealed class PerGameSettingsDialog : Window
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
+ if (existing.WindowMode is { } windowMode && WindowModes.Contains(windowMode, StringComparer.OrdinalIgnoreCase))
+ {
+ _windowModeRow.IsOverridden = true;
+ _windowMode.SelectedItem = ChoiceOrDefault(WindowModes, windowMode, "Windowed");
+ }
+ if (existing.Resolution is not null)
+ {
+ _resolutionRow.IsOverridden = true;
+ }
+ if (existing.DisplayIndex is not null)
+ {
+ _displayIndexRow.IsOverridden = true;
+ }
+ if (existing.RefreshRate is not null)
+ {
+ _refreshRateRow.IsOverridden = true;
+ }
+ if (existing.ScalingMode is { } scalingMode && ScalingModes.Contains(scalingMode, StringComparer.OrdinalIgnoreCase))
+ {
+ _scalingModeRow.IsOverridden = true;
+ _scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, scalingMode, "Fit");
+ }
+ if (existing.VSync is { } vsync)
+ {
+ _vsyncRow.IsOverridden = true;
+ _vsync.IsChecked = vsync;
+ }
+ if (existing.HdrMode is { } hdrMode && HdrModes.Contains(hdrMode, StringComparer.OrdinalIgnoreCase))
+ {
+ _hdrModeRow.IsOverridden = true;
+ _hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, hdrMode, "Auto");
+ }
if (existing.EnvironmentToggles is { } env)
{
_envRow.IsOverridden = true;
foreach (var (name, box) in _envBoxes)
{
- box.IsChecked = env.Contains(name);
+ box.IsChecked = IsEnvironmentEnabled(env, name, defaultValue: name == "SHARPEMU_GUEST_IMAGE_CPU_SYNC");
}
}
}
+ private static string ChoiceOrDefault(string[] choices, string? value, string fallback) =>
+ choices.FirstOrDefault(choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
+
+ private void OnHostDisplayChanged()
+ {
+ if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
+ {
+ return;
+ }
+
+ _updatingHostDisplayOptions = true;
+ try
+ {
+ PopulateHostModes(
+ display,
+ _resolution.SelectedItem as string ?? "1920x1080",
+ SelectedRefreshRate());
+ }
+ finally
+ {
+ _updatingHostDisplayOptions = false;
+ }
+ }
+
+ private void OnHostResolutionChanged()
+ {
+ if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
+ {
+ return;
+ }
+
+ var selectedRefreshRate = SelectedRefreshRate();
+ _updatingHostDisplayOptions = true;
+ try
+ {
+ PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
+ }
+ finally
+ {
+ _updatingHostDisplayOptions = false;
+ }
+ }
+
+ private void PopulateHostModes(
+ HostDisplayOption display,
+ string selectedResolution,
+ int selectedRefreshRate)
+ {
+ var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
+ _resolution.ItemsSource = resolutions;
+ _resolution.SelectedItem = resolutions.FirstOrDefault(resolution =>
+ string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
+ PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
+ }
+
+ private void PopulateRefreshRates(
+ HostDisplayOption display,
+ string? resolution,
+ int selectedRefreshRate)
+ {
+ var rates = HostDisplayOptions.BuildRefreshRates(
+ display,
+ resolution,
+ selectedRefreshRate,
+ Localization.Instance.Get("Options.RefreshRate.Automatic"));
+ _refreshRate.ItemsSource = rates;
+ _refreshRate.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
+ }
+
+ private int SelectedRefreshRate() =>
+ _refreshRate.SelectedItem is HostRefreshRateOption refreshRate ? refreshRate.Value : 0;
+
private void Persist()
{
var settings = new PerGameSettings
@@ -196,10 +380,54 @@ public sealed class PerGameSettingsDialog : Window
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
- EnvironmentToggles = _envRow.IsOverridden
- ? _envBoxes.Where(e => e.Box.IsChecked == true).Select(e => e.Name).ToList()
+ WindowMode = _windowModeRow.IsOverridden ? _windowMode.SelectedItem as string : null,
+ Resolution = _resolutionRow.IsOverridden ? _resolution.SelectedItem as string : null,
+ DisplayIndex = _displayIndexRow.IsOverridden && _displayIndex.SelectedItem is HostDisplayOption display
+ ? display.Index
: null,
+ RefreshRate = _refreshRateRow.IsOverridden ? SelectedRefreshRate() : null,
+ ScalingMode = _scalingModeRow.IsOverridden ? _scalingMode.SelectedItem as string : null,
+ VSync = _vsyncRow.IsOverridden ? _vsync.IsChecked == true : null,
+ HdrMode = _hdrModeRow.IsOverridden ? _hdrMode.SelectedItem as string : null,
+ EnvironmentToggles = _envRow.IsOverridden ? BuildEnvironmentEntries() : null,
};
settings.Save(_titleId);
}
+
+ private List BuildEnvironmentEntries()
+ {
+ const string guestImageCpuSync = "SHARPEMU_GUEST_IMAGE_CPU_SYNC";
+ var entries = _envBoxes
+ .Where(entry => entry.Name != guestImageCpuSync && entry.Box.IsChecked == true)
+ .Select(entry => entry.Name)
+ .ToList();
+ if (_envBoxes.First(entry => entry.Name == guestImageCpuSync).Box.IsChecked != true)
+ {
+ entries.Add(guestImageCpuSync + "=0");
+ }
+
+ return entries;
+ }
+
+ private static bool IsEnvironmentEnabled(
+ IEnumerable entries,
+ string name,
+ bool defaultValue)
+ {
+ foreach (var entry in entries)
+ {
+ if (string.Equals(entry, name + "=0", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (string.Equals(entry, name, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(entry, name + "=1", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return defaultValue;
+ }
}
diff --git a/src/SharpEmu.GUI/SharpEmu.GUI.csproj b/src/SharpEmu.GUI/SharpEmu.GUI.csproj
index 740bc0b2..16363611 100644
--- a/src/SharpEmu.GUI/SharpEmu.GUI.csproj
+++ b/src/SharpEmu.GUI/SharpEmu.GUI.csproj
@@ -9,16 +9,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
the executable is started without arguments. -->
false
-
true
-
+
+
diff --git a/src/SharpEmu.HLE/CpuContext.cs b/src/SharpEmu.HLE/CpuContext.cs
index 66c24d61..c4eb2810 100644
--- a/src/SharpEmu.HLE/CpuContext.cs
+++ b/src/SharpEmu.HLE/CpuContext.cs
@@ -20,6 +20,15 @@ public sealed class CpuContext(ICpuMemory memory, Generation generation)
public ulong Rip { get; set; }
+ ///
+ /// Index of the import this context is currently executing, or -1 when it is
+ /// running guest code. Only maintained while guest profiling is enabled;
+ /// alone cannot answer "what is this thread inside right
+ /// now" because it keeps pointing at the last import stub after the call
+ /// returns.
+ ///
+ public int ActiveImportIndex { get; set; } = -1;
+
public ulong Rflags { get; set; }
public ulong FsBase { get; set; }
diff --git a/src/SharpEmu.HLE/GuestImageWriteTracker.cs b/src/SharpEmu.HLE/GuestImageWriteTracker.cs
index 057d9ae8..638519ed 100644
--- a/src/SharpEmu.HLE/GuestImageWriteTracker.cs
+++ b/src/SharpEmu.HLE/GuestImageWriteTracker.cs
@@ -87,17 +87,14 @@ public static unsafe class GuestImageWriteTracker
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
- // Windows defaults off: VirtualProtect fault sync still regresses titles
- // like Dead Cells / Demon's Souls. Opt in with SHARPEMU_GUEST_IMAGE_CPU_SYNC=1
- // when a title needs CPU-written guest planes (e.g. GTA intro). Linux/macOS
- // keep the historical opt-out (=0 disables).
+ // CPU-written guest image synchronization is the compatible default. A few
+ // titles (currently GTA V) require the lower-overhead watch-only path and
+ // opt out explicitly with SHARPEMU_GUEST_IMAGE_CPU_SYNC=0.
private static readonly bool _enabled =
- OperatingSystem.IsWindows()
- ? string.Equals(
- Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"),
- "1",
- StringComparison.Ordinal)
- : Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
+ !string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"),
+ "0",
+ StringComparison.Ordinal);
private static readonly (bool Wildcard, ulong[] Addresses) _lifetimeTraceFilter =
ParseAddressList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
private static readonly (bool Wildcard, string[] Sources) _lifetimeSourceTraceFilter =
diff --git a/src/SharpEmu.HLE/Host/GuestAudioClock.cs b/src/SharpEmu.HLE/Host/GuestAudioClock.cs
new file mode 100644
index 00000000..953e730c
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/GuestAudioClock.cs
@@ -0,0 +1,69 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Diagnostics;
+
+namespace SharpEmu.HLE.Host;
+
+///
+/// How much guest audio the host device has actually played, in seconds.
+///
+/// This is the only clock in the emulator that advances at the rate the player
+/// hears. Wall clock runs ahead of it whenever the guest cannot feed the device
+/// (the stream underruns and the missing time is never played), so anything
+/// that has to stay in step with the guest's audio — host-decoded video being
+/// the case that matters — has to follow this rather than .
+///
+/// Reported per stream and kept as the furthest-along value: the guest's ports
+/// all carry one mix, and the leading port is the one whose position the
+/// listener perceives.
+///
+public static class GuestAudioClock
+{
+ private static long _playedMicroseconds;
+ private static long _lastAdvanceTimestamp;
+
+ /// Seconds of guest audio the device has played. Monotonic.
+ public static double PlayedSeconds =>
+ Interlocked.Read(ref _playedMicroseconds) / 1_000_000.0;
+
+ ///
+ /// True while a stream has reported progress recently. False means no guest
+ /// audio is playing, and callers must fall back to wall clock rather than
+ /// stalling on a clock that will never advance.
+ ///
+ public static bool IsRunning
+ {
+ get
+ {
+ var last = Interlocked.Read(ref _lastAdvanceTimestamp);
+ return last != 0 &&
+ Stopwatch.GetElapsedTime(last) < TimeSpan.FromMilliseconds(250);
+ }
+ }
+
+ public static void Report(double playedSeconds)
+ {
+ if (double.IsNaN(playedSeconds) || playedSeconds < 0)
+ {
+ return;
+ }
+
+ var microseconds = (long)(playedSeconds * 1_000_000.0);
+ var current = Interlocked.Read(ref _playedMicroseconds);
+ while (microseconds > current)
+ {
+ var seen = Interlocked.CompareExchange(
+ ref _playedMicroseconds,
+ microseconds,
+ current);
+ if (seen == current)
+ {
+ Interlocked.Exchange(ref _lastAdvanceTimestamp, Stopwatch.GetTimestamp());
+ return;
+ }
+
+ current = seen;
+ }
+ }
+}
diff --git a/src/SharpEmu.HLE/Host/HostGamepadState.cs b/src/SharpEmu.HLE/Host/HostGamepadState.cs
index 79760dca..36863376 100644
--- a/src/SharpEmu.HLE/Host/HostGamepadState.cs
+++ b/src/SharpEmu.HLE/Host/HostGamepadState.cs
@@ -28,8 +28,44 @@ public enum HostGamepadButtons : uint
R3 = 1 << 13,
Options = 1 << 14,
TouchPad = 1 << 15,
+ Create = 1 << 16,
+ Ps = 1 << 17,
+ Mic = 1 << 18,
}
+public enum HostGamepadType : byte
+{
+ Generic,
+ DualShock4,
+ DualSense,
+}
+
+public enum HostGamepadConnection : byte
+{
+ Unknown,
+ Wired,
+ Wireless,
+}
+
+public readonly record struct HostMotionState(
+ bool Available,
+ float AccelerationX,
+ float AccelerationY,
+ float AccelerationZ,
+ float AngularVelocityX,
+ float AngularVelocityY,
+ float AngularVelocityZ);
+
+public readonly record struct HostTouchPoint(
+ bool Active,
+ byte Id,
+ float X,
+ float Y);
+
+public readonly record struct HostTouchState(
+ HostTouchPoint First,
+ HostTouchPoint Second);
+
///
/// Snapshot of one host gamepad: sticks are 0..255 with 128 centered and Y growing
/// downward; triggers 0..255. Unmanaged on purpose so per-frame polls can stackalloc
@@ -43,4 +79,57 @@ public readonly record struct HostGamepadState(
byte RightX,
byte RightY,
byte LeftTrigger,
- byte RightTrigger);
+ byte RightTrigger,
+ HostGamepadType Type = HostGamepadType.Generic,
+ HostGamepadConnection Connection = HostGamepadConnection.Unknown,
+ HostMotionState Motion = default,
+ HostTouchState Touch = default,
+ byte BatteryPercent = 0);
+
+/// A complete 11-byte DualSense adaptive-trigger command.
+public readonly record struct HostAdaptiveTriggerEffect(
+ byte B0,
+ byte B1,
+ byte B2,
+ byte B3,
+ byte B4,
+ byte B5,
+ byte B6,
+ byte B7,
+ byte B8,
+ byte B9,
+ byte B10,
+ byte FallbackStrength = 0)
+{
+ public static HostAdaptiveTriggerEffect FromBytes(ReadOnlySpan source, byte fallbackStrength = 0)
+ {
+ if (source.Length < 11)
+ {
+ throw new ArgumentException("Adaptive-trigger source is too small.", nameof(source));
+ }
+
+ return new HostAdaptiveTriggerEffect(
+ source[0], source[1], source[2], source[3], source[4], source[5],
+ source[6], source[7], source[8], source[9], source[10], fallbackStrength);
+ }
+
+ public void CopyTo(Span destination)
+ {
+ if (destination.Length < 11)
+ {
+ throw new ArgumentException("Adaptive-trigger destination is too small.", nameof(destination));
+ }
+
+ destination[0] = B0;
+ destination[1] = B1;
+ destination[2] = B2;
+ destination[3] = B3;
+ destination[4] = B4;
+ destination[5] = B5;
+ destination[6] = B6;
+ destination[7] = B7;
+ destination[8] = B8;
+ destination[9] = B9;
+ destination[10] = B10;
+ }
+}
diff --git a/src/SharpEmu.HLE/Host/IHostAudioStream.cs b/src/SharpEmu.HLE/Host/IHostAudioStream.cs
index 2db99abb..43909c0d 100644
--- a/src/SharpEmu.HLE/Host/IHostAudioStream.cs
+++ b/src/SharpEmu.HLE/Host/IHostAudioStream.cs
@@ -15,4 +15,17 @@ public interface IHostAudioStream : IDisposable
/// audio, in which case the caller paces the guest itself.
///
bool Submit(ReadOnlySpan stereoPcm16);
+
+ ///
+ /// Audio already handed to the device and not yet played, in milliseconds —
+ /// the cushion protecting playback from a late submission. Zero means the
+ /// device has run dry and is emitting silence.
+ ///
+ /// Callers that pace the guest against an emulated hardware queue need this:
+ /// pacing purely on wall clock releases exactly one buffer per buffer-period
+ /// and so keeps the cushion at zero, which turns any scheduling jitter into
+ /// an audible dropout. Returns -1 when the backend cannot report a depth, in
+ /// which case callers must fall back to their own pacing.
+ ///
+ int QueuedMilliseconds => -1;
}
diff --git a/src/SharpEmu.HLE/Host/IHostInput.cs b/src/SharpEmu.HLE/Host/IHostInput.cs
index 52779bcf..853f9abb 100644
--- a/src/SharpEmu.HLE/Host/IHostInput.cs
+++ b/src/SharpEmu.HLE/Host/IHostInput.cs
@@ -32,6 +32,11 @@ public interface IHostInput
///
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
+ /// Applies native DualSense trigger effects when supported.
+ void SetAdaptiveTriggerEffect(
+ HostAdaptiveTriggerEffect? leftTrigger,
+ HostAdaptiveTriggerEffect? rightTrigger);
+
void SetLightbar(byte red, byte green, byte blue);
void ResetLightbar();
diff --git a/src/SharpEmu.HLE/Host/IHostPcmAudioOutput.cs b/src/SharpEmu.HLE/Host/IHostPcmAudioOutput.cs
new file mode 100644
index 00000000..1b8c671f
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/IHostPcmAudioOutput.cs
@@ -0,0 +1,19 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.HLE.Host;
+
+///
+/// Optional host-audio extension for backends that can accept the guest's
+/// interleaved PCM layout directly and perform device conversion themselves.
+///
+public interface IHostPcmAudioOutput : IHostAudioOutput
+{
+ IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format);
+}
+
+public enum HostPcmFormat
+{
+ Signed16,
+ Float32,
+}
diff --git a/src/SharpEmu.HLE/Host/IHostWindowInputSource.cs b/src/SharpEmu.HLE/Host/IHostWindowInputSource.cs
new file mode 100644
index 00000000..d33329fa
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/IHostWindowInputSource.cs
@@ -0,0 +1,42 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.HLE.Host;
+
+/// Input snapshots produced by the active host window.
+public interface IHostWindowInputSource
+{
+ bool HasKeyboardFocus { get; }
+
+ bool IsKeyDown(int virtualKey);
+
+ int GetGamepadStates(Span destination);
+
+ string? DescribeConnectedGamepad();
+
+ void SetRumble(byte largeMotor, byte smallMotor);
+
+ void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
+
+ void SetAdaptiveTriggerEffect(
+ HostAdaptiveTriggerEffect? leftTrigger,
+ HostAdaptiveTriggerEffect? rightTrigger);
+
+ void SetLightbar(byte red, byte green, byte blue);
+
+ void ResetLightbar();
+}
+
+/// Process-wide bridge between the window layer and host input.
+public static class HostWindowInputSource
+{
+ private static IHostWindowInputSource? _current;
+
+ public static IHostWindowInputSource? Current => Volatile.Read(ref _current);
+
+ public static void Set(IHostWindowInputSource source) =>
+ Volatile.Write(ref _current, source);
+
+ public static void Clear(IHostWindowInputSource source) =>
+ Interlocked.CompareExchange(ref _current, null, source);
+}
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs
deleted file mode 100644
index 93cd5c13..00000000
--- a/src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs
+++ /dev/null
@@ -1,186 +0,0 @@
-// Copyright (C) 2026 SharpEmu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-namespace SharpEmu.HLE.Host.Posix;
-
-///
-/// Bridges a window-provided input source into the host input seam. POSIX
-/// hosts have no user32/XInput/raw-HID readers; keyboard and gamepad state
-/// come from the presenter's GLFW window instead, which registers itself via
-/// once the window exists. Until then (and with no
-/// window at all, e.g. headless runs) every query reports neutral input.
-/// Rumble and lightbar are unsupported by the GLFW input layer and no-op.
-///
-public interface IPosixWindowInputSource
-{
- /// True while the window's keyboard is delivering events.
- bool HasKeyboardFocus { get; }
-
- /// Windows virtual-key semantics; the source translates.
- bool IsKeyDown(int virtualKey);
-
- /// Same contract as .
- int GetGamepadStates(Span destination);
-
- string? DescribeConnectedGamepad();
-}
-
-// Public so the presenter's window layer (SharpEmu.Libs) can register its
-// input source; the platform still constructs the singleton itself.
-public sealed class PosixHostInput : IHostInput
-{
- private static volatile IPosixWindowInputSource? _source;
-
- /// Called by the presenter's window layer when input is ready.
- public static void SetSource(IPosixWindowInputSource source)
- {
- _source = source;
- }
-
- public void EnsureStarted()
- {
- // Device readers are event-driven off the window thread; nothing to start.
- }
-
- public int GetGamepadStates(Span destination)
- {
- return _source?.GetGamepadStates(destination) ?? 0;
- }
-
- public string? DescribeConnectedGamepad() => _source?.DescribeConnectedGamepad();
-
- public void SetRumble(byte largeMotor, byte smallMotor)
- {
- }
-
- public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
- {
- }
-
- public void SetLightbar(byte red, byte green, byte blue)
- {
- }
-
- public void ResetLightbar()
- {
- }
-
- public bool IsHostWindowFocused()
- {
- // GLFW only delivers key events to the focused window, so a
- // delivering keyboard implies focus.
- return _source?.HasKeyboardFocus ?? IsEmbeddedX11WindowFocused();
- }
-
- public bool IsKeyDown(int virtualKey)
- {
- var source = _source;
- if (source is not null)
- {
- return source.IsKeyDown(virtualKey);
- }
-
- return IsEmbeddedX11WindowFocused() && IsEmbeddedX11KeyDown(virtualKey);
- }
-
- private static bool IsEmbeddedX11WindowFocused()
- {
- if (!OperatingSystem.IsLinux())
- {
- return false;
- }
-
- var display = HostSessionControl.EmbeddedHostDisplay;
- var window = HostSessionControl.EmbeddedHostWindow;
- if (display == 0 || window == 0 || XGetInputFocus(display, out var focusedWindow, out _) == 0 || focusedWindow == 0)
- {
- return false;
- }
-
- return GetTopLevelWindow(display, focusedWindow) == GetTopLevelWindow(display, window);
- }
-
- private static bool IsEmbeddedX11KeyDown(int virtualKey)
- {
- var display = HostSessionControl.EmbeddedHostDisplay;
- var keysym = ToX11Keysym(virtualKey);
- if (display == 0 || keysym == 0)
- {
- return false;
- }
-
- var keycode = XKeysymToKeycode(display, keysym);
- if (keycode == 0)
- {
- return false;
- }
-
- var keymap = new byte[32];
- XQueryKeymap(display, keymap);
- return (keymap[keycode >> 3] & (1 << (keycode & 7))) != 0;
- }
-
- private static nint GetTopLevelWindow(nint display, nint window)
- {
- var current = window;
- for (var depth = 0; depth < 16; depth++)
- {
- if (XQueryTree(display, current, out var root, out var parent, out var children, out _) == 0)
- {
- return 0;
- }
-
- if (children != 0)
- {
- XFree(children);
- }
-
- if (parent == 0 || parent == root)
- {
- return current;
- }
-
- current = parent;
- }
-
- return 0;
- }
-
- private static nuint ToX11Keysym(int virtualKey)
- {
- return virtualKey switch
- {
- 0x08 => 0xFF08, // Backspace
- 0x09 => 0xFF09, // Tab
- 0x0D => 0xFF0D, // Return
- 0x1B => 0xFF1B, // Escape
- 0x25 => 0xFF51, // Left
- 0x26 => 0xFF52, // Up
- 0x27 => 0xFF53, // Right
- 0x28 => 0xFF54, // Down
- >= 0x41 and <= 0x5A => (nuint)virtualKey,
- _ => 0,
- };
- }
-
- [System.Runtime.InteropServices.DllImport("libX11.so.6")]
- private static extern int XGetInputFocus(nint display, out nint focus, out int revertTo);
-
- [System.Runtime.InteropServices.DllImport("libX11.so.6")]
- private static extern int XQueryKeymap(nint display, [System.Runtime.InteropServices.Out] byte[] keysReturn);
-
- [System.Runtime.InteropServices.DllImport("libX11.so.6")]
- private static extern byte XKeysymToKeycode(nint display, nuint keysym);
-
- [System.Runtime.InteropServices.DllImport("libX11.so.6")]
- private static extern int XQueryTree(
- nint display,
- nint window,
- out nint root,
- out nint parent,
- out nint children,
- out uint childCount);
-
- [System.Runtime.InteropServices.DllImport("libX11.so.6")]
- private static extern int XFree(nint data);
-}
diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs
index fc452d31..495aee41 100644
--- a/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs
+++ b/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+using SharpEmu.HLE.Host.Sdl;
+
namespace SharpEmu.HLE.Host.Posix;
internal sealed class PosixHostPlatform : IHostPlatform
@@ -11,7 +13,7 @@ internal sealed class PosixHostPlatform : IHostPlatform
public IHostSymbolResolver Symbols { get; } = new PosixHostSymbolResolver();
- public IHostAudioOutput Audio { get; } = new PosixHostAudio();
+ public IHostAudioOutput Audio { get; } = new SdlHostAudio();
- public IHostInput Input { get; } = new PosixHostInput();
+ public IHostInput Input { get; } = new WindowHostInput();
}
diff --git a/src/SharpEmu.HLE/Host/Sdl/SdlHostAudio.cs b/src/SharpEmu.HLE/Host/Sdl/SdlHostAudio.cs
new file mode 100644
index 00000000..9e8f3a15
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/Sdl/SdlHostAudio.cs
@@ -0,0 +1,325 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using SDL;
+using static SDL.SDL3;
+
+namespace SharpEmu.HLE.Host.Sdl;
+
+internal sealed unsafe class SdlHostAudio : IHostPcmAudioOutput
+{
+ ///
+ /// Cap for streams this class paces itself (AudioOut). Blocking the guest
+ /// here is that path's only pacing, so the device settles at this depth —
+ /// it is the playback latency, and the floor under it is how much jitter the
+ /// stream can absorb before it runs dry.
+ ///
+ private static readonly int TargetQueuedMilliseconds =
+ int.TryParse(
+ Environment.GetEnvironmentVariable("SHARPEMU_AUDIO_LATENCY_MS"),
+ out var latencyMs) && latencyMs > 0
+ ? latencyMs
+ : 60;
+
+ private const int MaximumWaitMilliseconds = 250;
+ private static readonly object InitGate = new();
+ private static bool _initialized;
+
+ public string BackendName => "sdl3";
+
+ ///
+ /// Stereo PCM16 stream with a caller-chosen backpressure cap. Callers that
+ /// pace the guest themselves pass a deeper cap so this class's backpressure
+ /// does not fight their pacing.
+ ///
+ public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
+ => OpenStream(
+ sampleRate,
+ channels: 2,
+ HostPcmFormat.Signed16,
+ maxQueuedPcmBytes > 0 ? maxQueuedPcmBytes : 32 * 1024);
+
+ ///
+ /// Guest-format stream for AudioOut, which has no queue model of its own:
+ /// blocking here is that path's only pacing, so the device settles at
+ /// TargetQueuedMilliseconds and that depth is the playback latency.
+ ///
+ public IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format)
+ {
+ var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short);
+ var cap = checked((int)((long)sampleRate * channels * bytesPerSample *
+ TargetQueuedMilliseconds / 1_000));
+ return OpenStream(sampleRate, channels, format, cap);
+ }
+
+ private static IHostAudioStream OpenStream(
+ uint sampleRate,
+ int channels,
+ HostPcmFormat format,
+ int maximumQueuedBytes)
+ {
+ if (sampleRate is < 8_000 or > 384_000 || channels is < 1 or > 8)
+ {
+ throw new ArgumentOutOfRangeException(
+ sampleRate is < 8_000 or > 384_000 ? nameof(sampleRate) : nameof(channels));
+ }
+
+ EnsureInitialized();
+ return new AudioStream(sampleRate, channels, format, maximumQueuedBytes);
+ }
+
+ private static void EnsureInitialized()
+ {
+ lock (InitGate)
+ {
+ if (_initialized)
+ {
+ return;
+ }
+
+ if ((SDL_WasInit(SDL_InitFlags.SDL_INIT_AUDIO) & SDL_InitFlags.SDL_INIT_AUDIO) == 0 &&
+ !SDL_InitSubSystem(SDL_InitFlags.SDL_INIT_AUDIO))
+ {
+ throw new InvalidOperationException($"SDL audio initialization failed: {GetError()}");
+ }
+
+ _initialized = true;
+ }
+ }
+
+ private static string GetError()
+ {
+ var error = Unsafe_SDL_GetError();
+ return error is null ? "unknown SDL error" : Marshal.PtrToStringUTF8((nint)error) ?? "unknown SDL error";
+ }
+
+ private static readonly bool _traceQueue = string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_QUEUE"),
+ "1",
+ StringComparison.Ordinal);
+
+ private static int _nextStreamId;
+
+ private sealed class AudioStream : IHostAudioStream
+ {
+ private readonly object _gate = new();
+ private readonly int _maximumQueuedBytes;
+ private readonly int _bytesPerFrame;
+ private readonly uint _sampleRate;
+ private readonly int _streamId = Interlocked.Increment(ref _nextStreamId);
+ private SDL_AudioStream* _stream;
+ private bool _disposed;
+ private long _totalSubmittedBytes;
+
+ // Queue diagnostics for the current report window.
+ private long _windowStart = Stopwatch.GetTimestamp();
+ private long _submissions;
+ private long _submittedBytes;
+ private long _blockedTicks;
+ private long _drops;
+ private long _emptyObservations;
+ private int _minQueuedBytes = int.MaxValue;
+ private int _maxQueuedBytes;
+ private long _queuedByteSum;
+
+ public AudioStream(
+ uint sampleRate,
+ int channels,
+ HostPcmFormat format,
+ int maximumQueuedBytes)
+ {
+ var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short);
+ _bytesPerFrame = channels * bytesPerSample;
+ _sampleRate = sampleRate;
+ var spec = new SDL_AudioSpec
+ {
+ format = format == HostPcmFormat.Float32
+ ? SDL_AudioFormat.SDL_AUDIO_F32LE
+ : SDL_AudioFormat.SDL_AUDIO_S16LE,
+ channels = checked((byte)channels),
+ freq = checked((int)sampleRate),
+ };
+
+ _stream = SDL_OpenAudioDeviceStream(
+ SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK,
+ &spec,
+ null,
+ IntPtr.Zero);
+ if (_stream is null)
+ {
+ throw new InvalidOperationException($"SDL audio stream creation failed: {GetError()}");
+ }
+
+ if (!SDL_ResumeAudioStreamDevice(_stream))
+ {
+ SDL_DestroyAudioStream(_stream);
+ _stream = null;
+ throw new InvalidOperationException($"SDL audio stream start failed: {GetError()}");
+ }
+
+ _maximumQueuedBytes = maximumQueuedBytes;
+ }
+
+ public int QueuedMilliseconds
+ {
+ get
+ {
+ lock (_gate)
+ {
+ if (_disposed || _stream is null)
+ {
+ return -1;
+ }
+
+ var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
+ return bytesPerSecond <= 0
+ ? -1
+ : (int)(SDL_GetAudioStreamQueued(_stream) / bytesPerSecond * 1000.0);
+ }
+ }
+ }
+
+ public bool Submit(ReadOnlySpan pcm)
+ {
+ if (pcm.IsEmpty)
+ {
+ return true;
+ }
+
+ lock (_gate)
+ {
+ if (_disposed || _stream is null)
+ {
+ return false;
+ }
+
+ var blockStart = Stopwatch.GetTimestamp();
+ var deadline = blockStart +
+ (Stopwatch.Frequency * MaximumWaitMilliseconds / 1_000);
+ int queued;
+ var overrun = false;
+ while ((queued = SDL_GetAudioStreamQueued(_stream)) > _maximumQueuedBytes)
+ {
+ if (Stopwatch.GetTimestamp() >= deadline)
+ {
+ // Enqueue anyway rather than discarding the buffer. A gap in
+ // the stream is an audible click; the extra latency of one
+ // over-deep submission is not, and the queue recovers as soon
+ // as the device drains back under the cap.
+ overrun = true;
+ break;
+ }
+
+ Thread.Sleep(1);
+ }
+
+ RecordSubmission(queued, blockStart, dropped: overrun, bytes: pcm.Length);
+ bool submitted;
+ fixed (byte* data = pcm)
+ {
+ submitted = SDL_PutAudioStreamData(_stream, (nint)data, pcm.Length);
+ }
+
+ if (submitted)
+ {
+ // Everything handed over minus what the device still holds is
+ // what the player has actually heard.
+ _totalSubmittedBytes += pcm.Length;
+ var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
+ if (bytesPerSecond > 0)
+ {
+ GuestAudioClock.Report(
+ Math.Max(0, _totalSubmittedBytes - queued - pcm.Length) / bytesPerSecond);
+ }
+ }
+
+ return submitted;
+ }
+ }
+
+ ///
+ /// Samples the queue depth at the moment the guest was allowed to write.
+ /// That depth is the playback latency the guest's audio is subject to, so
+ /// it is the number to look at when the sound is late; an observed depth
+ /// of zero is a genuine underrun, which is what a crackle sounds like.
+ /// Caller holds .
+ ///
+ private void RecordSubmission(int queuedBytes, long blockStart, bool dropped, int bytes)
+ {
+ if (!_traceQueue)
+ {
+ return;
+ }
+
+ var now = Stopwatch.GetTimestamp();
+ _submissions++;
+ _submittedBytes += bytes;
+ _blockedTicks += now - blockStart;
+ _queuedByteSum += queuedBytes;
+ _minQueuedBytes = Math.Min(_minQueuedBytes, queuedBytes);
+ _maxQueuedBytes = Math.Max(_maxQueuedBytes, queuedBytes);
+ if (dropped)
+ {
+ _drops++;
+ }
+
+ if (queuedBytes == 0)
+ {
+ _emptyObservations++;
+ }
+
+ var elapsedTicks = now - _windowStart;
+ if (elapsedTicks < Stopwatch.Frequency)
+ {
+ return;
+ }
+
+ _windowStart = now;
+ var seconds = elapsedTicks / (double)Stopwatch.Frequency;
+ var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
+ Console.Error.WriteLine(
+ $"[PERF][AUDIO] stream#{_streamId} {seconds:F1}s " +
+ $"queued_ms min={ToMilliseconds(_minQueuedBytes, bytesPerSecond):F0} " +
+ $"avg={ToMilliseconds((int)(_queuedByteSum / Math.Max(1, _submissions)), bytesPerSecond):F0} " +
+ $"max={ToMilliseconds(_maxQueuedBytes, bytesPerSecond):F0} " +
+ $"cap={ToMilliseconds(_maximumQueuedBytes, bytesPerSecond):F0} " +
+ $"submits/s={_submissions / seconds:F0} " +
+ $"fill={_submittedBytes / seconds / bytesPerSecond * 100.0:F0}% " +
+ $"blocked={_blockedTicks * 100.0 / elapsedTicks:F0}% " +
+ $"empty={_emptyObservations} drops={_drops}");
+
+ _submissions = 0;
+ _submittedBytes = 0;
+ _blockedTicks = 0;
+ _drops = 0;
+ _emptyObservations = 0;
+ _minQueuedBytes = int.MaxValue;
+ _maxQueuedBytes = 0;
+ _queuedByteSum = 0;
+ }
+
+ private static double ToMilliseconds(int bytes, double bytesPerSecond) =>
+ bytesPerSecond <= 0 ? 0 : bytes / bytesPerSecond * 1000.0;
+
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ if (_stream is not null)
+ {
+ SDL_ClearAudioStream(_stream);
+ SDL_DestroyAudioStream(_stream);
+ _stream = null;
+ }
+ }
+ }
+ }
+}
diff --git a/src/SharpEmu.HLE/Host/WindowHostInput.cs b/src/SharpEmu.HLE/Host/WindowHostInput.cs
new file mode 100644
index 00000000..d3d5a8f2
--- /dev/null
+++ b/src/SharpEmu.HLE/Host/WindowHostInput.cs
@@ -0,0 +1,43 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.HLE.Host;
+
+///
+/// Routes emulated input through the active cross-platform host window.
+///
+internal sealed class WindowHostInput : IHostInput
+{
+ public void EnsureStarted()
+ {
+ // SDL owns device discovery and pumps it on the window thread.
+ }
+
+ public int GetGamepadStates(Span destination) =>
+ HostWindowInputSource.Current?.GetGamepadStates(destination) ?? 0;
+
+ public string? DescribeConnectedGamepad() =>
+ HostWindowInputSource.Current?.DescribeConnectedGamepad();
+
+ public void SetRumble(byte largeMotor, byte smallMotor) =>
+ HostWindowInputSource.Current?.SetRumble(largeMotor, smallMotor);
+
+ public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) =>
+ HostWindowInputSource.Current?.SetTriggerRumble(leftTrigger, rightTrigger);
+
+ public void SetAdaptiveTriggerEffect(
+ HostAdaptiveTriggerEffect? leftTrigger,
+ HostAdaptiveTriggerEffect? rightTrigger) =>
+ HostWindowInputSource.Current?.SetAdaptiveTriggerEffect(leftTrigger, rightTrigger);
+
+ public void SetLightbar(byte red, byte green, byte blue) =>
+ HostWindowInputSource.Current?.SetLightbar(red, green, blue);
+
+ public void ResetLightbar() => HostWindowInputSource.Current?.ResetLightbar();
+
+ public bool IsHostWindowFocused() =>
+ HostWindowInputSource.Current?.HasKeyboardFocus ?? false;
+
+ public bool IsKeyDown(int virtualKey) =>
+ HostWindowInputSource.Current?.IsKeyDown(virtualKey) ?? false;
+}
diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsDualSenseReader.cs b/src/SharpEmu.HLE/Host/Windows/WindowsDualSenseReader.cs
deleted file mode 100644
index c1b6cbe8..00000000
--- a/src/SharpEmu.HLE/Host/Windows/WindowsDualSenseReader.cs
+++ /dev/null
@@ -1,439 +0,0 @@
-// Copyright (C) 2026 SharpEmu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-using Microsoft.Win32.SafeHandles;
-
-namespace SharpEmu.HLE.Host.Windows;
-
-///
-/// Reads a DualSense controller over raw HID on a background thread.
-/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31,
-/// activated by requesting feature report 0x05), with hot-plug retry.
-///
-public static class WindowsDualSenseReader
-{
- private const ushort SonyVendorId = 0x054C;
- private const ushort DualSenseProductId = 0x0CE6;
- private const ushort DualSenseEdgeProductId = 0x0DF2;
-
- private static readonly object Gate = new();
- private static HostGamepadState _state;
- private static bool _started;
-
- // Output (rumble/lightbar) state, all guarded by Gate.
- private static string? _devicePath;
- private static bool _bluetooth;
- private static bool _outputReady;
- private static bool _lightbarSetupPending;
- private static byte _outputSequence;
- private static FileStream? _outputStream;
- private static byte _motorLeft;
- private static byte _motorRight;
- private static byte _lightbarRed;
- private static byte _lightbarGreen;
- private static byte _lightbarBlue = 64; // PS-style blue default
- private static byte _playerLeds = 0x04; // center LED = player 1
-
- /// Starts the background reader once; safe to call repeatedly.
- public static void EnsureStarted()
- {
- // The GUI source-links this reader and calls it directly, without the
- // host-platform resolution that otherwise guarantees Windows.
- if (!OperatingSystem.IsWindows())
- {
- return;
- }
-
- lock (Gate)
- {
- if (_started)
- {
- return;
- }
-
- _started = true;
- var thread = new Thread(ReadLoop)
- {
- IsBackground = true,
- Name = "DualSenseReader",
- };
- thread.Start();
- }
- }
-
- public static bool TryGetState(out HostGamepadState state)
- {
- lock (Gate)
- {
- state = _state;
- }
-
- return state.Connected;
- }
-
- private static void SetState(in HostGamepadState state)
- {
- lock (Gate)
- {
- _state = state;
- }
- }
-
- /// Sets rumble; large = left/strong motor, small = right/weak.
- internal static void SetRumble(byte largeMotor, byte smallMotor)
- {
- lock (Gate)
- {
- if (_motorLeft == largeMotor && _motorRight == smallMotor)
- {
- return;
- }
-
- _motorLeft = largeMotor;
- _motorRight = smallMotor;
- SendOutputLocked();
- }
- }
-
- internal static void SetLightbar(byte red, byte green, byte blue)
- {
- lock (Gate)
- {
- if (_lightbarRed == red && _lightbarGreen == green && _lightbarBlue == blue)
- {
- return;
- }
-
- _lightbarRed = red;
- _lightbarGreen = green;
- _lightbarBlue = blue;
- SendOutputLocked();
- }
- }
-
- internal static void ResetLightbar() => SetLightbar(0, 0, 64);
-
- private static void OnDeviceIdentified(string path, bool bluetooth)
- {
- lock (Gate)
- {
- _devicePath = path;
- _bluetooth = bluetooth;
- _outputReady = true;
- _lightbarSetupPending = true;
- // Announce ourselves on the hardware: default lightbar + player 1 LED.
- SendOutputLocked();
- }
- }
-
- private static void OnDeviceLost()
- {
- lock (Gate)
- {
- _devicePath = null;
- _outputReady = false;
- _motorLeft = 0;
- _motorRight = 0;
- _outputStream?.Dispose();
- _outputStream = null;
- }
- }
-
- private static void SendOutputLocked()
- {
- if (!_outputReady || _devicePath is null)
- {
- return; // flushed by OnDeviceIdentified once connected
- }
-
- try
- {
- if (_outputStream is null)
- {
- var handle = WindowsHidNative.CreateFile(
- _devicePath,
- WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite,
- WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
- 0, WindowsHidNative.OpenExisting, 0, 0);
- if (handle.IsInvalid)
- {
- handle.Dispose();
- return; // read-only device access: outputs unavailable
- }
-
- _outputStream = new FileStream(handle, FileAccess.Write, bufferSize: 1);
- }
-
- var report = BuildOutputReportLocked();
- _outputStream.Write(report, 0, report.Length);
- _outputStream.Flush();
- }
- catch (Exception)
- {
- _outputStream?.Dispose();
- _outputStream = null;
- }
- }
-
- private static byte[] BuildOutputReportLocked()
- {
- // Common 47-byte output payload (offsets per the DualSense output
- // report layout, same as Linux hid-playstation).
- Span common = stackalloc byte[47];
- common[0] = 0x03; // valid_flag0: compatible vibration + haptics select
- common[1] = 0x04 | 0x10; // valid_flag1: lightbar + player indicator
- common[2] = _motorRight; // right (weak) motor
- common[3] = _motorLeft; // left (strong) motor
- if (_lightbarSetupPending)
- {
- common[38] |= 0x02; // valid_flag2: lightbar setup control enable
- common[41] = 0x01; // lightbar_setup: light on
- _lightbarSetupPending = false;
- }
-
- common[43] = _playerLeds;
- common[44] = _lightbarRed;
- common[45] = _lightbarGreen;
- common[46] = _lightbarBlue;
-
- if (!_bluetooth)
- {
- var usbReport = new byte[48];
- usbReport[0] = 0x02;
- common.CopyTo(usbReport.AsSpan(1));
- return usbReport;
- }
-
- // Bluetooth: 0x31 wrapper with sequence tag and CRC32 over a 0xA2
- // seed byte plus the first 74 report bytes.
- var btReport = new byte[78];
- btReport[0] = 0x31;
- btReport[1] = (byte)((_outputSequence & 0x0F) << 4);
- _outputSequence = (byte)((_outputSequence + 1) & 0x0F);
- btReport[2] = 0x10;
- common.CopyTo(btReport.AsSpan(3));
- var crc = Crc32(0xA2, btReport.AsSpan(0, 74));
- btReport[74] = (byte)crc;
- btReport[75] = (byte)(crc >> 8);
- btReport[76] = (byte)(crc >> 16);
- btReport[77] = (byte)(crc >> 24);
- return btReport;
- }
-
- private static uint Crc32(byte seed, ReadOnlySpan data)
- {
- var crc = Crc32Update(0xFFFFFFFFu, seed);
- foreach (var value in data)
- {
- crc = Crc32Update(crc, value);
- }
-
- return ~crc;
- }
-
- private static uint Crc32Update(uint crc, byte value)
- {
- crc ^= value;
- for (var bit = 0; bit < 8; bit++)
- {
- crc = (crc >> 1) ^ (0xEDB88320u & (uint)-(int)(crc & 1));
- }
-
- return crc;
- }
-
- private static void ReadLoop()
- {
- var announcedConnect = false;
- while (true)
- {
- SafeFileHandle? handle = null;
- try
- {
- handle = OpenDualSense(out var devicePath);
- if (handle is null || devicePath is null)
- {
- SetState(default);
- announcedConnect = false;
- Thread.Sleep(1000);
- continue;
- }
-
- // Bluetooth quirk: the DualSense sends a simplified report
- // until feature report 0x05 is requested, which switches it
- // to the full 0x31 input report. Harmless over USB.
- var feature = new byte[41];
- feature[0] = 0x05;
- _ = WindowsHidNative.HidD_GetFeature(handle, feature, feature.Length);
-
- if (!announcedConnect)
- {
- Console.Error.WriteLine("[LOADER][INFO] DualSense controller connected.");
- announcedConnect = true;
- }
-
- using var stream = new FileStream(handle, FileAccess.Read, bufferSize: 1);
- handle = null; // stream owns it now
- var buffer = new byte[256];
- var transportKnown = false;
- while (true)
- {
- var read = stream.Read(buffer, 0, buffer.Length);
- if (read <= 0)
- {
- break;
- }
-
- if (TryParseReport(buffer.AsSpan(0, read), out var state))
- {
- if (!transportKnown)
- {
- // The first parsed report tells us the transport,
- // which the output (rumble/lightbar) path needs.
- transportKnown = true;
- OnDeviceIdentified(devicePath, bluetooth: buffer[0] == 0x31);
- }
-
- SetState(state);
- }
- }
- }
- catch (Exception)
- {
- // Unplugged or read error: fall through and retry.
- }
- finally
- {
- handle?.Dispose();
- }
-
- if (announcedConnect)
- {
- Console.Error.WriteLine("[LOADER][INFO] DualSense controller disconnected.");
- announcedConnect = false;
- }
-
- OnDeviceLost();
- SetState(default);
- Thread.Sleep(1000);
- }
- }
-
- private static SafeFileHandle? OpenDualSense(out string? devicePath)
- {
- devicePath = null;
- foreach (var path in WindowsHidNative.EnumerateHidDevicePaths())
- {
- // Open without access rights just to query VID/PID.
- using var probe = WindowsHidNative.CreateFile(
- path, 0, WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, 0, WindowsHidNative.OpenExisting, 0, 0);
- if (probe.IsInvalid)
- {
- continue;
- }
-
- var attributes = new WindowsHidNative.HiddAttributes { Size = 12 };
- if (!WindowsHidNative.HidD_GetAttributes(probe, ref attributes) ||
- attributes.VendorId != SonyVendorId ||
- (attributes.ProductId != DualSenseProductId && attributes.ProductId != DualSenseEdgeProductId))
- {
- continue;
- }
-
- // Read+write so feature reports work; fall back to read-only.
- var handle = WindowsHidNative.CreateFile(
- path,
- WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite,
- WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
- 0, WindowsHidNative.OpenExisting, 0, 0);
- if (handle.IsInvalid)
- {
- handle.Dispose();
- handle = WindowsHidNative.CreateFile(
- path,
- WindowsHidNative.GenericRead,
- WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
- 0, WindowsHidNative.OpenExisting, 0, 0);
- }
-
- if (!handle.IsInvalid)
- {
- devicePath = path;
- return handle;
- }
-
- handle.Dispose();
- }
-
- return null;
- }
-
- private static bool TryParseReport(ReadOnlySpan report, out HostGamepadState state)
- {
- // USB: report id 0x01, payload starts at [1].
- // Bluetooth extended: report id 0x31, sequence byte at [1], payload at [2].
- int offset;
- if (report.Length >= 11 && report[0] == 0x01)
- {
- offset = 1;
- }
- else if (report.Length >= 12 && report[0] == 0x31)
- {
- offset = 2;
- }
- else
- {
- state = default;
- return false;
- }
-
- var leftX = report[offset + 0];
- var leftY = report[offset + 1];
- var rightX = report[offset + 2];
- var rightY = report[offset + 3];
- var l2 = report[offset + 4];
- var r2 = report[offset + 5];
- var buttons0 = report[offset + 7];
- var buttons1 = report[offset + 8];
- var buttons2 = report[offset + 9];
-
- var buttons = HostGamepadButtons.None;
- buttons |= (buttons0 & 0x10) != 0 ? HostGamepadButtons.Square : 0;
- buttons |= (buttons0 & 0x20) != 0 ? HostGamepadButtons.Cross : 0;
- buttons |= (buttons0 & 0x40) != 0 ? HostGamepadButtons.Circle : 0;
- buttons |= (buttons0 & 0x80) != 0 ? HostGamepadButtons.Triangle : 0;
- buttons |= HatToButtons(buttons0 & 0x0F);
- buttons |= (buttons1 & 0x01) != 0 ? HostGamepadButtons.L1 : 0;
- buttons |= (buttons1 & 0x02) != 0 ? HostGamepadButtons.R1 : 0;
- buttons |= (buttons1 & 0x04) != 0 ? HostGamepadButtons.L2 : 0;
- buttons |= (buttons1 & 0x08) != 0 ? HostGamepadButtons.R2 : 0;
- buttons |= (buttons1 & 0x20) != 0 ? HostGamepadButtons.Options : 0;
- buttons |= (buttons1 & 0x40) != 0 ? HostGamepadButtons.L3 : 0;
- buttons |= (buttons1 & 0x80) != 0 ? HostGamepadButtons.R3 : 0;
- buttons |= (buttons2 & 0x02) != 0 ? HostGamepadButtons.TouchPad : 0;
-
- state = new HostGamepadState(
- Connected: true,
- Buttons: buttons,
- LeftX: leftX,
- LeftY: leftY,
- RightX: rightX,
- RightY: rightY,
- LeftTrigger: l2,
- RightTrigger: r2);
- return true;
- }
-
- private static HostGamepadButtons HatToButtons(int hat) => hat switch
- {
- 0 => HostGamepadButtons.Up,
- 1 => HostGamepadButtons.Up | HostGamepadButtons.Right,
- 2 => HostGamepadButtons.Right,
- 3 => HostGamepadButtons.Right | HostGamepadButtons.Down,
- 4 => HostGamepadButtons.Down,
- 5 => HostGamepadButtons.Down | HostGamepadButtons.Left,
- 6 => HostGamepadButtons.Left,
- 7 => HostGamepadButtons.Left | HostGamepadButtons.Up,
- _ => 0,
- };
-}
diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsHidNative.cs b/src/SharpEmu.HLE/Host/Windows/WindowsHidNative.cs
deleted file mode 100644
index 22e8afbb..00000000
--- a/src/SharpEmu.HLE/Host/Windows/WindowsHidNative.cs
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2026 SharpEmu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-using System.Runtime.InteropServices;
-using Microsoft.Win32.SafeHandles;
-
-namespace SharpEmu.HLE.Host.Windows;
-
-///
-/// Minimal Win32 HID interop used to talk to a DualSense controller
-/// directly, without any external input library.
-///
-internal static partial class WindowsHidNative
-{
- internal const int DigcfPresent = 0x02;
- internal const int DigcfDeviceInterface = 0x10;
- internal const uint GenericRead = 0x80000000;
- internal const uint GenericWrite = 0x40000000;
- internal const uint FileShareRead = 0x1;
- internal const uint FileShareWrite = 0x2;
- internal const uint OpenExisting = 3;
-
- [StructLayout(LayoutKind.Sequential)]
- internal struct SpDeviceInterfaceData
- {
- public int CbSize;
- public Guid InterfaceClassGuid;
- public int Flags;
- public nint Reserved;
- }
-
- [StructLayout(LayoutKind.Sequential)]
- internal struct HiddAttributes
- {
- public int Size;
- public ushort VendorId;
- public ushort ProductId;
- public ushort VersionNumber;
- }
-
- [LibraryImport("hid.dll")]
- internal static partial void HidD_GetHidGuid(out Guid hidGuid);
-
- [LibraryImport("hid.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- internal static partial bool HidD_GetAttributes(SafeFileHandle hidDeviceObject, ref HiddAttributes attributes);
-
- [LibraryImport("hid.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- internal static partial bool HidD_GetFeature(SafeFileHandle hidDeviceObject, [In, Out] byte[] reportBuffer, int reportBufferLength);
-
- [LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetClassDevsW")]
- internal static partial nint SetupDiGetClassDevs(ref Guid classGuid, nint enumerator, nint hwndParent, int flags);
-
- [LibraryImport("setupapi.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- internal static partial bool SetupDiEnumDeviceInterfaces(
- nint deviceInfoSet,
- nint deviceInfoData,
- ref Guid interfaceClassGuid,
- int memberIndex,
- ref SpDeviceInterfaceData deviceInterfaceData);
-
- [LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetDeviceInterfaceDetailW")]
- [return: MarshalAs(UnmanagedType.Bool)]
- internal static partial bool SetupDiGetDeviceInterfaceDetail(
- nint deviceInfoSet,
- ref SpDeviceInterfaceData deviceInterfaceData,
- nint deviceInterfaceDetailData,
- int deviceInterfaceDetailDataSize,
- out int requiredSize,
- nint deviceInfoData);
-
- [LibraryImport("setupapi.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- internal static partial bool SetupDiDestroyDeviceInfoList(nint deviceInfoSet);
-
- [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
- internal static partial SafeFileHandle CreateFile(
- string fileName,
- uint desiredAccess,
- uint shareMode,
- nint securityAttributes,
- uint creationDisposition,
- uint flagsAndAttributes,
- nint templateFile);
-
- ///
- /// Enumerates the device paths of all present HID interfaces.
- ///
- internal static List EnumerateHidDevicePaths()
- {
- var paths = new List();
- HidD_GetHidGuid(out var hidGuid);
- var deviceInfoSet = SetupDiGetClassDevs(ref hidGuid, 0, 0, DigcfPresent | DigcfDeviceInterface);
- if (deviceInfoSet == -1 || deviceInfoSet == 0)
- {
- return paths;
- }
-
- try
- {
- var interfaceData = new SpDeviceInterfaceData
- {
- CbSize = Marshal.SizeOf(),
- };
-
- for (var index = 0; SetupDiEnumDeviceInterfaces(deviceInfoSet, 0, ref hidGuid, index, ref interfaceData); index++)
- {
- SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, 0, 0, out var requiredSize, 0);
- if (requiredSize <= 0)
- {
- continue;
- }
-
- var detailBuffer = Marshal.AllocHGlobal(requiredSize);
- try
- {
- // SP_DEVICE_INTERFACE_DETAIL_DATA_W.cbSize is 8 on x64
- // (DWORD + aligned WCHAR[1]); the path string follows it.
- Marshal.WriteInt32(detailBuffer, 8);
- if (SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, detailBuffer, requiredSize, out _, 0) &&
- Marshal.PtrToStringUni(detailBuffer + 4) is { Length: > 0 } path)
- {
- paths.Add(path);
- }
- }
- finally
- {
- Marshal.FreeHGlobal(detailBuffer);
- }
- }
- }
- finally
- {
- SetupDiDestroyDeviceInfoList(deviceInfoSet);
- }
-
- return paths;
- }
-}
diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsHostInput.cs b/src/SharpEmu.HLE/Host/Windows/WindowsHostInput.cs
deleted file mode 100644
index 32673a54..00000000
--- a/src/SharpEmu.HLE/Host/Windows/WindowsHostInput.cs
+++ /dev/null
@@ -1,101 +0,0 @@
-// Copyright (C) 2026 SharpEmu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-using System.Runtime.InteropServices;
-
-namespace SharpEmu.HLE.Host.Windows;
-
-///
-/// Windows input backend: DualSense over raw HID plus XInput controllers for gamepads,
-/// user32 for the keyboard-fallback queries. Rumble fans out to every reader; lightbar
-/// only exists on the DualSense.
-///
-internal sealed partial class WindowsHostInput : IHostInput
-{
- public void EnsureStarted()
- {
- WindowsDualSenseReader.EnsureStarted();
- WindowsXInputReader.EnsureStarted();
- }
-
- public int GetGamepadStates(Span destination)
- {
- var count = 0;
- if (count < destination.Length && WindowsDualSenseReader.TryGetState(out var dualSense))
- {
- destination[count++] = dualSense;
- }
-
- if (count < destination.Length && WindowsXInputReader.TryGetState(out var xinput))
- {
- destination[count++] = xinput;
- }
-
- return count;
- }
-
- public string? DescribeConnectedGamepad()
- {
- if (WindowsDualSenseReader.TryGetState(out _))
- {
- return "DualSense";
- }
-
- return WindowsXInputReader.TryGetState(out _) ? "Xbox controller" : null;
- }
-
- public void SetRumble(byte largeMotor, byte smallMotor)
- {
- WindowsDualSenseReader.SetRumble(largeMotor, smallMotor);
- WindowsXInputReader.SetRumble(largeMotor, smallMotor);
- }
-
- public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) =>
- WindowsXInputReader.SetTriggerRumble(leftTrigger, rightTrigger);
-
- public void SetLightbar(byte red, byte green, byte blue) =>
- WindowsDualSenseReader.SetLightbar(red, green, blue);
-
- public void ResetLightbar() => WindowsDualSenseReader.ResetLightbar();
-
- public bool IsHostWindowFocused()
- {
- var foregroundWindow = GetForegroundWindow();
- if (foregroundWindow == 0)
- {
- return false;
- }
-
- GetWindowThreadProcessId(foregroundWindow, out var processId);
- if (processId == (uint)Environment.ProcessId)
- {
- return true;
- }
-
- // The GUI runs the emulator in an isolated child process. Its native
- // Vulkan surface is a child of the GUI window, so the foreground
- // window belongs to the launcher process rather than this one.
- var embeddedHostWindow = HostSessionControl.EmbeddedHostWindow;
- var hostTopLevelWindow = embeddedHostWindow == 0
- ? 0
- : GetAncestor(embeddedHostWindow, GetAncestorRoot);
- return hostTopLevelWindow != 0 && foregroundWindow == hostTopLevelWindow;
- }
-
- public bool IsKeyDown(int virtualKey) =>
- (GetAsyncKeyState(virtualKey) & 0x8000) != 0;
-
- [LibraryImport("user32.dll")]
- private static partial short GetAsyncKeyState(int vKey);
-
- [LibraryImport("user32.dll")]
- private static partial nint GetForegroundWindow();
-
- [LibraryImport("user32.dll")]
- private static partial uint GetWindowThreadProcessId(nint hWnd, out uint processId);
-
- [LibraryImport("user32.dll")]
- private static partial nint GetAncestor(nint hWnd, uint gaFlags);
-
- private const uint GetAncestorRoot = 2;
-}
diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsHostPlatform.cs b/src/SharpEmu.HLE/Host/Windows/WindowsHostPlatform.cs
index 0e5ab7e2..74374f98 100644
--- a/src/SharpEmu.HLE/Host/Windows/WindowsHostPlatform.cs
+++ b/src/SharpEmu.HLE/Host/Windows/WindowsHostPlatform.cs
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+using SharpEmu.HLE.Host.Sdl;
+
namespace SharpEmu.HLE.Host.Windows;
internal sealed class WindowsHostPlatform : IHostPlatform
@@ -11,7 +13,7 @@ internal sealed class WindowsHostPlatform : IHostPlatform
public IHostSymbolResolver Symbols { get; } = new WindowsHostSymbolResolver();
- public IHostAudioOutput Audio { get; } = new WindowsWaveOutAudio();
+ public IHostAudioOutput Audio { get; } = new SdlHostAudio();
- public IHostInput Input { get; } = new WindowsHostInput();
+ public IHostInput Input { get; } = new WindowHostInput();
}
diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsXInputReader.cs b/src/SharpEmu.HLE/Host/Windows/WindowsXInputReader.cs
deleted file mode 100644
index f01c0269..00000000
--- a/src/SharpEmu.HLE/Host/Windows/WindowsXInputReader.cs
+++ /dev/null
@@ -1,277 +0,0 @@
-// Copyright (C) 2026 SharpEmu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-using System.Runtime.InteropServices;
-
-namespace SharpEmu.HLE.Host.Windows;
-
-///
-/// Reads Xbox 360 / Xbox One (and other XInput-compatible) controllers via
-/// the Windows XInput API on a background thread, translated to
-/// conventions. Supports rumble and hot-plug
-/// retry; the first connected slot (of four) is used.
-///
-public static partial class WindowsXInputReader
-{
- private const uint ErrorSuccess = 0;
- private const int SlotCount = 4;
- private const byte TriggerThreshold = 30; // XINPUT_GAMEPAD_TRIGGER_THRESHOLD
-
- // XINPUT_GAMEPAD wButtons bit values.
- private const ushort XinputDpadUp = 0x0001;
- private const ushort XinputDpadDown = 0x0002;
- private const ushort XinputDpadLeft = 0x0004;
- private const ushort XinputDpadRight = 0x0008;
- private const ushort XinputStart = 0x0010;
- private const ushort XinputBack = 0x0020;
- private const ushort XinputLeftThumb = 0x0040;
- private const ushort XinputRightThumb = 0x0080;
- private const ushort XinputLeftShoulder = 0x0100;
- private const ushort XinputRightShoulder = 0x0200;
- private const ushort XinputA = 0x1000;
- private const ushort XinputB = 0x2000;
- private const ushort XinputX = 0x4000;
- private const ushort XinputY = 0x8000;
-
- private static readonly object Gate = new();
- private static HostGamepadState _state;
- private static bool _started;
- private static int _slot = -1; // connected XInput user index, -1 when none
- private static byte _motorLeft;
- private static byte _motorRight;
- private static byte _triggerLeft;
- private static byte _triggerRight;
-
- /// Starts the background reader once; safe to call repeatedly.
- public static void EnsureStarted()
- {
- // The GUI source-links this reader and calls it directly, without the
- // host-platform resolution that otherwise guarantees Windows.
- if (!OperatingSystem.IsWindows())
- {
- return;
- }
-
- lock (Gate)
- {
- if (_started)
- {
- return;
- }
-
- _started = true;
- var thread = new Thread(ReadLoop)
- {
- IsBackground = true,
- Name = "XInputReader",
- };
- thread.Start();
- }
- }
-
- public static bool TryGetState(out HostGamepadState state)
- {
- lock (Gate)
- {
- state = _state;
- }
-
- return state.Connected;
- }
-
- private static void SetState(in HostGamepadState state)
- {
- lock (Gate)
- {
- _state = state;
- }
- }
-
- /// Sets rumble; large = left/strong motor, small = right/weak.
- internal static void SetRumble(byte largeMotor, byte smallMotor)
- {
- lock (Gate)
- {
- if (_motorLeft == largeMotor && _motorRight == smallMotor)
- {
- return;
- }
-
- _motorLeft = largeMotor;
- _motorRight = smallMotor;
- SendRumbleLocked();
- }
- }
-
- /// Approximates per-trigger vibration on the two XInput body motors.
- internal static void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
- {
- lock (Gate)
- {
- var changed = false;
- if (leftTrigger is { } left)
- {
- changed |= _triggerLeft != left;
- _triggerLeft = left;
- }
-
- if (rightTrigger is { } right)
- {
- changed |= _triggerRight != right;
- _triggerRight = right;
- }
-
- if (changed)
- {
- SendRumbleLocked();
- }
- }
- }
-
- private static void SendRumbleLocked()
- {
- if (_slot < 0)
- {
- return; // resent on connect
- }
-
- var vibration = new XInputVibration
- {
- LeftMotorSpeed = (ushort)(Math.Max(_motorLeft, _triggerLeft) * 257),
- RightMotorSpeed = (ushort)(Math.Max(_motorRight, _triggerRight) * 257),
- };
- _ = XInputSetState((uint)_slot, ref vibration);
- }
-
- private static void ReadLoop()
- {
- try
- {
- while (true)
- {
- var slot = FindConnectedSlot();
- if (slot < 0)
- {
- SetState(default);
- Thread.Sleep(1000);
- continue;
- }
-
- lock (Gate)
- {
- _slot = slot;
- SendRumbleLocked();
- }
-
- Console.Error.WriteLine("[LOADER][INFO] XInput (Xbox) controller connected.");
- while (XInputGetState((uint)slot, out var state) == ErrorSuccess)
- {
- SetState(Translate(state.Gamepad));
- Thread.Sleep(8);
- }
-
- Console.Error.WriteLine("[LOADER][INFO] XInput (Xbox) controller disconnected.");
- lock (Gate)
- {
- _slot = -1;
- _motorLeft = 0;
- _motorRight = 0;
- _triggerLeft = 0;
- _triggerRight = 0;
- _state = default;
- }
-
- Thread.Sleep(1000);
- }
- }
- catch (DllNotFoundException)
- {
- // XInput unavailable on this system; leave the reader disconnected.
- }
- catch (EntryPointNotFoundException)
- {
- }
- }
-
- private static int FindConnectedSlot()
- {
- for (var index = 0; index < SlotCount; index++)
- {
- if (XInputGetState((uint)index, out _) == ErrorSuccess)
- {
- return index;
- }
- }
-
- return -1;
- }
-
- private static HostGamepadState Translate(in XInputGamepad pad)
- {
- var buttons = HostGamepadButtons.None;
- buttons |= (pad.Buttons & XinputDpadUp) != 0 ? HostGamepadButtons.Up : 0;
- buttons |= (pad.Buttons & XinputDpadDown) != 0 ? HostGamepadButtons.Down : 0;
- buttons |= (pad.Buttons & XinputDpadLeft) != 0 ? HostGamepadButtons.Left : 0;
- buttons |= (pad.Buttons & XinputDpadRight) != 0 ? HostGamepadButtons.Right : 0;
- buttons |= (pad.Buttons & XinputStart) != 0 ? HostGamepadButtons.Options : 0;
- buttons |= (pad.Buttons & XinputBack) != 0 ? HostGamepadButtons.TouchPad : 0;
- buttons |= (pad.Buttons & XinputLeftThumb) != 0 ? HostGamepadButtons.L3 : 0;
- buttons |= (pad.Buttons & XinputRightThumb) != 0 ? HostGamepadButtons.R3 : 0;
- buttons |= (pad.Buttons & XinputLeftShoulder) != 0 ? HostGamepadButtons.L1 : 0;
- buttons |= (pad.Buttons & XinputRightShoulder) != 0 ? HostGamepadButtons.R1 : 0;
- buttons |= (pad.Buttons & XinputA) != 0 ? HostGamepadButtons.Cross : 0;
- buttons |= (pad.Buttons & XinputB) != 0 ? HostGamepadButtons.Circle : 0;
- buttons |= (pad.Buttons & XinputX) != 0 ? HostGamepadButtons.Square : 0;
- buttons |= (pad.Buttons & XinputY) != 0 ? HostGamepadButtons.Triangle : 0;
- buttons |= pad.LeftTrigger > TriggerThreshold ? HostGamepadButtons.L2 : 0;
- buttons |= pad.RightTrigger > TriggerThreshold ? HostGamepadButtons.R2 : 0;
-
- return new HostGamepadState(
- Connected: true,
- Buttons: buttons,
- LeftX: AxisToByte(pad.ThumbLX),
- LeftY: AxisToByteInverted(pad.ThumbLY),
- RightX: AxisToByte(pad.ThumbRX),
- RightY: AxisToByteInverted(pad.ThumbRY),
- LeftTrigger: pad.LeftTrigger,
- RightTrigger: pad.RightTrigger);
- }
-
- private static byte AxisToByte(short value) => (byte)((value + 32768) >> 8);
-
- // XInput Y grows upward, host pad conventions report Y growing downward.
- private static byte AxisToByteInverted(short value) => (byte)(255 - ((value + 32768) >> 8));
-
- [StructLayout(LayoutKind.Sequential)]
- private struct XInputGamepad
- {
- public ushort Buttons;
- public byte LeftTrigger;
- public byte RightTrigger;
- public short ThumbLX;
- public short ThumbLY;
- public short ThumbRX;
- public short ThumbRY;
- }
-
- [StructLayout(LayoutKind.Sequential)]
- private struct XInputState
- {
- public uint PacketNumber;
- public XInputGamepad Gamepad;
- }
-
- [StructLayout(LayoutKind.Sequential)]
- private struct XInputVibration
- {
- public ushort LeftMotorSpeed;
- public ushort RightMotorSpeed;
- }
-
- // xinput1_4.dll ships with Windows 8 and later.
- [LibraryImport("xinput1_4.dll")]
- private static partial uint XInputGetState(uint userIndex, out XInputState state);
-
- [LibraryImport("xinput1_4.dll")]
- private static partial uint XInputSetState(uint userIndex, ref XInputVibration vibration);
-}
diff --git a/src/SharpEmu.HLE/HostMainThread.cs b/src/SharpEmu.HLE/HostMainThread.cs
index bcf94fda..74fa6e62 100644
--- a/src/SharpEmu.HLE/HostMainThread.cs
+++ b/src/SharpEmu.HLE/HostMainThread.cs
@@ -6,8 +6,8 @@ using System.Collections.Concurrent;
namespace SharpEmu.HLE;
///
-/// Runs work on the real process main thread. macOS only allows AppKit (and
-/// therefore GLFW windowing) on that thread, so the CLI moves emulation onto
+/// Runs work on the real process main thread. macOS requires its windowing
+/// event loop on that thread, so the CLI moves emulation onto
/// a worker thread, parks the main thread in , and the
/// video presenter posts its window loop here. On other platforms
/// stays false and nothing changes.
diff --git a/src/SharpEmu.HLE/HostSessionControl.cs b/src/SharpEmu.HLE/HostSessionControl.cs
index dd739179..cda5c33d 100644
--- a/src/SharpEmu.HLE/HostSessionControl.cs
+++ b/src/SharpEmu.HLE/HostSessionControl.cs
@@ -12,8 +12,6 @@ public static class HostSessionControl
private static Action? _shutdownHandler;
private static string? _pendingShutdownReason;
private static int _shutdownRequested;
- private static long _embeddedHostWindow;
- private static long _embeddedHostDisplay;
///
/// Indicates that the active host session is being stopped. Runtime code
@@ -22,21 +20,6 @@ public static class HostSessionControl
///
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
- ///
- /// Native GUI surface used by an isolated emulator child. Input backends
- /// use it to treat the launcher window as the active game window.
- ///
- public static nint EmbeddedHostWindow => unchecked((nint)Interlocked.Read(ref _embeddedHostWindow));
-
- /// X11 Display* paired with when available.
- public static nint EmbeddedHostDisplay => unchecked((nint)Interlocked.Read(ref _embeddedHostDisplay));
-
- public static void SetEmbeddedHostSurface(nint window, nint display = 0)
- {
- Interlocked.Exchange(ref _embeddedHostDisplay, unchecked((long)display));
- Interlocked.Exchange(ref _embeddedHostWindow, unchecked((long)window));
- }
-
///
/// Starts a fresh session after the previous guest has fully left its
/// execution backend.
diff --git a/src/SharpEmu.HLE/SharpEmu.HLE.csproj b/src/SharpEmu.HLE/SharpEmu.HLE.csproj
index e6ee05a5..8932bea0 100644
--- a/src/SharpEmu.HLE/SharpEmu.HLE.csproj
+++ b/src/SharpEmu.HLE/SharpEmu.HLE.csproj
@@ -21,6 +21,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
+
+
diff --git a/src/SharpEmu.GUI/Atrac9/Atrac9Config.cs b/src/SharpEmu.LibAtrac9/Atrac9Config.cs
similarity index 99%
rename from src/SharpEmu.GUI/Atrac9/Atrac9Config.cs
rename to src/SharpEmu.LibAtrac9/Atrac9Config.cs
index 8498fd8b..e9177216 100644
--- a/src/SharpEmu.GUI/Atrac9/Atrac9Config.cs
+++ b/src/SharpEmu.LibAtrac9/Atrac9Config.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using System.IO;
using LibAtrac9.Utilities;
diff --git a/src/SharpEmu.GUI/Atrac9/Atrac9Decoder.cs b/src/SharpEmu.LibAtrac9/Atrac9Decoder.cs
similarity index 99%
rename from src/SharpEmu.GUI/Atrac9/Atrac9Decoder.cs
rename to src/SharpEmu.LibAtrac9/Atrac9Decoder.cs
index e99d451a..fa76ff75 100644
--- a/src/SharpEmu.GUI/Atrac9/Atrac9Decoder.cs
+++ b/src/SharpEmu.LibAtrac9/Atrac9Decoder.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using System;
using LibAtrac9.Utilities;
diff --git a/src/SharpEmu.GUI/Atrac9/Atrac9Rng.cs b/src/SharpEmu.LibAtrac9/Atrac9Rng.cs
similarity index 96%
rename from src/SharpEmu.GUI/Atrac9/Atrac9Rng.cs
rename to src/SharpEmu.LibAtrac9/Atrac9Rng.cs
index 90b13c97..00fc4fd6 100644
--- a/src/SharpEmu.GUI/Atrac9/Atrac9Rng.cs
+++ b/src/SharpEmu.LibAtrac9/Atrac9Rng.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9
{
diff --git a/src/SharpEmu.GUI/Atrac9/BandExtension.cs b/src/SharpEmu.LibAtrac9/BandExtension.cs
similarity index 99%
rename from src/SharpEmu.GUI/Atrac9/BandExtension.cs
rename to src/SharpEmu.LibAtrac9/BandExtension.cs
index 57cc5b43..5a0e8f12 100644
--- a/src/SharpEmu.GUI/Atrac9/BandExtension.cs
+++ b/src/SharpEmu.LibAtrac9/BandExtension.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using System;
diff --git a/src/SharpEmu.GUI/Atrac9/BitAllocation.cs b/src/SharpEmu.LibAtrac9/BitAllocation.cs
similarity index 99%
rename from src/SharpEmu.GUI/Atrac9/BitAllocation.cs
rename to src/SharpEmu.LibAtrac9/BitAllocation.cs
index f178d8cc..40404a5c 100644
--- a/src/SharpEmu.GUI/Atrac9/BitAllocation.cs
+++ b/src/SharpEmu.LibAtrac9/BitAllocation.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using System;
diff --git a/src/SharpEmu.GUI/Atrac9/Block.cs b/src/SharpEmu.LibAtrac9/Block.cs
similarity index 98%
rename from src/SharpEmu.GUI/Atrac9/Block.cs
rename to src/SharpEmu.LibAtrac9/Block.cs
index e2675782..4b549ae6 100644
--- a/src/SharpEmu.GUI/Atrac9/Block.cs
+++ b/src/SharpEmu.LibAtrac9/Block.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9
{
diff --git a/src/SharpEmu.GUI/Atrac9/Channel.cs b/src/SharpEmu.LibAtrac9/Channel.cs
similarity index 98%
rename from src/SharpEmu.GUI/Atrac9/Channel.cs
rename to src/SharpEmu.LibAtrac9/Channel.cs
index 80d80b1f..7aef5183 100644
--- a/src/SharpEmu.GUI/Atrac9/Channel.cs
+++ b/src/SharpEmu.LibAtrac9/Channel.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using LibAtrac9.Utilities;
diff --git a/src/SharpEmu.GUI/Atrac9/ChannelConfig.cs b/src/SharpEmu.LibAtrac9/ChannelConfig.cs
similarity index 96%
rename from src/SharpEmu.GUI/Atrac9/ChannelConfig.cs
rename to src/SharpEmu.LibAtrac9/ChannelConfig.cs
index b3de1459..e440f941 100644
--- a/src/SharpEmu.GUI/Atrac9/ChannelConfig.cs
+++ b/src/SharpEmu.LibAtrac9/ChannelConfig.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9
{
diff --git a/src/SharpEmu.GUI/Atrac9/Frame.cs b/src/SharpEmu.LibAtrac9/Frame.cs
similarity index 94%
rename from src/SharpEmu.GUI/Atrac9/Frame.cs
rename to src/SharpEmu.LibAtrac9/Frame.cs
index 73e0b773..fbc7def1 100644
--- a/src/SharpEmu.GUI/Atrac9/Frame.cs
+++ b/src/SharpEmu.LibAtrac9/Frame.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9
{
diff --git a/src/SharpEmu.GUI/Atrac9/HuffmanCodebook.cs b/src/SharpEmu.LibAtrac9/HuffmanCodebook.cs
similarity index 98%
rename from src/SharpEmu.GUI/Atrac9/HuffmanCodebook.cs
rename to src/SharpEmu.LibAtrac9/HuffmanCodebook.cs
index dcd8a407..f73c1259 100644
--- a/src/SharpEmu.GUI/Atrac9/HuffmanCodebook.cs
+++ b/src/SharpEmu.LibAtrac9/HuffmanCodebook.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using System;
using LibAtrac9.Utilities;
diff --git a/src/SharpEmu.GUI/Atrac9/HuffmanCodebooks.cs b/src/SharpEmu.LibAtrac9/HuffmanCodebooks.cs
similarity index 99%
rename from src/SharpEmu.GUI/Atrac9/HuffmanCodebooks.cs
rename to src/SharpEmu.LibAtrac9/HuffmanCodebooks.cs
index 80c78a18..6138c9e8 100644
--- a/src/SharpEmu.GUI/Atrac9/HuffmanCodebooks.cs
+++ b/src/SharpEmu.LibAtrac9/HuffmanCodebooks.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9
{
@@ -1350,4 +1351,4 @@ namespace LibAtrac9
new byte[] {0, 0, 0, 0}
};
}
-}
\ No newline at end of file
+}
diff --git a/src/SharpEmu.GUI/Atrac9/LICENSE.txt b/src/SharpEmu.LibAtrac9/LICENSE.txt
similarity index 100%
rename from src/SharpEmu.GUI/Atrac9/LICENSE.txt
rename to src/SharpEmu.LibAtrac9/LICENSE.txt
diff --git a/src/SharpEmu.GUI/Atrac9/Quantization.cs b/src/SharpEmu.LibAtrac9/Quantization.cs
similarity index 98%
rename from src/SharpEmu.GUI/Atrac9/Quantization.cs
rename to src/SharpEmu.LibAtrac9/Quantization.cs
index 4e10dbf9..5fd5e2a8 100644
--- a/src/SharpEmu.GUI/Atrac9/Quantization.cs
+++ b/src/SharpEmu.LibAtrac9/Quantization.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using System;
diff --git a/src/SharpEmu.GUI/Atrac9/ScaleFactors.cs b/src/SharpEmu.LibAtrac9/ScaleFactors.cs
similarity index 99%
rename from src/SharpEmu.GUI/Atrac9/ScaleFactors.cs
rename to src/SharpEmu.LibAtrac9/ScaleFactors.cs
index ace92457..c0a1284e 100644
--- a/src/SharpEmu.GUI/Atrac9/ScaleFactors.cs
+++ b/src/SharpEmu.LibAtrac9/ScaleFactors.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using System;
using System.IO;
diff --git a/src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj b/src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj
new file mode 100644
index 00000000..be4224e9
--- /dev/null
+++ b/src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj
@@ -0,0 +1,12 @@
+
+
+
+
+ SharpEmu.LibAtrac9
+ LibAtrac9
+ false
+
+
diff --git a/src/SharpEmu.GUI/Atrac9/Stereo.cs b/src/SharpEmu.LibAtrac9/Stereo.cs
similarity index 97%
rename from src/SharpEmu.GUI/Atrac9/Stereo.cs
rename to src/SharpEmu.LibAtrac9/Stereo.cs
index ab158a47..d53ff463 100644
--- a/src/SharpEmu.GUI/Atrac9/Stereo.cs
+++ b/src/SharpEmu.LibAtrac9/Stereo.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9
{
diff --git a/src/SharpEmu.GUI/Atrac9/Tables.cs b/src/SharpEmu.LibAtrac9/Tables.cs
similarity index 99%
rename from src/SharpEmu.GUI/Atrac9/Tables.cs
rename to src/SharpEmu.LibAtrac9/Tables.cs
index 72b4e74b..90c1f1a6 100644
--- a/src/SharpEmu.GUI/Atrac9/Tables.cs
+++ b/src/SharpEmu.LibAtrac9/Tables.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using System;
using static LibAtrac9.HuffmanCodebooks;
diff --git a/src/SharpEmu.GUI/Atrac9/Unpack.cs b/src/SharpEmu.LibAtrac9/Unpack.cs
similarity index 99%
rename from src/SharpEmu.GUI/Atrac9/Unpack.cs
rename to src/SharpEmu.LibAtrac9/Unpack.cs
index 2ddbc45f..793e3fe4 100644
--- a/src/SharpEmu.GUI/Atrac9/Unpack.cs
+++ b/src/SharpEmu.LibAtrac9/Unpack.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using System;
using System.IO;
diff --git a/src/SharpEmu.GUI/Atrac9/Utilities/Bit.cs b/src/SharpEmu.LibAtrac9/Utilities/Bit.cs
similarity index 96%
rename from src/SharpEmu.GUI/Atrac9/Utilities/Bit.cs
rename to src/SharpEmu.LibAtrac9/Utilities/Bit.cs
index a3349be1..49eccebc 100644
--- a/src/SharpEmu.GUI/Atrac9/Utilities/Bit.cs
+++ b/src/SharpEmu.LibAtrac9/Utilities/Bit.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
namespace LibAtrac9.Utilities
{
diff --git a/src/SharpEmu.GUI/Atrac9/Utilities/BitReader.cs b/src/SharpEmu.LibAtrac9/Utilities/BitReader.cs
similarity index 99%
rename from src/SharpEmu.GUI/Atrac9/Utilities/BitReader.cs
rename to src/SharpEmu.LibAtrac9/Utilities/BitReader.cs
index 46225609..738f04b4 100644
--- a/src/SharpEmu.GUI/Atrac9/Utilities/BitReader.cs
+++ b/src/SharpEmu.LibAtrac9/Utilities/BitReader.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using System;
using System.Diagnostics;
diff --git a/src/SharpEmu.GUI/Atrac9/Utilities/Helpers.cs b/src/SharpEmu.LibAtrac9/Utilities/Helpers.cs
similarity index 98%
rename from src/SharpEmu.GUI/Atrac9/Utilities/Helpers.cs
rename to src/SharpEmu.LibAtrac9/Utilities/Helpers.cs
index bd5d9400..cfbd44d4 100644
--- a/src/SharpEmu.GUI/Atrac9/Utilities/Helpers.cs
+++ b/src/SharpEmu.LibAtrac9/Utilities/Helpers.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using System.Runtime.CompilerServices;
diff --git a/src/SharpEmu.GUI/Atrac9/Utilities/Mdct.cs b/src/SharpEmu.LibAtrac9/Utilities/Mdct.cs
similarity index 99%
rename from src/SharpEmu.GUI/Atrac9/Utilities/Mdct.cs
rename to src/SharpEmu.LibAtrac9/Utilities/Mdct.cs
index 0413b61f..b7d68aed 100644
--- a/src/SharpEmu.GUI/Atrac9/Utilities/Mdct.cs
+++ b/src/SharpEmu.LibAtrac9/Utilities/Mdct.cs
@@ -1,3 +1,4 @@
+// SPDX-License-Identifier: MIT
#nullable disable
using System;
using System.Collections.Generic;
diff --git a/src/SharpEmu.Libs/Acm/AcmExports.cs b/src/SharpEmu.Libs/Acm/AcmExports.cs
index 36da90d7..827f49df 100644
--- a/src/SharpEmu.Libs/Acm/AcmExports.cs
+++ b/src/SharpEmu.Libs/Acm/AcmExports.cs
@@ -2,13 +2,17 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
+using System.Collections.Concurrent;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Acm;
public static class AcmExports
{
+ private const int AcmBatchErrorBytes = 32;
+ private static readonly ConcurrentDictionary Contexts = new();
private static int _nextContextHandle;
+ private static int _nextBatchHandle;
[SysAbiExport(
Nid = "ZIXln2K3XMk",
@@ -23,12 +27,17 @@ public static class AcmExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
- var handle = (ulong)Interlocked.Increment(ref _nextContextHandle);
- Span handleBytes = stackalloc byte[sizeof(ulong)];
- BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle);
- return ctx.Memory.TryWrite(outContextAddress, handleBytes)
- ? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
- : ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ var handle = unchecked((uint)Interlocked.Increment(ref _nextContextHandle));
+ Span handleBytes = stackalloc byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32LittleEndian(handleBytes, handle);
+ if (!ctx.Memory.TryWrite(outContextAddress, handleBytes))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ Contexts[handle] = 0;
+ Trace($"context_create context={handle}");
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
@@ -38,7 +47,196 @@ public static class AcmExports
LibraryName = "libSceAcm")]
public static int AcmContextDestroy(CpuContext ctx)
{
- _ = ctx;
+ var context = unchecked((uint)ctx[CpuRegister.Rdi]);
+ Contexts.TryRemove(context, out _);
+ Trace($"context_destroy context={context}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
+
+ [SysAbiExport(
+ Nid = "tW9W+CAG4FE",
+ ExportName = "sceAcmBatchStartBuffer",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAcm")]
+ public static int AcmBatchStartBuffer(CpuContext ctx)
+ {
+ var context = unchecked((uint)ctx[CpuRegister.Rdi]);
+ var commandsAddress = ctx[CpuRegister.Rsi];
+ var commandsSize = ctx[CpuRegister.Rdx];
+ var errorAddress = ctx[CpuRegister.Rcx];
+ var batchAddress = ctx[CpuRegister.R8];
+
+ if (!Contexts.ContainsKey(context) ||
+ batchAddress == 0 ||
+ (commandsSize != 0 && commandsAddress == 0))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ return CompleteBatchStart(ctx, context, 1, errorAddress, batchAddress);
+ }
+
+ [SysAbiExport(
+ Nid = "8fe55ktlNVo",
+ ExportName = "sceAcmBatchStartBuffers",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAcm")]
+ public static int AcmBatchStartBuffers(CpuContext ctx)
+ {
+ var context = unchecked((uint)ctx[CpuRegister.Rdi]);
+ var infoCount = unchecked((uint)ctx[CpuRegister.Rsi]);
+ var infoArrayAddress = ctx[CpuRegister.Rdx];
+ var errorAddress = ctx[CpuRegister.Rcx];
+ var batchAddress = ctx[CpuRegister.R8];
+
+ if (!Contexts.ContainsKey(context) ||
+ batchAddress == 0 ||
+ (infoCount != 0 && infoArrayAddress == 0))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ return CompleteBatchStart(ctx, context, infoCount, errorAddress, batchAddress);
+ }
+
+ [SysAbiExport(
+ Nid = "RLN3gRlXJLE",
+ ExportName = "sceAcmBatchWait",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAcm")]
+ public static int AcmBatchWait(CpuContext ctx)
+ {
+ var context = unchecked((uint)ctx[CpuRegister.Rdi]);
+ return ctx.SetReturn(
+ Contexts.ContainsKey(context)
+ ? OrbisGen2Result.ORBIS_GEN2_OK
+ : OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ [SysAbiExport(
+ Nid = "r7z5YQFZo+U",
+ ExportName = "sceAcmBatchJobNotification",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAcm")]
+ public static int AcmBatchJobNotification(CpuContext ctx) =>
+ AdvanceBatchInfo(ctx, 32);
+
+ [SysAbiExport(
+ Nid = "u70oWo92SYQ",
+ ExportName = "sceAcm_ConvReverb_SharedInput",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAcm")]
+ public static int AcmConvReverbSharedInput(CpuContext ctx) =>
+ AdvanceBatchInfo(ctx, 1024);
+
+ [SysAbiExport(
+ Nid = "9nLbWmRDpa8",
+ ExportName = "sceAcm_ConvReverb_SharedIr",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAcm")]
+ public static int AcmConvReverbSharedIr(CpuContext ctx) =>
+ AdvanceBatchInfo(ctx, 1024);
+
+ [SysAbiExport(
+ Nid = "KovqaFbmtsM",
+ ExportName = "sceAcm_FFT",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAcm")]
+ public static int AcmFft(CpuContext ctx) =>
+ AdvanceBatchInfo(ctx, 256);
+
+ [SysAbiExport(
+ Nid = "DR-ZCmvVR9Q",
+ ExportName = "sceAcm_IFFT",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAcm")]
+ public static int AcmIfft(CpuContext ctx) =>
+ AdvanceBatchInfo(ctx, 256);
+
+ [SysAbiExport(
+ Nid = "LA4RCNKnFjg",
+ ExportName = "sceAcm_Panner",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAcm")]
+ public static int AcmPanner(CpuContext ctx) =>
+ AdvanceBatchInfo(ctx, 512);
+
+ internal static void ResetForTests()
+ {
+ Contexts.Clear();
+ Interlocked.Exchange(ref _nextContextHandle, 0);
+ Interlocked.Exchange(ref _nextBatchHandle, 0);
+ }
+
+ private static int CompleteBatchStart(
+ CpuContext ctx,
+ uint context,
+ uint infoCount,
+ ulong errorAddress,
+ ulong batchAddress)
+ {
+ if (errorAddress != 0)
+ {
+ Span error = stackalloc byte[AcmBatchErrorBytes];
+ error.Clear();
+ if (!ctx.Memory.TryWrite(errorAddress, error))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+ }
+
+ var batch = unchecked((uint)Interlocked.Increment(ref _nextBatchHandle));
+ Span batchBytes = stackalloc byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32LittleEndian(batchBytes, batch);
+ if (!ctx.Memory.TryWrite(batchAddress, batchBytes))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ Trace($"batch_start context={context} count={infoCount} batch={batch}");
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
+ }
+
+ private static int AdvanceBatchInfo(CpuContext ctx, ulong byteCount)
+ {
+ var infoAddress = ctx[CpuRegister.Rdi];
+ if (infoAddress == 0)
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
+ }
+
+ Span info = stackalloc byte[24];
+ if (!ctx.Memory.TryRead(infoAddress, info))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ var buffer = BinaryPrimitives.ReadUInt64LittleEndian(info);
+ var offset = BinaryPrimitives.ReadUInt64LittleEndian(info[8..]);
+ var size = BinaryPrimitives.ReadUInt64LittleEndian(info[16..]);
+ if (buffer != 0 && size != 0)
+ {
+ var nextOffset = offset > ulong.MaxValue - byteCount
+ ? ulong.MaxValue
+ : offset + byteCount;
+ BinaryPrimitives.WriteUInt64LittleEndian(info[8..], Math.Min(size, nextOffset));
+ if (!ctx.Memory.TryWrite(infoAddress, info))
+ {
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+ }
+
+ return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
+ }
+
+ private static void Trace(string message)
+ {
+ if (string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_LOG_ACM"),
+ "1",
+ StringComparison.Ordinal))
+ {
+ Console.Error.WriteLine($"[LOADER][TRACE] acm.{message}");
+ }
+ }
}
diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs
index 3ee6acfc..cc919c0e 100644
--- a/src/SharpEmu.Libs/Agc/AgcExports.cs
+++ b/src/SharpEmu.Libs/Agc/AgcExports.cs
@@ -4383,6 +4383,14 @@ public static partial class AgcExports
_tracedProducerlessWaits.Clear();
}
+ if (!stale)
+ {
+ // Count before the deduplication below: the warning fires once
+ // per label, so on its own it cannot say how often a queue
+ // actually suspends.
+ GpuWaitProfile.RecordSuspend(producer is not null);
+ }
+
if (!stale && producer is null &&
!_tracedProducerlessWaits.Add(
(memory, waiter.WaitAddress)))
@@ -5648,6 +5656,8 @@ public static partial class AgcExports
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot(
ctx.Memory);
+ GpuWaitProfile.RecordMonitorPoll(resumed != 0);
+ GpuWaitProfile.ReportIfDue(remaining);
if (remaining == 0)
{
gpuState.WaitMonitorRunning = false;
@@ -5841,6 +5851,7 @@ public static partial class AgcExports
$"submission={waiter.SubmissionId} label=0x{waiter.WaitAddress:X16} " +
$"resume=0x{waiter.ResumeAddress:X16} remaining_dwords={remainingDwords} " +
$"waited_ms={waitedMilliseconds:F3}");
+ GpuWaitProfile.RecordResume(waiter.WaitAddress, waitedMilliseconds);
if (remainingDwords == 0)
{
state.IsSuspended = false;
diff --git a/src/SharpEmu.Libs/Agc/GpuWaitProfile.cs b/src/SharpEmu.Libs/Agc/GpuWaitProfile.cs
new file mode 100644
index 00000000..39aa169f
--- /dev/null
+++ b/src/SharpEmu.Libs/Agc/GpuWaitProfile.cs
@@ -0,0 +1,160 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Diagnostics;
+
+namespace SharpEmu.Libs.Agc;
+
+///
+/// Aggregate accounting for suspended WAIT_REG_MEM packets, enabled with
+/// SHARPEMU_PROFILE_GPU_WAIT=1.
+///
+/// The existing agc.wait_suspended warning is deduplicated per label, so
+/// a label that suspends every frame is reported once and then goes silent —
+/// which makes the log useless for judging whether GPU waits cost frame time.
+/// This counts every suspension and every resume, and reports how long the
+/// queues actually sat blocked.
+///
+internal static class GpuWaitProfile
+{
+ public static readonly bool Enabled = string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GPU_WAIT"),
+ "1",
+ StringComparison.Ordinal);
+
+ private static readonly double _reportSeconds =
+ double.TryParse(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GPU_WAIT_REPORT_S"),
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var seconds) && seconds > 0
+ ? seconds
+ : 5.0;
+
+ private static readonly object _gate = new();
+ private static readonly Dictionary _byLabel = new();
+ private static long _suspensions;
+ private static long _resumes;
+ private static long _producerless;
+ private static long _monitorPolls;
+ private static long _monitorEmptyPolls;
+ private static double _totalWaitMilliseconds;
+ private static double _maxWaitMilliseconds;
+ private static long _windowStart = Stopwatch.GetTimestamp();
+
+ public static void RecordSuspend(bool hasProducer)
+ {
+ if (!Enabled)
+ {
+ return;
+ }
+
+ lock (_gate)
+ {
+ _suspensions++;
+ if (!hasProducer)
+ {
+ _producerless++;
+ }
+ }
+ }
+
+ public static void RecordResume(ulong label, double waitedMilliseconds)
+ {
+ if (!Enabled)
+ {
+ return;
+ }
+
+ lock (_gate)
+ {
+ _resumes++;
+ _totalWaitMilliseconds += waitedMilliseconds;
+ if (waitedMilliseconds > _maxWaitMilliseconds)
+ {
+ _maxWaitMilliseconds = waitedMilliseconds;
+ }
+
+ if (_byLabel.Count < 4096)
+ {
+ var existing = _byLabel.TryGetValue(label, out var entry) ? entry : default;
+ _byLabel[label] = (existing.Count + 1, existing.Milliseconds + waitedMilliseconds);
+ }
+ }
+ }
+
+ ///
+ /// Called once per wake of the wait monitor. An empty poll means the monitor
+ /// burned a wakeup without resuming anything, which is the cost of the
+ /// backoff loop rather than of the wait itself.
+ ///
+ public static void RecordMonitorPoll(bool resumedAny)
+ {
+ if (!Enabled)
+ {
+ return;
+ }
+
+ lock (_gate)
+ {
+ _monitorPolls++;
+ if (!resumedAny)
+ {
+ _monitorEmptyPolls++;
+ }
+ }
+ }
+
+ public static void ReportIfDue(int remainingWaiters)
+ {
+ if (!Enabled)
+ {
+ return;
+ }
+
+ string line;
+ lock (_gate)
+ {
+ var now = Stopwatch.GetTimestamp();
+ var elapsedTicks = now - _windowStart;
+ if (elapsedTicks < _reportSeconds * Stopwatch.Frequency)
+ {
+ return;
+ }
+
+ _windowStart = now;
+ var seconds = elapsedTicks / (double)Stopwatch.Frequency;
+
+ // Total blocked time across all queues. Above 1000ms/s the queues are
+ // overlapping their stalls, so compare it against the frame budget,
+ // not against wall time.
+ var top = _byLabel
+ .OrderByDescending(entry => entry.Value.Milliseconds)
+ .Take(5)
+ .Select(entry =>
+ $"0x{entry.Key:X}={entry.Value.Milliseconds / seconds:F0}ms/s" +
+ $"/n{entry.Value.Count}")
+ .ToArray();
+
+ line =
+ $"[PERF][GPUWAIT] {seconds:F1}s suspend/s={_suspensions / seconds:F0} " +
+ $"resume/s={_resumes / seconds:F0} producerless/s={_producerless / seconds:F0} " +
+ $"blocked_ms/s={_totalWaitMilliseconds / seconds:F0} " +
+ $"avg_ms={(_resumes > 0 ? _totalWaitMilliseconds / _resumes : 0):F2} " +
+ $"max_ms={_maxWaitMilliseconds:F1} " +
+ $"monitor_polls/s={_monitorPolls / seconds:F0} " +
+ $"empty={(_monitorPolls > 0 ? _monitorEmptyPolls * 100.0 / _monitorPolls : 0):F0}% " +
+ $"outstanding={remainingWaiters} top: {string.Join(" | ", top)}";
+
+ _suspensions = 0;
+ _resumes = 0;
+ _producerless = 0;
+ _monitorPolls = 0;
+ _monitorEmptyPolls = 0;
+ _totalWaitMilliseconds = 0;
+ _maxWaitMilliseconds = 0;
+ _byLabel.Clear();
+ }
+
+ Console.Error.WriteLine(line);
+ }
+}
diff --git a/src/SharpEmu.Libs/Audio/AjmExports.cs b/src/SharpEmu.Libs/Audio/AjmExports.cs
index 01571353..f9690010 100644
--- a/src/SharpEmu.Libs/Audio/AjmExports.cs
+++ b/src/SharpEmu.Libs/Audio/AjmExports.cs
@@ -1,7 +1,9 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+using LibAtrac9;
using SharpEmu.HLE;
+using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Threading;
@@ -16,9 +18,24 @@ public static class AjmExports
private const int OrbisAjmErrorOutOfResources = unchecked((int)0x80930007);
private const int OrbisAjmErrorCodecAlreadyRegistered = unchecked((int)0x80930009);
private const int OrbisAjmErrorCodecNotRegistered = unchecked((int)0x8093000A);
- private const int OrbisAjmErrorWrongRevisionFlag = unchecked((int)0x8093000B);
+ private const int OrbisAjmErrorJobCreation = unchecked((int)0x80930012);
+ private const ulong MaxSilentPcmBytes = 1 << 20;
+ private const uint Atrac9CodecType = 1;
private const uint MaxCodecType = 25;
private const int MaxInstanceIndex = 0x2FFF;
+ private const int MaxDecodeBufferBytes = 64 * 1024 * 1024;
+
+ // AjmJobFlags: version:3, codec:8, run_flags:2, control_flags:3,
+ // reserved:29, sideband_flags:3.
+ private const ulong AjmJobRunFlagGetCodecInfo = 1UL << 11;
+ private const ulong AjmJobRunFlagMultipleFrames = 1UL << 12;
+ private const ulong AjmJobSidebandFlagGaplessDecode = 1UL << 45;
+ private const ulong AjmJobSidebandFlagFormat = 1UL << 46;
+ private const ulong AjmJobSidebandFlagStream = 1UL << 47;
+
+ // AjmBuffer { u8* p_address; u64 size; }
+ private const int AjmBufferDescriptorBytes = 16;
+ private const int MaxBufferDescriptors = 64;
private static readonly ConcurrentDictionary Contexts = new();
private static int _nextContextId;
private static int _nextBatchId;
@@ -27,8 +44,20 @@ public static class AjmExports
private sealed class AjmInstanceState
{
+ public required uint Id { get; init; }
+
public required uint Codec { get; init; }
+
public required ulong Flags { get; init; }
+
+ /// Channel ceiling from the instance flags; the decoded stream's
+ /// own config decides the actual PCM layout.
+ public required int MaxChannels { get; init; }
+
+ public required Atrac9PcmEncoding Encoding { get; init; }
+
+ public Atrac9DecodeState? Atrac9 { get; init; }
+
public AjmMp3Decoder? Mp3 { get; init; }
public bool PreferPcm16
@@ -50,6 +79,8 @@ public static class AjmExports
public Dictionary InstancesBySlot { get; } = new();
+ public Dictionary RegisteredMemoryPages { get; } = new();
+
public int NextInstanceIndex { get; set; }
}
@@ -93,6 +124,65 @@ public static class AjmExports
return 0;
}
+ [SysAbiExport(
+ Nid = "bkRHEYG6lEM",
+ ExportName = "sceAjmMemoryRegister",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAjm")]
+ public static int AjmMemoryRegister(CpuContext ctx)
+ {
+ var contextId = unchecked((uint)ctx[CpuRegister.Rdi]);
+ var address = ctx[CpuRegister.Rsi];
+ var pages = ctx[CpuRegister.Rdx];
+ if (!Contexts.TryGetValue(contextId, out var state))
+ {
+ return ctx.SetReturn(OrbisAjmErrorInvalidContext);
+ }
+
+ if (address == 0 || pages == 0)
+ {
+ return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
+ }
+
+ // AJM hardware requires explicit registration. SharpEmu decodes from
+ // the shared guest address space, so retaining the range is enough.
+ lock (state.Gate)
+ {
+ state.RegisteredMemoryPages[address] = pages;
+ }
+
+ Trace($"memory_register context={contextId} address=0x{address:X16} pages={pages}");
+ return ctx.SetReturn(0);
+ }
+
+ [SysAbiExport(
+ Nid = "pIpGiaYkHkM",
+ ExportName = "sceAjmMemoryUnregister",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAjm")]
+ public static int AjmMemoryUnregister(CpuContext ctx)
+ {
+ var contextId = unchecked((uint)ctx[CpuRegister.Rdi]);
+ var address = ctx[CpuRegister.Rsi];
+ if (!Contexts.TryGetValue(contextId, out var state))
+ {
+ return ctx.SetReturn(OrbisAjmErrorInvalidContext);
+ }
+
+ if (address == 0)
+ {
+ return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
+ }
+
+ lock (state.Gate)
+ {
+ state.RegisteredMemoryPages.Remove(address);
+ }
+
+ Trace($"memory_unregister context={contextId} address=0x{address:X16}");
+ return ctx.SetReturn(0);
+ }
+
[SysAbiExport(
Nid = "Q3dyFuwGn64",
ExportName = "sceAjmModuleRegister",
@@ -144,30 +234,34 @@ public static class AjmExports
var outputAddress = ctx[CpuRegister.Rcx];
if (!Contexts.TryGetValue(contextId, out var state))
{
- return ctx.SetReturn(OrbisAjmErrorInvalidContext);
+ return TraceInstanceCreateFailure(ctx, contextId, codecType, flags, OrbisAjmErrorInvalidContext);
}
if (codecType >= MaxCodecType || outputAddress == 0)
{
- return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
+ return TraceInstanceCreateFailure(ctx, contextId, codecType, flags, OrbisAjmErrorInvalidParameter);
}
- if ((flags & 0x7) == 0)
- {
- return ctx.SetReturn(OrbisAjmErrorWrongRevisionFlag);
- }
+ // AjmInstanceFlags carries a codec id in the high bits plus a low
+ // channel/revision word whose exact split is not settled: Demon's Souls
+ // creates ATRAC9 voices with low words 1, 2, 4 and 8. Rejecting any of
+ // them leaves the title holding SCE_AJM_INSTANCE_INVALID for that voice
+ // and it plays silence forever, so nothing here is fatal — the decoded
+ // stream's own config is what actually describes the PCM anyway.
+ var maxChannels = unchecked((int)(flags & 0xF));
+ var encoding = GetPcmEncoding(flags);
uint instanceId;
lock (state.Gate)
{
if (!state.RegisteredCodecs.Contains(codecType))
{
- return ctx.SetReturn(OrbisAjmErrorCodecNotRegistered);
+ return TraceInstanceCreateFailure(ctx, contextId, codecType, flags, OrbisAjmErrorCodecNotRegistered);
}
if (state.InstancesBySlot.Count >= MaxInstanceIndex)
{
- return ctx.SetReturn(OrbisAjmErrorOutOfResources);
+ return TraceInstanceCreateFailure(ctx, contextId, codecType, flags, OrbisAjmErrorOutOfResources);
}
var nextInstanceIndex = state.NextInstanceIndex;
@@ -192,8 +286,12 @@ public static class AjmExports
instanceSlot,
new AjmInstanceState
{
+ Id = instanceId,
Codec = codecType,
Flags = flags,
+ MaxChannels = maxChannels,
+ Encoding = encoding,
+ Atrac9 = codecType == Atrac9CodecType ? new Atrac9DecodeState() : null,
Mp3 = codecType == AjmCodecMp3 ? new AjmMp3Decoder() : null,
});
}
@@ -247,11 +345,136 @@ public static class AjmExports
LibraryName = "libSceAjm")]
public static int AjmBatchInitialize(CpuContext ctx)
{
- // The caller owns and initializes the batch storage. This API resets
- // its submission cursor on hardware; FMOD does not consume a return
- // value or an additional output object here.
- ctx[CpuRegister.Rax] = 0;
- return 0;
+ var bufferAddress = ctx[CpuRegister.Rdi];
+ var bufferSize = ctx[CpuRegister.Rsi];
+ var infoAddress = ctx[CpuRegister.Rdx];
+ if (bufferAddress == 0 || infoAddress == 0)
+ {
+ return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
+ }
+
+ Span info = stackalloc byte[AjmBatchInfoBytes];
+ info.Clear();
+ BinaryPrimitives.WriteUInt64LittleEndian(info, bufferAddress);
+ BinaryPrimitives.WriteUInt64LittleEndian(info[16..], bufferSize);
+ if (!ctx.Memory.TryWrite(infoAddress, info))
+ {
+ return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
+ }
+
+ Trace($"batch_initialize buffer=0x{bufferAddress:X16} size=0x{bufferSize:X} info=0x{infoAddress:X16}");
+ return ctx.SetReturn(0);
+ }
+
+ [SysAbiExport(
+ Nid = "1t3ixYNXyuc",
+ ExportName = "sceAjmDecAt9ParseConfigData",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAjm")]
+ public static int AjmDecAt9ParseConfigData(CpuContext ctx)
+ {
+ var configAddress = ctx[CpuRegister.Rdi];
+ var outputAddress = ctx[CpuRegister.Rsi];
+ if (configAddress == 0 || outputAddress == 0)
+ {
+ return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
+ }
+
+ Span configData = stackalloc byte[4];
+ if (!ctx.Memory.TryRead(configAddress, configData))
+ {
+ return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
+ }
+
+ try
+ {
+ var config = new LibAtrac9.Atrac9Config(configData.ToArray());
+ Span info = stackalloc byte[20];
+ BinaryPrimitives.WriteUInt32LittleEndian(info[0..], unchecked((uint)config.ChannelCount));
+ BinaryPrimitives.WriteUInt32LittleEndian(info[4..], unchecked((uint)config.SampleRate));
+ BinaryPrimitives.WriteUInt32LittleEndian(info[8..], unchecked((uint)config.FrameSamples));
+ BinaryPrimitives.WriteUInt32LittleEndian(info[12..], unchecked((uint)config.SuperframeSamples));
+ BinaryPrimitives.WriteUInt32LittleEndian(info[16..], unchecked((uint)config.SuperframeBytes));
+ return ctx.SetReturn(
+ ctx.Memory.TryWrite(outputAddress, info)
+ ? 0
+ : OrbisAjmErrorInvalidParameter);
+ }
+ catch (InvalidDataException)
+ {
+ return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
+ }
+ }
+
+ [SysAbiExport(
+ Nid = "ezM2OhNxzck",
+ ExportName = "sceAjmBatchJobInitialize",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAjm")]
+ public static int AjmBatchJobInitialize(CpuContext ctx)
+ {
+ var infoAddress = ctx[CpuRegister.Rdi];
+ var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
+ var parametersAddress = ctx[CpuRegister.Rdx];
+ var parametersSize = ctx[CpuRegister.Rcx];
+ var resultAddress = ctx[CpuRegister.R8];
+
+ if (!TryAppendBatchJob(ctx, infoAddress, AjmJobControlSize))
+ {
+ return ctx.SetReturn(OrbisAjmErrorJobCreation);
+ }
+
+ var status = Atrac9DecodeState.ResultInvalidParameter;
+ if (TryGetInstance(instanceId, out var instance))
+ {
+ if (instance.Codec != Atrac9CodecType)
+ {
+ status = 0;
+ }
+ else if (instance.Atrac9 is not null &&
+ parametersAddress != 0 &&
+ parametersSize >= 4)
+ {
+ Span configData = stackalloc byte[4];
+ if (ctx.Memory.TryRead(parametersAddress, configData) &&
+ instance.Atrac9.TryInitialize(configData))
+ {
+ status = 0;
+ }
+ }
+ }
+
+ WriteBasicResult(ctx, resultAddress, status);
+ Trace(
+ $"batch_job_initialize instance=0x{instanceId:X8} params=0x{parametersAddress:X16}+0x{parametersSize:X} status=0x{status:X8}");
+ return ctx.SetReturn(0);
+ }
+
+ [SysAbiExport(
+ Nid = "uJ3m8INuikg",
+ ExportName = "sceAjmBatchJobClearContext",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAjm")]
+ public static int AjmBatchJobClearContext(CpuContext ctx)
+ {
+ var infoAddress = ctx[CpuRegister.Rdi];
+ var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
+ var resultAddress = ctx[CpuRegister.Rdx];
+
+ if (!TryAppendBatchJob(ctx, infoAddress, AjmJobControlSize))
+ {
+ return ctx.SetReturn(OrbisAjmErrorJobCreation);
+ }
+
+ var status = Atrac9DecodeState.ResultInvalidParameter;
+ if (TryGetInstance(instanceId, out var instance))
+ {
+ instance.Atrac9?.Reset();
+ status = 0;
+ }
+
+ WriteBasicResult(ctx, resultAddress, status);
+ return ctx.SetReturn(0);
}
///
@@ -264,103 +487,181 @@ public static class AjmExports
ExportName = "sceAjmBatchJobDecode",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAjm")]
- public static int AjmBatchJobDecode(CpuContext ctx)
+ public static int AjmBatchJobDecode(CpuContext ctx) =>
+ AjmBatchJobDecodeCore(ctx, multipleFrames: true);
+
+ [SysAbiExport(
+ Nid = "5LLWbpP5xi8",
+ ExportName = "sceAjmBatchJobDecodeSingle",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAjm")]
+ public static int AjmBatchJobDecodeSingle(CpuContext ctx) =>
+ AjmBatchJobDecodeCore(ctx, multipleFrames: false);
+
+ ///
+ /// The flag-driven decode entry point. Titles built on the modern AJM API
+ /// drive ATRAC9 playback exclusively through Run/RunSplit —
+ /// is never called — so leaving these
+ /// unresolved silences every streamed voice.
+ ///
+ [SysAbiExport(
+ Nid = "jVCWcthifr8",
+ ExportName = "sceAjmBatchJobRun",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAjm")]
+ public static int AjmBatchJobRun(CpuContext ctx) => AjmBatchJobRunCore(ctx, split: false);
+
+ [SysAbiExport(
+ Nid = "Z9NVCesiP0Q",
+ ExportName = "sceAjmBatchJobRunSplit",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAjm")]
+ public static int AjmBatchJobRunSplit(CpuContext ctx) => AjmBatchJobRunCore(ctx, split: true);
+
+ // The BufferRa variants only append a guest return address after the nine
+ // shared arguments, so they decode identically.
+ [SysAbiExport(
+ Nid = "ElslOCpOIns",
+ ExportName = "sceAjmBatchJobRunBufferRa",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAjm")]
+ public static int AjmBatchJobRunBufferRa(CpuContext ctx) => AjmBatchJobRunCore(ctx, split: false);
+
+ [SysAbiExport(
+ Nid = "7jdAXK+2fMo",
+ ExportName = "sceAjmBatchJobRunSplitBufferRa",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAjm")]
+ public static int AjmBatchJobRunSplitBufferRa(CpuContext ctx) => AjmBatchJobRunCore(ctx, split: true);
+
+ ///
+ /// Records the gapless-decode window for a looping voice. SharpEmu decodes
+ /// whole superframes and does not trim lead-in/lead-out samples yet, so this
+ /// only has to acknowledge the job instead of failing the batch.
+ ///
+ [SysAbiExport(
+ Nid = "SkEwpiu3tZg",
+ ExportName = "sceAjmBatchJobSetGaplessDecode",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAjm")]
+ public static int AjmBatchJobSetGaplessDecode(CpuContext ctx)
{
var infoAddress = ctx[CpuRegister.Rdi];
var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
- var inputAddress = ctx[CpuRegister.Rdx];
- var inputSize = ctx[CpuRegister.Rcx];
- var outputAddress = ctx[CpuRegister.R8];
- var outputSize = ctx[CpuRegister.R9];
- var resultAddress = ReadStackArg64(ctx, 0);
+ var gaplessAddress = ctx[CpuRegister.Rdx];
+ var resultAddress = ctx[CpuRegister.Rcx];
- if (infoAddress == 0)
+ if (!TryAppendBatchJob(ctx, infoAddress, AjmJobControlSize))
{
- return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
+ return ctx.SetReturn(OrbisAjmErrorJobCreation);
}
- _ = TryAppendBatchJob(ctx, infoAddress, AjmJobRunSize);
+ var status = TryGetInstance(instanceId, out _) ? 0 : Atrac9DecodeState.ResultInvalidParameter;
+ WriteBasicResult(ctx, resultAddress, status);
+ Trace($"batch_job_set_gapless_decode instance=0x{instanceId:X8} gapless=0x{gaplessAddress:X16} status=0x{status:X8}");
+ return ctx.SetReturn(0);
+ }
- var inputConsumed = 0;
- var outputWritten = 0;
- ulong totalSamples = 0;
- var frames = 0u;
- var decoded = false;
+ [SysAbiExport(
+ Nid = "3cAg7xN995U",
+ ExportName = "sceAjmBatchJobGetStatistics",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceAjm")]
+ public static int AjmBatchJobGetStatistics(CpuContext ctx)
+ {
+ var infoAddress = ctx[CpuRegister.Rdi];
+ var resultAddress = ctx[CpuRegister.Rsi];
- if (TryGetInstance(instanceId, out var instance) &&
- instance.Mp3 is not null &&
- inputAddress != 0 &&
- inputSize is > 0 and <= MaxSilentPcmBytes &&
+ if (!TryAppendBatchJob(ctx, infoAddress, AjmJobGetStatisticsSize))
+ {
+ return ctx.SetReturn(OrbisAjmErrorJobCreation);
+ }
+
+ if (resultAddress != 0)
+ {
+ Span result = stackalloc byte[AjmStatisticsResultBytes];
+ result.Clear();
+ if (!ctx.Memory.TryWrite(resultAddress, result))
+ {
+ return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
+ }
+ }
+
+ ctx.GetXmmRegister(0, out var intervalBits, out _);
+ var interval = BitConverter.Int32BitsToSingle(unchecked((int)(uint)intervalBits));
+ Trace($"batch_job_get_statistics interval={interval:R} result=0x{resultAddress:X16}");
+ return ctx.SetReturn(0);
+ }
+
+
+ ///
+ /// Decodes one MP3 job into the guest's output buffer. Reported through the
+ /// same result shape as ATRAC9 so the sideband writer stays codec-agnostic.
+ ///
+ private static Atrac9DecodeResult DecodeMp3(
+ CpuContext ctx,
+ AjmInstanceState instance,
+ ulong inputAddress,
+ ulong inputSize,
+ ulong outputAddress,
+ ulong outputSize)
+ {
+ var mp3 = instance.Mp3!;
+ if (inputAddress != 0 &&
+ inputSize is > 0 and <= MaxDecodeBufferBytes &&
outputAddress != 0 &&
- outputSize is > 0 and <= MaxSilentPcmBytes)
+ outputSize is > 0 and <= MaxDecodeBufferBytes)
{
var input = new byte[inputSize];
var output = new byte[outputSize];
if (ctx.Memory.TryRead(inputAddress, input))
{
- var result = instance.Mp3.Decode(input, output, pcm16: instance.PreferPcm16);
- if (result.OutputWritten > 0)
+ var decoded = mp3.Decode(input, output, pcm16: instance.PreferPcm16);
+ if (decoded.OutputWritten > 0 &&
+ ctx.Memory.TryWrite(outputAddress, output.AsSpan(0, decoded.OutputWritten)))
{
- if (!ctx.Memory.TryWrite(outputAddress, output.AsSpan(0, result.OutputWritten)))
- {
- return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
- }
-
// Zero any remainder so stale PCM does not leak into FMOD.
- if ((ulong)result.OutputWritten < outputSize)
+ if ((ulong)decoded.OutputWritten < outputSize)
{
ClearGuestMemory(
ctx,
- outputAddress + (ulong)result.OutputWritten,
- outputSize - (ulong)result.OutputWritten);
+ outputAddress + (ulong)decoded.OutputWritten,
+ outputSize - (ulong)decoded.OutputWritten);
}
- decoded = true;
- inputConsumed = result.InputConsumed;
- outputWritten = result.OutputWritten;
- frames = result.Frames;
- totalSamples = instance.Mp3.TotalDecodedSamples;
+ return new Atrac9DecodeResult(
+ 0,
+ decoded.InputConsumed,
+ decoded.OutputWritten,
+ mp3.TotalDecodedSamples,
+ decoded.Frames);
}
- else
+
+ if (decoded.InputConsumed > 0)
{
- inputConsumed = result.InputConsumed;
- totalSamples = instance.Mp3.TotalDecodedSamples;
+ ClearGuestMemory(ctx, outputAddress, outputSize);
+ return new Atrac9DecodeResult(
+ 0,
+ decoded.InputConsumed,
+ 0,
+ mp3.TotalDecodedSamples,
+ 0);
}
}
}
- if (!decoded)
+ // Fallback: silence and consume the input so the guest does not spin.
+ if (outputAddress != 0 && outputSize is > 0 and <= MaxDecodeBufferBytes)
{
- // Fallback: silence + consume input so the guest does not spin.
- if (outputAddress != 0 && outputSize != 0 && outputSize <= MaxSilentPcmBytes)
- {
- ClearGuestMemory(ctx, outputAddress, outputSize);
- }
-
- if (inputConsumed == 0)
- {
- inputConsumed = inputSize > int.MaxValue ? int.MaxValue : (int)inputSize;
- }
-
- if (frames == 0 && (inputSize != 0 || outputSize != 0))
- {
- frames = 1;
- }
+ ClearGuestMemory(ctx, outputAddress, outputSize);
}
- WriteDecodeStreamResult(
- ctx,
- resultAddress,
- inputConsumed,
- outputWritten,
- totalSamples,
- frames);
-
- Trace(
- $"batch_job_decode info=0x{infoAddress:X16} instance=0x{instanceId:X8} " +
- $"in=0x{inputAddress:X16}+0x{inputSize:X} out=0x{outputAddress:X16}+0x{outputSize:X} " +
- $"written={outputWritten} frames={frames} result=0x{resultAddress:X16}");
- return ctx.SetReturn(0);
+ return new Atrac9DecodeResult(
+ 0,
+ unchecked((int)Math.Min(inputSize, int.MaxValue)),
+ 0,
+ mp3.TotalDecodedSamples,
+ inputSize != 0 || outputSize != 0 ? 1u : 0u);
}
private static bool TryGetInstance(uint instanceId, out AjmInstanceState instance)
@@ -458,6 +759,476 @@ public static class AjmExports
return ctx.SetReturn(0);
}
+ private static int AjmBatchJobDecodeCore(CpuContext ctx, bool multipleFrames)
+ {
+ var infoAddress = ctx[CpuRegister.Rdi];
+ var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
+ var inputAddress = ctx[CpuRegister.Rdx];
+ var inputSize = ctx[CpuRegister.Rcx];
+ var outputAddress = ctx[CpuRegister.R8];
+ var outputSize = ctx[CpuRegister.R9];
+ var resultAddress = ReadStackArg64(ctx, 0);
+
+ if (!TryAppendBatchJob(ctx, infoAddress, AjmJobRunSize))
+ {
+ return ctx.SetReturn(OrbisAjmErrorJobCreation);
+ }
+
+ Atrac9DecodeResult result;
+ if (!TryGetInstance(instanceId, out var instance))
+ {
+ result = new Atrac9DecodeResult(
+ Atrac9DecodeState.ResultInvalidParameter,
+ 0,
+ 0,
+ 0,
+ 0);
+ }
+ else if (instance.Mp3 is not null)
+ {
+ // GTA V Enhanced streams menu music through AJM MP3 (codec 0).
+ // Decoded eagerly here so BatchStart/Wait stay synchronous no-ops.
+ result = DecodeMp3(
+ ctx,
+ instance,
+ inputAddress,
+ inputSize,
+ outputAddress,
+ outputSize);
+ }
+ else if (instance.Codec != Atrac9CodecType || instance.Atrac9 is null)
+ {
+ if (outputAddress != 0 &&
+ outputSize is > 0 and <= MaxDecodeBufferBytes)
+ {
+ ClearGuestMemory(ctx, outputAddress, outputSize);
+ }
+
+ result = new Atrac9DecodeResult(
+ 0,
+ unchecked((int)Math.Min(inputSize, int.MaxValue)),
+ 0,
+ 0,
+ inputSize != 0 || outputSize != 0 ? 1u : 0u);
+ }
+ else
+ {
+ result = DecodeAtrac9(
+ ctx,
+ instance,
+ inputAddress,
+ inputSize,
+ outputAddress,
+ outputSize,
+ multipleFrames);
+ }
+
+ WriteDecodeStreamResult(ctx, resultAddress, result, multipleFrames);
+ Trace(
+ $"batch_job_decode instance=0x{instanceId:X8} in=0x{inputAddress:X16}+0x{inputSize:X} " +
+ $"out=0x{outputAddress:X16}+0x{outputSize:X} consumed={result.InputConsumed} " +
+ $"produced={result.OutputWritten} frames={result.Frames} status=0x{result.Status:X8}");
+ return ctx.SetReturn(0);
+ }
+
+ private static int TraceInstanceCreateFailure(
+ CpuContext ctx,
+ uint contextId,
+ uint codecType,
+ ulong flags,
+ int error)
+ {
+ Trace($"instance_create_failed context={contextId} codec={codecType} flags=0x{flags:X} error=0x{unchecked((uint)error):X8}");
+ return ctx.SetReturn(error);
+ }
+
+ private readonly record struct AjmGuestBuffer(ulong Address, int Length);
+
+ ///
+ /// Shared body of sceAjmBatchJobRun / sceAjmBatchJobRunSplit. The split form
+ /// passes arrays of AjmBuffer descriptors instead of one pointer/size pair;
+ /// the descriptors are a single logical stream, so they are gathered on the
+ /// way in and scattered on the way out.
+ ///
+ private static int AjmBatchJobRunCore(CpuContext ctx, bool split)
+ {
+ var infoAddress = ctx[CpuRegister.Rdi];
+ var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
+ var flags = ctx[CpuRegister.Rdx];
+ var inputAddress = ctx[CpuRegister.Rcx];
+ var inputCountOrSize = ctx[CpuRegister.R8];
+ var outputAddress = ctx[CpuRegister.R9];
+ var outputCountOrSize = ReadStackArg64(ctx, 0);
+ var sidebandAddress = ReadStackArg64(ctx, 1);
+ var sidebandSize = ReadStackArg64(ctx, 2);
+
+ var descriptors = split
+ ? Math.Min(inputCountOrSize, MaxBufferDescriptors) + Math.Min(outputCountOrSize, MaxBufferDescriptors)
+ : 0;
+ if (!TryAppendBatchJob(ctx, infoAddress, AjmJobRunSize + (descriptors * AjmBufferDescriptorBytes)))
+ {
+ return ctx.SetReturn(OrbisAjmErrorJobCreation);
+ }
+
+ var multipleFrames = (flags & AjmJobRunFlagMultipleFrames) != 0;
+ Atrac9Config? config = null;
+ Atrac9DecodeResult result;
+
+ if (!TryGetInstance(instanceId, out var instance))
+ {
+ result = new Atrac9DecodeResult(Atrac9DecodeState.ResultInvalidParameter, 0, 0, 0, 0);
+ }
+ else if (!TryCollectBuffers(ctx, split, inputAddress, inputCountOrSize, out var inputs, out var inputLength) ||
+ !TryCollectBuffers(ctx, split, outputAddress, outputCountOrSize, out var outputs, out var outputLength))
+ {
+ result = new Atrac9DecodeResult(Atrac9DecodeState.ResultInvalidParameter, 0, 0, 0, 0);
+ }
+ else if (instance.Codec != Atrac9CodecType || instance.Atrac9 is null)
+ {
+ foreach (var buffer in outputs)
+ {
+ ClearGuestMemory(ctx, buffer.Address, (ulong)buffer.Length);
+ }
+
+ result = new Atrac9DecodeResult(
+ 0,
+ inputLength,
+ 0,
+ 0,
+ inputLength != 0 || outputLength != 0 ? 1u : 0u);
+ }
+ else
+ {
+ config = instance.Atrac9.Config;
+ result = DecodeAtrac9Scattered(ctx, instance, inputs, inputLength, outputs, outputLength, multipleFrames);
+ config ??= instance.Atrac9.Config;
+ }
+
+ WriteRunSideband(ctx, sidebandAddress, sidebandSize, flags, result, instance, config);
+ Trace(
+ $"batch_job_run{(split ? "_split" : string.Empty)} instance=0x{instanceId:X8} flags=0x{flags:X} " +
+ $"in=0x{inputAddress:X16}#{inputCountOrSize} out=0x{outputAddress:X16}#{outputCountOrSize} " +
+ $"sideband=0x{sidebandAddress:X16}+0x{sidebandSize:X} consumed={result.InputConsumed} " +
+ $"produced={result.OutputWritten} frames={result.Frames} status=0x{result.Status:X8}");
+ TraceBufferDescriptors(ctx, split, "in", inputAddress, inputCountOrSize);
+ TraceBufferDescriptors(ctx, split, "out", outputAddress, outputCountOrSize);
+ return ctx.SetReturn(0);
+ }
+
+ private static void TraceBufferDescriptors(
+ CpuContext ctx,
+ bool split,
+ string label,
+ ulong address,
+ ulong countOrSize)
+ {
+ if (!split ||
+ address == 0 ||
+ countOrSize == 0 ||
+ countOrSize > MaxBufferDescriptors ||
+ !string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AJM"), "1", StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ Span descriptor = stackalloc byte[AjmBufferDescriptorBytes];
+ // Hoisted out of the loop: a stackalloc per iteration accumulates across
+ // up to MaxBufferDescriptors passes and never unwinds until the method
+ // returns.
+ Span head = stackalloc byte[16];
+ for (var index = 0UL; index < countOrSize; index++)
+ {
+ if (!ctx.Memory.TryRead(address + (index * AjmBufferDescriptorBytes), descriptor))
+ {
+ return;
+ }
+
+ var bufferAddress = BinaryPrimitives.ReadUInt64LittleEndian(descriptor);
+ var bufferSize = BinaryPrimitives.ReadUInt64LittleEndian(descriptor[8..]);
+ var readable = bufferAddress != 0 && bufferSize != 0 && ctx.Memory.TryRead(bufferAddress, head);
+ Trace(
+ $"buffer[{label}][{index}] address=0x{bufferAddress:X16} size=0x{bufferSize:X} " +
+ $"head={(readable ? Convert.ToHexString(head) : "")}");
+ }
+ }
+
+ private static bool TryCollectBuffers(
+ CpuContext ctx,
+ bool split,
+ ulong address,
+ ulong countOrSize,
+ out List buffers,
+ out int totalLength)
+ {
+ buffers = new List(split ? (int)Math.Min(countOrSize, MaxBufferDescriptors) : 1);
+ totalLength = 0;
+
+ if (!split)
+ {
+ if (countOrSize > MaxDecodeBufferBytes || (countOrSize != 0 && address == 0))
+ {
+ return false;
+ }
+
+ if (countOrSize != 0)
+ {
+ buffers.Add(new AjmGuestBuffer(address, unchecked((int)countOrSize)));
+ totalLength = unchecked((int)countOrSize);
+ }
+
+ return true;
+ }
+
+ if (countOrSize > MaxBufferDescriptors || (countOrSize != 0 && address == 0))
+ {
+ return false;
+ }
+
+ Span descriptor = stackalloc byte[AjmBufferDescriptorBytes];
+ for (var index = 0UL; index < countOrSize; index++)
+ {
+ if (!ctx.Memory.TryRead(address + (index * AjmBufferDescriptorBytes), descriptor))
+ {
+ return false;
+ }
+
+ var bufferAddress = BinaryPrimitives.ReadUInt64LittleEndian(descriptor);
+ var bufferSize = BinaryPrimitives.ReadUInt64LittleEndian(descriptor[8..]);
+ if (bufferSize == 0)
+ {
+ continue;
+ }
+
+ if (bufferAddress == 0 ||
+ bufferSize > MaxDecodeBufferBytes ||
+ (ulong)totalLength + bufferSize > MaxDecodeBufferBytes)
+ {
+ return false;
+ }
+
+ buffers.Add(new AjmGuestBuffer(bufferAddress, unchecked((int)bufferSize)));
+ totalLength += unchecked((int)bufferSize);
+ }
+
+ return true;
+ }
+
+ private static Atrac9DecodeResult DecodeAtrac9Scattered(
+ CpuContext ctx,
+ AjmInstanceState instance,
+ List inputs,
+ int inputLength,
+ List outputs,
+ int outputLength,
+ bool multipleFrames)
+ {
+ var input = ArrayPool.Shared.Rent(Math.Max(inputLength, 1));
+ var output = ArrayPool.Shared.Rent(Math.Max(outputLength, 1));
+ try
+ {
+ var gathered = 0;
+ foreach (var buffer in inputs)
+ {
+ if (!ctx.Memory.TryRead(buffer.Address, input.AsSpan(gathered, buffer.Length)))
+ {
+ return new Atrac9DecodeResult(Atrac9DecodeState.ResultInvalidParameter, 0, 0, 0, 0);
+ }
+
+ gathered += buffer.Length;
+ }
+
+ var result = instance.Atrac9!.Decode(
+ input.AsSpan(0, inputLength),
+ output.AsSpan(0, outputLength),
+ instance.Encoding,
+ requestedChannels: 0,
+ multipleFrames);
+
+ var scattered = 0;
+ foreach (var buffer in outputs)
+ {
+ if (scattered >= result.OutputWritten)
+ {
+ break;
+ }
+
+ var chunk = Math.Min(buffer.Length, result.OutputWritten - scattered);
+ if (!ctx.Memory.TryWrite(buffer.Address, output.AsSpan(scattered, chunk)))
+ {
+ return result with
+ {
+ Status = result.Status | Atrac9DecodeState.ResultInvalidParameter,
+ OutputWritten = scattered,
+ };
+ }
+
+ scattered += chunk;
+ }
+
+ return result;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(input);
+ ArrayPool.Shared.Return(output);
+ }
+ }
+
+ private static void WriteRunSideband(
+ CpuContext ctx,
+ ulong address,
+ ulong size,
+ ulong flags,
+ Atrac9DecodeResult result,
+ AjmInstanceState? instance,
+ Atrac9Config? config)
+ {
+ if (address == 0 || size < AjmSidebandResultBytes)
+ {
+ return;
+ }
+
+ // Layout is positional and driven purely by the job flags:
+ // Result, then Stream, Format, GaplessDecode, MFrame, CodecInfo — each
+ // only present when its flag is set and there is still room.
+ Span sideband = stackalloc byte[
+ AjmSidebandResultBytes +
+ AjmSidebandStreamBytes +
+ AjmSidebandFormatBytes +
+ AjmSidebandGaplessDecodeBytes +
+ AjmSidebandMFrameBytes];
+ sideband.Clear();
+
+ BinaryPrimitives.WriteInt32LittleEndian(sideband, result.Status);
+ var offset = AjmSidebandResultBytes;
+
+ if ((flags & AjmJobSidebandFlagStream) != 0 && (ulong)(offset + AjmSidebandStreamBytes) <= size)
+ {
+ BinaryPrimitives.WriteInt32LittleEndian(sideband[offset..], result.InputConsumed);
+ BinaryPrimitives.WriteInt32LittleEndian(sideband[(offset + 4)..], result.OutputWritten);
+ BinaryPrimitives.WriteUInt64LittleEndian(sideband[(offset + 8)..], result.TotalDecodedSamples);
+ offset += AjmSidebandStreamBytes;
+ }
+
+ if ((flags & AjmJobSidebandFlagFormat) != 0 && (ulong)(offset + AjmSidebandFormatBytes) <= size)
+ {
+ var channels = config?.ChannelCount ?? instance?.MaxChannels ?? 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(sideband[offset..], unchecked((uint)channels));
+ BinaryPrimitives.WriteUInt32LittleEndian(sideband[(offset + 4)..], ChannelMaskFor(channels));
+ BinaryPrimitives.WriteUInt32LittleEndian(sideband[(offset + 8)..], unchecked((uint)(config?.SampleRate ?? 0)));
+ BinaryPrimitives.WriteUInt32LittleEndian(
+ sideband[(offset + 12)..],
+ unchecked((uint)(instance?.Encoding ?? Atrac9PcmEncoding.Signed16)));
+ offset += AjmSidebandFormatBytes;
+ }
+
+ if ((flags & AjmJobSidebandFlagGaplessDecode) != 0 && (ulong)(offset + AjmSidebandGaplessDecodeBytes) <= size)
+ {
+ offset += AjmSidebandGaplessDecodeBytes;
+ }
+
+ if ((flags & AjmJobRunFlagMultipleFrames) != 0 && (ulong)(offset + AjmSidebandMFrameBytes) <= size)
+ {
+ BinaryPrimitives.WriteUInt32LittleEndian(sideband[offset..], result.Frames);
+ offset += AjmSidebandMFrameBytes;
+ }
+
+ _ = ctx.Memory.TryWrite(address, sideband[..offset]);
+
+ if ((flags & AjmJobRunFlagGetCodecInfo) != 0 && (ulong)offset < size)
+ {
+ // No codec-info block is produced yet; zero it so the caller reads
+ // defined values instead of whatever the buffer held before.
+ ClearGuestMemory(
+ ctx,
+ address + (ulong)offset,
+ Math.Min(size - (ulong)offset, AjmSidebandCodecInfoBytes));
+ }
+ }
+
+ private static uint ChannelMaskFor(int channels) =>
+ channels switch
+ {
+ 1 => 0x4,
+ 2 => 0x3,
+ 6 => 0x3F,
+ 8 => 0x63F,
+ _ => 0,
+ };
+
+ private static Atrac9DecodeResult DecodeAtrac9(
+ CpuContext ctx,
+ AjmInstanceState instance,
+ ulong inputAddress,
+ ulong inputSize,
+ ulong outputAddress,
+ ulong outputSize,
+ bool multipleFrames)
+ {
+ if ((inputSize != 0 && inputAddress == 0) ||
+ (outputSize != 0 && outputAddress == 0) ||
+ inputSize > MaxDecodeBufferBytes ||
+ outputSize > MaxDecodeBufferBytes)
+ {
+ return new Atrac9DecodeResult(
+ Atrac9DecodeState.ResultInvalidParameter,
+ 0,
+ 0,
+ 0,
+ 0);
+ }
+
+ var inputLength = unchecked((int)inputSize);
+ var outputLength = unchecked((int)outputSize);
+ var input = ArrayPool.Shared.Rent(Math.Max(inputLength, 1));
+ var output = ArrayPool.Shared.Rent(Math.Max(outputLength, 1));
+ try
+ {
+ if (inputLength != 0 &&
+ !ctx.Memory.TryRead(inputAddress, input.AsSpan(0, inputLength)))
+ {
+ return new Atrac9DecodeResult(
+ Atrac9DecodeState.ResultInvalidParameter,
+ 0,
+ 0,
+ 0,
+ 0);
+ }
+
+ var result = instance.Atrac9!.Decode(
+ input.AsSpan(0, inputLength),
+ output.AsSpan(0, outputLength),
+ instance.Encoding,
+ requestedChannels: 0,
+ multipleFrames);
+
+ if (result.OutputWritten != 0 &&
+ !ctx.Memory.TryWrite(outputAddress, output.AsSpan(0, result.OutputWritten)))
+ {
+ return result with
+ {
+ Status = result.Status | Atrac9DecodeState.ResultInvalidParameter,
+ OutputWritten = 0,
+ };
+ }
+
+ return result;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(input);
+ ArrayPool.Shared.Return(output);
+ }
+ }
+
+ private static Atrac9PcmEncoding GetPcmEncoding(ulong flags) =>
+ ((flags >> 7) & 0x7) switch
+ {
+ 1 => Atrac9PcmEncoding.Signed32,
+ 2 => Atrac9PcmEncoding.Float,
+ _ => Atrac9PcmEncoding.Signed16,
+ };
+
internal static void ResetForTests()
{
Contexts.Clear();
@@ -466,14 +1237,24 @@ public static class AjmExports
}
// AjmBatchInfo: buffer, offset, size, last_good_job, last_good_job_ra (5× u64).
+ private const int AjmBatchInfoBytes = 40;
private const ulong AjmBatchInfoOffsetField = 8;
private const ulong AjmBatchInfoSizeField = 16;
private const ulong AjmBatchInfoLastGoodJobField = 24;
+ private const ulong AjmBatchInfoLastGoodJobRaField = 32;
+ private const ulong AjmJobControlSize = 48;
private const ulong AjmJobRunSize = 64;
- private const int OrbisAjmErrorJobCreation = unchecked((int)0x80930012);
- private const ulong MaxSilentPcmBytes = 1 << 20;
+ private const ulong AjmJobGetStatisticsSize = 88;
+ private const int AjmStatisticsResultBytes = 48;
+ private const int AjmSidebandResultBytes = 8;
+ private const int AjmSidebandStreamBytes = 16;
+ private const int AjmSidebandFormatBytes = 24;
+ private const int AjmSidebandGaplessDecodeBytes = 8;
+ private const int AjmSidebandMFrameBytes = 8;
+ private const int AjmSidebandCodecInfoBytes = 64;
// AjmSidebandResult (8) + AjmSidebandStream (16) + AjmSidebandMFrame (8).
- private const int DecodeSidebandBytes = 32;
+ private const int DecodeSidebandBytes =
+ AjmSidebandResultBytes + AjmSidebandStreamBytes + AjmSidebandMFrameBytes;
private static bool TryAppendBatchJob(CpuContext ctx, ulong infoAddress, ulong jobSize)
{
@@ -492,6 +1273,7 @@ public static class AjmExports
var jobAddress = buffer + offset;
ClearGuestMemory(ctx, jobAddress, jobSize);
return TryWriteUInt64(ctx, infoAddress + AjmBatchInfoLastGoodJobField, jobAddress) &&
+ TryWriteUInt64(ctx, infoAddress + AjmBatchInfoLastGoodJobRaField, 0) &&
TryWriteUInt64(ctx, infoAddress + AjmBatchInfoOffsetField, offset + jobSize);
}
@@ -513,10 +1295,8 @@ public static class AjmExports
private static void WriteDecodeStreamResult(
CpuContext ctx,
ulong resultAddress,
- int inputConsumed,
- int outputWritten,
- ulong totalDecodedSamples,
- uint frames)
+ Atrac9DecodeResult result,
+ bool multipleFrames)
{
if (resultAddress == 0)
{
@@ -525,12 +1305,31 @@ public static class AjmExports
Span sideband = stackalloc byte[DecodeSidebandBytes];
sideband.Clear();
- // AjmSidebandResult.result / internal_result = 0 (OK)
- BinaryPrimitives.WriteInt32LittleEndian(sideband.Slice(8, 4), inputConsumed);
- BinaryPrimitives.WriteInt32LittleEndian(sideband.Slice(12, 4), outputWritten);
- BinaryPrimitives.WriteUInt64LittleEndian(sideband.Slice(16, 8), totalDecodedSamples);
- BinaryPrimitives.WriteUInt32LittleEndian(sideband.Slice(24, 4), frames);
- _ = ctx.Memory.TryWrite(resultAddress, sideband);
+ BinaryPrimitives.WriteInt32LittleEndian(sideband, result.Status);
+ BinaryPrimitives.WriteInt32LittleEndian(sideband[8..], result.InputConsumed);
+ BinaryPrimitives.WriteInt32LittleEndian(sideband[12..], result.OutputWritten);
+ BinaryPrimitives.WriteUInt64LittleEndian(sideband[16..], result.TotalDecodedSamples);
+ if (multipleFrames)
+ {
+ BinaryPrimitives.WriteUInt32LittleEndian(sideband[24..], result.Frames);
+ }
+
+ _ = ctx.Memory.TryWrite(
+ resultAddress,
+ multipleFrames ? sideband : sideband[..24]);
+ }
+
+ private static void WriteBasicResult(CpuContext ctx, ulong resultAddress, int status)
+ {
+ if (resultAddress == 0)
+ {
+ return;
+ }
+
+ Span result = stackalloc byte[8];
+ result.Clear();
+ BinaryPrimitives.WriteInt32LittleEndian(result, status);
+ _ = ctx.Memory.TryWrite(resultAddress, result);
}
private static void ClearGuestMemory(CpuContext ctx, ulong address, ulong byteCount)
diff --git a/src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs b/src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs
new file mode 100644
index 00000000..6f02d3bc
--- /dev/null
+++ b/src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs
@@ -0,0 +1,409 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using LibAtrac9;
+
+namespace SharpEmu.Libs.Audio;
+
+internal enum Atrac9PcmEncoding
+{
+ Signed16,
+ Signed32,
+ Float,
+}
+
+internal readonly record struct Atrac9DecodeResult(
+ int Status,
+ int InputConsumed,
+ int OutputWritten,
+ ulong TotalDecodedSamples,
+ uint Frames);
+
+internal sealed class Atrac9DecodeState
+{
+ internal const int ResultNotInitialized = 0x00000001;
+ internal const int ResultInvalidData = 0x00000002;
+ internal const int ResultInvalidParameter = 0x00000004;
+ internal const int ResultPartialInput = 0x00000008;
+ internal const int ResultNotEnoughRoom = 0x00000010;
+ internal const int ResultCodecError = 0x40000000;
+
+ private const int MaxContainerHeaderBytes = 8 * 1024;
+
+ private enum ContainerScan
+ {
+ NotContainer,
+ NeedMoreData,
+ Found,
+ }
+
+ private readonly object _gate = new();
+ private Atrac9Decoder? _decoder;
+ private byte[]? _configData;
+ private byte[]? _compressed;
+ private short[][]? _planarPcm;
+ private byte[]? _containerHeader;
+ private int _containerHeaderLength;
+ private int _compressedLength;
+ private ulong _totalDecodedSamples;
+
+ public Atrac9Config? Config
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _decoder?.Config;
+ }
+ }
+ }
+
+ public bool TryInitialize(ReadOnlySpan configData)
+ {
+ if (configData.Length < 4)
+ {
+ return false;
+ }
+
+ lock (_gate)
+ {
+ try
+ {
+ var normalizedConfig = configData[..4].ToArray();
+ var decoder = new Atrac9Decoder();
+ decoder.Initialize(normalizedConfig);
+ var config = decoder.Config;
+
+ _decoder = decoder;
+ _configData = normalizedConfig;
+ _compressed = new byte[config.SuperframeBytes];
+ _planarPcm = CreatePcmBuffer(config.ChannelCount, config.SuperframeSamples);
+ _compressedLength = 0;
+ _totalDecodedSamples = 0;
+ _containerHeaderLength = 0;
+ Trace(
+ $"initialized config={Convert.ToHexString(normalizedConfig)} channels={config.ChannelCount} " +
+ $"rate={config.SampleRate} frame_samples={config.FrameSamples} " +
+ $"superframe_samples={config.SuperframeSamples} superframe_bytes={config.SuperframeBytes} " +
+ $"frames_per_superframe={config.FramesPerSuperframe}");
+ return true;
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException or InvalidDataException or InvalidOperationException)
+ {
+ Clear();
+ return false;
+ }
+ }
+ }
+
+ public void Reset()
+ {
+ lock (_gate)
+ {
+ if (_configData is null)
+ {
+ Clear();
+ return;
+ }
+
+ var decoder = new Atrac9Decoder();
+ decoder.Initialize((byte[])_configData.Clone());
+ _decoder = decoder;
+ _compressedLength = 0;
+ _totalDecodedSamples = 0;
+ _containerHeaderLength = 0;
+ if (_compressed is not null)
+ {
+ Array.Clear(_compressed);
+ }
+ }
+ }
+
+ public Atrac9DecodeResult Decode(
+ ReadOnlySpan input,
+ Span output,
+ Atrac9PcmEncoding encoding,
+ int requestedChannels,
+ bool multipleFrames)
+ {
+ lock (_gate)
+ {
+ if (_decoder is null || _compressed is null || _planarPcm is null)
+ {
+ return new Atrac9DecodeResult(
+ ResultNotInitialized,
+ 0,
+ 0,
+ _totalDecodedSamples,
+ 0);
+ }
+
+ var config = _decoder.Config;
+ var channels = requestedChannels > 0 ? requestedChannels : config.ChannelCount;
+ if (channels is < 1 or > 16)
+ {
+ return new Atrac9DecodeResult(
+ ResultInvalidParameter,
+ 0,
+ 0,
+ _totalDecodedSamples,
+ 0);
+ }
+
+ var bytesPerSample = GetBytesPerSample(encoding);
+ var outputBytesPerSuperframe = checked(config.SuperframeSamples * channels * bytesPerSample);
+ var consumed = 0;
+ var written = 0;
+ uint frames = 0;
+ var status = 0;
+
+ while (_compressedLength == config.SuperframeBytes ||
+ consumed < input.Length)
+ {
+ // Titles that stream whole .at9 files hand AJM the RIFF/WAVE
+ // container rather than a pointer into its `data` chunk, so the
+ // stream has to be advanced past the header before the first
+ // superframe — otherwise every job fails with invalid data and
+ // the title drops the voice. This is checked at every superframe
+ // boundary, not just after initialize, because a looping voice
+ // rewinds to the file header without reinitialising.
+ if (_compressedLength == 0 && (_containerHeaderLength != 0 || consumed < input.Length))
+ {
+ var scan = ScanContainerHeader(input[consumed..], out var headerBytes);
+ if (scan == ContainerScan.NeedMoreData)
+ {
+ consumed = input.Length;
+ status |= ResultPartialInput;
+ break;
+ }
+
+ if (scan == ContainerScan.Found)
+ {
+ _containerHeader = null;
+ _containerHeaderLength = 0;
+ consumed += headerBytes;
+ continue;
+ }
+ }
+
+ if (_compressedLength < config.SuperframeBytes)
+ {
+ var copied = Math.Min(config.SuperframeBytes - _compressedLength, input.Length - consumed);
+ input.Slice(consumed, copied).CopyTo(_compressed.AsSpan(_compressedLength));
+ _compressedLength += copied;
+ consumed += copied;
+ }
+
+ if (_compressedLength < config.SuperframeBytes)
+ {
+ status |= ResultPartialInput;
+ break;
+ }
+
+ if (output.Length - written < outputBytesPerSuperframe)
+ {
+ status |= ResultNotEnoughRoom;
+ break;
+ }
+
+ try
+ {
+ _decoder.Decode(_compressed, _planarPcm);
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException or InvalidDataException or InvalidOperationException or IndexOutOfRangeException)
+ {
+ Trace(
+ $"decode_failed superframe_bytes={config.SuperframeBytes} " +
+ $"config={Convert.ToHexString(_configData ?? [])} " +
+ $"head={Convert.ToHexString(_compressed.AsSpan(0, Math.Min(16, _compressed.Length)))} " +
+ $"error={exception.GetType().Name}: {exception.Message}");
+ _compressedLength = 0;
+ return new Atrac9DecodeResult(
+ status | ResultInvalidData | ResultCodecError,
+ consumed,
+ written,
+ _totalDecodedSamples,
+ frames);
+ }
+
+ WriteInterleaved(
+ _planarPcm,
+ output.Slice(written, outputBytesPerSuperframe),
+ config.SuperframeSamples,
+ channels,
+ encoding);
+
+ written += outputBytesPerSuperframe;
+ _compressedLength = 0;
+ _totalDecodedSamples += unchecked((uint)config.SuperframeSamples);
+ frames += unchecked((uint)config.FramesPerSuperframe);
+
+ if (!multipleFrames)
+ {
+ break;
+ }
+ }
+
+ return new Atrac9DecodeResult(
+ status,
+ consumed,
+ written,
+ _totalDecodedSamples,
+ frames);
+ }
+ }
+
+ private ContainerScan ScanContainerHeader(ReadOnlySpan input, out int inputHeaderBytes)
+ {
+ inputHeaderBytes = 0;
+
+ if (_containerHeaderLength == 0 &&
+ (input.Length < 4 || !input[..4].SequenceEqual("RIFF"u8)))
+ {
+ return ContainerScan.NotContainer;
+ }
+
+ _containerHeader ??= new byte[MaxContainerHeaderBytes];
+ var previousLength = _containerHeaderLength;
+ var copied = Math.Min(input.Length, MaxContainerHeaderBytes - previousLength);
+ input[..copied].CopyTo(_containerHeader.AsSpan(previousLength));
+ var totalLength = previousLength + copied;
+
+ if (!TryFindRiffDataOffset(_containerHeader.AsSpan(0, totalLength), out var dataOffset))
+ {
+ if (totalLength >= MaxContainerHeaderBytes)
+ {
+ // Not a shape we understand — fall back to treating the stream
+ // as raw superframes rather than swallowing it forever. The
+ // scratch is dropped without advancing the caller's cursor, so
+ // the bytes are still decoded normally.
+ Trace($"container_scan_gave_up bytes={totalLength}");
+ _containerHeader = null;
+ _containerHeaderLength = 0;
+ return ContainerScan.NotContainer;
+ }
+
+ _containerHeaderLength = totalLength;
+ return ContainerScan.NeedMoreData;
+ }
+
+ inputHeaderBytes = dataOffset - previousLength;
+ Trace($"container_header_skipped bytes={dataOffset} from_this_input={inputHeaderBytes}");
+ return ContainerScan.Found;
+ }
+
+ private static bool TryFindRiffDataOffset(ReadOnlySpan header, out int dataOffset)
+ {
+ dataOffset = 0;
+ if (header.Length < 12 ||
+ !header[..4].SequenceEqual("RIFF"u8) ||
+ !header.Slice(8, 4).SequenceEqual("WAVE"u8))
+ {
+ return false;
+ }
+
+ var offset = 12;
+ while (offset + 8 <= header.Length)
+ {
+ var chunkId = header.Slice(offset, 4);
+ var chunkSize = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(offset + 4, 4));
+ offset += 8;
+ if (chunkId.SequenceEqual("data"u8))
+ {
+ dataOffset = offset;
+ return true;
+ }
+
+ // RIFF chunks are word aligned.
+ var advance = chunkSize + (chunkSize & 1);
+ if (advance > (ulong)(int.MaxValue - offset))
+ {
+ return false;
+ }
+
+ offset += (int)advance;
+ }
+
+ return false;
+ }
+
+ private static short[][] CreatePcmBuffer(int channels, int samples)
+ {
+ var result = new short[channels][];
+ for (var channel = 0; channel < channels; channel++)
+ {
+ result[channel] = new short[samples];
+ }
+
+ return result;
+ }
+
+ private static int GetBytesPerSample(Atrac9PcmEncoding encoding) =>
+ encoding switch
+ {
+ Atrac9PcmEncoding.Signed16 => sizeof(short),
+ Atrac9PcmEncoding.Signed32 => sizeof(int),
+ Atrac9PcmEncoding.Float => sizeof(float),
+ _ => throw new ArgumentOutOfRangeException(nameof(encoding)),
+ };
+
+ private static void WriteInterleaved(
+ short[][] source,
+ Span destination,
+ int samples,
+ int channels,
+ Atrac9PcmEncoding encoding)
+ {
+ var offset = 0;
+ for (var sample = 0; sample < samples; sample++)
+ {
+ for (var channel = 0; channel < channels; channel++)
+ {
+ var sourceChannel = Math.Min(channel, source.Length - 1);
+ var value = source[sourceChannel][sample];
+ switch (encoding)
+ {
+ case Atrac9PcmEncoding.Signed16:
+ BinaryPrimitives.WriteInt16LittleEndian(destination[offset..], value);
+ offset += sizeof(short);
+ break;
+ case Atrac9PcmEncoding.Signed32:
+ BinaryPrimitives.WriteInt32LittleEndian(destination[offset..], value << 16);
+ offset += sizeof(int);
+ break;
+ case Atrac9PcmEncoding.Float:
+ BinaryPrimitives.WriteInt32LittleEndian(
+ destination[offset..],
+ BitConverter.SingleToInt32Bits(value / 32768.0f));
+ offset += sizeof(float);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(encoding));
+ }
+ }
+ }
+ }
+
+ private static void Trace(string message)
+ {
+ if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AJM"), "1", StringComparison.Ordinal))
+ {
+ Console.Error.WriteLine($"[LOADER][TRACE] ajm.at9.{message}");
+ }
+ }
+
+ private void Clear()
+ {
+ _decoder = null;
+ _configData = null;
+ _compressed = null;
+ _planarPcm = null;
+ _containerHeader = null;
+ _containerHeaderLength = 0;
+ _compressedLength = 0;
+ _totalDecodedSamples = 0;
+ }
+}
diff --git a/src/SharpEmu.Libs/Audio/AudioOutExports.cs b/src/SharpEmu.Libs/Audio/AudioOutExports.cs
index 85bed025..cf13faa1 100644
--- a/src/SharpEmu.Libs/Audio/AudioOutExports.cs
+++ b/src/SharpEmu.Libs/Audio/AudioOutExports.cs
@@ -44,6 +44,7 @@ public static class AudioOutExports
int channels,
int bytesPerSample,
bool isFloat,
+ bool preservesGuestFormat,
IHostAudioStream? backend)
{
UserId = userId;
@@ -54,6 +55,7 @@ public static class AudioOutExports
Channels = channels;
BytesPerSample = bytesPerSample;
IsFloat = isFloat;
+ PreservesGuestFormat = preservesGuestFormat;
Backend = backend;
}
@@ -65,6 +67,7 @@ public static class AudioOutExports
public int Channels { get; }
public int BytesPerSample { get; }
public bool IsFloat { get; }
+ public bool PreservesGuestFormat { get; }
public IHostAudioStream? Backend { get; }
public object SubmissionGate { get; } = new();
public volatile float Volume = 1.0f;
@@ -140,6 +143,7 @@ public static class AudioOutExports
}
IHostAudioStream? backend = null;
+ var preservesGuestFormat = false;
string backendName;
try
{
@@ -152,7 +156,18 @@ public static class AudioOutExports
else
{
var audio = HostPlatform.Current.Audio;
- backend = audio.OpenStereoPcm16Stream(frequency);
+ if (audio is IHostPcmAudioOutput pcmAudio)
+ {
+ backend = pcmAudio.OpenPcmStream(
+ frequency,
+ channels,
+ isFloat ? HostPcmFormat.Float32 : HostPcmFormat.Signed16);
+ preservesGuestFormat = true;
+ }
+ else
+ {
+ backend = audio.OpenStereoPcm16Stream(frequency);
+ }
backendName = audio.BackendName;
}
}
@@ -173,6 +188,7 @@ public static class AudioOutExports
channels,
bytesPerSample,
isFloat,
+ preservesGuestFormat,
backend);
Console.Error.WriteLine(
$"[LOADER][INFO] AudioOut port {handle}: {frequency} Hz, " +
@@ -321,18 +337,13 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
- var outputLength = checked((int)port.BufferLength * AudioPcmConversion.OutputFrameSize);
+ var outputLength = port.PreservesGuestFormat
+ ? port.BufferByteLength
+ : checked((int)port.BufferLength * AudioPcmConversion.OutputFrameSize);
var output = ArrayPool.Shared.Rent(outputLength);
try
{
- AudioPcmConversion.ConvertToStereoPcm16(
- source,
- output.AsSpan(0, outputLength),
- checked((int)port.BufferLength),
- port.Channels,
- port.BytesPerSample,
- port.IsFloat,
- port.Volume);
+ ConvertForHost(port, source, output.AsSpan(0, outputLength));
if (!port.Backend.Submit(output.AsSpan(0, outputLength)))
{
port.PaceSilence();
@@ -449,17 +460,11 @@ public static class AudioOutExports
TraceOutput(output.Handle, output.Port, source);
- output.HostBufferLength = checked(
- (int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize);
+ output.HostBufferLength = output.Port.PreservesGuestFormat
+ ? output.Port.BufferByteLength
+ : checked((int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize);
output.HostBuffer = ArrayPool.Shared.Rent(output.HostBufferLength);
- AudioPcmConversion.ConvertToStereoPcm16(
- source,
- output.HostBuffer.AsSpan(0, output.HostBufferLength),
- checked((int)output.Port.BufferLength),
- output.Port.Channels,
- output.Port.BytesPerSample,
- output.Port.IsFloat,
- output.Port.Volume);
+ ConvertForHost(output.Port, source, output.HostBuffer.AsSpan(0, output.HostBufferLength));
}
finally
{
@@ -513,6 +518,24 @@ public static class AudioOutExports
(ulong)candidate.BufferLength * current.Frequency >
(ulong)current.BufferLength * candidate.Frequency;
+ private static void ConvertForHost(PortState port, ReadOnlySpan source, Span destination)
+ {
+ if (port.PreservesGuestFormat)
+ {
+ AudioPcmConversion.CopyWithVolume(source, destination, port.IsFloat, port.Volume);
+ return;
+ }
+
+ AudioPcmConversion.ConvertToStereoPcm16(
+ source,
+ destination,
+ checked((int)port.BufferLength),
+ port.Channels,
+ port.BytesPerSample,
+ port.IsFloat,
+ port.Volume);
+ }
+
private static void TraceOutput(int handle, PortState port, ReadOnlySpan source)
{
if (!_traceOutput)
diff --git a/src/SharpEmu.Libs/Audio/AudioPcmConversion.cs b/src/SharpEmu.Libs/Audio/AudioPcmConversion.cs
index 6294402f..f560409e 100644
--- a/src/SharpEmu.Libs/Audio/AudioPcmConversion.cs
+++ b/src/SharpEmu.Libs/Audio/AudioPcmConversion.cs
@@ -43,6 +43,46 @@ internal static class AudioPcmConversion
}
}
+ ///
+ /// Copies interleaved PCM without changing its channel layout. SDL can convert
+ /// this directly to the physical device, which preserves surround mixes that
+ /// would otherwise be truncated to the first two guest channels.
+ ///
+ public static void CopyWithVolume(
+ ReadOnlySpan source,
+ Span destination,
+ bool isFloat,
+ float volume)
+ {
+ var clampedVolume = Math.Clamp(volume, 0.0f, 1.0f);
+ if (clampedVolume >= 1.0f)
+ {
+ source.CopyTo(destination);
+ return;
+ }
+
+ if (isFloat)
+ {
+ for (var offset = 0; offset < source.Length; offset += sizeof(float))
+ {
+ var sample = BinaryPrimitives.ReadSingleLittleEndian(source.Slice(offset, sizeof(float)));
+ BinaryPrimitives.WriteSingleLittleEndian(
+ destination.Slice(offset, sizeof(float)),
+ sample * clampedVolume);
+ }
+
+ return;
+ }
+
+ for (var offset = 0; offset < source.Length; offset += sizeof(short))
+ {
+ var sample = BinaryPrimitives.ReadInt16LittleEndian(source.Slice(offset, sizeof(short)));
+ BinaryPrimitives.WriteInt16LittleEndian(
+ destination.Slice(offset, sizeof(short)),
+ ApplyVolume(sample, clampedVolume));
+ }
+ }
+
private static short ReadSample(
ReadOnlySpan frame,
int channel,
diff --git a/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs b/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs
index 36124b25..907c2f08 100644
--- a/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs
+++ b/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs
@@ -133,10 +133,12 @@ internal static class Bink2MovieBridge
if (_playback.IsFinished)
{
var completedPath = _activePath;
+ var progress = _playback.PlaybackProgress;
CloseActiveLocked();
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge completed: " +
- Path.GetFileName(completedPath));
+ $"{Path.GetFileName(completedPath)} after " +
+ $"{progress.Seconds:F2}s at frame {progress.FrameIndex}");
AttachNextQueuedMovieLocked();
}
return false;
diff --git a/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs b/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs
index aa0600f4..1f392e6e 100644
--- a/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs
+++ b/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
+using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Bink;
@@ -36,6 +37,8 @@ internal sealed class BinkFramePlayback : IDisposable
private long _currentFrameIndex = -1;
private long _nextDecodedFrameIndex;
private long _playbackStartTimestamp;
+ private double _audioStartSeconds;
+ private long _lastSkewTraceTimestamp;
private bool _playbackClockStarted;
private bool _decoderCompleted;
private bool _stopRequested;
@@ -83,6 +86,26 @@ internal sealed class BinkFramePlayback : IDisposable
}
}
+ ///
+ /// Wall-clock seconds since the first frame was presented, and the index of
+ /// the last frame shown. Playback is on its own time base when these agree
+ /// with the movie's frame rate.
+ ///
+ internal (double Seconds, long FrameIndex) PlaybackProgress
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return (
+ _playbackClockStarted
+ ? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
+ : 0,
+ _currentFrameIndex);
+ }
+ }
+ }
+
internal bool TryGetFrame(
bool advanceClock,
out byte[] pixels,
@@ -118,14 +141,13 @@ internal sealed class BinkFramePlayback : IDisposable
if (advanceClock && !_playbackClockStarted)
{
_playbackStartTimestamp = Stopwatch.GetTimestamp();
+ _audioStartSeconds = GuestAudioClock.PlayedSeconds;
_playbackClockStarted = true;
}
- var elapsedSeconds = _playbackClockStarted
- ? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
- : 0;
- var targetFrameIndex = (long)Math.Floor(
- elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
+ var elapsedSeconds = CurrentPlaybackSecondsLocked();
+ TraceClockSkewLocked();
+ var targetFrameIndex = CurrentTargetFrameIndexLocked();
DecodedFrame? replacement = null;
while (_decodedFrames.Count > 0 &&
_decodedFrames.Peek().Index <= targetFrameIndex)
@@ -166,6 +188,86 @@ internal sealed class BinkFramePlayback : IDisposable
}
}
+ ///
+ /// Time base for playback. A host-decoded movie runs on whatever clock it is
+ /// given, but the audio that belongs to it comes from the guest, which does
+ /// not advance at wall-clock rate on a slow frame. Following the audio keeps
+ /// the two together; SHARPEMU_MOVIE_CLOCK=wall restores the old behaviour.
+ ///
+ private static readonly bool _followGuestAudioClock = !string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_MOVIE_CLOCK"),
+ "wall",
+ StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Seconds of playback elapsed on the movie's time base. Falls back to wall
+ /// clock whenever guest audio is not flowing: a movie whose audio never
+ /// starts — or stops early — must still finish rather than hang on a clock
+ /// that will never advance again.
+ ///
+ private double CurrentPlaybackSecondsLocked()
+ {
+ if (!_playbackClockStarted)
+ {
+ return 0;
+ }
+
+ var wallSeconds = Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds;
+ if (!_followGuestAudioClock || !GuestAudioClock.IsRunning)
+ {
+ return wallSeconds;
+ }
+
+ return Math.Clamp(GuestAudioClock.PlayedSeconds - _audioStartSeconds, 0, wallSeconds);
+ }
+
+ private static readonly bool _traceClockSkew = string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_LOG_MOVIE_SYNC"),
+ "1",
+ StringComparison.Ordinal);
+
+ ///
+ /// Logs how far the movie's wall clock has drifted from the guest audio the
+ /// movie is supposed to be in step with. A skew that is flat across playback
+ /// is a late audio start; one that grows is a rate mismatch, and the two need
+ /// different fixes. Caller holds .
+ ///
+ private void TraceClockSkewLocked()
+ {
+ if (!_traceClockSkew || !_playbackClockStarted)
+ {
+ return;
+ }
+
+ var now = Stopwatch.GetTimestamp();
+ if (_lastSkewTraceTimestamp != 0 &&
+ Stopwatch.GetElapsedTime(_lastSkewTraceTimestamp) < TimeSpan.FromSeconds(1))
+ {
+ return;
+ }
+
+ _lastSkewTraceTimestamp = now;
+ var wallSeconds = Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds;
+ var audioSeconds = GuestAudioClock.PlayedSeconds - _audioStartSeconds;
+ Console.Error.WriteLine(
+ $"[PERF][MOVIE] wall_s={wallSeconds:F2} audio_s={audioSeconds:F2} " +
+ $"playback_s={CurrentPlaybackSecondsLocked():F2} " +
+ $"skew_s={wallSeconds - audioSeconds:F2} frame={_currentFrameIndex} " +
+ $"audio_running={GuestAudioClock.IsRunning}");
+ }
+
+ ///
+ /// The frame the movie's own time base says should be on screen right now.
+ /// Returns -1 until the first frame is presented, so the queue prefills
+ /// instead of instantly declaring everything late.
+ ///
+ private long CurrentTargetFrameIndexLocked() =>
+ _playbackClockStarted
+ ? (long)Math.Floor(
+ CurrentPlaybackSecondsLocked() *
+ FramesPerSecondNumerator / FramesPerSecondDenominator)
+ : -1;
+
private void DecodeLoop()
{
try
@@ -199,8 +301,28 @@ internal sealed class BinkFramePlayback : IDisposable
lock (_gate)
{
- _decodedFrames.Enqueue(new DecodedFrame(
- _nextDecodedFrameIndex++, destination));
+ var frameIndex = _nextDecodedFrameIndex++;
+
+ // Frames are pulled once per guest flip, so a title running
+ // well under the movie's frame rate cannot drain a queue
+ // this shallow fast enough and the movie stretches past its
+ // real duration — audio finishes while the last picture sits
+ // on screen and the next movie starts late. Once the clock
+ // has passed a queued frame it can never be shown, so retire
+ // it in favour of this newer one. Only superseded frames are
+ // dropped, never the newest, so a decoder that cannot keep
+ // up still advances the picture instead of freezing it.
+ var targetFrameIndex = CurrentTargetFrameIndexLocked();
+ if (frameIndex <= targetFrameIndex)
+ {
+ while (_decodedFrames.Count > 0 &&
+ _decodedFrames.Peek().Index <= targetFrameIndex)
+ {
+ _freeBuffers.Enqueue(_decodedFrames.Dequeue().Pixels);
+ }
+ }
+
+ _decodedFrames.Enqueue(new DecodedFrame(frameIndex, destination));
Monitor.PulseAll(_gate);
}
}
diff --git a/src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs b/src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs
index ab0bd581..0551ad70 100644
--- a/src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs
+++ b/src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs
@@ -1,7 +1,9 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+using System.Buffers;
using FFmpeg.AutoGen;
+using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Bink;
@@ -13,13 +15,29 @@ namespace SharpEmu.Libs.Bink;
///
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
{
+ private const int OutputAudioChannels = 2;
+ private const int OutputAudioBytesPerSample = sizeof(short);
+
+ private readonly object _decodeGate = new();
private AVFormatContext* _formatContext;
private AVCodecContext* _codecContext;
+ private AVCodecContext* _audioCodecContext;
private SwsContext* _swsContext;
+ private SwrContext* _swrContext;
private AVFrame* _frame;
+ private AVFrame* _audioFrame;
private AVPacket* _packet;
+ private IHostAudioStream? _audioStream;
private readonly int _videoStreamIndex;
+ private readonly int _audioStreamIndex;
+ private readonly int _audioOutputSampleRate;
+ private AVChannelLayout _swrInputLayout;
+ private AVSampleFormat _swrInputFormat = AVSampleFormat.AV_SAMPLE_FMT_NONE;
+ private int _swrInputSampleRate;
+ private bool _swrInputLayoutValid;
private bool _draining;
+ private bool _audioDraining;
+ private bool _audioFailed;
private int _disposed;
public uint Width { get; }
@@ -34,6 +52,10 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
AVFormatContext* formatContext,
AVCodecContext* codecContext,
int videoStreamIndex,
+ AVCodecContext* audioCodecContext,
+ int audioStreamIndex,
+ IHostAudioStream? audioStream,
+ int audioOutputSampleRate,
uint width,
uint height,
uint framesPerSecondNumerator,
@@ -42,11 +64,16 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
_formatContext = formatContext;
_codecContext = codecContext;
_videoStreamIndex = videoStreamIndex;
+ _audioCodecContext = audioCodecContext;
+ _audioStreamIndex = audioStreamIndex;
+ _audioStream = audioStream;
+ _audioOutputSampleRate = audioOutputSampleRate;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
_frame = ffmpeg.av_frame_alloc();
+ _audioFrame = audioCodecContext is null ? null : ffmpeg.av_frame_alloc();
_packet = ffmpeg.av_packet_alloc();
}
@@ -93,6 +120,8 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
AVFormatContext* formatContext = null;
AVCodecContext* codecContext = null;
+ AVCodecContext* audioCodecContext = null;
+ IHostAudioStream? audioStream = null;
try
{
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
@@ -151,6 +180,28 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
frameRate = new AVRational { num = 30, den = 1 };
}
+ var audioStreamIndex = TryOpenAudioDecoder(
+ formatContext,
+ out audioCodecContext,
+ out var audioOutputSampleRate);
+ if (audioStreamIndex >= 0 && audioCodecContext is not null)
+ {
+ try
+ {
+ audioStream = HostPlatform.Current.Audio.OpenStereoPcm16Stream(
+ checked((uint)audioOutputSampleRate));
+ }
+ catch (Exception exception) when (exception is InvalidOperationException or
+ ArgumentOutOfRangeException)
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] Bink audio output unavailable: {exception.Message}");
+ ffmpeg.avcodec_free_context(&audioCodecContext);
+ audioStreamIndex = -1;
+ audioOutputSampleRate = 0;
+ }
+ }
+
var outputWidth = (uint)codecContext->width;
var outputHeight = (uint)codecContext->height;
if (maximumWidth > 0 && maximumHeight > 0 &&
@@ -175,12 +226,18 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
formatContext,
codecContext,
videoStreamIndex,
+ audioCodecContext,
+ audioStreamIndex,
+ audioStream,
+ audioOutputSampleRate,
outputWidth,
outputHeight,
(uint)frameRate.num,
(uint)frameRate.den);
formatContext = null;
codecContext = null;
+ audioCodecContext = null;
+ audioStream = null;
return true;
}
catch (DllNotFoundException)
@@ -194,6 +251,13 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
ffmpeg.avcodec_free_context(&codecContext);
}
+ if (audioCodecContext is not null)
+ {
+ ffmpeg.avcodec_free_context(&audioCodecContext);
+ }
+
+ audioStream?.Dispose();
+
if (formatContext is not null)
{
ffmpeg.avformat_close_input(&formatContext);
@@ -201,52 +265,102 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
}
}
+ private static int TryOpenAudioDecoder(
+ AVFormatContext* formatContext,
+ out AVCodecContext* codecContext,
+ out int outputSampleRate)
+ {
+ codecContext = null;
+ outputSampleRate = 0;
+
+ AVCodec* decoder = null;
+ var streamIndex = ffmpeg.av_find_best_stream(
+ formatContext, AVMediaType.AVMEDIA_TYPE_AUDIO, -1, -1, &decoder, 0);
+ if (streamIndex < 0 || decoder is null)
+ {
+ return -1;
+ }
+
+ var candidate = ffmpeg.avcodec_alloc_context3(decoder);
+ if (candidate is null)
+ {
+ return -1;
+ }
+
+ var stream = formatContext->streams[streamIndex];
+ if (ffmpeg.avcodec_parameters_to_context(candidate, stream->codecpar) < 0)
+ {
+ ffmpeg.avcodec_free_context(&candidate);
+ return -1;
+ }
+
+ candidate->thread_count = 0;
+ candidate->thread_type = ffmpeg.FF_THREAD_FRAME | ffmpeg.FF_THREAD_SLICE;
+ if (ffmpeg.avcodec_open2(candidate, decoder, null) < 0)
+ {
+ ffmpeg.avcodec_free_context(&candidate);
+ return -1;
+ }
+
+ outputSampleRate = candidate->sample_rate > 0 ? candidate->sample_rate : 48_000;
+ codecContext = candidate;
+ return streamIndex;
+ }
+
public bool TryDecodeNextFrame(Span destination)
{
- var stride = checked((int)(Width * 4));
- var required = (long)stride * Height;
- if (destination.Length < required)
+ lock (_decodeGate)
{
- return false;
- }
+ if (Volatile.Read(ref _disposed) != 0)
+ {
+ return false;
+ }
- if (!TryReceiveFrame())
- {
- return false;
- }
+ var stride = checked((int)(Width * 4));
+ var required = (long)stride * Height;
+ if (destination.Length < required)
+ {
+ return false;
+ }
- _swsContext = ffmpeg.sws_getCachedContext(
- _swsContext,
- _frame->width,
- _frame->height,
- (AVPixelFormat)_frame->format,
- (int)Width,
- (int)Height,
- AVPixelFormat.AV_PIX_FMT_BGRA,
- ffmpeg.SWS_FAST_BILINEAR,
- null,
- null,
- null);
- if (_swsContext is null)
- {
- ffmpeg.av_frame_unref(_frame);
- return false;
- }
+ if (!TryReceiveFrame())
+ {
+ return false;
+ }
- fixed (byte* destinationPointer = destination)
- {
- var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
- var destinationStrides = new int[4] { stride, 0, 0, 0 };
- var convertedRows = ffmpeg.sws_scale(
+ _swsContext = ffmpeg.sws_getCachedContext(
_swsContext,
- _frame->data,
- _frame->linesize,
- 0,
+ _frame->width,
_frame->height,
- destinationPlanes,
- destinationStrides);
- ffmpeg.av_frame_unref(_frame);
- return convertedRows == (int)Height;
+ (AVPixelFormat)_frame->format,
+ (int)Width,
+ (int)Height,
+ AVPixelFormat.AV_PIX_FMT_BGRA,
+ ffmpeg.SWS_FAST_BILINEAR,
+ null,
+ null,
+ null);
+ if (_swsContext is null)
+ {
+ ffmpeg.av_frame_unref(_frame);
+ return false;
+ }
+
+ fixed (byte* destinationPointer = destination)
+ {
+ var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
+ var destinationStrides = new int[4] { stride, 0, 0, 0 };
+ var convertedRows = ffmpeg.sws_scale(
+ _swsContext,
+ _frame->data,
+ _frame->linesize,
+ 0,
+ _frame->height,
+ destinationPlanes,
+ destinationStrides);
+ ffmpeg.av_frame_unref(_frame);
+ return convertedRows == (int)Height;
+ }
}
}
@@ -291,9 +405,17 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
{
_draining = true;
ffmpeg.avcodec_send_packet(_codecContext, null);
+ DrainAudioDecoder();
return true;
}
+ if (_packet->stream_index == _audioStreamIndex)
+ {
+ DecodeAudioPacket(_packet);
+ ffmpeg.av_packet_unref(_packet);
+ continue;
+ }
+
if (_packet->stream_index != _videoStreamIndex)
{
ffmpeg.av_packet_unref(_packet);
@@ -311,6 +433,248 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
}
}
+ private void DecodeAudioPacket(AVPacket* packet)
+ {
+ if (_audioCodecContext is null || _audioFrame is null || _audioFailed)
+ {
+ return;
+ }
+
+ var sendResult = ffmpeg.avcodec_send_packet(_audioCodecContext, packet);
+ if (sendResult == ffmpeg.AVERROR(ffmpeg.EAGAIN))
+ {
+ DrainAvailableAudioFrames();
+ sendResult = ffmpeg.avcodec_send_packet(_audioCodecContext, packet);
+ }
+
+ if (sendResult < 0)
+ {
+ DisableAudio("packet decode failed");
+ return;
+ }
+
+ DrainAvailableAudioFrames();
+ }
+
+ private void DrainAudioDecoder()
+ {
+ if (_audioCodecContext is null || _audioFrame is null ||
+ _audioDraining || _audioFailed)
+ {
+ return;
+ }
+
+ _audioDraining = true;
+ var sendResult = ffmpeg.avcodec_send_packet(_audioCodecContext, null);
+ if (sendResult >= 0 || sendResult == ffmpeg.AVERROR(ffmpeg.EAGAIN))
+ {
+ DrainAvailableAudioFrames();
+ }
+ }
+
+ private void DrainAvailableAudioFrames()
+ {
+ while (_audioCodecContext is not null && _audioFrame is not null)
+ {
+ var receiveResult = ffmpeg.avcodec_receive_frame(_audioCodecContext, _audioFrame);
+ if (receiveResult == ffmpeg.AVERROR(ffmpeg.EAGAIN) ||
+ receiveResult == ffmpeg.AVERROR_EOF)
+ {
+ return;
+ }
+
+ if (receiveResult < 0)
+ {
+ DisableAudio("frame decode failed");
+ return;
+ }
+
+ if (!SubmitAudioFrame())
+ {
+ ffmpeg.av_frame_unref(_audioFrame);
+ DisableAudio("host submission failed");
+ return;
+ }
+
+ ffmpeg.av_frame_unref(_audioFrame);
+ }
+ }
+
+ private bool SubmitAudioFrame()
+ {
+ if (_audioStream is null || _audioFrame is null ||
+ _audioFrame->nb_samples <= 0 || _audioFrame->extended_data is null)
+ {
+ return true;
+ }
+
+ var sampleRate = _audioFrame->sample_rate > 0
+ ? _audioFrame->sample_rate
+ : _audioCodecContext->sample_rate;
+ if (sampleRate <= 0)
+ {
+ return false;
+ }
+
+ var inputLayout = _audioFrame->ch_layout;
+ var ownsInputLayout = false;
+ if (ffmpeg.av_channel_layout_check(&inputLayout) == 0)
+ {
+ inputLayout = _audioCodecContext->ch_layout;
+ }
+ if (ffmpeg.av_channel_layout_check(&inputLayout) == 0)
+ {
+ ffmpeg.av_channel_layout_default(
+ &inputLayout,
+ Math.Max(1, _audioFrame->ch_layout.nb_channels));
+ ownsInputLayout = true;
+ }
+
+ try
+ {
+ if (!EnsureAudioResampler(
+ &inputLayout,
+ (AVSampleFormat)_audioFrame->format,
+ sampleRate))
+ {
+ return false;
+ }
+
+ var maximumSamples = ffmpeg.swr_get_out_samples(
+ _swrContext, _audioFrame->nb_samples);
+ if (maximumSamples <= 0)
+ {
+ return true;
+ }
+
+ var outputBytes = checked(
+ maximumSamples * OutputAudioChannels * OutputAudioBytesPerSample);
+ var buffer = ArrayPool.Shared.Rent(outputBytes);
+ try
+ {
+ fixed (byte* output = buffer)
+ {
+ var outputPlanes = stackalloc byte*[1];
+ outputPlanes[0] = output;
+ var convertedSamples = ffmpeg.swr_convert(
+ _swrContext,
+ outputPlanes,
+ maximumSamples,
+ _audioFrame->extended_data,
+ _audioFrame->nb_samples);
+ if (convertedSamples < 0)
+ {
+ return false;
+ }
+
+ var convertedBytes = checked(
+ convertedSamples * OutputAudioChannels * OutputAudioBytesPerSample);
+ return _audioStream.Submit(buffer.AsSpan(0, convertedBytes));
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+ finally
+ {
+ if (ownsInputLayout)
+ {
+ ffmpeg.av_channel_layout_uninit(&inputLayout);
+ }
+ }
+ }
+
+ private bool EnsureAudioResampler(
+ AVChannelLayout* inputLayout,
+ AVSampleFormat inputFormat,
+ int inputSampleRate)
+ {
+ var storedInputLayout = _swrInputLayout;
+ if (_swrContext is not null &&
+ _swrInputFormat == inputFormat &&
+ _swrInputSampleRate == inputSampleRate &&
+ ffmpeg.av_channel_layout_compare(&storedInputLayout, inputLayout) == 0)
+ {
+ return true;
+ }
+
+ FreeAudioResampler();
+
+ AVChannelLayout copiedInputLayout = default;
+ if (ffmpeg.av_channel_layout_copy(&copiedInputLayout, inputLayout) < 0)
+ {
+ return false;
+ }
+
+ AVChannelLayout outputLayout = default;
+ ffmpeg.av_channel_layout_default(&outputLayout, OutputAudioChannels);
+ SwrContext* context = null;
+ var allocateResult = ffmpeg.swr_alloc_set_opts2(
+ &context,
+ &outputLayout,
+ AVSampleFormat.AV_SAMPLE_FMT_S16,
+ _audioOutputSampleRate,
+ &copiedInputLayout,
+ inputFormat,
+ inputSampleRate,
+ 0,
+ null);
+ ffmpeg.av_channel_layout_uninit(&outputLayout);
+ if (allocateResult < 0 || context is null || ffmpeg.swr_init(context) < 0)
+ {
+ if (context is not null)
+ {
+ ffmpeg.swr_free(&context);
+ }
+ ffmpeg.av_channel_layout_uninit(&copiedInputLayout);
+ return false;
+ }
+
+ _swrContext = context;
+ _swrInputLayout = copiedInputLayout;
+ _swrInputLayoutValid = true;
+ _swrInputFormat = inputFormat;
+ _swrInputSampleRate = inputSampleRate;
+ return true;
+ }
+
+ private void DisableAudio(string reason)
+ {
+ if (_audioFailed)
+ {
+ return;
+ }
+
+ _audioFailed = true;
+ Console.Error.WriteLine($"[LOADER][WARN] Bink audio disabled: {reason}.");
+ FreeAudioResampler();
+ _audioStream?.Dispose();
+ _audioStream = null;
+ }
+
+ private void FreeAudioResampler()
+ {
+ if (_swrContext is not null)
+ {
+ var context = _swrContext;
+ ffmpeg.swr_free(&context);
+ _swrContext = null;
+ }
+
+ if (_swrInputLayoutValid)
+ {
+ var inputLayout = _swrInputLayout;
+ ffmpeg.av_channel_layout_uninit(&inputLayout);
+ _swrInputLayout = default;
+ _swrInputLayoutValid = false;
+ }
+
+ _swrInputFormat = AVSampleFormat.AV_SAMPLE_FMT_NONE;
+ _swrInputSampleRate = 0;
+ }
+
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
@@ -318,38 +682,59 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
return;
}
- if (_swsContext is not null)
+ lock (_decodeGate)
{
- ffmpeg.sws_freeContext(_swsContext);
- _swsContext = null;
- }
+ FreeAudioResampler();
+ _audioStream?.Dispose();
+ _audioStream = null;
- if (_packet is not null)
- {
- var packet = _packet;
- ffmpeg.av_packet_free(&packet);
- _packet = null;
- }
+ if (_swsContext is not null)
+ {
+ ffmpeg.sws_freeContext(_swsContext);
+ _swsContext = null;
+ }
- if (_frame is not null)
- {
- var frame = _frame;
- ffmpeg.av_frame_free(&frame);
- _frame = null;
- }
+ if (_packet is not null)
+ {
+ var packet = _packet;
+ ffmpeg.av_packet_free(&packet);
+ _packet = null;
+ }
- if (_codecContext is not null)
- {
- var codecContext = _codecContext;
- ffmpeg.avcodec_free_context(&codecContext);
- _codecContext = null;
- }
+ if (_frame is not null)
+ {
+ var frame = _frame;
+ ffmpeg.av_frame_free(&frame);
+ _frame = null;
+ }
- if (_formatContext is not null)
- {
- var formatContext = _formatContext;
- ffmpeg.avformat_close_input(&formatContext);
- _formatContext = null;
+ if (_audioFrame is not null)
+ {
+ var frame = _audioFrame;
+ ffmpeg.av_frame_free(&frame);
+ _audioFrame = null;
+ }
+
+ if (_codecContext is not null)
+ {
+ var codecContext = _codecContext;
+ ffmpeg.avcodec_free_context(&codecContext);
+ _codecContext = null;
+ }
+
+ if (_audioCodecContext is not null)
+ {
+ var codecContext = _audioCodecContext;
+ ffmpeg.avcodec_free_context(&codecContext);
+ _audioCodecContext = null;
+ }
+
+ if (_formatContext is not null)
+ {
+ var formatContext = _formatContext;
+ ffmpeg.avformat_close_input(&formatContext);
+ _formatContext = null;
+ }
}
}
}
diff --git a/src/SharpEmu.Libs/Codec/CodecExports.cs b/src/SharpEmu.Libs/Codec/CodecExports.cs
index fbf85acd..b93a5381 100644
--- a/src/SharpEmu.Libs/Codec/CodecExports.cs
+++ b/src/SharpEmu.Libs/Codec/CodecExports.cs
@@ -2,28 +2,84 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
+using SharpEmu.Libs.Audio;
using SharpEmu.Libs.Kernel;
+using System.Buffers;
+using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Threading;
namespace SharpEmu.Libs.Codec;
///
-/// libSceVideodec / libSceAudiodec handle management. Actual H.264/HEVC and
-/// AAC/AT9 decoding requires an external codec, which is out of scope; these
-/// exports keep the decoder lifecycle resolvable (create/decode/flush/delete)
-/// and report "no output produced" so guests advance instead of failing on
-/// unresolved imports.
+/// libSceVideodec / libSceAudiodec compatibility exports.
///
public static class CodecExports
{
private const int Ok = 0;
private const int VideodecErrorInvalidArg = unchecked((int)0x80620801);
+ private const int AudiodecErrorInvalidType = unchecked((int)0x807F0001);
private const int AudiodecErrorInvalidArg = unchecked((int)0x807F0002);
+ private const int AudiodecErrorInvalidParamSize = unchecked((int)0x807F0004);
+ private const int AudiodecErrorInvalidBsiInfoSize = unchecked((int)0x807F0005);
+ private const int AudiodecErrorInvalidAuInfoSize = unchecked((int)0x807F0006);
+ private const int AudiodecErrorInvalidPcmItemSize = unchecked((int)0x807F0007);
+ private const int AudiodecErrorInvalidCtrlPointer = unchecked((int)0x807F0008);
+ private const int AudiodecErrorInvalidParamPointer = unchecked((int)0x807F0009);
+ private const int AudiodecErrorInvalidBsiInfoPointer = unchecked((int)0x807F000A);
+ private const int AudiodecErrorInvalidAuInfoPointer = unchecked((int)0x807F000B);
+ private const int AudiodecErrorInvalidPcmItemPointer = unchecked((int)0x807F000C);
+ private const int AudiodecErrorInvalidAuPointer = unchecked((int)0x807F000D);
+ private const int AudiodecErrorInvalidPcmPointer = unchecked((int)0x807F000E);
+ private const int AudiodecErrorInvalidHandle = unchecked((int)0x807F000F);
+ private const int AudiodecErrorInvalidWordLength = unchecked((int)0x807F0010);
+ private const int AudiodecErrorInvalidAuSize = unchecked((int)0x807F0011);
+ private const int AudiodecErrorInvalidPcmSize = unchecked((int)0x807F0012);
+ private const uint AudiodecTypeAt9 = 1;
+ private const uint AudiodecTypeMp3 = 2;
+ private const uint AudiodecTypeAac = 3;
+ private const int MaxAudioDecoders = 64;
+ private const int MaxDecodeBufferBytes = 64 * 1024 * 1024;
private static readonly ConcurrentDictionary VideoDecoders = new();
- private static readonly ConcurrentDictionary AudioDecoders = new();
+ private static readonly ConcurrentDictionary AudioDecoders = new();
+ private static readonly ConcurrentDictionary AudioCodecInitCounts = new();
+ private static readonly object AudioDecoderGate = new();
private static long _nextHandle = 1;
+ private static int _nextAudioHandle;
+
+ private sealed class AudioDecoderState
+ {
+ public required uint CodecType { get; init; }
+
+ public required int WordSize { get; init; }
+
+ public required int Channels { get; init; }
+
+ public required int SampleRate { get; init; }
+
+ public required int FrameBytes { get; init; }
+
+ public required int FramesPerSuperframe { get; init; }
+
+ public required int FrameSamples { get; init; }
+
+ public Atrac9DecodeState? Atrac9 { get; init; }
+ }
+
+ private readonly record struct AudioControl(
+ ulong ParamAddress,
+ ulong BsiInfoAddress,
+ ulong AuInfoAddress,
+ ulong PcmItemAddress,
+ ulong AuAddress,
+ uint AuSize,
+ ulong PcmAddress,
+ uint PcmSize,
+ int WordSize,
+ byte[]? Atrac9Config,
+ uint AacMaxChannels,
+ uint AacSampleRateIndex);
// ---- Video decoder ----
@@ -65,34 +121,572 @@ public static class CodecExports
// ---- Audio decoder ----
+ [SysAbiExport(Nid = "VjhsmxpcezI", ExportName = "sceAudiodecInitLibrary",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
+ public static int AudiodecInitLibrary(CpuContext ctx)
+ {
+ var codecType = unchecked((uint)ctx[CpuRegister.Rdi]);
+ if (!IsValidAudioCodecType(codecType))
+ {
+ return SetReturn(ctx, AudiodecErrorInvalidType);
+ }
+
+ AudioCodecInitCounts.AddOrUpdate(codecType, 1, static (_, count) => count + 1);
+ return SetReturn(ctx, Ok);
+ }
+
+ [SysAbiExport(Nid = "h5jSB2QIDV0", ExportName = "sceAudiodecTermLibrary",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
+ public static int AudiodecTermLibrary(CpuContext ctx)
+ {
+ var codecType = unchecked((uint)ctx[CpuRegister.Rdi]);
+ if (!IsValidAudioCodecType(codecType))
+ {
+ return SetReturn(ctx, AudiodecErrorInvalidType);
+ }
+
+ AudioCodecInitCounts.AddOrUpdate(codecType, 0, static (_, count) => Math.Max(0, count - 1));
+ return SetReturn(ctx, Ok);
+ }
+
[SysAbiExport(Nid = "O3f1sLMWRvs", ExportName = "sceAudiodecCreateDecoder",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecCreateDecoder(CpuContext ctx)
{
- var handle = (ulong)Interlocked.Increment(ref _nextHandle);
- AudioDecoders[handle] = 1;
- // sceAudiodec returns the handle directly (>= 0) or a negative error.
- ctx[CpuRegister.Rax] = handle;
- return unchecked((int)handle);
+ var controlAddress = ctx[CpuRegister.Rdi];
+ var codecType = unchecked((uint)ctx[CpuRegister.Rsi]);
+ if (!IsValidAudioCodecType(codecType))
+ {
+ return SetReturn(ctx, AudiodecErrorInvalidType);
+ }
+
+ var validation = TryReadAudioControl(ctx, controlAddress, codecType, decode: false, out var control);
+ if (validation != Ok)
+ {
+ return SetReturn(ctx, validation);
+ }
+
+ if (!AudioCodecInitCounts.TryGetValue(codecType, out var initCount) || initCount == 0)
+ {
+ return SetReturn(ctx, AudiodecErrorInvalidArg);
+ }
+
+ var decoder = CreateAudioDecoder(codecType, control);
+ if (decoder is null)
+ {
+ return SetReturn(ctx, AudiodecErrorInvalidArg);
+ }
+
+ int handle;
+ lock (AudioDecoderGate)
+ {
+ if (AudioDecoders.Count >= MaxAudioDecoders)
+ {
+ return SetReturn(ctx, AudiodecErrorInvalidArg);
+ }
+
+ do
+ {
+ _nextAudioHandle = _nextAudioHandle % MaxAudioDecoders + 1;
+ handle = _nextAudioHandle;
+ }
+ while (AudioDecoders.ContainsKey(handle));
+
+ AudioDecoders[handle] = decoder;
+ }
+
+ WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, Ok);
+ return SetReturn(ctx, handle);
}
[SysAbiExport(Nid = "KHXHMDLkILw", ExportName = "sceAudiodecDecode",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecDecode(CpuContext ctx)
{
- // No decoder present: report success with zero output samples so the
- // caller treats the frame as silent rather than erroring.
- return SetReturn(ctx, AudioDecoders.ContainsKey(ctx[CpuRegister.Rdi]) ? Ok : AudiodecErrorInvalidArg);
+ var handle = unchecked((int)ctx[CpuRegister.Rdi]);
+ if (!AudioDecoders.TryGetValue(handle, out var decoder))
+ {
+ return SetReturn(ctx, AudiodecErrorInvalidHandle);
+ }
+
+ var validation = TryReadAudioControl(
+ ctx,
+ ctx[CpuRegister.Rsi],
+ decoder.CodecType,
+ decode: true,
+ out var control);
+ if (validation != Ok)
+ {
+ return SetReturn(ctx, validation);
+ }
+
+ if (control.AuSize > MaxDecodeBufferBytes)
+ {
+ return SetReturn(ctx, AudiodecErrorInvalidAuSize);
+ }
+
+ if (control.PcmSize > MaxDecodeBufferBytes)
+ {
+ return SetReturn(ctx, AudiodecErrorInvalidPcmSize);
+ }
+
+ if (decoder.CodecType == AudiodecTypeAt9 && decoder.Atrac9 is not null)
+ {
+ DecodeAtrac9(ctx, decoder, control);
+ }
+ else
+ {
+ DecodeAudioSilence(ctx, decoder, control);
+ }
+
+ return SetReturn(ctx, Ok);
}
[SysAbiExport(Nid = "Tp+ZEy69mLk", ExportName = "sceAudiodecDeleteDecoder",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecDeleteDecoder(CpuContext ctx)
{
- AudioDecoders.TryRemove(ctx[CpuRegister.Rdi], out _);
+ var handle = unchecked((int)ctx[CpuRegister.Rdi]);
+ return SetReturn(
+ ctx,
+ AudioDecoders.TryRemove(handle, out _)
+ ? Ok
+ : AudiodecErrorInvalidHandle);
+ }
+
+ [SysAbiExport(Nid = "6Vf9WTLDoss", ExportName = "sceAudiodecClearContext",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
+ public static int AudiodecClearContext(CpuContext ctx)
+ {
+ var handle = unchecked((int)ctx[CpuRegister.Rdi]);
+ if (!AudioDecoders.TryGetValue(handle, out var decoder))
+ {
+ return SetReturn(ctx, AudiodecErrorInvalidHandle);
+ }
+
+ decoder.Atrac9?.Reset();
return SetReturn(ctx, Ok);
}
+ private static bool IsValidAudioCodecType(uint codecType) =>
+ codecType is AudiodecTypeAt9 or AudiodecTypeMp3 or AudiodecTypeAac;
+
+ private static int TryReadAudioControl(
+ CpuContext ctx,
+ ulong controlAddress,
+ uint codecType,
+ bool decode,
+ out AudioControl control)
+ {
+ control = default;
+ if (controlAddress == 0)
+ {
+ return AudiodecErrorInvalidCtrlPointer;
+ }
+
+ Span controlData = stackalloc byte[32];
+ if (!ctx.Memory.TryRead(controlAddress, controlData))
+ {
+ return AudiodecErrorInvalidCtrlPointer;
+ }
+
+ var paramAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData);
+ var bsiInfoAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[8..]);
+ var auInfoAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[16..]);
+ var pcmItemAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[24..]);
+ if (paramAddress == 0)
+ {
+ return AudiodecErrorInvalidParamPointer;
+ }
+
+ if (bsiInfoAddress == 0)
+ {
+ return AudiodecErrorInvalidBsiInfoPointer;
+ }
+
+ if (auInfoAddress == 0)
+ {
+ return AudiodecErrorInvalidAuInfoPointer;
+ }
+
+ if (pcmItemAddress == 0)
+ {
+ return AudiodecErrorInvalidPcmItemPointer;
+ }
+
+ Span auInfo = stackalloc byte[24];
+ if (!ctx.Memory.TryRead(auInfoAddress, auInfo))
+ {
+ return AudiodecErrorInvalidAuInfoPointer;
+ }
+
+ if (BinaryPrimitives.ReadUInt32LittleEndian(auInfo) != auInfo.Length)
+ {
+ return AudiodecErrorInvalidAuInfoSize;
+ }
+
+ Span pcmItem = stackalloc byte[24];
+ if (!ctx.Memory.TryRead(pcmItemAddress, pcmItem))
+ {
+ return AudiodecErrorInvalidPcmItemPointer;
+ }
+
+ if (BinaryPrimitives.ReadUInt32LittleEndian(pcmItem) != pcmItem.Length)
+ {
+ return AudiodecErrorInvalidPcmItemSize;
+ }
+
+ var auAddress = BinaryPrimitives.ReadUInt64LittleEndian(auInfo[8..]);
+ var auSize = BinaryPrimitives.ReadUInt32LittleEndian(auInfo[16..]);
+ var pcmAddress = BinaryPrimitives.ReadUInt64LittleEndian(pcmItem[8..]);
+ var pcmSize = BinaryPrimitives.ReadUInt32LittleEndian(pcmItem[16..]);
+ if (decode && auAddress == 0)
+ {
+ return AudiodecErrorInvalidAuPointer;
+ }
+
+ if (decode && pcmAddress == 0)
+ {
+ return AudiodecErrorInvalidPcmPointer;
+ }
+
+ var parameterResult = TryReadAudioParameters(
+ ctx,
+ codecType,
+ paramAddress,
+ bsiInfoAddress,
+ out var wordSize,
+ out var atrac9Config,
+ out var aacMaxChannels,
+ out var aacSampleRateIndex);
+ if (parameterResult != Ok)
+ {
+ return parameterResult;
+ }
+
+ if (decode && auSize == 0)
+ {
+ return AudiodecErrorInvalidAuSize;
+ }
+
+ if (decode && pcmSize == 0)
+ {
+ return AudiodecErrorInvalidPcmSize;
+ }
+
+ control = new AudioControl(
+ paramAddress,
+ bsiInfoAddress,
+ auInfoAddress,
+ pcmItemAddress,
+ auAddress,
+ auSize,
+ pcmAddress,
+ pcmSize,
+ wordSize,
+ atrac9Config,
+ aacMaxChannels,
+ aacSampleRateIndex);
+ return Ok;
+ }
+
+ private static int TryReadAudioParameters(
+ CpuContext ctx,
+ uint codecType,
+ ulong paramAddress,
+ ulong bsiInfoAddress,
+ out int wordSize,
+ out byte[]? atrac9Config,
+ out uint aacMaxChannels,
+ out uint aacSampleRateIndex)
+ {
+ wordSize = 0;
+ atrac9Config = null;
+ aacMaxChannels = 0;
+ aacSampleRateIndex = 0;
+
+ var paramSize = codecType switch
+ {
+ AudiodecTypeAt9 => 12,
+ AudiodecTypeMp3 => 8,
+ AudiodecTypeAac => 24,
+ _ => 0,
+ };
+ var bsiSize = codecType switch
+ {
+ AudiodecTypeAt9 => 36,
+ AudiodecTypeMp3 => 24,
+ AudiodecTypeAac => 20,
+ _ => 0,
+ };
+ if (paramSize == 0 || bsiSize == 0)
+ {
+ return AudiodecErrorInvalidType;
+ }
+
+ Span param = stackalloc byte[paramSize];
+ if (!ctx.Memory.TryRead(paramAddress, param))
+ {
+ return AudiodecErrorInvalidParamPointer;
+ }
+
+ var suppliedParamSize = BinaryPrimitives.ReadUInt32LittleEndian(param);
+ if (codecType == AudiodecTypeAac
+ ? suppliedParamSize < paramSize
+ : suppliedParamSize != paramSize)
+ {
+ return AudiodecErrorInvalidParamSize;
+ }
+
+ Span bsi = stackalloc byte[bsiSize];
+ if (!ctx.Memory.TryRead(bsiInfoAddress, bsi))
+ {
+ return AudiodecErrorInvalidBsiInfoPointer;
+ }
+
+ if (BinaryPrimitives.ReadUInt32LittleEndian(bsi) != bsiSize)
+ {
+ return AudiodecErrorInvalidBsiInfoSize;
+ }
+
+ wordSize = BinaryPrimitives.ReadInt32LittleEndian(param[4..]);
+ if (wordSize is < 0 or > 2)
+ {
+ return AudiodecErrorInvalidWordLength;
+ }
+
+ if (codecType == AudiodecTypeAt9)
+ {
+ atrac9Config = param[8..12].ToArray();
+ }
+ else if (codecType == AudiodecTypeAac)
+ {
+ aacSampleRateIndex = BinaryPrimitives.ReadUInt32LittleEndian(param[12..]);
+ aacMaxChannels = BinaryPrimitives.ReadUInt32LittleEndian(param[16..]);
+ }
+
+ return Ok;
+ }
+
+ private static AudioDecoderState? CreateAudioDecoder(uint codecType, AudioControl control)
+ {
+ if (codecType == AudiodecTypeAt9)
+ {
+ var atrac9 = new Atrac9DecodeState();
+ if (control.Atrac9Config is null || !atrac9.TryInitialize(control.Atrac9Config))
+ {
+ return null;
+ }
+
+ var config = atrac9.Config!;
+ return new AudioDecoderState
+ {
+ CodecType = codecType,
+ WordSize = control.WordSize,
+ Channels = config.ChannelCount,
+ SampleRate = config.SampleRate,
+ FrameBytes = config.SuperframeBytes,
+ FramesPerSuperframe = config.FramesPerSuperframe,
+ FrameSamples = config.FrameSamples,
+ Atrac9 = atrac9,
+ };
+ }
+
+ if (codecType == AudiodecTypeMp3)
+ {
+ return new AudioDecoderState
+ {
+ CodecType = codecType,
+ WordSize = control.WordSize,
+ Channels = 2,
+ SampleRate = 48_000,
+ FrameBytes = 1_441,
+ FramesPerSuperframe = 1,
+ FrameSamples = 1_152,
+ };
+ }
+
+ var channels = control.AacMaxChannels == 0
+ ? 2
+ : unchecked((int)Math.Min(control.AacMaxChannels, 8));
+ return new AudioDecoderState
+ {
+ CodecType = codecType,
+ WordSize = control.WordSize,
+ Channels = channels,
+ SampleRate = GetAacSampleRate(control.AacSampleRateIndex),
+ FrameBytes = 4_608,
+ FramesPerSuperframe = 1,
+ FrameSamples = 2_048,
+ };
+ }
+
+ private static void DecodeAtrac9(CpuContext ctx, AudioDecoderState decoder, AudioControl control)
+ {
+ var input = ArrayPool.Shared.Rent(checked((int)control.AuSize));
+ var output = ArrayPool.Shared.Rent(checked((int)control.PcmSize));
+ try
+ {
+ if (!ctx.Memory.TryRead(control.AuAddress, input.AsSpan(0, checked((int)control.AuSize))))
+ {
+ WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, AudiodecErrorInvalidAuPointer);
+ WriteAudioBufferSizes(ctx, control, 0, 0);
+ return;
+ }
+
+ var result = decoder.Atrac9!.Decode(
+ input.AsSpan(0, checked((int)control.AuSize)),
+ output.AsSpan(0, checked((int)control.PcmSize)),
+ GetAtrac9Encoding(decoder.WordSize),
+ decoder.Channels,
+ multipleFrames: false);
+
+ var outputWritten = result.OutputWritten;
+ if (outputWritten != 0 &&
+ !ctx.Memory.TryWrite(control.PcmAddress, output.AsSpan(0, outputWritten)))
+ {
+ outputWritten = 0;
+ }
+
+ WriteAudioBufferSizes(
+ ctx,
+ control,
+ unchecked((uint)result.InputConsumed),
+ unchecked((uint)outputWritten));
+ WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, result.Status);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(input);
+ ArrayPool.Shared.Return(output);
+ }
+ }
+
+ private static void DecodeAudioSilence(CpuContext ctx, AudioDecoderState decoder, AudioControl control)
+ {
+ var wantedPcm = checked(
+ decoder.FrameSamples *
+ decoder.Channels *
+ GetAudioBytesPerSample(decoder.WordSize));
+ var outputSize = Math.Min(control.PcmSize, unchecked((uint)wantedPcm));
+ ClearGuestMemory(ctx, control.PcmAddress, outputSize);
+ WriteAudioBufferSizes(
+ ctx,
+ control,
+ Math.Min(control.AuSize, unchecked((uint)decoder.FrameBytes)),
+ outputSize);
+ WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, Ok);
+ }
+
+ private static void WriteAudioBufferSizes(
+ CpuContext ctx,
+ AudioControl control,
+ uint inputConsumed,
+ uint outputWritten)
+ {
+ Span value = stackalloc byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32LittleEndian(value, inputConsumed);
+ _ = ctx.Memory.TryWrite(control.AuInfoAddress + 16, value);
+ BinaryPrimitives.WriteUInt32LittleEndian(value, outputWritten);
+ _ = ctx.Memory.TryWrite(control.PcmItemAddress + 16, value);
+ }
+
+ private static void WriteAudioDecoderInfo(
+ CpuContext ctx,
+ ulong infoAddress,
+ AudioDecoderState decoder,
+ int result)
+ {
+ if (decoder.CodecType == AudiodecTypeAt9)
+ {
+ Span info = stackalloc byte[36];
+ info.Clear();
+ BinaryPrimitives.WriteUInt32LittleEndian(info, unchecked((uint)info.Length));
+ BinaryPrimitives.WriteUInt32LittleEndian(info[4..], unchecked((uint)decoder.Channels));
+ var superframeSamples = checked(decoder.FrameSamples * decoder.FramesPerSuperframe);
+ var bitrate = superframeSamples == 0
+ ? 0u
+ : unchecked((uint)((ulong)decoder.FrameBytes * 8 * (uint)decoder.SampleRate /
+ (uint)superframeSamples));
+ BinaryPrimitives.WriteUInt32LittleEndian(info[8..], bitrate);
+ BinaryPrimitives.WriteUInt32LittleEndian(info[12..], unchecked((uint)decoder.SampleRate));
+ BinaryPrimitives.WriteUInt32LittleEndian(info[16..], unchecked((uint)decoder.FrameBytes));
+ BinaryPrimitives.WriteUInt32LittleEndian(info[20..], unchecked((uint)decoder.FramesPerSuperframe));
+ BinaryPrimitives.WriteUInt32LittleEndian(
+ info[24..],
+ unchecked((uint)(decoder.FrameBytes / Math.Max(decoder.FramesPerSuperframe, 1))));
+ BinaryPrimitives.WriteUInt32LittleEndian(info[28..], unchecked((uint)decoder.FrameSamples));
+ BinaryPrimitives.WriteInt32LittleEndian(info[32..], result);
+ _ = ctx.Memory.TryWrite(infoAddress, info);
+ return;
+ }
+
+ if (decoder.CodecType == AudiodecTypeMp3)
+ {
+ Span info = stackalloc byte[24];
+ info.Clear();
+ BinaryPrimitives.WriteUInt32LittleEndian(info, unchecked((uint)info.Length));
+ BinaryPrimitives.WriteInt32LittleEndian(info[20..], result);
+ _ = ctx.Memory.TryWrite(infoAddress, info);
+ return;
+ }
+
+ Span aacInfo = stackalloc byte[20];
+ aacInfo.Clear();
+ BinaryPrimitives.WriteUInt32LittleEndian(aacInfo, unchecked((uint)aacInfo.Length));
+ BinaryPrimitives.WriteUInt32LittleEndian(aacInfo[4..], unchecked((uint)decoder.SampleRate));
+ BinaryPrimitives.WriteUInt32LittleEndian(aacInfo[8..], unchecked((uint)decoder.Channels));
+ BinaryPrimitives.WriteInt32LittleEndian(aacInfo[16..], result);
+ _ = ctx.Memory.TryWrite(infoAddress, aacInfo);
+ }
+
+ private static Atrac9PcmEncoding GetAtrac9Encoding(int wordSize) =>
+ wordSize switch
+ {
+ 0 => Atrac9PcmEncoding.Signed32,
+ 1 => Atrac9PcmEncoding.Signed16,
+ 2 => Atrac9PcmEncoding.Float,
+ _ => throw new ArgumentOutOfRangeException(nameof(wordSize)),
+ };
+
+ private static int GetAudioBytesPerSample(int wordSize) =>
+ wordSize == 1 ? sizeof(short) : sizeof(int);
+
+ private static int GetAacSampleRate(uint index)
+ {
+ ReadOnlySpan rates =
+ [
+ 96_000, 88_200, 64_000, 48_000, 44_100, 32_000,
+ 24_000, 22_050, 16_000, 12_000, 11_025, 8_000,
+ ];
+ return index < rates.Length ? rates[unchecked((int)index)] : 48_000;
+ }
+
+ private static void ClearGuestMemory(CpuContext ctx, ulong address, uint byteCount)
+ {
+ Span zero = stackalloc byte[256];
+ var cursor = address;
+ var remaining = byteCount;
+ while (remaining != 0)
+ {
+ var length = unchecked((int)Math.Min(remaining, (uint)zero.Length));
+ if (!ctx.Memory.TryWrite(cursor, zero[..length]))
+ {
+ return;
+ }
+
+ cursor += unchecked((uint)length);
+ remaining -= unchecked((uint)length);
+ }
+ }
+
+ internal static void ResetAudioDecodersForTests()
+ {
+ AudioDecoders.Clear();
+ AudioCodecInitCounts.Clear();
+ Interlocked.Exchange(ref _nextAudioHandle, 0);
+ }
+
private static bool TryWriteHandle(CpuContext ctx, ulong address, ulong handle) =>
ctx.TryWriteUInt64(address, handle);
diff --git a/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs
index 818621eb..16142a06 100644
--- a/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs
+++ b/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs
@@ -237,7 +237,21 @@ internal interface IGuestGpuBackend
void SubmitGuestImageFill(ulong address, uint fillValue);
- void SubmitGuestImageWrite(ulong address, byte[] pixels);
+ ///
+ /// Uploads guest-authored pixels into a live guest image.
+ /// is the first image row the buffer covers, so a caller that knows only part
+ /// of the surface changed can send that band instead of the whole thing; the
+ /// untouched rows on the host already hold the same bytes.
+ ///
+ void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0);
+
+ ///
+ /// Whether a non-zero rowOffset is honoured. Backends that cannot
+ /// upload a sub-range must report false so callers keep sending the whole
+ /// surface: a dropped band would leave the host copy stale, which is worse
+ /// than an oversized upload.
+ ///
+ bool SupportsPartialImageWrite => false;
///
/// Asks the presenter to refresh CPU-dirty guest images on its render/present
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs
index cffef1ff..e60eafc4 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs
@@ -398,7 +398,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
MetalVideoPresenter.SubmitGuestImageFill(address, fillValue);
- public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
+ public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs
deleted file mode 100644
index 83dbfa29..00000000
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs
+++ /dev/null
@@ -1,182 +0,0 @@
-// Copyright (C) 2026 SharpEmu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-using SharpEmu.HLE.Host;
-using SharpEmu.HLE.Host.Posix;
-
-namespace SharpEmu.Libs.Gpu.Metal;
-
-///
-/// Keyboard state sampled from the Metal presenter's window, feeding the POSIX
-/// host input seam so pad emulation works like the Vulkan presenter's
-/// HostWindowInput. Key events arrive on the AppKit main thread as macOS
-/// virtual key codes; pad reads happen on guest threads, so state is guarded.
-/// Window gamepads are not surfaced by AppKit — controller support would go
-/// through GameController.framework and is out of scope here.
-///
-internal static class MetalHostInput
-{
- private static readonly object Gate = new();
- private static readonly HashSet Pressed = new();
- private static volatile bool _connected;
-
- /// Registers this window's keyboard as the host input source.
- public static void Attach()
- {
- _connected = true;
- PosixHostInput.SetSource(new MetalWindowInputSource());
- Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
- }
-
- // Debug automation: SHARPEMU_METAL_AUTOKEY="12:0x24,15:0x24" presses the
- // macOS key code at each elapsed-seconds mark for a few frames, letting
- // headless test runs navigate menus without a human at the keyboard.
- private static readonly List<(double At, ushort Key, bool[] State)> _autoKeys = ParseAutoKeys();
- private static readonly System.Diagnostics.Stopwatch _autoKeyClock =
- System.Diagnostics.Stopwatch.StartNew();
-
- private static List<(double, ushort, bool[])> ParseAutoKeys()
- {
- var keys = new List<(double, ushort, bool[])>();
- var spec = Environment.GetEnvironmentVariable("SHARPEMU_METAL_AUTOKEY");
- if (string.IsNullOrWhiteSpace(spec))
- {
- return keys;
- }
-
- foreach (var entry in spec.Split(',', StringSplitOptions.RemoveEmptyEntries))
- {
- var parts = entry.Split(':');
- if (parts.Length == 2 &&
- double.TryParse(parts[0], out var at) &&
- TryParseKeyCode(parts[1], out var key))
- {
- keys.Add((at, key, new bool[2]));
- }
- }
-
- return keys;
- }
-
- private static bool TryParseKeyCode(string text, out ushort key)
- {
- return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
- ? ushort.TryParse(text[2..], System.Globalization.NumberStyles.HexNumber, null, out key)
- : ushort.TryParse(text, out key);
- }
-
- /// Called once per render frame; fires and releases scripted keys.
- public static void PumpAutoKeys()
- {
- if (_autoKeys.Count == 0)
- {
- return;
- }
-
- var elapsed = _autoKeyClock.Elapsed.TotalSeconds;
- foreach (var (at, key, state) in _autoKeys)
- {
- if (!state[0] && elapsed >= at)
- {
- state[0] = true;
- KeyDown(key, isRepeat: false);
- Console.Error.WriteLine($"[LOADER][INFO] Metal autokey press 0x{key:X} at {elapsed:F1}s");
- }
- else if (state[0] && !state[1] && elapsed >= at + 0.2)
- {
- state[1] = true;
- KeyUp(key);
- }
- }
- }
-
- public static void KeyDown(ushort keyCode, bool isRepeat)
- {
- // kVK_F1: parity with the Vulkan window's perf-overlay toggle.
- if (keyCode == 0x7A && !isRepeat)
- {
- VideoOut.PerfOverlay.Toggle();
- }
-
- lock (Gate)
- {
- Pressed.Add(keyCode);
- }
- }
-
- public static void KeyUp(ushort keyCode)
- {
- lock (Gate)
- {
- Pressed.Remove(keyCode);
- }
- }
-
- private static bool IsKeyCodeDown(ushort keyCode)
- {
- lock (Gate)
- {
- return Pressed.Contains(keyCode);
- }
- }
-
- private sealed class MetalWindowInputSource : IPosixWindowInputSource
- {
- public bool HasKeyboardFocus => _connected;
-
- public bool IsKeyDown(int virtualKey) =>
- TryMapVirtualKey(virtualKey, out var keyCode) && IsKeyCodeDown(keyCode);
-
- public int GetGamepadStates(Span destination) => 0;
-
- public string? DescribeConnectedGamepad() => null;
- }
-
- /// Windows virtual-key semantics (the seam's contract) to macOS
- /// kVK virtual key codes, covering the keys pad emulation polls.
- private static bool TryMapVirtualKey(int vk, out ushort keyCode)
- {
- keyCode = vk switch
- {
- 0x08 => 0x33, // Backspace -> kVK_Delete
- 0x09 => 0x30, // Tab
- 0x0D => 0x24, // Enter -> kVK_Return
- 0x1B => 0x35, // Escape
- 0x20 => 0x31, // Space
- 0x25 => 0x7B, // Left
- 0x26 => 0x7E, // Up
- 0x27 => 0x7C, // Right
- 0x28 => 0x7D, // Down
- // Letters: macOS ANSI key codes are layout-position based and
- // non-contiguous, so map each polled letter explicitly.
- 0x41 => 0x00, // A
- 0x42 => 0x0B, // B
- 0x43 => 0x08, // C
- 0x44 => 0x02, // D
- 0x45 => 0x0E, // E
- 0x46 => 0x03, // F
- 0x47 => 0x05, // G
- 0x48 => 0x04, // H
- 0x49 => 0x22, // I
- 0x4A => 0x26, // J
- 0x4B => 0x28, // K
- 0x4C => 0x25, // L
- 0x4D => 0x2E, // M
- 0x4E => 0x2D, // N
- 0x4F => 0x1F, // O
- 0x50 => 0x23, // P
- 0x51 => 0x0C, // Q
- 0x52 => 0x0F, // R
- 0x53 => 0x01, // S
- 0x54 => 0x11, // T
- 0x55 => 0x20, // U
- 0x56 => 0x09, // V
- 0x57 => 0x0D, // W
- 0x58 => 0x07, // X
- 0x59 => 0x10, // Y
- 0x5A => 0x06, // Z
- _ => ushort.MaxValue,
- };
- return keyCode != ushort.MaxValue;
- }
-}
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs
index 2afb7f10..15ca01e3 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs
@@ -5,19 +5,6 @@ using System.Runtime.InteropServices;
namespace SharpEmu.Libs.Gpu.Metal;
-// Core Graphics / Metal ABI structs passed by value through objc_msgSend. Struct
-// *returns* are deliberately never used: on x86-64 (this process runs under Rosetta
-// on Apple silicon) large struct returns switch to objc_msgSend_stret, and avoiding
-// them entirely keeps one calling convention everywhere.
-[StructLayout(LayoutKind.Sequential)]
-internal struct CGRect
-{
- public double X;
- public double Y;
- public double Width;
- public double Height;
-}
-
[StructLayout(LayoutKind.Sequential)]
internal struct CGSize
{
@@ -93,31 +80,21 @@ internal struct MtlViewport
}
///
-/// Objective-C runtime access for the Metal presenter: AppKit, QuartzCore, and Metal
+/// Objective-C runtime access for the Metal presenter: QuartzCore and Metal
/// through objc_msgSend, with one LibraryImport overload per distinct native
/// signature. Dependency-free by design — this plus the OS frameworks is the entire
/// Metal path, which is what keeps it NativeAOT-clean.
///
internal static partial class MetalNative
{
- private const string CoreFoundation =
- "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
-
- [LibraryImport(CoreFoundation)]
- public static partial nint CFRunLoopGetMain();
-
- [LibraryImport(CoreFoundation)]
- public static partial void CFRunLoopStop(nint runLoop);
-
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
- private const string AppKitFramework = "/System/Library/Frameworks/AppKit.framework/AppKit";
private const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
private static bool _frameworksLoaded;
///
- /// Makes the AppKit and QuartzCore classes visible to objc_getClass; Metal is
+ /// Makes QuartzCore classes visible to objc_getClass; Metal is
/// pulled in by its own LibraryImport. Call once before any Class() lookup.
///
public static void EnsureFrameworksLoaded()
@@ -127,7 +104,6 @@ internal static partial class MetalNative
return;
}
- NativeLibrary.Load(AppKitFramework);
NativeLibrary.Load(QuartzCoreFramework);
_frameworksLoaded = true;
}
@@ -141,16 +117,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint sel_registerName(string name);
- [LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
- public static partial nint objc_allocateClassPair(nint superclass, string name, nuint extraBytes);
-
- [LibraryImport(ObjCLibrary)]
- public static partial void objc_registerClassPair(nint cls);
-
- [LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
- [return: MarshalAs(UnmanagedType.I1)]
- public static partial bool class_addMethod(nint cls, nint name, nint imp, string types);
-
[LibraryImport(ObjCLibrary)]
public static partial nint objc_autoreleasePoolPush();
@@ -169,14 +135,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument);
-
- /// objc_msgSend for a CGRect-returning selector (e.g. -bounds).
- /// A 32-byte struct is returned via the x86-64 stret ABI — a hidden
- /// pointer to caller storage passed ahead of self/_cmd — so this must not
- /// be folded into the plain objc_msgSend overloads.
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend_stret")]
- public static partial void SendStretRect(out CGRect result, nint receiver, nint selector);
-
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
@@ -209,17 +167,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
- /// performSelectorOnMainThread:withObject:waitUntilDone: — the SEL
- /// to perform is itself an argument, followed by the object and the wait
- /// flag.
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
- public static partial void SendVoidPerformSelector(
- nint receiver,
- nint selector,
- nint performedSelector,
- nint argument,
- [MarshalAs(UnmanagedType.I1)] bool waitUntilDone);
-
/// setSwizzle: on MTLTextureDescriptor. Four one-byte
/// MTLTextureSwizzle values, passed packed like the framework expects.
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
@@ -237,9 +184,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size);
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
- public static partial void SendVoidRect(nint receiver, nint selector, CGRect rect);
-
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
@@ -328,37 +272,6 @@ internal static partial class MetalNative
nuint indexBufferOffset,
nuint instanceCount);
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
- public static partial nint SendTimer(
- nint receiver,
- nint selector,
- double interval,
- nint target,
- nint timerSelector,
- nint userInfo,
- [MarshalAs(UnmanagedType.I1)] bool repeats);
-
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
- public static partial nint SendInitFrame(nint receiver, nint selector, CGRect frame);
-
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
- public static partial nint SendInitWindow(
- nint receiver,
- nint selector,
- CGRect contentRect,
- nuint styleMask,
- nuint backing,
- [MarshalAs(UnmanagedType.I1)] bool defer);
-
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
- public static partial nint SendNextEvent(
- nint receiver,
- nint selector,
- ulong eventMask,
- nint untilDate,
- nint inMode,
- [MarshalAs(UnmanagedType.I1)] bool dequeue);
-
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTextureDescriptor(
nint receiver,
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs
index ee9f8095..d0f2fee3 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs
@@ -1,8 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
using SharpEmu.HLE;
using SharpEmu.Libs.VideoOut;
using SharpEmu.ShaderCompiler;
@@ -11,28 +9,12 @@ using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal;
///
-/// The Metal presenter: an AppKit window hosting a CAMetalLayer. A CADisplayLink
-/// requested from the content view drives in sync with the
-/// display refresh, on the main run loop — the loop whose Core Animation observer
-/// commits presented drawables to the window server, so it must be a real running
-/// run loop (a hand-pumped event drain never fires that observer and the window
-/// stays black). Everything AppKit runs on the process main thread via
-/// (AppKit traps off-main), which the CLI parks for us.
+/// The Metal presenter uses SDL for its host window, events and input, then renders
+/// into the CAMetalLayer owned by SDL's Metal view. Windowing and input therefore
+/// share the Vulkan path while all Metal command encoding remains native.
///
internal static partial class MetalVideoPresenter
{
- private const uint DefaultWindowWidth = 1280;
- private const uint DefaultWindowHeight = 720;
-
- // NSWindow style: Titled | Closable | Miniaturizable | Resizable. Resizable
- // both lets the user drag the window edges and turns the green zoom button
- // into the full-screen toggle (paired with the collection behavior below).
- private const nuint WindowStyleMask = 1 | 2 | 4 | 8;
-
- // NSWindowCollectionBehaviorFullScreenPrimary: opt this window into native
- // full-screen, so the green button enters full-screen rather than zooming.
- private const nuint FullScreenPrimaryBehavior = 1 << 7;
- private const nuint BackingStoreBuffered = 2;
private const nuint PixelFormatBgra8Unorm = (nuint)MtlPixelFormat.Bgra8Unorm;
private const nuint LoadActionLoad = 1;
private const nuint LoadActionClear = 2;
@@ -69,17 +51,14 @@ internal static partial class MetalVideoPresenter
private static readonly byte[] _overlayPixels =
new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4];
- // Presenter objects and per-frame present state, shared between window setup
- // and the display-link RenderFrame callback (both on the main thread).
+ // Presenter objects and per-frame present state, all used on the SDL host thread.
private static nint _device;
private static nint _commandQueue;
private static nint _metalLayer;
private static nint _presentPipeline;
private static nint _presentSampler;
- private static nint _window;
- private static nint _application;
- private static nint _renderTimer;
- private static nint _renderTimerTarget;
+ private static SdlHostWindow? _hostWindow;
+ private static HostVideoOptions _videoOptions = HostVideoOptions.Default;
private static double _drawableWidth;
private static double _drawableHeight;
private static nint _frameTexture;
@@ -91,10 +70,23 @@ internal static partial class MetalVideoPresenter
private static nint _ownedVersionTexture;
private static ulong _presentGuestAddress;
private static long _presentedSequence = -1;
- private static bool _userClosed;
private static uint _windowWidth;
private static uint _windowHeight;
+ public static bool TryConfigureVideo(HostVideoOptions options)
+ {
+ lock (_gate)
+ {
+ if (_thread is not null)
+ {
+ return false;
+ }
+
+ _videoOptions = options.Normalize();
+ return true;
+ }
+ }
+
public static void EnsureStarted(uint width, uint height)
{
if (width == 0 || height == 0)
@@ -183,6 +175,7 @@ internal static partial class MetalVideoPresenter
public static void RequestClose()
{
Volatile.Write(ref _closeRequested, true);
+ Volatile.Read(ref _hostWindow)?.Close();
}
private static void StartPresenterLocked()
@@ -247,33 +240,23 @@ internal static partial class MetalVideoPresenter
VideoOutExports.SetSelectedGpuName(deviceName);
}
- // Fixed window like the Vulkan presenter: guest frames letterbox into
- // it. Sizing the window from the guest's display mode (4K) exceeds the
- // screen — macOS clamps the window while the layer keeps the requested
- // geometry, leaving the visible region showing nothing but clear.
- const uint width = DefaultWindowWidth;
- const uint height = DefaultWindowHeight;
+ HostVideoOptions videoOptions;
+ lock (_gate)
+ {
+ videoOptions = _videoOptions;
+ }
+ using var hostWindow = new SdlHostWindow(
+ VideoOutExports.GetWindowTitle(),
+ videoOptions,
+ SdlGraphicsApi.Metal,
+ ToggleMetalPerformanceHud);
+ Volatile.Write(ref _hostWindow, hostWindow);
var setupPool = MetalNative.objc_autoreleasePoolPush();
try
{
- _application = MetalNative.Send(
- MetalNative.Class("NSApplication"), MetalNative.Selector("sharedApplication"));
- // NSApplicationActivationPolicyRegular: dock icon + key window like any app.
- MetalNative.Send(_application, MetalNative.Selector("setActivationPolicy:"), 0);
- MetalNative.SendVoid(_application, MetalNative.Selector("finishLaunching"));
-
- _window = CreateWindow(width, height);
-
- // Swap in the key-capturing view before the metal layer attaches so
- // the layer lands on the input-aware content view.
- var keyView = MetalNative.SendInitFrame(
- MetalNative.Send(CreateKeyViewClass(), MetalNative.Selector("alloc")),
- MetalNative.Selector("initWithFrame:"),
- new CGRect { X = 0, Y = 0, Width = width, Height = height });
- MetalNative.SendVoid(_window, MetalNative.Selector("setContentView:"), keyView);
-
- _metalLayer = CreateLayer(_device, _window, out _drawableWidth, out _drawableHeight);
+ _metalLayer = hostWindow.CreateMetalLayer();
+ ConfigureMetalLayer(_device, _metalLayer);
_commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue"));
if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError))
{
@@ -282,31 +265,7 @@ internal static partial class MetalVideoPresenter
}
_presentSampler = CreateLinearSampler(_device);
-
- MetalNative.SendVoid(_window, MetalNative.Selector("makeKeyAndOrderFront:"), 0);
- MetalNative.SendVoidBool(
- _application, MetalNative.Selector("activateIgnoringOtherApps:"), true);
- MetalNative.SendVoid(_window, MetalNative.Selector("makeFirstResponder:"), keyView);
- MetalHostInput.Attach();
-
- // A repeating NSTimer on this (main) run loop fires onFrame: at the
- // display rate. CADisplayLink (NSView.displayLinkWithTarget:selector:)
- // is the natural choice but its callback never fires under the x86-64
- // Rosetta process this emulator runs as — proven in isolation against
- // a bare AppKit harness, where a timer fires and composites and the
- // display link does not. nextDrawable still throttles presentation to
- // the display, so the timer only needs to keep up, not pace precisely.
- _renderTimerTarget = CreateRenderTimerTarget();
- _renderTimer = MetalNative.Send(
- MetalNative.SendTimer(
- MetalNative.Class("NSTimer"),
- MetalNative.Selector("scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:"),
- 1.0 / 60.0,
- _renderTimerTarget,
- MetalNative.Selector("onFrame:"),
- 0,
- repeats: true),
- MetalNative.Selector("retain"));
+ SyncDrawableSizeToLayer();
}
finally
{
@@ -314,25 +273,20 @@ internal static partial class MetalVideoPresenter
}
Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut presenter started.");
-
- // [NSApp run] runs the main run loop (its Core Animation observer commits
- // presented drawables to the window server) AND fully activates the app,
- // which a bare CFRunLoopRun does not — the CADisplayLink is only serviced
- // once the app is running, and NSApp dispatches window events itself.
- // Returns once RenderFrame stops it.
- MetalNative.SendVoid(_application, MetalNative.Selector("run"));
-
- var closePool = MetalNative.objc_autoreleasePoolPush();
try
{
- MetalNative.SendVoid(_window, MetalNative.Selector("close"));
+ hostWindow.Run(
+ static () => { },
+ static _ => RenderFrameSafely(),
+ static () => { });
}
finally
{
- MetalNative.objc_autoreleasePoolPop(closePool);
+ Volatile.Write(ref _hostWindow, null);
+ _metalLayer = 0;
}
- if (_userClosed)
+ if (hostWindow.ClosedByUser)
{
Console.Error.WriteLine(
"[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown.");
@@ -340,115 +294,8 @@ internal static partial class MetalVideoPresenter
}
}
- ///
- /// An NSView subclass that records key events for pad emulation. Overriding
- /// keyDown:/keyUp: (instead of an event monitor) needs no ObjC blocks, and
- /// swallowing the events also silences the system alert beep AppKit plays
- /// for unhandled keys. Registered once per process.
- ///
- private static unsafe nint CreateKeyViewClass()
- {
- var cls = MetalNative.objc_allocateClassPair(
- MetalNative.Class("NSView"), "SharpEmuMetalView", 0);
- if (cls == 0)
- {
- return MetalNative.Class("SharpEmuMetalView");
- }
-
- var keyDown = (nint)(delegate* unmanaged[Cdecl])&OnKeyDown;
- MetalNative.class_addMethod(cls, MetalNative.Selector("keyDown:"), keyDown, "v@:@");
- var keyUp = (nint)(delegate* unmanaged[Cdecl])&OnKeyUp;
- MetalNative.class_addMethod(cls, MetalNative.Selector("keyUp:"), keyUp, "v@:@");
- // Command-modified keys never reach keyDown: — AppKit routes them through
- // performKeyEquivalent:, so Cmd+F1 (Metal Performance HUD) hooks in here.
- var keyEquivalent = (nint)(delegate* unmanaged[Cdecl])&OnPerformKeyEquivalent;
- MetalNative.class_addMethod(
- cls, MetalNative.Selector("performKeyEquivalent:"), keyEquivalent, "c@:@");
- // First responder status is what routes key events to this view.
- var accepts = (nint)(delegate* unmanaged[Cdecl])&AcceptsFirstResponder;
- MetalNative.class_addMethod(
- cls, MetalNative.Selector("acceptsFirstResponder"), accepts, "c@:");
- MetalNative.objc_registerClassPair(cls);
- return cls;
- }
-
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static void OnKeyDown(nint self, nint cmd, nint nsEvent)
- {
- try
- {
- var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
- var isRepeat = MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat"));
-
- // Function keys can arrive here even with Command held (AppKit only
- // reroutes some chords through the key-equivalent path), so catch
- // Cmd+F1 in both places — and keep it away from MetalHostInput so it
- // never toggles the plain-F1 perf overlay.
- var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags"));
- if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0)
- {
- if (!isRepeat)
- {
- ToggleMetalPerformanceHud();
- }
-
- return;
- }
-
- MetalHostInput.KeyDown(keyCode, isRepeat);
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][WARN] Metal key-down handler failed: {exception.Message}");
- }
- }
-
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static void OnKeyUp(nint self, nint cmd, nint nsEvent)
- {
- try
- {
- var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
- MetalHostInput.KeyUp(keyCode);
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][WARN] Metal key-up handler failed: {exception.Message}");
- }
- }
-
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static byte AcceptsFirstResponder(nint self, nint cmd) => 1;
-
- private const ushort KeyCodeF1 = 0x7A;
- private const ulong NsEventModifierFlagCommand = 1UL << 20;
private static bool _metalHudVisible;
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static byte OnPerformKeyEquivalent(nint self, nint cmd, nint nsEvent)
- {
- try
- {
- var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
- var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags"));
- if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0)
- {
- if (!MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat")))
- {
- ToggleMetalPerformanceHud();
- }
-
- return 1; // handled: no system beep, no further routing
- }
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][WARN] Metal key-equivalent handler failed: {exception.Message}");
- }
-
- return 0;
- }
-
///
/// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps
/// the built-in CPU-rasterized perf overlay). Configured per Apple's
@@ -456,7 +303,7 @@ internal static partial class MetalVideoPresenter
/// mode=default|disabled and logging=default, plus any MTL_HUD_* environment
/// keys directly in the dictionary — all three HUD flags (enabled, per-frame
/// logging, shader-compile logging) ride in one property set. Runs on the
- /// AppKit main thread (the key-equivalent path), same thread as the render loop.
+ /// SDL host thread, the same thread as the render loop.
///
private static void ToggleMetalPerformanceHud()
{
@@ -508,33 +355,13 @@ internal static partial class MetalVideoPresenter
$"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1).");
}
- private static unsafe nint CreateRenderTimerTarget()
+ /// The SDL host loop drains guest work continuously. The method
+ /// remains as the enqueue-side hook used by guest image synchronization.
+ internal static void ScheduleGuestWorkDrain()
{
- // A minimal NSObject subclass whose onFrame: is our unmanaged callback —
- // the dependency-free way to hand a target/selector to NSTimer without a
- // binding library. Registered once per process.
- var cls = MetalNative.objc_allocateClassPair(
- MetalNative.Class("NSObject"), "SharpEmuRenderTimerTarget", 0);
- if (cls != 0)
- {
- var imp = (nint)(delegate* unmanaged[Cdecl])&OnRenderTimer;
- // "v@:@": void return, self, _cmd, one object argument (the timer).
- MetalNative.class_addMethod(cls, MetalNative.Selector("onFrame:"), imp, "v@:@");
- var wakeImp = (nint)(delegate* unmanaged[Cdecl])&OnGuestWorkWake;
- MetalNative.class_addMethod(cls, MetalNative.Selector("onGuestWork:"), wakeImp, "v@:@");
- MetalNative.objc_registerClassPair(cls);
- }
- else
- {
- cls = MetalNative.Class("SharpEmuRenderTimerTarget");
- }
-
- return MetalNative.Send(
- MetalNative.Send(cls, MetalNative.Selector("alloc")), MetalNative.Selector("init"));
}
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static void OnRenderTimer(nint self, nint cmd, nint timer)
+ private static void RenderFrameSafely()
{
try
{
@@ -546,78 +373,13 @@ internal static partial class MetalVideoPresenter
}
}
- /// Set while an onGuestWork: wake is scheduled on the main run
- /// loop; coalesces enqueue-side wake requests to one in-flight message.
- private static int _guestWorkWakeScheduled;
-
- /// Wakes the main run loop to drain guest work now instead of at
- /// the next render tick. Guest submit→wait round-trips (release-mem labels,
- /// CPU-visible write-backs) otherwise cost a full frame interval each —
- /// games that chain several per frame crawl at a fraction of the display
- /// rate. Safe from any thread; no-op until the presenter starts.
- internal static void ScheduleGuestWorkDrain()
- {
- if (Interlocked.CompareExchange(ref _guestWorkWakeScheduled, 1, 0) != 0)
- {
- return;
- }
-
- var target = _renderTimerTarget;
- if (target == 0)
- {
- Volatile.Write(ref _guestWorkWakeScheduled, 0);
- return;
- }
-
- MetalNative.SendVoidPerformSelector(
- target,
- MetalNative.Selector("performSelectorOnMainThread:withObject:waitUntilDone:"),
- MetalNative.Selector("onGuestWork:"),
- 0,
- waitUntilDone: false);
- }
-
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static void OnGuestWorkWake(nint self, nint cmd, nint argument)
- {
- Volatile.Write(ref _guestWorkWakeScheduled, 0);
- if (_device == 0 || _commandQueue == 0 || Volatile.Read(ref _closeRequested))
- {
- return;
- }
-
- var pool = MetalNative.objc_autoreleasePoolPush();
- try
- {
- DrainGuestWork(_device, _commandQueue);
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][ERROR] Metal guest work wake failed: {exception}");
- }
- finally
- {
- MetalNative.objc_autoreleasePoolPop(pool);
- }
- }
-
private static void RenderFrame()
{
- MetalHostInput.PumpAutoKeys();
var pool = MetalNative.objc_autoreleasePoolPush();
try
{
- // NSApp.run dispatches window events itself, so there is no manual
- // event drain here.
- var visible = MetalNative.SendBool(_window, MetalNative.Selector("isVisible"));
- if (Volatile.Read(ref _closeRequested) || !visible)
+ if (Volatile.Read(ref _closeRequested))
{
- _userClosed = !visible && !Volatile.Read(ref _closeRequested);
- MetalNative.SendVoid(_renderTimer, MetalNative.Selector("invalidate"));
- // Stop both the AppKit loop and the underlying CFRunLoop so
- // [NSApp run] returns.
- MetalNative.SendVoid(_application, MetalNative.Selector("stop:"), 0);
- MetalNative.CFRunLoopStop(MetalNative.CFRunLoopGetMain());
return;
}
@@ -715,8 +477,7 @@ internal static partial class MetalVideoPresenter
if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal))
{
_lastWindowTitle = title;
- MetalNative.SendVoid(
- _window, MetalNative.Selector("setTitle:"), MetalNative.NsString(title));
+ Volatile.Read(ref _hostWindow)?.SetTitle(title);
}
}
@@ -725,8 +486,20 @@ internal static partial class MetalVideoPresenter
// for a drawable, or nextDrawable keeps handing back the original
// resolution and Core Animation stretches it (blurry, mis-scaled
// overlay). No-op when the size is unchanged, i.e. almost every tick.
+ var hostWindow = Volatile.Read(ref _hostWindow);
+ if (hostWindow?.ConsumeSurfaceRestore() == true)
+ {
+ _drawableWidth = 0;
+ _drawableHeight = 0;
+ }
+
SyncDrawableSizeToLayer();
+ if (hostWindow?.IsMinimized == true)
+ {
+ return;
+ }
+
var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable"));
if (drawable == 0)
{
@@ -846,43 +619,33 @@ internal static partial class MetalVideoPresenter
3);
}
- private static nint CreateWindow(uint width, uint height)
+ private static void ConfigureMetalLayer(nint device, nint layer)
{
- var window = MetalNative.SendInitWindow(
- MetalNative.Send(MetalNative.Class("NSWindow"), MetalNative.Selector("alloc")),
- MetalNative.Selector("initWithContentRect:styleMask:backing:defer:"),
- new CGRect { X = 0, Y = 0, Width = width, Height = height },
- WindowStyleMask,
- BackingStoreBuffered,
- defer: false);
- // The presenter owns the handle; AppKit must not free it on user close.
- MetalNative.SendVoidBool(window, MetalNative.Selector("setReleasedWhenClosed:"), false);
- MetalNative.Send(
- window, MetalNative.Selector("setCollectionBehavior:"), (nint)FullScreenPrimaryBehavior);
- MetalNative.SendVoid(
- window,
- MetalNative.Selector("setTitle:"),
- MetalNative.NsString(VideoOutExports.GetWindowTitle()));
- MetalNative.SendVoid(window, MetalNative.Selector("center"));
- // makeKeyAndOrderFront happens after the metal layer is attached.
- return window;
+ MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
+ MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
+ MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
+ MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
+
+ var setDisplaySync = MetalNative.Selector("setDisplaySyncEnabled:");
+ if (MetalNative.SendBool(layer, MetalNative.Selector("respondsToSelector:"), setDisplaySync))
+ {
+ MetalNative.SendVoidBool(layer, setDisplaySync, _videoOptions.VSync);
+ }
}
- /// Keeps the CAMetalLayer's drawable size (pixels) matched to its
- /// current bounds (points) × scale as the window resizes or moves between
- /// displays. CAMetalLayer never updates drawableSize on its own, even as a
- /// view's backing layer, so the render loop drives it.
+ /// Keeps the SDL-owned CAMetalLayer drawable in host pixels as the
+ /// window resizes or moves between displays with different scale factors.
private static void SyncDrawableSizeToLayer()
{
- MetalNative.SendStretRect(out var bounds, _metalLayer, MetalNative.Selector("bounds"));
- var scale = MetalNative.SendDouble(_metalLayer, MetalNative.Selector("contentsScale"));
- if (scale <= 0)
+ var hostWindow = Volatile.Read(ref _hostWindow);
+ if (hostWindow is null || _metalLayer == 0)
{
- scale = 1;
+ return;
}
- var width = Math.Max(1, Math.Round(bounds.Width * scale));
- var height = Math.Max(1, Math.Round(bounds.Height * scale));
+ var pixelSize = hostWindow.PixelSize;
+ var width = (double)pixelSize.Width;
+ var height = (double)pixelSize.Height;
if (width == _drawableWidth && height == _drawableHeight)
{
return;
@@ -896,57 +659,6 @@ internal static partial class MetalVideoPresenter
new CGSize { Width = width, Height = height });
}
- private static nint CreateLayer(nint device, nint window, out double drawableWidth, out double drawableHeight)
- {
- var contentView = MetalNative.Send(window, MetalNative.Selector("contentView"));
- var scale = MetalNative.SendDouble(window, MetalNative.Selector("backingScaleFactor"));
- if (scale <= 0)
- {
- scale = 1;
- }
-
- const uint width = DefaultWindowWidth;
- const uint height = DefaultWindowHeight;
- drawableWidth = width * scale;
- drawableHeight = height * scale;
-
- var layer = MetalNative.Send(
- MetalNative.Send(MetalNative.Class("CAMetalLayer"), MetalNative.Selector("alloc")),
- MetalNative.Selector("init"));
- MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
- MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
- // A Core Animation layer composites with its alpha channel by default, so
- // a presented frame whose guest alpha is zero would show through as the
- // window background (black). The presenter output is a finished opaque
- // frame; mark the layer opaque so alpha never reaches the compositor.
- MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
- MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
- MetalNative.SendVoidDouble(layer, MetalNative.Selector("setContentsScale:"), scale);
- MetalNative.SendVoidSize(
- layer,
- MetalNative.Selector("setDrawableSize:"),
- new CGSize { Width = drawableWidth, Height = drawableHeight });
-
- // A manually created layer defaults to a zero-size frame, and a hosted
- // layer's geometry is the caller's job: without this the presenter
- // happily presents every drawable into a layer with no on-screen
- // extent — a permanently black window.
- MetalNative.SendVoidRect(
- layer,
- MetalNative.Selector("setFrame:"),
- new CGRect { X = 0, Y = 0, Width = width, Height = height });
-
- // wantsLayer FIRST, then the layer: that makes the metal layer the
- // view's AppKit-managed BACKING layer (geometry and window-server
- // commits handled by AppKit) — the SDL/GLFW pattern. The reverse order
- // creates a layer-hosting view whose tree the app must commit itself,
- // which never composites under a manually pumped run loop.
- MetalNative.SendVoidBool(contentView, MetalNative.Selector("setWantsLayer:"), true);
- MetalNative.SendVoid(contentView, MetalNative.Selector("setLayer:"), layer);
- MetalNative.SendVoid(MetalNative.Class("CATransaction"), MetalNative.Selector("flush"));
- return layer;
- }
-
private static bool TryCreatePresentPipeline(nint device, out nint pipeline, out string error)
{
pipeline = 0;
@@ -1105,10 +817,24 @@ internal static partial class MetalVideoPresenter
{
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline);
- // Aspect-fit letterbox: scale the frame into the drawable via the viewport.
- var scale = Math.Min(drawableWidth / frameWidth, drawableHeight / frameHeight);
- var viewportWidth = frameWidth * scale;
- var viewportHeight = frameHeight * scale;
+ var scaleX = drawableWidth / frameWidth;
+ var scaleY = drawableHeight / frameHeight;
+ var viewportWidth = drawableWidth;
+ var viewportHeight = drawableHeight;
+ if (_videoOptions.ScalingMode != HostScalingMode.Stretch)
+ {
+ var scale = _videoOptions.ScalingMode == HostScalingMode.Cover
+ ? Math.Max(scaleX, scaleY)
+ : Math.Min(scaleX, scaleY);
+ if (_videoOptions.ScalingMode == HostScalingMode.Integer && scale >= 1)
+ {
+ scale = Math.Floor(scale);
+ }
+
+ viewportWidth = frameWidth * scale;
+ viewportHeight = frameHeight * scale;
+ }
+
MetalNative.SendVoidViewport(
encoder,
MetalNative.Selector("setViewport:"),
diff --git a/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs
index fbe46938..d8973905 100644
--- a/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs
+++ b/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs
@@ -378,8 +378,10 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
- public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
- VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
+ public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
+ VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels, rowOffset);
+
+ public bool SupportsPartialImageWrite => true;
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
VulkanVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
index 03fc9f40..319ee53d 100644
--- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
@@ -129,6 +129,24 @@ public static partial class KernelMemoryCompatExports
private static readonly HashSet _negativeStatCache = new(HostFsPathComparer);
private static readonly ConcurrentDictionary _aprFileSizeCache = new(HostFsPathComparer);
private static long _nextFileDescriptor = 2;
+ private static string _applicationTitleId = "UNKNOWN";
+
+ public static void ConfigureApplicationInfo(string? titleId)
+ {
+ var value = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId.Trim();
+ Span sanitized = value.Length <= 128
+ ? stackalloc char[value.Length]
+ : new char[value.Length];
+ for (var index = 0; index < value.Length; index++)
+ {
+ var character = value[index];
+ sanitized[index] = char.IsAsciiLetterOrDigit(character) || character is '-' or '_'
+ ? char.ToUpperInvariant(character)
+ : '_';
+ }
+
+ Volatile.Write(ref _applicationTitleId, new string(sanitized));
+ }
internal static int AllocateGuestFileDescriptor()
{
@@ -5350,7 +5368,7 @@ public static partial class KernelMemoryCompatExports
}
else
{
- root = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "logs", "devlog", "app"));
+ root = Path.Combine(ResolveGameLogRoot(), "devlog", "app");
}
Directory.CreateDirectory(root);
@@ -5419,14 +5437,20 @@ public static partial class KernelMemoryCompatExports
}
else
{
- root = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "logs", "hostapp"));
- Environment.SetEnvironmentVariable(hostappVariableName, root);
+ root = Path.Combine(ResolveGameLogRoot(), "hostapp");
}
Directory.CreateDirectory(root);
return root;
}
+ private static string ResolveGameLogRoot() =>
+ Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "user",
+ "game_logs",
+ Volatile.Read(ref _applicationTitleId)));
+
private static string GetPerAppWritableRoot()
{
var app0Root = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
diff --git a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs
index 724ee02c..3a02c2eb 100644
--- a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs
@@ -35,9 +35,13 @@ public static class KernelPthreadCompatExports
private static readonly bool _tracePthreadConds =
_tracePthreads ||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_CONDS"), "1", StringComparison.Ordinal);
+ private static readonly bool _tracePthreadFastPath =
+ string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_FASTPATH"), "1", StringComparison.Ordinal);
private static readonly HashSet? _tracePthreadMutexFilter = ParseTraceAddressFilter(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_MUTEX_FILTER"));
private static long _nextSynchronizationWaiterId;
+ private static int _pthreadFastPathTraceWritten;
+ private static readonly ConcurrentDictionary _pthreadFastPathBusyTraced = new();
private sealed class PthreadMutexState
{
@@ -809,6 +813,7 @@ public static class KernelPthreadCompatExports
if (!TryResolveMutexState(ctx, mutexAddress, createIfZero: true, out var resolvedAddress, out var state))
{
+ TracePthreadFastPathBusy(tryOnly ? "trylock_missing" : "lock_missing", mutexAddress, resolvedAddress, null, KernelPthreadState.GetCurrentThreadHandle(), (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, null, KernelPthreadState.GetCurrentThreadHandle(), (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
@@ -861,6 +866,7 @@ public static class KernelPthreadCompatExports
var ownedResult = tryOnly
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
+ TracePthreadFastPathBusy(tryOnly ? "trylock_self" : "lock_self", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
return ownedResult;
}
@@ -949,6 +955,7 @@ public static class KernelPthreadCompatExports
if (tryOnly)
{
+ TracePthreadFastPathBusy("trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
@@ -1021,6 +1028,7 @@ public static class KernelPthreadCompatExports
{
if (state.RecursionCount <= 0)
{
+ TracePthreadFastPathUnlock(ctx, mutexAddress, resolvedAddress, state, currentThreadId);
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
@@ -1187,17 +1195,23 @@ public static class KernelPthreadCompatExports
return 0;
}
- if (_mutexStates.ContainsKey(mutexAddress))
+ var hasPointedHandle =
+ KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress, out var pointedHandle) &&
+ pointedHandle != 0 &&
+ pointedHandle != mutexAddress;
+
+ if (_mutexStates.TryGetValue(mutexAddress, out var cachedState))
{
- return mutexAddress;
+ return hasPointedHandle &&
+ _mutexStates.TryGetValue(pointedHandle, out var pointedState) &&
+ !ReferenceEquals(pointedState, cachedState)
+ ? pointedHandle
+ : mutexAddress;
}
- if (KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress, out var pointedHandle) && pointedHandle != 0)
+ if (hasPointedHandle && _mutexStates.ContainsKey(pointedHandle))
{
- if (_mutexStates.ContainsKey(pointedHandle))
- {
- return pointedHandle;
- }
+ return pointedHandle;
}
return mutexAddress;
@@ -1212,13 +1226,35 @@ public static class KernelPthreadCompatExports
return false;
}
+ var hasPointedHandle = KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress, out var pointedHandle);
+
if (_mutexStates.TryGetValue(mutexAddress, out state))
{
+ // `mutexAddress` is often the address of the guest's ScePthreadMutex
+ // variable rather than the handle itself, and that storage is
+ // reusable — a stack frame recycles the slot, or the guest assigns a
+ // different mutex to it. The slot therefore outranks anything cached
+ // under its address: keeping the stale entry would resolve a release
+ // onto the wrong mutex, leave the real one owned forever and wedge
+ // every waiter on it (Demon's Souls' Scream audio engine did exactly
+ // this and spun on scePthreadMutexTrylock).
+ if (hasPointedHandle &&
+ pointedHandle != 0 &&
+ pointedHandle != mutexAddress &&
+ _mutexStates.TryGetValue(pointedHandle, out var pointedState) &&
+ !ReferenceEquals(pointedState, state))
+ {
+ _mutexStates[mutexAddress] = pointedState;
+ resolvedAddress = pointedHandle;
+ state = pointedState;
+ return true;
+ }
+
resolvedAddress = mutexAddress;
return true;
}
- if (!KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress, out var pointedHandle))
+ if (!hasPointedHandle)
{
return false;
}
@@ -2163,6 +2199,60 @@ public static class KernelPthreadCompatExports
$"recursion={(state?.RecursionCount ?? 0)} type={(state?.Type ?? 0)} result=0x{unchecked((uint)result):X8}");
}
+ private static void TracePthreadFastPathUnlock(
+ CpuContext ctx,
+ ulong mutexAddress,
+ ulong resolvedAddress,
+ PthreadMutexState state,
+ ulong currentThreadId)
+ {
+ if (!_tracePthreadFastPath || Interlocked.Increment(ref _pthreadFastPathTraceWritten) > 16)
+ {
+ return;
+ }
+
+ Span objectBytes = stackalloc byte[0x50];
+ if (!ctx.Memory.TryRead(resolvedAddress, objectBytes))
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] pthread_fastpath_unlock: mutex=0x{mutexAddress:X16} resolved=0x{resolvedAddress:X16} read=failed");
+ return;
+ }
+
+ Span words = stackalloc ulong[10];
+ for (var index = 0; index < words.Length; index++)
+ {
+ words[index] = BinaryPrimitives.ReadUInt64LittleEndian(objectBytes.Slice(index * sizeof(ulong), sizeof(ulong)));
+ }
+
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] pthread_fastpath_unlock: mutex=0x{mutexAddress:X16} resolved=0x{resolvedAddress:X16} " +
+ $"current=0x{currentThreadId:X16} owner=0x{state.OwnerThreadId:X16} recursion={state.RecursionCount} " +
+ $"q00=0x{words[0]:X16} q08=0x{words[1]:X16} q10=0x{words[2]:X16} q18=0x{words[3]:X16} " +
+ $"q20=0x{words[4]:X16} q28=0x{words[5]:X16} q30=0x{words[6]:X16} q38=0x{words[7]:X16} " +
+ $"q40=0x{words[8]:X16} q48=0x{words[9]:X16}");
+ }
+
+ private static void TracePthreadFastPathBusy(
+ string operation,
+ ulong mutexAddress,
+ ulong resolvedAddress,
+ PthreadMutexState? state,
+ ulong currentThreadId,
+ int result)
+ {
+ if (!_tracePthreadFastPath || !_pthreadFastPathBusyTraced.TryAdd(mutexAddress, 0))
+ {
+ return;
+ }
+
+ Console.Error.WriteLine(
+ $"[LOADER][TRACE] pthread_fastpath_{operation}: mutex=0x{mutexAddress:X16} resolved=0x{resolvedAddress:X16} " +
+ $"current=0x{currentThreadId:X16} owner=0x{(state?.OwnerThreadId ?? 0):X16} " +
+ $"recursion={(state?.RecursionCount ?? 0)} type={(state?.Type ?? 0)} " +
+ $"waiters={(state?.QueuedWaiterCount ?? 0)} result=0x{unchecked((uint)result):X8}");
+ }
+
private static void TracePthreadCond(string operation, ulong condAddress, ulong mutexAddress, PthreadCondState? state, bool timed, int result)
{
if (!_tracePthreadConds)
diff --git a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs
index 19ed2b48..41a3b26e 100644
--- a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs
+++ b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs
@@ -30,7 +30,7 @@ public static class Ngs2Exports
// The grain length defaults to 256 frames (matching the 8192-byte AudioOut
// buffers games copy it into) until the title overrides it.
private const int DefaultGrainSamples = 256;
- private const double OutputSampleRate = 48000.0;
+ private const int DefaultSampleRate = 48000;
private sealed class SystemState
{
@@ -38,6 +38,7 @@ public static class Ngs2Exports
public uint Uid { get; }
public int GrainSamples { get; set; } = DefaultGrainSamples;
+ public int SampleRate { get; set; } = DefaultSampleRate;
}
private sealed record RackState(ulong SystemHandle, uint RackId);
@@ -496,6 +497,7 @@ public static class Ngs2Exports
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
+ Span renderBufferInfo = stackalloc byte[RenderBufferInfoSize];
for (uint i = 0; i < bufferInfoCount; i++)
{
var entryAddress = bufferInfoAddress + (i * RenderBufferInfoSize);
@@ -527,10 +529,9 @@ public static class Ngs2Exports
if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4)
{
- Span rbi = stackalloc byte[RenderBufferInfoSize];
- ctx.Memory.TryRead(entryAddress, rbi);
+ ctx.Memory.TryRead(entryAddress, renderBufferInfo);
Console.Error.WriteLine(
- $"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(rbi)}");
+ $"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(renderBufferInfo)}");
}
}
}
@@ -552,6 +553,7 @@ public static class Ngs2Exports
CpuContext ctx, ulong systemHandle, ulong bufferAddress, ulong bufferSize, int channels)
{
int grain;
+ int sampleRate;
lock (StateGate)
{
if (!Systems.TryGetValue(systemHandle, out var system))
@@ -560,6 +562,7 @@ public static class Ngs2Exports
}
grain = system.GrainSamples;
+ sampleRate = system.SampleRate;
}
var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float)));
@@ -590,7 +593,7 @@ public static class Ngs2Exports
continue;
}
- MixOneVoice(accum, capacityFrames, channels, voice);
+ MixOneVoice(accum, capacityFrames, channels, sampleRate, voice);
mixedAnything = true;
}
}
@@ -606,15 +609,20 @@ public static class Ngs2Exports
}
}
- // Resample one voice from its source rate to 48 kHz (nearest-sample) and add
+ // Resample one voice to the system rate and add
// it to the front stereo pair. Advances the voice cursor and handles loop /
// one-shot end. Must be called under StateGate.
- private static void MixOneVoice(float[] accum, int frames, int channels, VoiceState voice)
+ private static void MixOneVoice(
+ float[] accum,
+ int frames,
+ int channels,
+ int outputSampleRate,
+ VoiceState voice)
{
var pcm = voice.Pcm!;
var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length;
var loopStart = voice.LoopStart;
- var step = voice.SourceRate / OutputSampleRate;
+ var step = voice.SourceRate / (double)outputSampleRate;
var gain = voice.Gain / 32768f;
var pos = voice.Position;
for (var f = 0; f < frames; f++)
@@ -640,7 +648,14 @@ public static class Ngs2Exports
break;
}
- var sample = pcm[idx] * gain;
+ var next = idx + 1;
+ if (next >= loopEnd)
+ {
+ next = loopStart >= 0 && loopStart < loopEnd ? loopStart : idx;
+ }
+
+ var fraction = pos - idx;
+ var sample = (float)((pcm[idx] + ((pcm[next] - pcm[idx]) * fraction)) * gain);
var baseIndex = f * channels;
accum[baseIndex] += sample;
if (channels > 1)
@@ -736,7 +751,27 @@ public static class Ngs2Exports
ExportName = "sceNgs2SystemSetSampleRate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
- public static int Ngs2SystemSetSampleRate(CpuContext ctx) => ValidateSystem(ctx);
+ public static int Ngs2SystemSetSampleRate(CpuContext ctx)
+ {
+ var systemHandle = ctx[CpuRegister.Rdi];
+ var sampleRate = unchecked((int)ctx[CpuRegister.Rsi]);
+ lock (StateGate)
+ {
+ if (!Systems.TryGetValue(systemHandle, out var system))
+ {
+ return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
+ }
+
+ if (sampleRate is < 8000 or > 192000)
+ {
+ return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ system.SampleRate = sampleRate;
+ }
+
+ return SetReturn(ctx, 0);
+ }
[SysAbiExport(
Nid = "gThZqM5PYlQ",
diff --git a/src/SharpEmu.Libs/Pad/HostWindowInput.cs b/src/SharpEmu.Libs/Pad/HostWindowInput.cs
index 89ff43e0..116fad99 100644
--- a/src/SharpEmu.Libs/Pad/HostWindowInput.cs
+++ b/src/SharpEmu.Libs/Pad/HostWindowInput.cs
@@ -2,138 +2,135 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host;
-using SharpEmu.HLE.Host.Posix;
-using Silk.NET.Input;
namespace SharpEmu.Libs.Pad;
-///
-/// Keyboard and gamepad state sampled from the presenter's window, feeding
-/// the POSIX host input seam (macOS/Linux have no user32/XInput/raw-HID
-/// readers). The presenter attaches the window's input context once the
-/// window exists; input events arrive on the window thread and pad reads
-/// happen on guest threads, so all state is guarded.
-///
+/// Cross-platform input state supplied by the SDL game window.
public static class HostWindowInput
{
private static readonly object Gate = new();
- private static readonly HashSet Pressed = new();
- private static volatile bool _connected;
-
- // Latest window-gamepad snapshot in the host seam's conventions.
+ private static readonly HashSet PressedKeys = new();
+ private static bool _focused;
private static bool _gamepadConnected;
private static string? _gamepadName;
- private static HostGamepadButtons _gamepadButtons;
- private static byte _gamepadLeftX = 128;
- private static byte _gamepadLeftY = 128;
- private static byte _gamepadRightX = 128;
- private static byte _gamepadRightY = 128;
- private static byte _gamepadL2;
- private static byte _gamepadR2;
+ private static HostGamepadState _gamepadState;
+ private static IHostGamepadOutput? _gamepadOutput;
+ private static readonly WindowInputSource Source = new();
- /// True once a window keyboard is delivering events.
- public static bool IsConnected => _connected;
-
- public static void Attach(IInputContext input)
- {
- foreach (var keyboard in input.Keyboards)
- {
- keyboard.KeyDown += (_, key, _) =>
- {
- if (key == Key.F1)
- {
- VideoOut.PerfOverlay.Toggle();
- }
-
- lock (Gate)
- {
- Pressed.Add(key);
- }
- };
- keyboard.KeyUp += (_, key, _) =>
- {
- lock (Gate)
- {
- Pressed.Remove(key);
- }
- };
- }
-
- if (input.Keyboards.Count > 0)
- {
- _connected = true;
- }
-
- foreach (var gamepad in input.Gamepads)
- {
- AttachGamepad(gamepad);
- }
-
- input.ConnectionChanged += (device, connected) =>
- {
- if (device is not IGamepad gamepad)
- {
- return;
- }
-
- if (connected)
- {
- AttachGamepad(gamepad);
- return;
- }
-
- lock (Gate)
- {
- _gamepadConnected = false;
- _gamepadName = null;
- _gamepadButtons = HostGamepadButtons.None;
- _gamepadLeftX = 128;
- _gamepadLeftY = 128;
- _gamepadRightX = 128;
- _gamepadRightY = 128;
- _gamepadL2 = 0;
- _gamepadR2 = 0;
- }
- };
-
- PosixHostInput.SetSource(new WindowInputSource());
- }
-
- public static bool IsKeyDown(Key key)
+ public static void Connect(IHostGamepadOutput? gamepadOutput = null)
{
lock (Gate)
{
- return Pressed.Contains(key);
+ _focused = true;
+ _gamepadOutput = gamepadOutput;
+ PressedKeys.Clear();
+ }
+
+ HostWindowInputSource.Set(Source);
+ }
+
+ public static void Disconnect()
+ {
+ lock (Gate)
+ {
+ _focused = false;
+ _gamepadConnected = false;
+ _gamepadName = null;
+ _gamepadState = default;
+ _gamepadOutput = null;
+ PressedKeys.Clear();
+ }
+
+ HostWindowInputSource.Clear(Source);
+ }
+
+ public static void SetFocused(bool focused)
+ {
+ lock (Gate)
+ {
+ _focused = focused;
+ if (!focused)
+ {
+ PressedKeys.Clear();
+ }
}
}
- private sealed class WindowInputSource : IPosixWindowInputSource
+ public static void SetKey(int virtualKey, bool down)
{
- public bool HasKeyboardFocus => _connected;
+ lock (Gate)
+ {
+ if (down)
+ {
+ PressedKeys.Add(virtualKey);
+ }
+ else
+ {
+ PressedKeys.Remove(virtualKey);
+ }
+ }
+ }
+
+ public static void SetGamepad(string? name, HostGamepadState state)
+ {
+ lock (Gate)
+ {
+ _gamepadConnected = state.Connected;
+ _gamepadName = name;
+ _gamepadState = state;
+ }
+ }
+
+ public static void ClearGamepad()
+ {
+ lock (Gate)
+ {
+ _gamepadConnected = false;
+ _gamepadName = null;
+ _gamepadState = default;
+ }
+ }
+
+ internal static byte ToStickByte(short value)
+ {
+ var normalized = value + 32768;
+ return (byte)Math.Clamp((normalized * 255 + 32767) / 65535, 0, 255);
+ }
+
+ internal static byte ToTriggerByte(short value) =>
+ (byte)Math.Clamp(value * 255 / 32767, 0, 255);
+
+ private sealed class WindowInputSource : IHostWindowInputSource
+ {
+ public bool HasKeyboardFocus
+ {
+ get
+ {
+ lock (Gate)
+ {
+ return _focused;
+ }
+ }
+ }
public bool IsKeyDown(int virtualKey)
{
- return TryMapVirtualKey(virtualKey, out var key) && HostWindowInput.IsKeyDown(key);
+ lock (Gate)
+ {
+ return PressedKeys.Contains(virtualKey);
+ }
}
public int GetGamepadStates(Span destination)
{
lock (Gate)
{
- if (!_gamepadConnected || destination.Length == 0)
+ if (!_gamepadConnected || destination.IsEmpty)
{
return 0;
}
- destination[0] = new HostGamepadState(
- Connected: true,
- Buttons: _gamepadButtons,
- LeftX: _gamepadLeftX,
- LeftY: _gamepadLeftY,
- RightX: _gamepadRightX,
- RightY: _gamepadRightY,
- LeftTrigger: _gamepadL2,
- RightTrigger: _gamepadR2);
+ destination[0] = _gamepadState;
return 1;
}
}
@@ -142,139 +139,80 @@ public static class HostWindowInput
{
lock (Gate)
{
- return _gamepadConnected ? _gamepadName ?? "GLFW gamepad" : null;
+ return _gamepadConnected ? _gamepadName ?? "SDL gamepad" : null;
}
}
- }
- private static bool TryMapVirtualKey(int vk, out Key key)
- {
- key = vk switch
+ public void SetRumble(byte largeMotor, byte smallMotor)
{
- 0x08 => Key.Backspace,
- 0x09 => Key.Tab,
- 0x0D => Key.Enter,
- 0x1B => Key.Escape,
- 0x25 => Key.Left,
- 0x26 => Key.Up,
- 0x27 => Key.Right,
- 0x28 => Key.Down,
- >= 0x41 and <= 0x5A => Key.A + (vk - 0x41),
- _ => Key.Unknown,
- };
- return key != Key.Unknown;
- }
+ IHostGamepadOutput? output;
+ lock (Gate)
+ {
+ output = _gamepadOutput;
+ }
- private static void AttachGamepad(IGamepad gamepad)
- {
- lock (Gate)
- {
- _gamepadConnected = true;
- _gamepadName = gamepad.Name;
+ output?.SetRumble(largeMotor, smallMotor);
}
- gamepad.ButtonDown += (_, button) =>
+ public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
{
- var bit = MapButton(button.Name);
- if (bit == HostGamepadButtons.None)
+ IHostGamepadOutput? output;
+ lock (Gate)
{
- return;
+ output = _gamepadOutput;
}
+ output?.SetTriggerRumble(leftTrigger, rightTrigger);
+ }
+
+ public void SetAdaptiveTriggerEffect(
+ HostAdaptiveTriggerEffect? leftTrigger,
+ HostAdaptiveTriggerEffect? rightTrigger)
+ {
+ IHostGamepadOutput? output;
lock (Gate)
{
- _gamepadButtons |= bit;
- }
- };
- gamepad.ButtonUp += (_, button) =>
- {
- var bit = MapButton(button.Name);
- if (bit == HostGamepadButtons.None)
- {
- return;
+ output = _gamepadOutput;
}
- lock (Gate)
- {
- _gamepadButtons &= ~bit;
- }
- };
- gamepad.ThumbstickMoved += (_, thumbstick) =>
+ output?.SetAdaptiveTriggerEffect(leftTrigger, rightTrigger);
+ }
+
+ public void SetLightbar(byte red, byte green, byte blue)
{
- // Silk's GLFW backend reports sticks -1..1 with +Y pointing down,
- // matching the seam's 0..255 down-growing convention after biasing.
- var x = ToStickByte(thumbstick.X);
- var y = ToStickByte(thumbstick.Y);
+ IHostGamepadOutput? output;
lock (Gate)
{
- if (thumbstick.Index == 0)
- {
- _gamepadLeftX = x;
- _gamepadLeftY = y;
- }
- else
- {
- _gamepadRightX = x;
- _gamepadRightY = y;
- }
+ output = _gamepadOutput;
}
- };
- gamepad.TriggerMoved += (_, trigger) =>
+
+ output?.SetLightbar(red, green, blue);
+ }
+
+ public void ResetLightbar()
{
- // GLFW gamepad triggers rest at -1 and saturate at +1.
- var value = (byte)Math.Clamp((int)((trigger.Position + 1.0f) * 0.5f * 255.0f), 0, 255);
+ IHostGamepadOutput? output;
lock (Gate)
{
- if (trigger.Index == 0)
- {
- _gamepadL2 = value;
- if (value > 64)
- {
- _gamepadButtons |= HostGamepadButtons.L2;
- }
- else
- {
- _gamepadButtons &= ~HostGamepadButtons.L2;
- }
- }
- else
- {
- _gamepadR2 = value;
- if (value > 64)
- {
- _gamepadButtons |= HostGamepadButtons.R2;
- }
- else
- {
- _gamepadButtons &= ~HostGamepadButtons.R2;
- }
- }
+ output = _gamepadOutput;
}
- };
+
+ output?.ResetLightbar();
+ }
}
-
- internal static byte ToStickByte(float value)
- {
- return (byte)Math.Clamp((int)MathF.Round((value + 1.0f) * 127.5f), 0, 255);
- }
-
- private static HostGamepadButtons MapButton(ButtonName name) => name switch
- {
- // GLFW reports the Xbox layout: A=Cross, B=Circle, X=Square, Y=Triangle.
- ButtonName.A => HostGamepadButtons.Cross,
- ButtonName.B => HostGamepadButtons.Circle,
- ButtonName.X => HostGamepadButtons.Square,
- ButtonName.Y => HostGamepadButtons.Triangle,
- ButtonName.LeftBumper => HostGamepadButtons.L1,
- ButtonName.RightBumper => HostGamepadButtons.R1,
- ButtonName.Back => HostGamepadButtons.TouchPad,
- ButtonName.Start => HostGamepadButtons.Options,
- ButtonName.LeftStick => HostGamepadButtons.L3,
- ButtonName.RightStick => HostGamepadButtons.R3,
- ButtonName.DPadUp => HostGamepadButtons.Up,
- ButtonName.DPadRight => HostGamepadButtons.Right,
- ButtonName.DPadDown => HostGamepadButtons.Down,
- ButtonName.DPadLeft => HostGamepadButtons.Left,
- _ => HostGamepadButtons.None,
- };
+}
+
+public interface IHostGamepadOutput
+{
+ void SetRumble(byte largeMotor, byte smallMotor);
+
+ void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
+
+ void SetAdaptiveTriggerEffect(
+ HostAdaptiveTriggerEffect? leftTrigger,
+ HostAdaptiveTriggerEffect? rightTrigger);
+
+ void SetLightbar(byte red, byte green, byte blue);
+
+ void ResetLightbar();
}
diff --git a/src/SharpEmu.Libs/Pad/PadExports.cs b/src/SharpEmu.Libs/Pad/PadExports.cs
index 12990402..6db7e017 100644
--- a/src/SharpEmu.Libs/Pad/PadExports.cs
+++ b/src/SharpEmu.Libs/Pad/PadExports.cs
@@ -37,6 +37,7 @@ public static class PadExports
private static PadState _cachedInputState;
private static bool _initialized;
+ private static int _motionSensorEnabled;
private static int _controlsAnnouncementLogged;
[SysAbiExport(
@@ -151,9 +152,13 @@ public static class PadExports
public static int PadSetMotionSensorState(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
- return IsPrimaryPadHandle(handle)
- ? ctx.SetReturn(0)
- : ctx.SetReturn(OrbisPadErrorInvalidHandle);
+ if (!IsPrimaryPadHandle(handle))
+ {
+ return ctx.SetReturn(OrbisPadErrorInvalidHandle);
+ }
+
+ Volatile.Write(ref _motionSensorEnabled, ctx[CpuRegister.Rsi] != 0 ? 1 : 0);
+ return ctx.SetReturn(0);
}
[SysAbiExport(
@@ -362,24 +367,170 @@ public static class PadExports
}
var triggerMask = parameter[0];
- HostPlatform.Current.Input.SetTriggerRumble(
- (triggerMask & 0x01) != 0 ? DecodeTriggerVibration(parameter[8..64]) : null,
- (triggerMask & 0x02) != 0 ? DecodeTriggerVibration(parameter[64..120]) : null);
+ HostPlatform.Current.Input.SetAdaptiveTriggerEffect(
+ (triggerMask & 0x01) != 0 ? DecodeTriggerEffect(parameter[8..64]) : null,
+ (triggerMask & 0x02) != 0 ? DecodeTriggerEffect(parameter[64..120]) : null);
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
}
- private static byte DecodeTriggerVibration(ReadOnlySpan command)
+ private static HostAdaptiveTriggerEffect DecodeTriggerEffect(ReadOnlySpan command)
{
var mode = BinaryPrimitives.ReadUInt32LittleEndian(command);
- var amplitude = mode switch
+ var parameters = command[8..];
+ Span native = stackalloc byte[11];
+ native.Clear();
+ byte fallbackStrength = 0;
+ switch (mode)
{
- 3 when command[10] != 0 => command[9],
- 6 when command[8] != 0 => command[9..19].ToArray().Max(),
- _ => (byte)0,
- };
- return (byte)(Math.Min(amplitude, (byte)8) * 255 / 8);
+ case 1:
+ EncodeFeedback(native, parameters[0], parameters[1]);
+ fallbackStrength = ScaleTriggerStrength(parameters[1]);
+ break;
+ case 2:
+ EncodeWeapon(native, parameters[0], parameters[1], parameters[2]);
+ fallbackStrength = ScaleTriggerStrength(parameters[2]);
+ break;
+ case 3:
+ EncodeZonedEffect(native, 0x26, parameters[0], parameters[1], parameters[2]);
+ fallbackStrength = ScaleTriggerStrength(parameters[1]);
+ break;
+ case 4:
+ EncodeZonedStrengths(native, 0x21, parameters[..10], 0);
+ fallbackStrength = ScaleTriggerStrength(Max(parameters[..10]));
+ break;
+ case 5:
+ EncodeSlope(native, parameters[0], parameters[1], parameters[2], parameters[3]);
+ fallbackStrength = ScaleTriggerStrength(Math.Max(parameters[2], parameters[3]));
+ break;
+ case 6:
+ EncodeZonedStrengths(native, 0x26, parameters[1..11], parameters[0]);
+ fallbackStrength = parameters[0] == 0 ? (byte)0 : ScaleTriggerStrength(Max(parameters[1..11]));
+ break;
+ default:
+ native[0] = 0x05;
+ break;
+ }
+
+ return HostAdaptiveTriggerEffect.FromBytes(native, fallbackStrength);
}
+ private static void EncodeFeedback(Span destination, byte position, byte strength)
+ {
+ if (position > 9 || strength is 0 or > 8)
+ {
+ destination[0] = 0x05;
+ return;
+ }
+
+ Span strengths = stackalloc byte[10];
+ strengths[position..].Fill(strength);
+ EncodeZonedStrengths(destination, 0x21, strengths, 0);
+ }
+
+ private static void EncodeWeapon(Span destination, byte start, byte end, byte strength)
+ {
+ if (start is < 2 or > 7 || end <= start || end > 8 || strength is 0 or > 8)
+ {
+ destination[0] = 0x05;
+ return;
+ }
+
+ var zones = (ushort)((1 << start) | (1 << end));
+ destination[0] = 0x25;
+ BinaryPrimitives.WriteUInt16LittleEndian(destination[1..], zones);
+ destination[3] = (byte)(strength - 1);
+ }
+
+ private static void EncodeZonedEffect(
+ Span destination,
+ byte nativeMode,
+ byte position,
+ byte strength,
+ byte frequency)
+ {
+ if (position > 9 || strength is 0 or > 8 || frequency == 0)
+ {
+ destination[0] = 0x05;
+ return;
+ }
+
+ Span strengths = stackalloc byte[10];
+ strengths[position..].Fill(strength);
+ EncodeZonedStrengths(destination, nativeMode, strengths, frequency);
+ }
+
+ private static void EncodeSlope(
+ Span destination,
+ byte startPosition,
+ byte endPosition,
+ byte startStrength,
+ byte endStrength)
+ {
+ if (startPosition > 8 || endPosition <= startPosition || endPosition > 9 ||
+ startStrength is 0 or > 8 || endStrength is 0 or > 8)
+ {
+ destination[0] = 0x05;
+ return;
+ }
+
+ Span strengths = stackalloc byte[10];
+ var distance = endPosition - startPosition;
+ for (var index = startPosition; index < strengths.Length; index++)
+ {
+ strengths[index] = index <= endPosition
+ ? (byte)Math.Round(startStrength + ((endStrength - startStrength) * (index - startPosition) / (double)distance))
+ : endStrength;
+ }
+
+ EncodeZonedStrengths(destination, 0x21, strengths, 0);
+ }
+
+ private static void EncodeZonedStrengths(
+ Span destination,
+ byte nativeMode,
+ ReadOnlySpan strengths,
+ byte frequency)
+ {
+ ushort activeZones = 0;
+ uint packedStrengths = 0;
+ for (var index = 0; index < Math.Min(strengths.Length, 10); index++)
+ {
+ var strength = strengths[index];
+ if (strength is 0 or > 8)
+ {
+ continue;
+ }
+
+ activeZones |= (ushort)(1 << index);
+ packedStrengths |= (uint)(strength - 1) << (index * 3);
+ }
+
+ if (activeZones == 0 || (nativeMode == 0x26 && frequency == 0))
+ {
+ destination[0] = 0x05;
+ return;
+ }
+
+ destination[0] = nativeMode;
+ BinaryPrimitives.WriteUInt16LittleEndian(destination[1..], activeZones);
+ BinaryPrimitives.WriteUInt32LittleEndian(destination[3..], packedStrengths);
+ destination[9] = frequency;
+ }
+
+ private static byte Max(ReadOnlySpan values)
+ {
+ byte result = 0;
+ foreach (var value in values)
+ {
+ result = Math.Max(result, value);
+ }
+
+ return result;
+ }
+
+ private static byte ScaleTriggerStrength(byte strength) =>
+ (byte)(Math.Min(strength, (byte)8) * 255 / 8);
+
[SysAbiExport(
Nid = "yFVnOdGxvZY",
ExportName = "scePadSetVibration",
@@ -478,6 +629,17 @@ public static class PadExports
data[0x08] = l2;
data[0x09] = r2;
BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f);
+ if (Volatile.Read(ref _motionSensorEnabled) != 0 && input.Motion.Available)
+ {
+ BinaryPrimitives.WriteSingleLittleEndian(data[0x1C..], input.Motion.AccelerationX);
+ BinaryPrimitives.WriteSingleLittleEndian(data[0x20..], input.Motion.AccelerationY);
+ BinaryPrimitives.WriteSingleLittleEndian(data[0x24..], input.Motion.AccelerationZ);
+ BinaryPrimitives.WriteSingleLittleEndian(data[0x28..], input.Motion.AngularVelocityX);
+ BinaryPrimitives.WriteSingleLittleEndian(data[0x2C..], input.Motion.AngularVelocityY);
+ BinaryPrimitives.WriteSingleLittleEndian(data[0x30..], input.Motion.AngularVelocityZ);
+ }
+
+ WriteTouchData(data, input.Touch);
data[0x4C] = 1;
var timestampTicks = Stopwatch.GetTimestamp();
var timestampMicroseconds =
@@ -508,6 +670,10 @@ public static class PadExports
var rightY = acceptsKeyboardInput ? ReadAnalogStick(input.IsKeyDown(0x49), input.IsKeyDown(0x4B)) : (byte)128;
var l2 = acceptsKeyboardInput && input.IsKeyDown(0x52) ? (byte)255 : (byte)0;
var r2 = acceptsKeyboardInput && input.IsKeyDown(0x46) ? (byte)255 : (byte)0;
+ var gamepadType = HostGamepadType.Generic;
+ var connection = HostGamepadConnection.Unknown;
+ var motion = default(HostMotionState);
+ var touch = default(HostTouchState);
Span gamepads = stackalloc HostGamepadState[2];
var gamepadCount = input.GetGamepadStates(gamepads);
@@ -523,6 +689,13 @@ public static class PadExports
rightY = MergeAxis(pad.RightY, rightY);
l2 = Math.Max(l2, pad.LeftTrigger);
r2 = Math.Max(r2, pad.RightTrigger);
+ if (index == 0)
+ {
+ gamepadType = pad.Type;
+ connection = pad.Connection;
+ motion = pad.Motion;
+ touch = pad.Touch;
+ }
}
if (IsAutoCrossActive())
@@ -538,7 +711,11 @@ public static class PadExports
RightX: rightX,
RightY: rightY,
L2: l2,
- R2: r2);
+ R2: r2,
+ Type: gamepadType,
+ Connection: connection,
+ Motion: motion,
+ Touch: touch);
_lastInputSampleTicks = now;
return _cachedInputState;
}
@@ -592,6 +769,7 @@ public static class PadExports
private static uint ToOrbisButtons(HostGamepadButtons buttons)
{
uint result = 0;
+ if ((buttons & HostGamepadButtons.Create) != 0) result |= OrbisPadButton.Share;
if ((buttons & HostGamepadButtons.Up) != 0) result |= OrbisPadButton.Up;
if ((buttons & HostGamepadButtons.Down) != 0) result |= OrbisPadButton.Down;
if ((buttons & HostGamepadButtons.Left) != 0) result |= OrbisPadButton.Left;
@@ -611,6 +789,34 @@ public static class PadExports
return result;
}
+ private static void WriteTouchData(Span data, HostTouchState touch)
+ {
+ Span active = stackalloc HostTouchPoint[2];
+ var count = 0;
+ if (touch.First.Active)
+ {
+ active[count++] = touch.First;
+ }
+ if (touch.Second.Active)
+ {
+ active[count++] = touch.Second;
+ }
+
+ data[0x34] = (byte)count;
+ for (var index = 0; index < count; index++)
+ {
+ var offset = 0x3C + (index * 8);
+ var point = active[index];
+ BinaryPrimitives.WriteUInt16LittleEndian(
+ data[offset..],
+ (ushort)Math.Round(Math.Clamp(point.X, 0, 1) * 1919));
+ BinaryPrimitives.WriteUInt16LittleEndian(
+ data[(offset + 2)..],
+ (ushort)Math.Round(Math.Clamp(point.Y, 0, 1) * 942));
+ data[offset + 4] = point.Id;
+ }
+ }
+
private static uint ReadKeyboardButtons(IHostInput input)
{
uint buttons = 0;
diff --git a/src/SharpEmu.Libs/Pad/PadState.cs b/src/SharpEmu.Libs/Pad/PadState.cs
index c9342558..2707f1fc 100644
--- a/src/SharpEmu.Libs/Pad/PadState.cs
+++ b/src/SharpEmu.Libs/Pad/PadState.cs
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+using SharpEmu.HLE.Host;
+
namespace SharpEmu.Libs.Pad;
///
@@ -16,11 +18,16 @@ internal readonly record struct PadState(
byte RightX,
byte RightY,
byte L2,
- byte R2);
+ byte R2,
+ HostGamepadType Type = HostGamepadType.Generic,
+ HostGamepadConnection Connection = HostGamepadConnection.Unknown,
+ HostMotionState Motion = default,
+ HostTouchState Touch = default);
/// SCE_PAD_BUTTON bit values.
internal static class OrbisPadButton
{
+ internal const uint Share = 0x0001;
internal const uint L3 = 0x0002;
internal const uint R3 = 0x0004;
internal const uint Options = 0x0008;
diff --git a/src/SharpEmu.Libs/Pad/SdlGamepadStateReader.cs b/src/SharpEmu.Libs/Pad/SdlGamepadStateReader.cs
new file mode 100644
index 00000000..95e45132
--- /dev/null
+++ b/src/SharpEmu.Libs/Pad/SdlGamepadStateReader.cs
@@ -0,0 +1,121 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE.Host;
+using SDL;
+using static SDL.SDL3;
+
+namespace SharpEmu.Libs.Pad;
+
+internal static unsafe class SdlGamepadStateReader
+{
+ public static void EnableSonyHidApi()
+ {
+ fixed (byte* enabled = "1"u8)
+ fixed (byte* ps4 = SDL_HINT_JOYSTICK_HIDAPI_PS4)
+ fixed (byte* ps5 = SDL_HINT_JOYSTICK_HIDAPI_PS5)
+ {
+ SDL_SetHint(ps4, enabled);
+ SDL_SetHint(ps5, enabled);
+ }
+ }
+
+ public static SDL_Gamepad* OpenPreferredGamepad()
+ {
+ using var gamepads = SDL_GetGamepads();
+ if (gamepads is null || gamepads.Count == 0)
+ {
+ return null;
+ }
+
+ var selected = gamepads[0];
+ for (var index = 0; index < gamepads.Count; index++)
+ {
+ var type = SDL_GetRealGamepadTypeForID(gamepads[index]);
+ if (type is SDL_GamepadType.SDL_GAMEPAD_TYPE_PS5 or SDL_GamepadType.SDL_GAMEPAD_TYPE_PS4)
+ {
+ selected = gamepads[index];
+ break;
+ }
+ }
+
+ return SDL_OpenGamepad(selected);
+ }
+
+ public static HostGamepadState Read(SDL_Gamepad* gamepad)
+ {
+ var buttons = HostGamepadButtons.None;
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_SOUTH, HostGamepadButtons.Cross, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_EAST, HostGamepadButtons.Circle, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_WEST, HostGamepadButtons.Square, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_NORTH, HostGamepadButtons.Triangle, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, HostGamepadButtons.L1, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, HostGamepadButtons.R1, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_LEFT_STICK, HostGamepadButtons.L3, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_RIGHT_STICK, HostGamepadButtons.R3, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_BACK, HostGamepadButtons.Create, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_GUIDE, HostGamepadButtons.Ps, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_START, HostGamepadButtons.Options, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_MISC1, HostGamepadButtons.Mic, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_TOUCHPAD, HostGamepadButtons.TouchPad, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_UP, HostGamepadButtons.Up, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_RIGHT, HostGamepadButtons.Right, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_DOWN, HostGamepadButtons.Down, ref buttons);
+ AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_LEFT, HostGamepadButtons.Left, ref buttons);
+
+ var leftTrigger = HostWindowInput.ToTriggerByte(
+ SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_LEFT_TRIGGER));
+ var rightTrigger = HostWindowInput.ToTriggerByte(
+ SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_RIGHT_TRIGGER));
+ if (leftTrigger > 64)
+ {
+ buttons |= HostGamepadButtons.L2;
+ }
+ if (rightTrigger > 64)
+ {
+ buttons |= HostGamepadButtons.R2;
+ }
+
+ var battery = 0;
+ SDL_GetGamepadPowerInfo(gamepad, &battery);
+ return new HostGamepadState(
+ Connected: true,
+ Buttons: buttons,
+ LeftX: HostWindowInput.ToStickByte(SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_LEFTX)),
+ LeftY: HostWindowInput.ToStickByte(SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_LEFTY)),
+ RightX: HostWindowInput.ToStickByte(SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_RIGHTX)),
+ RightY: HostWindowInput.ToStickByte(SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_RIGHTY)),
+ LeftTrigger: leftTrigger,
+ RightTrigger: rightTrigger,
+ Type: MapGamepadType(SDL_GetRealGamepadType(gamepad)),
+ Connection: GetConnection(gamepad),
+ BatteryPercent: (byte)Math.Clamp(battery, 0, 100));
+ }
+
+ public static HostGamepadType MapGamepadType(SDL_GamepadType type) => type switch
+ {
+ SDL_GamepadType.SDL_GAMEPAD_TYPE_PS4 => HostGamepadType.DualShock4,
+ SDL_GamepadType.SDL_GAMEPAD_TYPE_PS5 => HostGamepadType.DualSense,
+ _ => HostGamepadType.Generic,
+ };
+
+ public static HostGamepadConnection GetConnection(SDL_Gamepad* gamepad) =>
+ SDL_GetGamepadConnectionState(gamepad) switch
+ {
+ SDL_JoystickConnectionState.SDL_JOYSTICK_CONNECTION_WIRED => HostGamepadConnection.Wired,
+ SDL_JoystickConnectionState.SDL_JOYSTICK_CONNECTION_WIRELESS => HostGamepadConnection.Wireless,
+ _ => HostGamepadConnection.Unknown,
+ };
+
+ private static void AddButton(
+ SDL_Gamepad* gamepad,
+ SDL_GamepadButton source,
+ HostGamepadButtons target,
+ ref HostGamepadButtons buttons)
+ {
+ if (SDL_GetGamepadButton(gamepad, source))
+ {
+ buttons |= target;
+ }
+ }
+}
diff --git a/src/SharpEmu.Libs/Pad/SdlLauncherGamepad.cs b/src/SharpEmu.Libs/Pad/SdlLauncherGamepad.cs
new file mode 100644
index 00000000..aa556931
--- /dev/null
+++ b/src/SharpEmu.Libs/Pad/SdlLauncherGamepad.cs
@@ -0,0 +1,85 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE.Host;
+using SDL;
+using static SDL.SDL3;
+
+namespace SharpEmu.Libs.Pad;
+
+/// Cross-platform SDL gamepad polling for launcher navigation.
+public static unsafe class SdlLauncherGamepad
+{
+ private const SDL_InitFlags InitFlags = SDL_InitFlags.SDL_INIT_GAMEPAD;
+ private static SDL_Gamepad* _gamepad;
+ private static bool _initialized;
+
+ public static void EnsureStarted()
+ {
+ if (_initialized)
+ {
+ return;
+ }
+
+ SdlGamepadStateReader.EnableSonyHidApi();
+ if (!SDL_InitSubSystem(InitFlags))
+ {
+ Console.Error.WriteLine("[GUI][WARN] SDL gamepad initialization failed.");
+ return;
+ }
+
+ _initialized = true;
+ OpenFirstGamepad();
+ }
+
+ public static bool TryGetState(out HostGamepadState state)
+ {
+ state = default;
+ if (!_initialized)
+ {
+ return false;
+ }
+
+ SDL_UpdateGamepads();
+ if (_gamepad is not null && !SDL_GamepadConnected(_gamepad))
+ {
+ SDL_CloseGamepad(_gamepad);
+ _gamepad = null;
+ }
+
+ if (_gamepad is null)
+ {
+ OpenFirstGamepad();
+ }
+
+ if (_gamepad is null)
+ {
+ return false;
+ }
+
+ state = SdlGamepadStateReader.Read(_gamepad);
+ return true;
+ }
+
+ public static void Shutdown()
+ {
+ if (!_initialized)
+ {
+ return;
+ }
+
+ if (_gamepad is not null)
+ {
+ SDL_CloseGamepad(_gamepad);
+ _gamepad = null;
+ }
+
+ SDL_QuitSubSystem(InitFlags);
+ _initialized = false;
+ }
+
+ private static void OpenFirstGamepad()
+ {
+ _gamepad = SdlGamepadStateReader.OpenPreferredGamepad();
+ }
+}
diff --git a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs
index 97b5ccbe..e051ddcf 100644
--- a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs
+++ b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs
@@ -38,6 +38,7 @@ public static class SaveDataExports
private static readonly object _memoryGate = new();
private static readonly HashSet _preparedTransactionResources = [];
private static string? _titleId;
+ private static int _legacySaveMigrationChecked;
public static void ConfigureApplicationInfo(string? titleId)
{
@@ -1115,7 +1116,7 @@ public static class SaveDataExports
}
// Saves are keyed by title id only (single-user emulation) under
- // ~/SharpEmu/Saves//; userId is accepted for API fidelity but not
+ // user/savedata//; userId is accepted for API fidelity but not
// part of the host path.
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId);
@@ -1133,7 +1134,24 @@ public static class SaveDataExports
ctx.TryReadUInt64(address + 0x10, out offset);
}
- private static string ResolveSaveDataRoot() => SaveDataStorage.Root();
+ private static string ResolveSaveDataRoot()
+ {
+ var root = SaveDataStorage.Root();
+ if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR")) &&
+ Interlocked.Exchange(ref _legacySaveMigrationChecked, 1) == 0)
+ {
+ try
+ {
+ SaveDataStorage.MigrateLegacyLayout(root);
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ TraceSaveData($"migration_failed root='{root}' error='{exception.Message}'");
+ }
+ }
+
+ return root;
+ }
private static string ResolveConfiguredTitleId()
{
diff --git a/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs b/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs
index e8e966e9..b27ed105 100644
--- a/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs
+++ b/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs
@@ -8,7 +8,7 @@ namespace SharpEmu.Libs.SaveData;
///
/// Host-side layout and metadata for PS5 save data. Saves live under
-/// ~/SharpEmu/Saves/<titleId>/<dirName>/ (overridable via
+/// user/savedata/<titleId>/<dirName>/ next to the executable (overridable via
/// SHARPEMU_SAVEDATA_DIR); the game's files are written directly inside a
/// slot through the mounted /savedata0 filesystem, and the PS5 UI
/// metadata (title/subtitle/detail/userParam) plus icon live under
@@ -17,19 +17,74 @@ namespace SharpEmu.Libs.SaveData;
///
public static class SaveDataStorage
{
- /// Root of all saves: the env override, else ~/SharpEmu/Saves.
+ /// Root of all saves: the env override, else the portable user/savedata directory.
public static string Root(string? overrideDir = null)
{
var configured = overrideDir ?? Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
var root = string.IsNullOrWhiteSpace(configured)
- ? Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
- "SharpEmu",
- "Saves")
+ ? Path.Combine(AppContext.BaseDirectory, "user", "savedata")
: configured;
return Path.GetFullPath(root);
}
+ ///
+ /// Imports saves written by the short-lived profile layout and by the old
+ /// numeric-user layout. Newer destination files are never overwritten.
+ ///
+ public static void MigrateLegacyLayout(string destinationRoot, string? profileRoot = null)
+ {
+ destinationRoot = Path.GetFullPath(destinationRoot);
+ profileRoot ??= Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "SharpEmu",
+ "Saves");
+
+ if (Directory.Exists(profileRoot) &&
+ !string.Equals(Path.GetFullPath(profileRoot), destinationRoot, StringComparison.OrdinalIgnoreCase))
+ {
+ MergeDirectory(profileRoot, destinationRoot);
+ }
+
+ if (!Directory.Exists(destinationRoot))
+ {
+ return;
+ }
+
+ foreach (var userRoot in Directory.EnumerateDirectories(destinationRoot).ToArray())
+ {
+ if (!uint.TryParse(Path.GetFileName(userRoot), out _))
+ {
+ continue;
+ }
+
+ foreach (var titleRoot in Directory.EnumerateDirectories(userRoot))
+ {
+ MergeDirectory(titleRoot, Path.Combine(destinationRoot, Path.GetFileName(titleRoot)));
+ }
+ }
+ }
+
+ private static void MergeDirectory(string sourceRoot, string destinationRoot)
+ {
+ Directory.CreateDirectory(destinationRoot);
+ foreach (var sourceFile in Directory.EnumerateFiles(sourceRoot))
+ {
+ var destinationFile = Path.Combine(destinationRoot, Path.GetFileName(sourceFile));
+ if (!File.Exists(destinationFile) ||
+ File.GetLastWriteTimeUtc(sourceFile) > File.GetLastWriteTimeUtc(destinationFile))
+ {
+ File.Copy(sourceFile, destinationFile, overwrite: true);
+ }
+ }
+
+ foreach (var sourceDirectory in Directory.EnumerateDirectories(sourceRoot))
+ {
+ MergeDirectory(
+ sourceDirectory,
+ Path.Combine(destinationRoot, Path.GetFileName(sourceDirectory)));
+ }
+ }
+
/// Per-title directory: <root>/<titleId>.
public static string TitleRoot(string root, string titleId) =>
Path.Combine(root, Sanitize(titleId));
diff --git a/src/SharpEmu.Libs/SharpEmu.Libs.csproj b/src/SharpEmu.Libs/SharpEmu.Libs.csproj
index 13169f87..ed4d1e4b 100644
--- a/src/SharpEmu.Libs/SharpEmu.Libs.csproj
+++ b/src/SharpEmu.Libs/SharpEmu.Libs.csproj
@@ -5,7 +5,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
@@ -27,11 +29,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
+
-
diff --git a/src/SharpEmu.Libs/VideoOut/HostDisplayCatalog.cs b/src/SharpEmu.Libs/VideoOut/HostDisplayCatalog.cs
new file mode 100644
index 00000000..6c755ecc
--- /dev/null
+++ b/src/SharpEmu.Libs/VideoOut/HostDisplayCatalog.cs
@@ -0,0 +1,129 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SDL;
+using static SDL.SDL3;
+
+namespace SharpEmu.Libs.VideoOut;
+
+public sealed record HostDisplayMode(int Width, int Height, int RefreshRate);
+
+public sealed record HostDisplayInfo(
+ int Index,
+ string Name,
+ IReadOnlyList Modes);
+
+public static unsafe class HostDisplayCatalog
+{
+ private const SDL_InitFlags VideoFlag = SDL_InitFlags.SDL_INIT_VIDEO;
+ private static int _queryFailureLogged;
+
+ public static IReadOnlyList Query()
+ {
+ var initializedHere = false;
+ var videoReady = false;
+ try
+ {
+ initializedHere = (SDL_WasInit(VideoFlag) & VideoFlag) == 0;
+ if (initializedHere && !SDL_InitSubSystem(VideoFlag))
+ {
+ LogQueryFailure(SDL_GetError() ?? "unknown SDL error");
+ return CreateFallback();
+ }
+ videoReady = true;
+
+ using var displays = SDL_GetDisplays();
+ if (displays is null || displays.Count == 0)
+ {
+ LogQueryFailure("SDL reported no displays");
+ return CreateFallback();
+ }
+
+ var result = new List(displays.Count);
+ for (var index = 0; index < displays.Count; index++)
+ {
+ var display = displays[index];
+ var name = SDL_GetDisplayName(display);
+ var modes = ReadModes(display);
+ result.Add(new HostDisplayInfo(
+ index,
+ string.IsNullOrWhiteSpace(name) ? $"Display {index + 1}" : name,
+ modes));
+ }
+
+ return result;
+ }
+ catch (Exception exception)
+ {
+ LogQueryFailure(exception.Message);
+ return CreateFallback();
+ }
+ finally
+ {
+ if (initializedHere && videoReady)
+ {
+ SDL_QuitSubSystem(VideoFlag);
+ }
+ }
+ }
+
+ private static IReadOnlyList ReadModes(SDL_DisplayID display)
+ {
+ var modes = new HashSet();
+ using (var fullscreenModes = SDL_GetFullscreenDisplayModes(display))
+ {
+ if (fullscreenModes is not null)
+ {
+ for (var index = 0; index < fullscreenModes.Count; index++)
+ {
+ var mode = fullscreenModes[index];
+ AddMode(modes, &mode);
+ }
+ }
+ }
+
+ AddMode(modes, SDL_GetDesktopDisplayMode(display));
+ if (modes.Count == 0)
+ {
+ return CreateFallbackModes();
+ }
+
+ return modes
+ .OrderByDescending(mode => (long)mode.Width * mode.Height)
+ .ThenByDescending(mode => mode.Width)
+ .ThenByDescending(mode => mode.RefreshRate)
+ .ToArray();
+ }
+
+ private static void AddMode(HashSet modes, SDL_DisplayMode* mode)
+ {
+ if (mode is null || mode->w <= 0 || mode->h <= 0)
+ {
+ return;
+ }
+
+ var refreshRate = mode->refresh_rate > 0
+ ? Math.Max(1, (int)Math.Round(mode->refresh_rate, MidpointRounding.AwayFromZero))
+ : 0;
+ modes.Add(new HostDisplayMode(mode->w, mode->h, refreshRate));
+ }
+
+ private static IReadOnlyList CreateFallback() =>
+ [new HostDisplayInfo(0, "Display 1", CreateFallbackModes())];
+
+ private static IReadOnlyList CreateFallbackModes() =>
+ [
+ new HostDisplayMode(3840, 2160, 60),
+ new HostDisplayMode(2560, 1440, 60),
+ new HostDisplayMode(1920, 1080, 60),
+ new HostDisplayMode(1280, 720, 60),
+ ];
+
+ private static void LogQueryFailure(string message)
+ {
+ if (Interlocked.Exchange(ref _queryFailureLogged, 1) == 0)
+ {
+ Console.Error.WriteLine($"[GUI][WARN] SDL display query failed: {message}");
+ }
+ }
+}
diff --git a/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs b/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs
new file mode 100644
index 00000000..0511d541
--- /dev/null
+++ b/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs
@@ -0,0 +1,68 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.Libs.VideoOut;
+
+using SharpEmu.Libs.Gpu.Metal;
+
+public enum HostWindowMode
+{
+ Windowed,
+ Borderless,
+ ExclusiveFullscreen,
+}
+
+public enum HostScalingMode
+{
+ Fit,
+ Cover,
+ Stretch,
+ Integer,
+}
+
+public enum HostHdrMode
+{
+ Auto,
+ On,
+ Off,
+}
+
+public sealed record HostVideoOptions
+{
+ public static HostVideoOptions Default { get; } = new();
+
+ public HostWindowMode WindowMode { get; init; } = HostWindowMode.Windowed;
+
+ public HostScalingMode ScalingMode { get; init; } = HostScalingMode.Fit;
+
+ public int Width { get; init; } = 1920;
+
+ public int Height { get; init; } = 1080;
+
+ public int DisplayIndex { get; init; }
+
+ public int RefreshRate { get; init; }
+
+ public bool VSync { get; init; } = true;
+
+ public HostHdrMode HdrMode { get; init; } = HostHdrMode.Auto;
+
+ public HostVideoOptions Normalize() => this with
+ {
+ Width = Math.Clamp(Width, 640, 16384),
+ Height = Math.Clamp(Height, 360, 16384),
+ DisplayIndex = Math.Max(0, DisplayIndex),
+ RefreshRate = Math.Clamp(RefreshRate, 0, 1000),
+ HdrMode = Enum.IsDefined(HdrMode) ? HdrMode : HostHdrMode.Auto,
+ };
+}
+
+public static class HostVideoHost
+{
+ public static bool TryConfigureVideo(HostVideoOptions options)
+ {
+ var normalized = options.Normalize();
+ return VulkanVideoPresenter.TryConfigureVideo(normalized) &
+ MetalVideoPresenter.TryConfigureVideo(normalized);
+ }
+}
diff --git a/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs b/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs
new file mode 100644
index 00000000..2f2fb5b9
--- /dev/null
+++ b/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs
@@ -0,0 +1,252 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Diagnostics;
+
+namespace SharpEmu.Libs.VideoOut;
+
+///
+/// Self-time accounting for the render thread, enabled with
+/// SHARPEMU_PROFILE_RENDER=1. The existing videoout counters report how much
+/// work was done (draws, pipelines, SPIR-V) but not where the render thread's
+/// second went, which is the number that decides whether a low frame rate is
+/// the emulator recording commands, the GPU executing them, or neither.
+///
+/// Scopes nest: entering a phase suspends the enclosing one and resumes it on
+/// dispose, so a inside
+/// is never counted twice.
+///
+internal static class RenderPhaseProfile
+{
+ internal enum Phase
+ {
+ /// Outside any measured phase — loop overhead.
+ Unattributed = 0,
+ /// Parked because no guest work and no newer flip exist.
+ Idle,
+ /// Blocked on the frame slot's fence: the GPU is behind.
+ FrameSlotWait,
+ /// Reaping completed guest submissions (fence polls).
+ Collect,
+ Evict,
+ /// Dequeuing the next guest work item.
+ TakeWork,
+ /// Building the diagnostic label for a work item.
+ Describe,
+ /// Publishing a work item's completion to its waiters.
+ CompleteWork,
+ /// Selecting the presentation to show this iteration.
+ TakePresentation,
+ Draw,
+ Compute,
+ ColorClear,
+ ImageWrite,
+ OrderedAction,
+ Flip,
+ /// Closing and submitting the batched guest command buffer.
+ Flush,
+ /// vkQueueSubmit itself.
+ QueueSubmit,
+ /// vkAcquireNextImageKHR.
+ Acquire,
+ /// Recording + submitting the presentation command buffer.
+ Present,
+ /// vkQueuePresentKHR.
+ QueuePresent,
+ Count,
+ }
+
+ public static readonly bool Enabled = string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_RENDER"),
+ "1",
+ StringComparison.Ordinal);
+
+ ///
+ /// Breaks down the CPU-visible actions which are deliberately serialized
+ /// behind guest GPU work. This stays opt-in because it is diagnostic data,
+ /// not a normal render-thread cost.
+ ///
+ public static readonly bool OrderedActionDetailsEnabled = string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_ORDERED_ACTION"),
+ "1",
+ StringComparison.Ordinal);
+
+ private static readonly double _reportSeconds =
+ double.TryParse(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_RENDER_REPORT_S"),
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var seconds) && seconds > 0
+ ? seconds
+ : 5.0;
+
+ private static readonly long[] _ticks = new long[(int)Phase.Count];
+ private static readonly long[] _entries = new long[(int)Phase.Count];
+ private static long _frames;
+ private static long _windowStart = Stopwatch.GetTimestamp();
+ private static readonly Dictionary _orderedActions =
+ new(StringComparer.Ordinal);
+
+ // The render loop is single-threaded, so plain fields are enough and keep
+ // the per-scope cost to two timestamp reads.
+ [ThreadStatic] private static Phase _current;
+ [ThreadStatic] private static long _lastTimestamp;
+
+ internal readonly ref struct Scope
+ {
+ private readonly Phase _previous;
+ private readonly bool _active;
+
+ internal Scope(Phase previous)
+ {
+ _previous = previous;
+ _active = true;
+ }
+
+ public void Dispose()
+ {
+ if (!_active)
+ {
+ return;
+ }
+
+ Charge(_previous);
+ }
+ }
+
+ public static Scope Measure(Phase phase)
+ {
+ if (!Enabled)
+ {
+ return default;
+ }
+
+ var previous = Charge(phase);
+ _entries[(int)phase]++;
+ return new Scope(previous);
+ }
+
+ public static void RecordOrderedAction(string debugName, bool completed)
+ {
+ if (!OrderedActionDetailsEnabled)
+ {
+ return;
+ }
+
+ var category = GetOrderedActionCategory(debugName);
+ ref var stats = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrAddDefault(
+ _orderedActions,
+ category,
+ out _);
+ stats.Executed += completed ? 1 : 0;
+ stats.Deferred += completed ? 0 : 1;
+ }
+
+ ///
+ /// Closes out the running phase and switches to ,
+ /// returning the phase that was running.
+ ///
+ private static Phase Charge(Phase next)
+ {
+ var now = Stopwatch.GetTimestamp();
+ var previous = _current;
+ if (_lastTimestamp != 0)
+ {
+ _ticks[(int)previous] += now - _lastTimestamp;
+ }
+
+ _lastTimestamp = now;
+ _current = next;
+ return previous;
+ }
+
+ /// Called once per presented frame; also drives the report.
+ public static void RecordFrame()
+ {
+ if (!Enabled)
+ {
+ return;
+ }
+
+ _frames++;
+ var now = Stopwatch.GetTimestamp();
+ var elapsedTicks = now - _windowStart;
+ if (elapsedTicks < _reportSeconds * Stopwatch.Frequency)
+ {
+ return;
+ }
+
+ _windowStart = now;
+ var seconds = elapsedTicks / (double)Stopwatch.Frequency;
+ var frames = _frames;
+ _frames = 0;
+
+ var parts = new List<(Phase Phase, double Percent, long Entries)>((int)Phase.Count);
+ var accounted = 0L;
+ for (var index = 0; index < (int)Phase.Count; index++)
+ {
+ var phaseTicks = _ticks[index];
+ _ticks[index] = 0;
+ var entries = _entries[index];
+ _entries[index] = 0;
+ accounted += phaseTicks;
+ if (phaseTicks <= 0)
+ {
+ continue;
+ }
+
+ parts.Add(((Phase)index, phaseTicks * 100.0 / elapsedTicks, entries));
+ }
+
+ parts.Sort(static (left, right) => right.Percent.CompareTo(left.Percent));
+ Console.Error.WriteLine(
+ $"[PERF][RENDER] {seconds:F1}s fps={frames / seconds:F1} " +
+ $"covered={accounted * 100.0 / elapsedTicks:F0}% " +
+ string.Join(
+ " ",
+ parts.Select(part =>
+ $"{part.Phase}={part.Percent:F1}%" +
+ (part.Entries > 0 ? $"/n{part.Entries}" : string.Empty))));
+
+ if (OrderedActionDetailsEnabled && _orderedActions.Count != 0)
+ {
+ var ordered = _orderedActions
+ .OrderByDescending(static pair => pair.Value.Executed + pair.Value.Deferred)
+ .Take(12)
+ .Select(static pair =>
+ $"{pair.Key}=ok{pair.Value.Executed}/defer{pair.Value.Deferred}");
+ Console.Error.WriteLine($"[PERF][ORDERED] {string.Join(" ", ordered)}");
+ _orderedActions.Clear();
+ }
+ }
+
+ private static string GetOrderedActionCategory(string debugName)
+ {
+ if (debugName.EndsWith(" completion", StringComparison.Ordinal))
+ {
+ return "completion";
+ }
+
+ var firstSpace = debugName.IndexOf(' ');
+ if (firstSpace < 0)
+ {
+ return debugName;
+ }
+
+ // AGC labels conventionally begin with "agc ". Keeping the
+ // packet token separates DMA, submit and register traffic without
+ // retaining guest addresses in the diagnostic key.
+ if (debugName.StartsWith("agc ", StringComparison.Ordinal))
+ {
+ var secondSpace = debugName.IndexOf(' ', firstSpace + 1);
+ return secondSpace < 0 ? debugName : debugName[..secondSpace];
+ }
+
+ return debugName[..firstSpace];
+ }
+
+ private struct OrderedActionStats
+ {
+ public long Executed;
+ public long Deferred;
+ }
+}
diff --git a/src/SharpEmu.Libs/VideoOut/SdlHostWindow.cs b/src/SharpEmu.Libs/VideoOut/SdlHostWindow.cs
new file mode 100644
index 00000000..5cfa46a0
--- /dev/null
+++ b/src/SharpEmu.Libs/VideoOut/SdlHostWindow.cs
@@ -0,0 +1,832 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using SharpEmu.HLE.Host;
+using SharpEmu.Libs.Pad;
+using SDL;
+using Silk.NET.Vulkan;
+using static SDL.SDL3;
+
+namespace SharpEmu.Libs.VideoOut;
+
+internal enum SdlGraphicsApi
+{
+ Vulkan,
+ Metal,
+}
+
+internal readonly record struct SdlHdrState(
+ bool Enabled,
+ float SdrWhiteLevel,
+ float Headroom);
+
+internal sealed unsafe class SdlHostWindow : IDisposable, IHostGamepadOutput
+{
+ private const SDL_InitFlags InitFlags = SDL_InitFlags.SDL_INIT_VIDEO | SDL_InitFlags.SDL_INIT_GAMEPAD;
+ private static readonly long CursorHideDelayTicks = 2 * Stopwatch.Frequency;
+ private const uint OutputDurationMs = 5_000;
+
+ private readonly HostVideoOptions _options;
+ private readonly SdlGraphicsApi _graphicsApi;
+ private readonly Action? _toggleBackendHud;
+ private readonly object _gamepadGate = new();
+ private SDL_Window* _window;
+ private nint _metalView;
+ private SDL_Gamepad* _gamepad;
+ private HostGamepadType _gamepadType;
+ private byte _leftTriggerRumble;
+ private byte _rightTriggerRumble;
+ private int _closeRequested;
+ private bool _closedByUser;
+ private bool _fullscreen;
+ private bool _focused = true;
+ private bool _cursorVisible = true;
+ private long _cursorHideDeadline;
+ private bool _surfaceRestorePending;
+ private bool _hdrStateChangePending;
+ private bool _disposed;
+
+ public SdlHostWindow(
+ string title,
+ HostVideoOptions options,
+ SdlGraphicsApi graphicsApi,
+ Action? toggleBackendHud = null)
+ {
+ _options = options.Normalize();
+ _graphicsApi = graphicsApi;
+ _toggleBackendHud = toggleBackendHud;
+ SdlGamepadStateReader.EnableSonyHidApi();
+ if (!SDL_InitSubSystem(InitFlags))
+ {
+ throw new InvalidOperationException($"SDL video initialization failed: {GetError()}");
+ }
+
+ var graphicsFlag = graphicsApi == SdlGraphicsApi.Vulkan
+ ? SDL_WindowFlags.SDL_WINDOW_VULKAN
+ : SDL_WindowFlags.SDL_WINDOW_METAL;
+ var flags = graphicsFlag |
+ SDL_WindowFlags.SDL_WINDOW_RESIZABLE |
+ SDL_WindowFlags.SDL_WINDOW_HIGH_PIXEL_DENSITY |
+ SDL_WindowFlags.SDL_WINDOW_HIDDEN;
+ _window = CreateWindow(title, _options.Width, _options.Height, flags);
+ if (_window is null)
+ {
+ SDL_QuitSubSystem(InitFlags);
+ throw new InvalidOperationException($"SDL window creation failed: {GetError()}");
+ }
+
+ MoveToConfiguredDisplay();
+ ApplyConfiguredMode(_options.WindowMode);
+ SetIcon();
+ SDL_ShowWindow(_window);
+ SDL_RaiseWindow(_window);
+ if (_fullscreen && !SDL_SyncWindow(_window))
+ {
+ Console.Error.WriteLine($"[LOADER][WARN] SDL initial fullscreen sync failed: {GetError()}");
+ }
+ HostWindowInput.Connect(this);
+ OpenFirstGamepad();
+
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] SDL3 window ready: mode={_options.WindowMode} " +
+ $"size={_options.Width}x{_options.Height} display={_options.DisplayIndex} " +
+ $"refresh={(_options.RefreshRate == 0 ? "auto" : _options.RefreshRate)}");
+ var hdr = HdrState;
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] SDL3 display HDR: enabled={hdr.Enabled} " +
+ $"sdr_white={hdr.SdrWhiteLevel:F3} headroom={hdr.Headroom:F3}");
+ }
+
+ public (int Width, int Height) PixelSize
+ {
+ get
+ {
+ var width = 0;
+ var height = 0;
+ if (_window is not null)
+ {
+ SDL_GetWindowSizeInPixels(_window, &width, &height);
+ }
+
+ return (Math.Max(width, 1), Math.Max(height, 1));
+ }
+ }
+
+ public bool IsMinimized =>
+ _window is not null &&
+ (SDL_GetWindowFlags(_window) & SDL_WindowFlags.SDL_WINDOW_MINIMIZED) != 0;
+
+ public bool ClosedByUser => _closedByUser;
+
+ public SdlHdrState HdrState
+ {
+ get
+ {
+ if (_window is null)
+ {
+ return default;
+ }
+
+ var properties = SDL_GetWindowProperties(_window);
+ fixed (byte* hdrEnabledName = SDL_PROP_WINDOW_HDR_ENABLED_BOOLEAN)
+ fixed (byte* sdrWhiteName = SDL_PROP_WINDOW_SDR_WHITE_LEVEL_FLOAT)
+ fixed (byte* headroomName = SDL_PROP_WINDOW_HDR_HEADROOM_FLOAT)
+ {
+ return new SdlHdrState(
+ (bool)SDL_GetBooleanProperty(properties, hdrEnabledName, false),
+ SDL_GetFloatProperty(properties, sdrWhiteName, 1f),
+ SDL_GetFloatProperty(properties, headroomName, 1f));
+ }
+ }
+ }
+
+ public bool ConsumeSurfaceRestore()
+ {
+ var pending = _surfaceRestorePending;
+ _surfaceRestorePending = false;
+ return pending;
+ }
+
+ public bool ConsumeHdrStateChange()
+ {
+ var pending = _hdrStateChangePending;
+ _hdrStateChangePending = false;
+ return pending;
+ }
+
+ public byte** GetRequiredVulkanInstanceExtensions(out uint count)
+ {
+ EnsureGraphicsApi(SdlGraphicsApi.Vulkan);
+ uint extensionCount = 0;
+ var extensions = SDL_Vulkan_GetInstanceExtensions(&extensionCount);
+ count = extensionCount;
+ if (extensions is null || count == 0)
+ {
+ throw new InvalidOperationException($"SDL did not provide Vulkan instance extensions: {GetError()}");
+ }
+
+ return extensions;
+ }
+
+ public SurfaceKHR CreateVulkanSurface(Instance instance)
+ {
+ EnsureGraphicsApi(SdlGraphicsApi.Vulkan);
+ VkSurfaceKHR_T* surface = null;
+ if (!SDL_Vulkan_CreateSurface(
+ _window,
+ (VkInstance_T*)instance.Handle,
+ null,
+ &surface) || surface is null)
+ {
+ throw new InvalidOperationException($"SDL Vulkan surface creation failed: {GetError()}");
+ }
+
+ return new SurfaceKHR(unchecked((ulong)surface));
+ }
+
+ public nint CreateMetalLayer()
+ {
+ EnsureGraphicsApi(SdlGraphicsApi.Metal);
+ if (_metalView == 0)
+ {
+ _metalView = SDL_Metal_CreateView(_window);
+ if (_metalView == 0)
+ {
+ throw new InvalidOperationException($"SDL Metal view creation failed: {GetError()}");
+ }
+ }
+
+ var layer = SDL_Metal_GetLayer(_metalView);
+ if (layer == 0)
+ {
+ throw new InvalidOperationException($"SDL Metal layer lookup failed: {GetError()}");
+ }
+
+ return layer;
+ }
+
+ public void SetTitle(string title)
+ {
+ if (_window is null)
+ {
+ return;
+ }
+
+ var utf8 = Marshal.StringToCoTaskMemUTF8(title);
+ try
+ {
+ SDL_SetWindowTitle(_window, (byte*)utf8);
+ }
+ finally
+ {
+ Marshal.FreeCoTaskMem(utf8);
+ }
+ }
+
+ public void Close() => Volatile.Write(ref _closeRequested, 1);
+
+ public void SetRumble(byte largeMotor, byte smallMotor)
+ {
+ lock (_gamepadGate)
+ {
+ if (IsGamepadConnected())
+ {
+ SDL_RumbleGamepad(
+ _gamepad,
+ ExpandByte(largeMotor),
+ ExpandByte(smallMotor),
+ OutputDurationMs);
+ }
+ }
+ }
+
+ public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
+ {
+ lock (_gamepadGate)
+ {
+ if (IsGamepadConnected())
+ {
+ if (leftTrigger is { } left)
+ {
+ _leftTriggerRumble = left;
+ }
+ if (rightTrigger is { } right)
+ {
+ _rightTriggerRumble = right;
+ }
+
+ SDL_RumbleGamepadTriggers(
+ _gamepad,
+ ExpandByte(_leftTriggerRumble),
+ ExpandByte(_rightTriggerRumble),
+ OutputDurationMs);
+ }
+ }
+ }
+
+ public void SetAdaptiveTriggerEffect(
+ HostAdaptiveTriggerEffect? leftTrigger,
+ HostAdaptiveTriggerEffect? rightTrigger)
+ {
+ lock (_gamepadGate)
+ {
+ if (!IsGamepadConnected())
+ {
+ return;
+ }
+
+ if (_gamepadType != HostGamepadType.DualSense)
+ {
+ if (leftTrigger is { } fallbackLeft)
+ {
+ _leftTriggerRumble = fallbackLeft.FallbackStrength;
+ }
+ if (rightTrigger is { } fallbackRight)
+ {
+ _rightTriggerRumble = fallbackRight.FallbackStrength;
+ }
+
+ SDL_RumbleGamepadTriggers(
+ _gamepad,
+ ExpandByte(_leftTriggerRumble),
+ ExpandByte(_rightTriggerRumble),
+ OutputDurationMs);
+ return;
+ }
+
+ Span state = stackalloc byte[47];
+ state.Clear();
+ if (rightTrigger is { } nativeRight)
+ {
+ state[0] |= 0x04;
+ nativeRight.CopyTo(state[10..21]);
+ }
+ if (leftTrigger is { } nativeLeft)
+ {
+ state[0] |= 0x08;
+ nativeLeft.CopyTo(state[21..32]);
+ }
+
+ if (state[0] == 0)
+ {
+ return;
+ }
+
+ fixed (byte* data = state)
+ {
+ SDL_SendGamepadEffect(_gamepad, (nint)data, state.Length);
+ }
+ }
+ }
+
+ public void SetLightbar(byte red, byte green, byte blue)
+ {
+ lock (_gamepadGate)
+ {
+ if (IsGamepadConnected())
+ {
+ SDL_SetGamepadLED(_gamepad, red, green, blue);
+ }
+ }
+ }
+
+ public void ResetLightbar() => SetLightbar(0, 0, 64);
+
+ public void Run(
+ Action initialize,
+ Action render,
+ Action closing,
+ Action? idle = null)
+ {
+ initialize();
+ var timer = Stopwatch.StartNew();
+ var last = timer.Elapsed.TotalSeconds;
+ try
+ {
+ while (Volatile.Read(ref _closeRequested) == 0)
+ {
+ PumpEvents();
+ if (Volatile.Read(ref _closeRequested) != 0)
+ {
+ break;
+ }
+
+ UpdateCursorAutoHide();
+ SampleGamepad();
+ var now = timer.Elapsed.TotalSeconds;
+ render(now - last);
+ last = now;
+ if (IsMinimized)
+ {
+ SDL_Delay(10);
+ }
+ else
+ {
+ idle?.Invoke();
+ }
+ }
+ }
+ finally
+ {
+ closing();
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ SDL_ShowCursor();
+ HostWindowInput.Disconnect();
+ CloseGamepad();
+ if (_metalView != 0)
+ {
+ SDL_Metal_DestroyView(_metalView);
+ _metalView = 0;
+ }
+
+ if (_window is not null)
+ {
+ SDL_DestroyWindow(_window);
+ _window = null;
+ }
+
+ SDL_QuitSubSystem(InitFlags);
+ }
+
+ private void PumpEvents()
+ {
+ SDL_Event windowEvent;
+ while (SDL_PollEvent(&windowEvent))
+ {
+ switch (windowEvent.Type)
+ {
+ case SDL_EventType.SDL_EVENT_QUIT:
+ case SDL_EventType.SDL_EVENT_WINDOW_CLOSE_REQUESTED:
+ _closedByUser = true;
+ Volatile.Write(ref _closeRequested, 1);
+ break;
+ case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_GAINED:
+ _focused = true;
+ UpdateCursorVisibility();
+ HostWindowInput.SetFocused(true);
+ break;
+ case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_LOST:
+ _focused = false;
+ UpdateCursorVisibility();
+ HostWindowInput.SetFocused(false);
+ break;
+ case SDL_EventType.SDL_EVENT_WINDOW_MINIMIZED:
+ _focused = false;
+ UpdateCursorVisibility();
+ HostWindowInput.SetFocused(false);
+ break;
+ case SDL_EventType.SDL_EVENT_WINDOW_RESTORED:
+ case SDL_EventType.SDL_EVENT_WINDOW_RESIZED:
+ case SDL_EventType.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:
+ case SDL_EventType.SDL_EVENT_WINDOW_MAXIMIZED:
+ case SDL_EventType.SDL_EVENT_WINDOW_ENTER_FULLSCREEN:
+ case SDL_EventType.SDL_EVENT_WINDOW_LEAVE_FULLSCREEN:
+ case SDL_EventType.SDL_EVENT_WINDOW_EXPOSED:
+ case SDL_EventType.SDL_EVENT_WINDOW_DISPLAY_CHANGED:
+ case SDL_EventType.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED:
+ _surfaceRestorePending = true;
+ break;
+ case SDL_EventType.SDL_EVENT_WINDOW_HDR_STATE_CHANGED:
+ _hdrStateChangePending = true;
+ break;
+ case SDL_EventType.SDL_EVENT_KEY_DOWN:
+ case SDL_EventType.SDL_EVENT_KEY_UP:
+ HandleKey(windowEvent.key);
+ break;
+ case SDL_EventType.SDL_EVENT_MOUSE_MOTION:
+ ShowCursorTemporarily();
+ break;
+ case SDL_EventType.SDL_EVENT_MOUSE_BUTTON_DOWN:
+ ShowCursorTemporarily();
+ if (windowEvent.button.button == 1 && windowEvent.button.clicks == 2)
+ {
+ ToggleFullscreen();
+ }
+ break;
+ case SDL_EventType.SDL_EVENT_GAMEPAD_ADDED:
+ if (_gamepad is null)
+ {
+ OpenFirstGamepad();
+ }
+ break;
+ case SDL_EventType.SDL_EVENT_GAMEPAD_REMOVED:
+ if (_gamepad is not null && !SDL_GamepadConnected(_gamepad))
+ {
+ CloseGamepad();
+ OpenFirstGamepad();
+ }
+ break;
+ }
+ }
+ }
+
+ private void HandleKey(SDL_KeyboardEvent keyEvent)
+ {
+ var down = keyEvent.type == SDL_EventType.SDL_EVENT_KEY_DOWN;
+ if (down && !keyEvent.repeat)
+ {
+ if (keyEvent.key == SDL_Keycode.SDLK_F1 &&
+ (keyEvent.mod & SDL_Keymod.SDL_KMOD_GUI) != 0 &&
+ _toggleBackendHud is not null)
+ {
+ _toggleBackendHud();
+ }
+ else if (keyEvent.key == SDL_Keycode.SDLK_F1)
+ {
+ PerfOverlay.Toggle();
+ }
+ else if (keyEvent.key == SDL_Keycode.SDLK_F11)
+ {
+ ToggleFullscreen();
+ }
+ }
+
+ if (TryMapVirtualKey(keyEvent.key, out var virtualKey))
+ {
+ HostWindowInput.SetKey(virtualKey, down);
+ }
+ }
+
+ private void ToggleFullscreen()
+ {
+ if (_fullscreen)
+ {
+ SDL_SetWindowFullscreen(_window, false);
+ SDL_SetWindowFullscreenMode(_window, null);
+ SDL_SetWindowResizable(_window, true);
+ SDL_SetWindowSize(_window, _options.Width, _options.Height);
+ MoveToConfiguredDisplay();
+ _fullscreen = false;
+ UpdateCursorVisibility();
+ return;
+ }
+
+ ApplyConfiguredMode(
+ _options.WindowMode == HostWindowMode.ExclusiveFullscreen
+ ? HostWindowMode.ExclusiveFullscreen
+ : HostWindowMode.Borderless);
+ }
+
+ private void ApplyConfiguredMode(HostWindowMode mode)
+ {
+ if (mode == HostWindowMode.Windowed)
+ {
+ _fullscreen = false;
+ UpdateCursorVisibility();
+ return;
+ }
+
+ if (mode == HostWindowMode.ExclusiveFullscreen)
+ {
+ var display = GetConfiguredDisplay();
+ SDL_DisplayMode closest;
+ if (SDL_GetClosestFullscreenDisplayMode(
+ display,
+ _options.Width,
+ _options.Height,
+ _options.RefreshRate,
+ true,
+ &closest))
+ {
+ SDL_SetWindowFullscreenMode(_window, &closest);
+ }
+ else
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] SDL exclusive mode unavailable; using borderless: {GetError()}");
+ SDL_SetWindowFullscreenMode(_window, null);
+ }
+ }
+ else
+ {
+ SDL_SetWindowFullscreenMode(_window, null);
+ }
+
+ SDL_SetWindowFullscreen(_window, true);
+ _fullscreen = true;
+ UpdateCursorVisibility();
+ }
+
+ private void UpdateCursorVisibility()
+ {
+ _cursorHideDeadline = 0;
+ SetCursorVisible(!_fullscreen || !_focused);
+ }
+
+ private void ShowCursorTemporarily()
+ {
+ if (!_fullscreen || !_focused)
+ {
+ return;
+ }
+
+ SetCursorVisible(true);
+ _cursorHideDeadline = Stopwatch.GetTimestamp() + CursorHideDelayTicks;
+ }
+
+ private void UpdateCursorAutoHide()
+ {
+ if (!_fullscreen || !_focused || !_cursorVisible || _cursorHideDeadline == 0 ||
+ Stopwatch.GetTimestamp() < _cursorHideDeadline)
+ {
+ return;
+ }
+
+ _cursorHideDeadline = 0;
+ SetCursorVisible(false);
+ }
+
+ private void SetCursorVisible(bool visible)
+ {
+ if (_cursorVisible == visible)
+ {
+ return;
+ }
+
+ if (visible)
+ {
+ SDL_ShowCursor();
+ }
+ else
+ {
+ SDL_HideCursor();
+ }
+
+ _cursorVisible = visible;
+ }
+
+ private void MoveToConfiguredDisplay()
+ {
+ var display = GetConfiguredDisplay();
+ SDL_Rect bounds;
+ if (SDL_GetDisplayBounds(display, &bounds))
+ {
+ var x = bounds.x + Math.Max(0, (bounds.w - _options.Width) / 2);
+ var y = bounds.y + Math.Max(0, (bounds.h - _options.Height) / 2);
+ SDL_SetWindowPosition(_window, x, y);
+ }
+ }
+
+ private SDL_DisplayID GetConfiguredDisplay()
+ {
+ using var displays = SDL_GetDisplays();
+ if (displays is null || displays.Count == 0)
+ {
+ return SDL_GetPrimaryDisplay();
+ }
+
+ return displays[Math.Clamp(_options.DisplayIndex, 0, displays.Count - 1)];
+ }
+
+ private void OpenFirstGamepad()
+ {
+ lock (_gamepadGate)
+ {
+ _gamepad = SdlGamepadStateReader.OpenPreferredGamepad();
+ if (_gamepad is null)
+ {
+ return;
+ }
+
+ _gamepadType = SdlGamepadStateReader.MapGamepadType(SDL_GetRealGamepadType(_gamepad));
+ EnableSensor(SDL_SensorType.SDL_SENSOR_ACCEL);
+ EnableSensor(SDL_SensorType.SDL_SENSOR_GYRO);
+ }
+
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] SDL gamepad connected: {DescribeGamepad()} " +
+ $"type={_gamepadType} connection={SdlGamepadStateReader.GetConnection(_gamepad)}");
+ SampleGamepad();
+ }
+
+ private void CloseGamepad()
+ {
+ lock (_gamepadGate)
+ {
+ if (_gamepad is not null)
+ {
+ SDL_CloseGamepad(_gamepad);
+ _gamepad = null;
+ }
+
+ _gamepadType = HostGamepadType.Generic;
+ _leftTriggerRumble = 0;
+ _rightTriggerRumble = 0;
+ }
+
+ HostWindowInput.ClearGamepad();
+ }
+
+ private void SampleGamepad()
+ {
+ lock (_gamepadGate)
+ {
+ if (!IsGamepadConnected())
+ {
+ HostWindowInput.ClearGamepad();
+ return;
+ }
+
+ SDL_UpdateGamepads();
+ var state = SdlGamepadStateReader.Read(_gamepad) with
+ {
+ Motion = ReadMotion(),
+ Touch = ReadTouch(),
+ };
+ HostWindowInput.SetGamepad(
+ DescribeGamepad(),
+ state);
+ }
+ }
+
+ private void EnableSensor(SDL_SensorType sensor)
+ {
+ if (SDL_GamepadHasSensor(_gamepad, sensor))
+ {
+ SDL_SetGamepadSensorEnabled(_gamepad, sensor, true);
+ }
+ }
+
+ private HostMotionState ReadMotion()
+ {
+ Span acceleration = stackalloc float[3];
+ Span angularVelocity = stackalloc float[3];
+ acceleration.Clear();
+ angularVelocity.Clear();
+ var hasAcceleration = false;
+ var hasAngularVelocity = false;
+ fixed (float* data = acceleration)
+ {
+ hasAcceleration = SDL_GamepadHasSensor(_gamepad, SDL_SensorType.SDL_SENSOR_ACCEL) &&
+ SDL_GetGamepadSensorData(_gamepad, SDL_SensorType.SDL_SENSOR_ACCEL, data, acceleration.Length);
+ }
+ fixed (float* data = angularVelocity)
+ {
+ hasAngularVelocity = SDL_GamepadHasSensor(_gamepad, SDL_SensorType.SDL_SENSOR_GYRO) &&
+ SDL_GetGamepadSensorData(_gamepad, SDL_SensorType.SDL_SENSOR_GYRO, data, angularVelocity.Length);
+ }
+
+ return new HostMotionState(
+ hasAcceleration || hasAngularVelocity,
+ acceleration[0],
+ acceleration[1],
+ acceleration[2],
+ angularVelocity[0],
+ angularVelocity[1],
+ angularVelocity[2]);
+ }
+
+ private HostTouchState ReadTouch()
+ {
+ if (SDL_GetNumGamepadTouchpads(_gamepad) <= 0)
+ {
+ return default;
+ }
+
+ return new HostTouchState(ReadTouchPoint(0), ReadTouchPoint(1));
+ }
+
+ private HostTouchPoint ReadTouchPoint(int finger)
+ {
+ if (SDL_GetNumGamepadTouchpadFingers(_gamepad, 0) <= finger)
+ {
+ return default;
+ }
+
+ SDLBool down = false;
+ float x = 0;
+ float y = 0;
+ float pressure = 0;
+ if (!SDL_GetGamepadTouchpadFinger(_gamepad, 0, finger, &down, &x, &y, &pressure))
+ {
+ return default;
+ }
+
+ return new HostTouchPoint(down, (byte)finger, Math.Clamp(x, 0, 1), Math.Clamp(y, 0, 1));
+ }
+
+ private bool IsGamepadConnected() => _gamepad is not null && SDL_GamepadConnected(_gamepad);
+
+ private string DescribeGamepad() =>
+ Marshal.PtrToStringUTF8((nint)Unsafe_SDL_GetGamepadName(_gamepad)) ?? "SDL gamepad";
+
+ private static ushort ExpandByte(byte value) => (ushort)(value * 257);
+
+ private static bool TryMapVirtualKey(SDL_Keycode key, out int virtualKey)
+ {
+ virtualKey = key switch
+ {
+ SDL_Keycode.SDLK_BACKSPACE => 0x08,
+ SDL_Keycode.SDLK_TAB => 0x09,
+ SDL_Keycode.SDLK_RETURN => 0x0D,
+ SDL_Keycode.SDLK_ESCAPE => 0x1B,
+ SDL_Keycode.SDLK_LEFT => 0x25,
+ SDL_Keycode.SDLK_UP => 0x26,
+ SDL_Keycode.SDLK_RIGHT => 0x27,
+ SDL_Keycode.SDLK_DOWN => 0x28,
+ >= SDL_Keycode.SDLK_A and <= SDL_Keycode.SDLK_Z => 0x41 + (int)(key - SDL_Keycode.SDLK_A),
+ _ => 0,
+ };
+ return virtualKey != 0;
+ }
+
+ private void SetIcon()
+ {
+ if (!PngSplashLoader.TryLoadIcon(out var pixels, out var width, out var height))
+ {
+ return;
+ }
+
+ fixed (byte* data = pixels)
+ {
+ var surface = SDL_CreateSurfaceFrom(
+ (int)width,
+ (int)height,
+ SDL_PixelFormat.SDL_PIXELFORMAT_ABGR8888,
+ (nint)data,
+ checked((int)width * 4));
+ if (surface is not null)
+ {
+ SDL_SetWindowIcon(_window, surface);
+ SDL_DestroySurface(surface);
+ }
+ }
+ }
+
+ private static SDL_Window* CreateWindow(string title, int width, int height, SDL_WindowFlags flags)
+ {
+ var utf8 = Marshal.StringToCoTaskMemUTF8(title);
+ try
+ {
+ return SDL_CreateWindow((byte*)utf8, width, height, flags);
+ }
+ finally
+ {
+ Marshal.FreeCoTaskMem(utf8);
+ }
+ }
+
+ private static string GetError() =>
+ Marshal.PtrToStringUTF8((nint)Unsafe_SDL_GetError()) ?? "unknown SDL error";
+
+ private void EnsureGraphicsApi(SdlGraphicsApi expected)
+ {
+ if (_graphicsApi != expected)
+ {
+ throw new InvalidOperationException(
+ $"SDL host window uses {_graphicsApi}, not {expected}.");
+ }
+ }
+}
diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
index 290d9bd7..1fb386fb 100644
--- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
+++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
+using SharpEmu.Logging;
using SharpEmu.HLE.Host;
using SharpEmu.Libs.Diagnostics;
using SharpEmu.Libs.Gpu;
@@ -69,11 +70,14 @@ public static class VideoOutExports
private static readonly Dictionary _ports = new();
private static int _presentationWindowCloseNotified;
private static int _vblankStopRequested;
+ private static int _hdrOutputRequested;
private static readonly Dictionary<(int Handle, int BufferIndex, ulong Address), ulong> _lastFrameFingerprints = new();
private static int _nextHandle = 1;
private static int _frameDumpCount;
private static long _nextFrameDumpIndex;
- private static string _windowTitle = "SharpEmu VideoOut";
+ private static string _applicationWindowTitle = "VideoOut";
+ private static string _selectedGpuName = string.Empty;
+ private static string _applicationTitleId = "UNKNOWN";
private static readonly bool _logFrameRate = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_FPS"),
"1",
@@ -113,7 +117,18 @@ public static class VideoOutExports
var versionSuffix = string.IsNullOrWhiteSpace(version) ? string.Empty : $" v{version.Trim()}";
lock (_stateGate)
{
- _windowTitle = $"SharpEmu - {application}{versionSuffix}";
+ _applicationTitleId = string.IsNullOrWhiteSpace(titleId)
+ ? "UNKNOWN"
+ : titleId.Trim();
+ _applicationWindowTitle = $"{application}{versionSuffix}";
+ }
+ }
+
+ internal static string GetApplicationTitleId()
+ {
+ lock (_stateGate)
+ {
+ return _applicationTitleId;
}
}
@@ -121,7 +136,10 @@ public static class VideoOutExports
{
lock (_stateGate)
{
- return _windowTitle;
+ var gpuSuffix = string.IsNullOrWhiteSpace(_selectedGpuName)
+ ? string.Empty
+ : $" · {_selectedGpuName}";
+ return $"SharpEmu · {BuildInfo.CommitSha ?? "dev"} - {_applicationWindowTitle}{gpuSuffix}";
}
}
@@ -139,7 +157,7 @@ public static class VideoOutExports
: string.Empty;
lock (_stateGate)
{
- _windowTitle = $"{_windowTitle} · {gpuName.Trim()}{backendSuffix}";
+ _selectedGpuName = $"{gpuName.Trim()}{backendSuffix}";
}
}
@@ -166,30 +184,17 @@ public static class VideoOutExports
private static void RequestHostShutdown(string reason)
{
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
- var embedded = VulkanVideoHost.IsEmbedded;
AudioOutExports.ShutdownAllPorts();
Interlocked.Exchange(ref _vblankStopRequested, 1);
HostSessionControl.RequestShutdown(reason);
+ GuestGpu.Current.RequestClose();
- // A hosted game can still be issuing AGC work after it requests its
- // own shutdown. Keep the presenter's resources alive until the GUI
- // session reaches its guest-safe exit path and disposes the host
- // surface.
- if (!embedded)
+ // Give guest and GPU threads a bounded window to leave cooperatively.
+ ThreadPool.QueueUserWorkItem(static _ =>
{
- GuestGpu.Current.RequestClose();
- }
-
- // The embedded GUI owns the process lifetime. A guest shutdown should
- // end only that session rather than terminating the launcher itself.
- if (!embedded)
- {
- ThreadPool.QueueUserWorkItem(static _ =>
- {
- Thread.Sleep(2000);
- Environment.Exit(0);
- });
- }
+ Thread.Sleep(2000);
+ Environment.Exit(0);
+ });
}
private sealed class VideoOutPortState
@@ -871,6 +876,8 @@ public static class VideoOutExports
}
}
+ internal static bool IsHdrOutputRequested => Volatile.Read(ref _hdrOutputRequested) != 0;
+
[SysAbiExport(
Nid = "MTxxrOCeSig",
ExportName = "sceVideoOutSetWindowModeMargins",
@@ -1175,16 +1182,21 @@ public static class VideoOutExports
var guestImageSubmitted = false;
ulong guestImageAddress = 0;
- if (submitGpuImage &&
- bufferIndex >= 0 &&
+ if (bufferIndex >= 0 &&
TryGetDisplayBufferInfo(handle, bufferIndex, out var displayBuffer))
{
+ Interlocked.Exchange(
+ ref _hdrOutputRequested,
+ IsHdrPixelFormat(displayBuffer.PixelFormat) ? 1 : 0);
guestImageAddress = displayBuffer.Address;
- guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
- displayBuffer.Address,
- displayBuffer.Width,
- displayBuffer.Height,
- displayBuffer.PitchInPixel);
+ if (submitGpuImage)
+ {
+ guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
+ displayBuffer.Address,
+ displayBuffer.Width,
+ displayBuffer.Height,
+ displayBuffer.PitchInPixel);
+ }
}
if (_dumpVideoOut)
@@ -1711,6 +1723,12 @@ public static class VideoOutExports
internal static bool IsPacked10BitPixelFormat(ulong pixelFormat) =>
IsPacked10BitPixelFormatNormalized(NormalizePixelFormat(pixelFormat));
+ internal static bool IsHdrPixelFormat(ulong pixelFormat) =>
+ NormalizePixelFormat(pixelFormat) is
+ SceVideoOutPixelFormatA2R10G10B10Bt2020Pq or
+ SceVideoOutPixelFormat2R10G10B10A2Bt2100Pq or
+ SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq;
+
private static bool IsPacked10BitPixelFormatNormalized(ulong pixelFormat) =>
pixelFormat is
SceVideoOutPixelFormatA2R10G10B10 or
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanHostSurface.cs b/src/SharpEmu.Libs/VideoOut/VulkanHostSurface.cs
deleted file mode 100644
index 311af154..00000000
--- a/src/SharpEmu.Libs/VideoOut/VulkanHostSurface.cs
+++ /dev/null
@@ -1,270 +0,0 @@
-// Copyright (C) 2026 SharpEmu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-namespace SharpEmu.Libs.VideoOut;
-
-///
-/// A native child surface owned by a host UI. The presenter consumes this
-/// directly, avoiding a second top-level GLFW window when the GUI is active.
-///
-public enum VulkanHostSurfaceKind
-{
- Win32,
- Xlib,
- Metal,
-}
-
-///
-/// Platform-native handles required to create a Vulkan presentation surface.
-/// The GUI owns their lifetime and updates the physical pixel size on resize.
-///
-public sealed class VulkanHostSurface : IDisposable
-{
- private int _pixelWidth;
- private int _pixelHeight;
- private int _resizeGeneration;
- private readonly bool _ownsDisplay;
- private readonly bool _pollNativeSize;
- private long _nextNativeSizePoll;
-
- public VulkanHostSurface(
- VulkanHostSurfaceKind kind,
- nint windowHandle,
- nint displayHandle = 0,
- nint metalLayerHandle = 0,
- bool ownsDisplay = false,
- bool pollNativeSize = false)
- {
- Kind = kind;
- WindowHandle = windowHandle;
- DisplayHandle = displayHandle;
- MetalLayerHandle = metalLayerHandle;
- _ownsDisplay = ownsDisplay;
- _pollNativeSize = pollNativeSize;
- }
-
- public VulkanHostSurfaceKind Kind { get; }
-
- public nint WindowHandle { get; }
-
- /// X11 Display* when is .
- public nint DisplayHandle { get; }
-
- /// CAMetalLayer* when is .
- public nint MetalLayerHandle { get; }
-
- public int PixelWidth => Volatile.Read(ref _pixelWidth);
-
- public int PixelHeight => Volatile.Read(ref _pixelHeight);
-
- internal int ResizeGeneration => Volatile.Read(ref _resizeGeneration);
-
- public void UpdatePixelSize(int width, int height)
- {
- width = Math.Max(width, 1);
- height = Math.Max(height, 1);
- if (Volatile.Read(ref _pixelWidth) == width && Volatile.Read(ref _pixelHeight) == height)
- {
- return;
- }
-
- Volatile.Write(ref _pixelWidth, width);
- Volatile.Write(ref _pixelHeight, height);
- Interlocked.Increment(ref _resizeGeneration);
- }
-
- ///
- /// The child emulator cannot receive Avalonia resize notifications. Poll
- /// the native host at a bounded rate so embedded child swapchains still
- /// follow normal resize and F11 transitions.
- ///
- internal void RefreshChildProcessPixelSize()
- {
- if (!_pollNativeSize)
- {
- return;
- }
-
- var now = System.Diagnostics.Stopwatch.GetTimestamp();
- var due = Volatile.Read(ref _nextNativeSizePoll);
- if (now < due || Interlocked.CompareExchange(
- ref _nextNativeSizePoll,
- now + (System.Diagnostics.Stopwatch.Frequency / 8),
- due) != due)
- {
- return;
- }
-
- if (Kind == VulkanHostSurfaceKind.Win32 && GetClientRect(WindowHandle, out var rect))
- {
- UpdatePixelSize(rect.Right - rect.Left, rect.Bottom - rect.Top);
- return;
- }
-
- if (Kind == VulkanHostSurfaceKind.Xlib && DisplayHandle != 0 &&
- XGetGeometry(
- DisplayHandle,
- WindowHandle,
- out _,
- out _,
- out _,
- out var width,
- out var height,
- out _,
- out _) != 0)
- {
- UpdatePixelSize(unchecked((int)width), unchecked((int)height));
- }
- }
-
- ///
- /// Serializes a native child handle for a separately hosted emulator
- /// process. Metal object pointers are process-local, so macOS falls back
- /// to a standalone child window until an IPC Metal host is implemented.
- ///
- public bool TryGetChildProcessDescriptor(out string descriptor)
- {
- descriptor = string.Empty;
- if (Kind == VulkanHostSurfaceKind.Metal || WindowHandle == 0)
- {
- return false;
- }
-
- var kind = Kind == VulkanHostSurfaceKind.Win32 ? "win32" : "xlib";
- descriptor = string.Create(
- System.Globalization.CultureInfo.InvariantCulture,
- $"{kind}:{unchecked((ulong)WindowHandle):X}:{Math.Max(PixelWidth, 1)}:{Math.Max(PixelHeight, 1)}:{unchecked((ulong)DisplayHandle):X}");
- return true;
- }
-
- ///
- /// Reconstructs a surface in the isolated emulator process. X11 clients
- /// must open their own Display connection; Display* values cannot cross a
- /// process boundary.
- ///
- public static bool TryCreateChildProcessSurface(
- string descriptor,
- out VulkanHostSurface? surface,
- out string? error)
- {
- surface = null;
- error = null;
- var parts = descriptor.Split(':');
- if (parts.Length != 5 ||
- !ulong.TryParse(parts[1], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var window) ||
- !int.TryParse(parts[2], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var width) ||
- !int.TryParse(parts[3], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var height) ||
- !ulong.TryParse(parts[4], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var nativeDisplay))
- {
- error = "invalid host-surface descriptor";
- return false;
- }
-
- if (window == 0 || width <= 0 || height <= 0)
- {
- error = "host-surface descriptor has an invalid size or handle";
- return false;
- }
-
- if (string.Equals(parts[0], "win32", StringComparison.OrdinalIgnoreCase))
- {
- surface = new VulkanHostSurface(
- VulkanHostSurfaceKind.Win32,
- unchecked((nint)window),
- unchecked((nint)nativeDisplay),
- pollNativeSize: true);
- }
- else if (string.Equals(parts[0], "xlib", StringComparison.OrdinalIgnoreCase))
- {
- var display = XOpenDisplay(0);
- if (display == 0)
- {
- error = "could not open an X11 display for the host surface";
- return false;
- }
-
- surface = new VulkanHostSurface(
- VulkanHostSurfaceKind.Xlib,
- unchecked((nint)window),
- display,
- ownsDisplay: true,
- pollNativeSize: true);
- }
- else
- {
- error = $"unsupported host-surface kind '{parts[0]}'";
- return false;
- }
-
- surface.UpdatePixelSize(width, height);
- return true;
- }
-
- public void Dispose()
- {
- if (_ownsDisplay && DisplayHandle != 0 && OperatingSystem.IsLinux())
- {
- _ = XCloseDisplay(DisplayHandle);
- }
- }
-
- [System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
- private static extern nint XOpenDisplay(nint displayName);
-
- [System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
- private static extern int XCloseDisplay(nint display);
-
- [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetClientRect", SetLastError = true)]
- [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
- private static extern bool GetClientRect(nint window, out Rect rect);
-
- [System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XGetGeometry")]
- private static extern int XGetGeometry(
- nint display,
- nint drawable,
- out nint root,
- out int x,
- out int y,
- out uint width,
- out uint height,
- out uint borderWidth,
- out uint depth);
-
- [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
- private struct Rect
- {
- public int Left;
- public int Top;
- public int Right;
- public int Bottom;
- }
-}
-
-///
-/// Small public bridge between a desktop UI and the internal Vulkan
-/// presenter. Launchers can host a surface without depending on renderer
-/// submission internals.
-///
-public static class VulkanVideoHost
-{
- ///
- /// Raised after the first successful Vulkan present to an embedded host
- /// surface. UI hosts use this to retire their launch affordance only once
- /// a real frame can be seen.
- ///
- public static event Action? FirstFramePresented
- {
- add => VulkanVideoPresenter.FirstHostFramePresented += value;
- remove => VulkanVideoPresenter.FirstHostFramePresented -= value;
- }
-
- public static bool TryAttachSurface(VulkanHostSurface surface) =>
- VulkanVideoPresenter.TryAttachHostSurface(surface);
-
- public static void DetachSurface(VulkanHostSurface surface) =>
- VulkanVideoPresenter.DetachHostSurface(surface);
-
- public static void RequestClose() => VulkanVideoPresenter.RequestClose();
-
- public static bool IsEmbedded => VulkanVideoPresenter.UsesHostSurface;
-}
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs b/src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs
new file mode 100644
index 00000000..137c91a8
--- /dev/null
+++ b/src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs
@@ -0,0 +1,96 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.Libs.VideoOut;
+
+internal static class VulkanPipelineCacheStorage
+{
+ private const string CacheFileName = "vulkan-pipeline-cache.bin";
+
+ internal static string ResolvePath(string? titleId, string? configuredPath)
+ {
+ if (!string.IsNullOrWhiteSpace(configuredPath))
+ {
+ return Path.GetFullPath(
+ Environment.ExpandEnvironmentVariables(configuredPath));
+ }
+
+ return Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "user",
+ "pipeline_cache",
+ SanitizeTitleId(titleId),
+ CacheFileName));
+ }
+
+ internal static string GetLegacyPath()
+ {
+ var root = OperatingSystem.IsMacOS()
+ ? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Library",
+ "Caches")
+ : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ return Path.Combine(root, "SharpEmu", CacheFileName);
+ }
+
+ internal static bool ImportLegacyCache(string legacyPath, string destinationPath)
+ {
+ if (File.Exists(destinationPath) || !File.Exists(legacyPath))
+ {
+ return false;
+ }
+
+ var directory = Path.GetDirectoryName(destinationPath);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ try
+ {
+ File.Copy(legacyPath, destinationPath, overwrite: false);
+ try
+ {
+ File.Delete(legacyPath);
+ }
+ catch (IOException)
+ {
+ // The destination is valid; a locked legacy cache can remain
+ // as an unused artifact without affecting future writes.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // See above. Cache migration must not block game startup.
+ }
+
+ return true;
+ }
+ catch (IOException) when (File.Exists(destinationPath))
+ {
+ return false;
+ }
+ }
+
+ private static string SanitizeTitleId(string? titleId)
+ {
+ if (string.IsNullOrWhiteSpace(titleId))
+ {
+ return "UNKNOWN";
+ }
+
+ var source = titleId.Trim();
+ Span sanitized = source.Length <= 128
+ ? stackalloc char[source.Length]
+ : new char[source.Length];
+ for (var index = 0; index < source.Length; index++)
+ {
+ var value = source[index];
+ sanitized[index] = char.IsAsciiLetterOrDigit(value) || value is '-' or '_'
+ ? char.ToUpperInvariant(value)
+ : '_';
+ }
+
+ return new string(sanitized);
+ }
+}
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
index 9928d675..7c0d3498 100644
--- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
+++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
@@ -3,18 +3,15 @@
using Silk.NET.Core;
using Silk.NET.Core.Native;
-using Silk.NET.Maths;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Bink;
-using Silk.NET.Input;
using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Vulkan;
using Silk.NET.Vulkan;
using Silk.NET.Vulkan.Extensions.KHR;
using Silk.NET.Vulkan.Extensions.EXT;
-using Silk.NET.Windowing;
using System.Buffers.Binary;
using System.Diagnostics;
using System.Numerics;
@@ -110,8 +107,7 @@ internal readonly record struct VulkanGuestQueueIdentity(
internal static unsafe class VulkanVideoPresenter
{
- // Standalone CLI launches use a desktop-sized surface. The embedded GUI
- // always takes its dimensions from the native child control instead.
+ // Standalone launches use a desktop-sized SDL surface unless configured.
private const uint DefaultWindowWidth = 1920;
private const uint DefaultWindowHeight = 1080;
internal const uint Gen5TextureType2D = 9;
@@ -557,9 +553,7 @@ internal static unsafe class VulkanVideoPresenter
private static readonly HashSet<(ulong Address, uint Width, uint Height)>
_tracedGuestImageSubmissions = [];
private static Thread? _thread;
- private static VulkanHostSurface? _hostSurface;
- private static VulkanHostSurface? _hostSurfacePendingDetach;
- internal static event Action? FirstHostFramePresented;
+ private static HostVideoOptions _videoOptions = HostVideoOptions.Default;
private static Presentation? _latestPresentation;
private static byte[]? _copyFragmentSpirv;
private static uint _windowWidth;
@@ -567,6 +561,7 @@ internal static unsafe class VulkanVideoPresenter
private static bool _closed;
private static bool _presenterCloseRequested;
private const string DebugUtilsExtensionName = "VK_EXT_debug_utils";
+ private const string SwapchainColorspaceExtensionName = "VK_EXT_swapchain_colorspace";
private const uint NvidiaVendorId = 0x10DE;
private const uint AmdVendorId = 0x1002;
// Other GPU PCI vendor IDs, for reference when adding future rules:
@@ -574,12 +569,6 @@ internal static unsafe class VulkanVideoPresenter
private const int LastResortPenalty = 1000;
private const string PortabilityEnumerationExtensionName = "VK_KHR_portability_enumeration";
private const string PortabilitySubsetExtensionName = "VK_KHR_portability_subset";
- private const int GlfwPlatformHint = 0x00050003;
- private const int GlfwPlatformWin32 = 0x00060001;
- private const int GlfwPlatformCocoa = 0x00060002;
- private const int GlfwPlatformWayland = 0x00060003;
- private const int GlfwPlatformX11 = 0x00060004;
- private const int GlfwPlatformNull = 0x00060005;
internal static int ScorePhysicalDevice(
PhysicalDeviceProperties properties,
@@ -760,26 +749,13 @@ internal static unsafe class VulkanVideoPresenter
TranslatedDraw: null,
RequiredGuestWorkSequence: 0,
IsSplash: false);
- if (_hostSurface is not null && _latestPresentation is { IsSplash: true })
- {
- // The GUI keeps its native child hidden while the launcher is
- // loading. Reveal it for the real VideoOut splash rather than
- // substituting a launcher-side image.
- Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted splash ready.");
- }
StartPresenterLocked();
}
}
- ///
- /// Selects a same-process native surface for the next VideoOut session.
- /// This is intentionally rejected once the presenter has started: Vulkan
- /// surface ownership cannot change under an active swapchain.
- ///
- public static bool TryAttachHostSurface(VulkanHostSurface surface)
+ public static bool TryConfigureVideo(HostVideoOptions options)
{
- ArgumentNullException.ThrowIfNull(surface);
-
+ ArgumentNullException.ThrowIfNull(options);
lock (_gate)
{
if (_thread is not null)
@@ -787,72 +763,11 @@ internal static unsafe class VulkanVideoPresenter
return false;
}
- ResetHostSessionStateLocked();
- _hostSurface = surface;
- _closed = false;
- _presenterCloseRequested = false;
+ _videoOptions = options.Normalize();
return true;
}
}
- ///
- /// Releases the host surface after the guest session has stopped.
- ///
- public static void DetachHostSurface(VulkanHostSurface surface)
- {
- ArgumentNullException.ThrowIfNull(surface);
-
- lock (_gate)
- {
- if (!ReferenceEquals(_hostSurface, surface))
- {
- return;
- }
-
- if (_thread is null)
- {
- _hostSurface = null;
- }
- else
- {
- _hostSurfacePendingDetach = surface;
- }
- }
- }
-
- internal static bool UsesHostSurface
- {
- get
- {
- lock (_gate)
- {
- return _hostSurface is not null;
- }
- }
- }
-
- private static void NotifyFirstHostFramePresented(VulkanHostSurface surface)
- {
- // This marker crosses the GUI child-process pipe. The in-process
- // event remains for hosts that embed the renderer directly.
- Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted first frame presented.");
- var callback = FirstHostFramePresented;
- if (callback is null)
- {
- return;
- }
-
- try
- {
- callback(surface);
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine(
- $"[LOADER][WARN] Embedded first-frame notification failed: {exception.Message}");
- }
- }
-
private static void ResetHostSessionStateLocked()
{
_latestPresentation = null;
@@ -879,7 +794,6 @@ internal static unsafe class VulkanVideoPresenter
_completedGuestWorkOutOfOrder.Clear();
_lastEnqueuedGuestWorkByQueue.Clear();
_executingGuestWorkSequence = 0;
- _hostSurfacePendingDetach = null;
}
public static void HideSplashScreen()
@@ -1266,7 +1180,8 @@ internal static unsafe class VulkanVideoPresenter
private sealed record VulkanGuestImageWrite(
ulong Address,
byte[]? Pixels,
- uint FillValue);
+ uint FillValue,
+ uint RowOffset = 0);
///
/// Reports the extent of a live guest image so DMA writes to its backing
@@ -1369,7 +1284,7 @@ internal static unsafe class VulkanVideoPresenter
}
}
- internal static void SubmitGuestImageWrite(ulong address, byte[] pixels)
+ internal static void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0)
{
lock (_gate)
{
@@ -1379,7 +1294,7 @@ internal static unsafe class VulkanVideoPresenter
}
_guestImageWorkSequences[address] = EnqueueGuestWorkLocked(
- new VulkanGuestImageWrite(address, pixels, 0));
+ new VulkanGuestImageWrite(address, pixels, 0, rowOffset));
}
}
@@ -1671,7 +1586,8 @@ internal static unsafe class VulkanVideoPresenter
// renderer and turns every flip into a dropped black frame.
RequiredGuestWorkSequence: requiredWorkSequence,
IsSplash: false,
- GuestImageAddress: address);
+ GuestImageAddress: address,
+ IsHdr: VideoOutExports.IsHdrOutputRequested);
_latestPresentation = presentation;
_pendingGuestImagePresentations.Enqueue(presentation);
while (_pendingGuestImagePresentations.Count > MaxPendingGuestFlipVersions)
@@ -2258,9 +2174,9 @@ internal static unsafe class VulkanVideoPresenter
private static void StartPresenterLocked()
{
- if (_hostSurface is null && HostMainThread.IsAvailable)
+ if (HostMainThread.IsAvailable)
{
- // AppKit (and therefore GLFW) traps when touched off the process
+ // AppKit windowing traps when touched off the process
// main thread on macOS, so hand the whole window loop to the
// main-thread pump the CLI parked for us. _thread only marks the
// presenter as running; Run() clears it on exit either way.
@@ -2287,162 +2203,19 @@ internal static unsafe class VulkanVideoPresenter
Volatile.Write(ref _presenterCloseRequested, true);
}
- ///
- /// GLFW resolves Vulkan with dlopen("libvulkan.1.dylib"), which cannot
- /// find the app-local MoltenVK on macOS (Homebrew's Vulkan libraries are
- /// arm64-only and this is an x86-64 process). GLFW 3.4 accepts the
- /// loader entry point directly instead, so hand it MoltenVK's
- /// vkGetInstanceProcAddr before any window exists.
- ///
- private static unsafe void InitializeMacVulkanLoader()
- {
- if (!OperatingSystem.IsMacOS())
- {
- return;
- }
-
- try
- {
- nint vulkan = 0;
- foreach (var candidate in new[]
- {
- Path.Combine(AppContext.BaseDirectory, "libvulkan.1.dylib"),
- Path.Combine(AppContext.BaseDirectory, "libMoltenVK.dylib"),
- "libvulkan.1.dylib",
- "libMoltenVK.dylib",
- })
- {
- if (System.Runtime.InteropServices.NativeLibrary.TryLoad(candidate, out vulkan))
- {
- break;
- }
- }
-
- if (vulkan == 0 ||
- !System.Runtime.InteropServices.NativeLibrary.TryGetExport(
- vulkan, "vkGetInstanceProcAddr", out var procAddr))
- {
- Console.Error.WriteLine(
- "[LOADER][WARN] No Vulkan loader for GLFW; place a universal libMoltenVK.dylib " +
- "next to SharpEmu as libvulkan.1.dylib.");
- return;
- }
-
- var glfw = System.Runtime.InteropServices.NativeLibrary.Load(
- Path.Combine(AppContext.BaseDirectory, "libglfw.3.dylib"));
- var initVulkanLoader = (delegate* unmanaged)
- System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwInitVulkanLoader");
- initVulkanLoader(procAddr);
- Console.Error.WriteLine("[LOADER][INFO] GLFW Vulkan loader wired to MoltenVK.");
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][WARN] GLFW Vulkan loader setup failed: {exception.Message}");
- }
- }
-
- private static unsafe void PreferX11OnLinuxWayland()
- {
- if (!OperatingSystem.IsLinux() ||
- string.Equals(
- Environment.GetEnvironmentVariable("SHARPEMU_ENABLE_WAYLAND"),
- "1",
- StringComparison.Ordinal))
- {
- return;
- }
-
- if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")))
- {
- return;
- }
-
- if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")))
- {
- Console.Error.WriteLine(
- "[LOADER][WARN] Wayland session without an X server (DISPLAY unset); " +
- "cannot steer GLFW to XWayland. Set SHARPEMU_ENABLE_WAYLAND=1 to use native Wayland.");
- return;
- }
-
- if (!TryLoadGlfw(out var glfw))
- {
- return;
- }
-
- try
- {
- var initHint = (delegate* unmanaged)
- System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwInitHint");
- initHint(GlfwPlatformHint, GlfwPlatformX11);
- Console.Error.WriteLine(
- "[LOADER][INFO] Wayland session detected; requested GLFW X11/XWayland backend.");
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine(
- $"[LOADER][WARN] Could not set GLFW X11 platform hint: {exception.Message}");
- }
- }
-
- private static unsafe void LogGlfwPlatformInUse()
- {
- if (OperatingSystem.IsWindows() || !TryLoadGlfw(out var glfw))
- {
- return;
- }
-
- try
- {
- var getPlatform = (delegate* unmanaged)
- System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwGetPlatform");
- var platform = getPlatform();
- var label = platform switch
- {
- GlfwPlatformWin32 => "Win32",
- GlfwPlatformCocoa => "Cocoa",
- GlfwPlatformWayland => "Wayland",
- GlfwPlatformX11 => "X11",
- GlfwPlatformNull => "Null",
- _ => $"0x{platform:X}",
- };
- Console.Error.WriteLine($"[LOADER][INFO] GLFW windowing platform in use: {label}");
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][WARN] Could not query GLFW platform: {exception.Message}");
- }
- }
-
- private static bool TryLoadGlfw(out nint handle)
- {
- var name = OperatingSystem.IsMacOS() ? "libglfw.3.dylib" : "libglfw.so.3";
- return System.Runtime.InteropServices.NativeLibrary.TryLoad(
- Path.Combine(AppContext.BaseDirectory, name), out handle) ||
- System.Runtime.InteropServices.NativeLibrary.TryLoad(name, out handle);
- }
-
private static void Run()
{
uint width;
uint height;
- VulkanHostSurface? hostSurface;
lock (_gate)
{
width = _windowWidth == 0 ? _latestPresentation?.Width ?? 1280 : _windowWidth;
height = _windowHeight == 0 ? _latestPresentation?.Height ?? 720 : _windowHeight;
- hostSurface = _hostSurface;
- }
-
- if (hostSurface is null)
- {
- PreferX11OnLinuxWayland();
- InitializeMacVulkanLoader();
}
try
{
- using var presenter = new Presenter(width, height, hostSurface);
+ using var presenter = new Presenter(width, height);
presenter.Run();
}
catch (Exception exception)
@@ -2455,12 +2228,6 @@ internal static unsafe class VulkanVideoPresenter
{
_closed = true;
_thread = null;
- if (_hostSurfacePendingDetach is not null &&
- ReferenceEquals(_hostSurface, _hostSurfacePendingDetach))
- {
- _hostSurface = null;
- _hostSurfacePendingDetach = null;
- }
System.Threading.Monitor.PulseAll(_gate);
}
}
@@ -2672,8 +2439,7 @@ internal static unsafe class VulkanVideoPresenter
}
_pendingGuestWorkBytes = SaturatingAdd(_pendingGuestWorkBytes, payloadBytes);
- // Wake the embedded render loop parked in WaitForRenderWork; without a
- // pulse it only notices new work when its timed wait expires.
+ // Wake any thread waiting for guest work completion or queue space.
System.Threading.Monitor.PulseAll(_gate);
return sequence;
}
@@ -3181,7 +2947,8 @@ internal static unsafe class VulkanVideoPresenter
long RequiredGuestWorkSequence,
bool IsSplash,
ulong GuestImageAddress = 0,
- long GuestImageVersion = 0);
+ long GuestImageVersion = 0,
+ bool IsHdr = false);
private sealed class Presenter : IDisposable
{
@@ -3191,10 +2958,7 @@ internal static unsafe class VulkanVideoPresenter
private const string FullscreenBarycentricFragmentSpirv =
"AwIjBwAAAQALAAgAEgAAAAAAAAARAAIAAQAAAAsABgABAAAAR0xTTC5zdGQuNDUwAAAAAA4AAwAAAAAAAQAAAA8ABwAEAAAABAAAAG1haW4AAAAACQAAAAwAAAAQAAMABAAAAAcAAAADAAMAAgAAAMIBAAAFAAQABAAAAG1haW4AAAAABQAFAAkAAABvdXRDb2xvcgAAAAAFAAUADAAAAGJhcnljZW50cmljAEcABAAJAAAAHgAAAAAAAABHAAQADAAAAB4AAAAAAAAAEwACAAIAAAAhAAMAAwAAAAIAAAAWAAMABgAAACAAAAAXAAQABwAAAAYAAAAEAAAAIAAEAAgAAAADAAAABwAAADsABAAIAAAACQAAAAMAAAAXAAQACgAAAAYAAAACAAAAIAAEAAsAAAABAAAACgAAADsABAALAAAADAAAAAEAAAArAAQABgAAAA4AAAAAAAAANgAFAAIAAAAEAAAAAAAAAAMAAAD4AAIABQAAAD0ABAAKAAAADQAAAAwAAABRAAUABgAAAA8AAAANAAAAAAAAAFEABQAGAAAAEAAAAA0AAAABAAAAUAAHAAcAAAARAAAADwAAABAAAAAOAAAADgAAAD4AAwAJAAAAEQAAAP0AAQA4AAEA";
- private readonly IWindow? _window;
- private readonly VulkanHostSurface? _hostSurface;
- private int _lastHostResizeGeneration;
- private bool _embeddedLoopClosed;
+ private readonly SdlHostWindow _window;
private const int MaxInFlightGuestSubmissions = 8;
private Vk _vk = null!;
private KhrSurface _surfaceApi = null!;
@@ -3238,6 +3002,25 @@ internal static unsafe class VulkanVideoPresenter
private Framebuffer[] _framebuffers = [];
private bool[] _imageInitialized = [];
private Format _swapchainFormat;
+ private ColorSpaceKHR _swapchainColorSpace;
+ private bool _hdrOutputActive;
+ private bool _hdrRequestedForSwapchain;
+ private float _hdrSdrWhiteLevel = 1f;
+ private float _hdrHeadroom = 1f;
+ private Image[] _presentationImages = [];
+ private DeviceMemory[] _presentationImageMemory = [];
+ private ImageView[] _presentationImageViews = [];
+ private ImageView[] _presentationSampleViews = [];
+ private RenderPass _hdrRenderPass;
+ private Framebuffer[] _hdrFramebuffers = [];
+ private DescriptorSetLayout _hdrDescriptorSetLayout;
+ private DescriptorPool _hdrDescriptorPool;
+ private DescriptorSet[] _hdrDescriptorSets = [];
+ private DescriptorSet[] _hdrPqDescriptorSets = [];
+ private PipelineLayout _hdrPipelineLayout;
+ private Pipeline _hdrPipeline;
+ private Pipeline _hdrPqPipeline;
+ private Sampler _hdrSampler;
private Extent2D _extent;
private RenderPass _renderPass;
private PipelineLayout _pipelineLayout;
@@ -3330,11 +3113,8 @@ internal static unsafe class VulkanVideoPresenter
private long _presentNotTakenLoggedSequence = long.MinValue;
private bool _vulkanReady;
private bool _firstFramePresented;
- private bool _firstHostFramePresented;
private bool _firstGuestDrawPresented;
private bool _splashPresented;
- private Presentation? _lastHostSplashPresentation;
- private Presentation? _pendingHostSplashReplay;
private bool _swapchainRecreateDeferred;
private bool _tracedPresentedSwapchain;
private bool _swapchainReadbackPending;
@@ -3580,6 +3360,7 @@ internal static unsafe class VulkanVideoPresenter
}
private const Format DepthFormat = Format.D32Sfloat;
+ private const ulong SwapchainAcquireTimeoutNs = 250_000_000;
private sealed class GuestDepthResource
{
@@ -3658,124 +3439,55 @@ internal static unsafe class VulkanVideoPresenter
VulkanGuestQueueIdentity Queue,
long WorkSequence);
- public Presenter(uint width, uint height, VulkanHostSurface? hostSurface)
+ public Presenter(uint width, uint height)
{
- _hostSurface = hostSurface;
_hostBufferPool = new VulkanHostBufferPool(
MaximumCachedHostBufferBytes,
DestroyHostBufferAllocation);
-
- if (_hostSurface is not null)
- {
- _hostSurface.UpdatePixelSize(
- _hostSurface.PixelWidth > 0 ? _hostSurface.PixelWidth : (int)width,
- _hostSurface.PixelHeight > 0 ? _hostSurface.PixelHeight : (int)height);
- _lastHostResizeGeneration = _hostSurface.ResizeGeneration;
- return;
- }
-
- var options = WindowOptions.DefaultVulkan;
- options.Size = new Vector2D((int)DefaultWindowWidth, (int)DefaultWindowHeight);
- options.Title = VideoOutExports.GetWindowTitle();
- options.WindowBorder = WindowBorder.Fixed;
- options.VSync = true;
- options.FramesPerSecond = 60;
- options.UpdatesPerSecond = 60;
- _window = Window.Create(options);
- _window.Load += Initialize;
- _window.Render += Render;
- _window.Closing += () =>
- {
- Console.Error.WriteLine(
- $"[LOADER][WARN] Vulkan VideoOut window closing; " +
- $"requested={Volatile.Read(ref _presenterCloseRequested)} " +
- $"deviceLost={_deviceLost}");
- VideoOutExports.NotifyPresentationWindowClosed();
- DisposeVulkan();
- };
+ _window = new SdlHostWindow(
+ VideoOutExports.GetWindowTitle(),
+ _videoOptions,
+ SdlGraphicsApi.Vulkan);
}
public void Run()
{
- if (_window is not null)
- {
- _window.Run();
- return;
- }
-
- Initialize();
- while (!_embeddedLoopClosed)
- {
- Render(0);
- // Guest draws and flips execute on this thread. An unconditional
- // Thread.Sleep here is quantized to the Windows timer period
- // (~15.6ms) and throttles every hosted game far below the GLFW
- // path. Wait only while there is genuinely no render work.
- WaitForRenderWork();
- }
+ _window.Run(
+ Initialize,
+ Render,
+ () =>
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] Vulkan VideoOut window closing; " +
+ $"requested={Volatile.Read(ref _presenterCloseRequested)} " +
+ $"deviceLost={_deviceLost}");
+ VideoOutExports.NotifyPresentationWindowClosed();
+ DisposeVulkan();
+ },
+ WaitForRenderWork);
}
public void Dispose()
{
DisposeVulkan();
- try
- {
- _window?.Dispose();
- }
- catch (InvalidOperationException exception)
- when (exception.Message.Contains("render loop", StringComparison.OrdinalIgnoreCase))
- {
- Console.Error.WriteLine(
- $"[LOADER][WARN] Vulkan VideoOut window dispose skipped during render loop: {exception.Message}");
- }
+ _window.Dispose();
}
private void Initialize()
{
- if (_window is not null)
- {
- LogGlfwPlatformInUse();
- if (!OperatingSystem.IsWindows())
- {
- try
- {
- Pad.HostWindowInput.Attach(_window.CreateInput());
- Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][WARN] Window keyboard input unavailable: {exception.Message}");
- }
- }
-
- if (PngSplashLoader.TryLoadIcon(out var iconPixels, out var iconWidth, out var iconHeight))
- {
- var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
- _window.SetWindowIcon(ref icon);
- }
- }
-
- try
- {
- WaitForRenderDocAttachIfRequested();
- _vk = Vk.GetApi();
- CreateInstance();
- CreateSurface();
- SelectPhysicalDevice();
- CreateDevice();
- CreatePipelineCache();
- CreateSwapchain();
- CreateCommandResources();
- CreateGuestDrawResources();
- _vulkanReady = true;
- Console.Error.WriteLine(
- $"[LOADER][INFO] Vulkan VideoOut ready: {_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
- }
- catch (Exception exception)
- {
- _vulkanReady = false;
- Console.Error.WriteLine($"[LOADER][WARN] Vulkan VideoOut disabled: {exception.Message}");
- }
+ WaitForRenderDocAttachIfRequested();
+ _vk = Vk.GetApi();
+ CreateInstance();
+ CreateSurface();
+ SelectPhysicalDevice();
+ CreateDevice();
+ CreatePipelineCache();
+ CreateSwapchain();
+ CreateCommandResources();
+ CreateGuestDrawResources();
+ _vulkanReady = true;
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] Vulkan VideoOut ready: {_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
}
private static void WaitForRenderDocAttachIfRequested()
@@ -4016,45 +3728,17 @@ internal static unsafe class VulkanVideoPresenter
ApiVersion = Vk.Version12,
};
- var hostExtensionNames = _hostSurface is null
- ? null
- : GetHostSurfaceExtensions(_hostSurface.Kind);
- byte** extensions;
- uint extensionCount;
- if (hostExtensionNames is not null)
- {
- extensions = null;
- extensionCount = (uint)hostExtensionNames.Length;
- }
- else if (_window?.VkSurface is { } glfwSurface)
- {
- extensions = glfwSurface.GetRequiredExtensions(out extensionCount);
- }
- else
- {
- throw new InvalidOperationException("GLFW did not provide Vulkan surface extensions.");
- }
+ var extensions = _window.GetRequiredVulkanInstanceExtensions(out var extensionCount);
byte* debugUtilsExtension = null;
byte* portabilityExtension = null;
+ byte* swapchainColorspaceExtension = null;
var instanceCreateFlags = InstanceCreateFlags.None;
var enabledExtensionCount = (int)extensionCount;
- var enabledExtensions = stackalloc byte*[(int)extensionCount + 2];
- var allocatedHostExtensions = hostExtensionNames is null
- ? null
- : new nint[hostExtensionNames.Length];
+ var enabledExtensions = stackalloc byte*[(int)extensionCount + 3];
for (var index = 0; index < (int)extensionCount; index++)
{
- if (hostExtensionNames is null)
- {
- enabledExtensions[index] = extensions[index];
- }
- else
- {
- var extension = (byte*)SilkMarshal.StringToPtr(hostExtensionNames[index]);
- allocatedHostExtensions![index] = (nint)extension;
- enabledExtensions[index] = extension;
- }
+ enabledExtensions[index] = extensions[index];
}
if (_vulkanDebugUtilsEnabled &&
@@ -4073,6 +3757,13 @@ internal static unsafe class VulkanVideoPresenter
instanceCreateFlags |= InstanceCreateFlags.EnumeratePortabilityBitKhr;
}
+ if (IsInstanceExtensionAvailable(SwapchainColorspaceExtensionName))
+ {
+ swapchainColorspaceExtension =
+ (byte*)SilkMarshal.StringToPtr(SwapchainColorspaceExtensionName);
+ enabledExtensions[enabledExtensionCount++] = swapchainColorspaceExtension;
+ }
+
if (_vulkanValidationEnabled &&
IsInstanceLayerAvailable("VK_LAYER_KHRONOS_validation"))
{
@@ -4125,12 +3816,9 @@ internal static unsafe class VulkanVideoPresenter
{
SilkMarshal.Free((nint)portabilityExtension);
}
- if (allocatedHostExtensions is not null)
+ if (swapchainColorspaceExtension is not null)
{
- foreach (var extension in allocatedHostExtensions)
- {
- SilkMarshal.Free(extension);
- }
+ SilkMarshal.Free((nint)swapchainColorspaceExtension);
}
}
}
@@ -4241,99 +3929,9 @@ internal static unsafe class VulkanVideoPresenter
}
private void CreateSurface()
{
- if (_hostSurface is not null)
- {
- CreateHostSurface(_hostSurface);
- return;
- }
-
- var instanceHandle = new VkHandle(_instance.Handle);
- var surfaceHandle = _window!.VkSurface!.Create(instanceHandle, null);
- _surface = new SurfaceKHR(surfaceHandle.Handle);
+ _surface = _window.CreateVulkanSurface(_instance);
}
- private static string[] GetHostSurfaceExtensions(VulkanHostSurfaceKind kind) => kind switch
- {
- VulkanHostSurfaceKind.Win32 => ["VK_KHR_surface", "VK_KHR_win32_surface"],
- VulkanHostSurfaceKind.Xlib => ["VK_KHR_surface", "VK_KHR_xlib_surface"],
- VulkanHostSurfaceKind.Metal => ["VK_KHR_surface", "VK_EXT_metal_surface"],
- _ => throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {kind}."),
- };
-
- private void CreateHostSurface(VulkanHostSurface hostSurface)
- {
- switch (hostSurface.Kind)
- {
- case VulkanHostSurfaceKind.Win32:
- CreateWin32HostSurface(hostSurface);
- return;
- case VulkanHostSurfaceKind.Xlib:
- CreateXlibHostSurface(hostSurface);
- return;
- case VulkanHostSurfaceKind.Metal:
- CreateMetalHostSurface(hostSurface);
- return;
- default:
- throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {hostSurface.Kind}.");
- }
- }
-
- private void CreateWin32HostSurface(VulkanHostSurface hostSurface)
- {
- if (!_vk.TryGetInstanceExtension(_instance, out KhrWin32Surface win32Surface))
- {
- throw new InvalidOperationException("VK_KHR_win32_surface is unavailable.");
- }
-
- var createInfo = new Win32SurfaceCreateInfoKHR
- {
- SType = StructureType.Win32SurfaceCreateInfoKhr,
- Hinstance = hostSurface.DisplayHandle != 0
- ? hostSurface.DisplayHandle
- : GetModuleHandleW(null),
- Hwnd = hostSurface.WindowHandle,
- };
- Check(win32Surface.CreateWin32Surface(_instance, &createInfo, null, out _surface), "vkCreateWin32SurfaceKHR");
- }
-
- private void CreateXlibHostSurface(VulkanHostSurface hostSurface)
- {
- if (!_vk.TryGetInstanceExtension(_instance, out KhrXlibSurface xlibSurface))
- {
- throw new InvalidOperationException("VK_KHR_xlib_surface is unavailable.");
- }
-
- var createInfo = new XlibSurfaceCreateInfoKHR
- {
- SType = StructureType.XlibSurfaceCreateInfoKhr,
- Dpy = (nint*)hostSurface.DisplayHandle,
- Window = hostSurface.WindowHandle,
- };
- Check(xlibSurface.CreateXlibSurface(_instance, &createInfo, null, out _surface), "vkCreateXlibSurfaceKHR");
- }
-
- private void CreateMetalHostSurface(VulkanHostSurface hostSurface)
- {
- var proc = _vk.GetInstanceProcAddr(_instance, "vkCreateMetalSurfaceEXT");
- if (proc == 0)
- {
- throw new InvalidOperationException("VK_EXT_metal_surface is unavailable.");
- }
-
- var createInfo = new MetalSurfaceCreateInfoEXT
- {
- SType = StructureType.MetalSurfaceCreateInfoExt,
- PLayer = (nint*)hostSurface.MetalLayerHandle,
- };
- var createSurface = (delegate* unmanaged)proc.Handle;
- SurfaceKHR surface;
- Check(createSurface(_instance, &createInfo, null, &surface), "vkCreateMetalSurfaceEXT");
- _surface = surface;
- }
-
- [DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
- private static extern nint GetModuleHandleW(string? moduleName);
-
private void SelectPhysicalDevice()
{
uint deviceCount = 0;
@@ -4402,7 +4000,7 @@ internal static unsafe class VulkanVideoPresenter
VideoOutExports.SetSelectedGpuName(selectedName);
if (_window is not null)
{
- _window.Title = VideoOutExports.GetWindowTitle();
+ _window.SetTitle(VideoOutExports.GetWindowTitle());
}
}
@@ -4708,19 +4306,28 @@ internal static unsafe class VulkanVideoPresenter
private static string GetPipelineCachePath()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_VK_PIPELINE_CACHE_PATH");
- if (!string.IsNullOrWhiteSpace(configured))
+ var cachePath = VulkanPipelineCacheStorage.ResolvePath(
+ VideoOutExports.GetApplicationTitleId(),
+ configured);
+ if (string.IsNullOrWhiteSpace(configured))
{
- return Path.GetFullPath(
- Environment.ExpandEnvironmentVariables(configured));
+ try
+ {
+ var legacyPath = VulkanPipelineCacheStorage.GetLegacyPath();
+ if (VulkanPipelineCacheStorage.ImportLegacyCache(legacyPath, cachePath))
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] Imported legacy Vulkan pipeline cache: source={legacyPath} destination={cachePath}");
+ }
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] Vulkan pipeline cache migration failed: {exception.Message}");
+ }
}
- var root = OperatingSystem.IsMacOS()
- ? Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
- "Library",
- "Caches")
- : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
- return Path.Combine(root, "SharpEmu", "vulkan-pipeline-cache.bin");
+ return cachePath;
}
private void MarkPipelineCacheDirty()
@@ -4830,8 +4437,20 @@ internal static unsafe class VulkanVideoPresenter
"vkGetPhysicalDeviceSurfaceFormatsKHR");
}
- var surfaceFormat = ChooseSurfaceFormat(formats);
+ var hdrState = _window.HdrState;
+ var guestHdrRequested = VideoOutExports.IsHdrOutputRequested;
+ var requestHdr = _videoOptions.HdrMode switch
+ {
+ HostHdrMode.On => true,
+ HostHdrMode.Auto => hdrState.Enabled && guestHdrRequested,
+ _ => false,
+ };
+ _hdrRequestedForSwapchain = requestHdr;
+ var surfaceFormat = ChooseSurfaceFormat(formats, requestHdr, out _hdrOutputActive);
+ _hdrSdrWhiteLevel = _hdrOutputActive ? Math.Max(1f, hdrState.SdrWhiteLevel) : 1f;
+ _hdrHeadroom = _hdrOutputActive ? Math.Max(1f, hdrState.Headroom) : 1f;
_swapchainFormat = surfaceFormat.Format;
+ _swapchainColorSpace = surfaceFormat.ColorSpace;
_extent = ChooseExtent(capabilities);
Bink2MovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
var presentMode = ChoosePresentMode();
@@ -4877,6 +4496,16 @@ internal static unsafe class VulkanVideoPresenter
}
_imageInitialized = new bool[swapchainImageCount];
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] Vulkan output color: requested={_videoOptions.HdrMode} " +
+ $"display_hdr={hdrState.Enabled} guest_hdr={guestHdrRequested} active={_hdrOutputActive} " +
+ $"format={_swapchainFormat} colorspace={_swapchainColorSpace} " +
+ $"sdr_white={_hdrSdrWhiteLevel:F3} headroom={_hdrHeadroom:F3}");
+ if (requestHdr && !_hdrOutputActive)
+ {
+ Console.Error.WriteLine(
+ "[LOADER][WARN] HDR output requested but the Vulkan surface exposes no scRGB format; using SDR.");
+ }
}
private PresentModeKHR ChoosePresentMode()
@@ -4907,6 +4536,17 @@ internal static unsafe class VulkanVideoPresenter
return PresentModeKHR.FifoKhr;
}
+ if (!_videoOptions.VSync)
+ {
+ for (var index = 0u; index < modeCount; index++)
+ {
+ if (modes[index] == PresentModeKHR.ImmediateKhr)
+ {
+ return PresentModeKHR.ImmediateKhr;
+ }
+ }
+ }
+
for (var index = 0u; index < modeCount; index++)
{
if (modes[index] == PresentModeKHR.MailboxKhr)
@@ -5054,6 +4694,7 @@ internal static unsafe class VulkanVideoPresenter
(void*)_overlayStagingMapped[frameSlot],
PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4);
PerfOverlay.Fill(pixels, pendingWork, _pendingGuestSubmissions.Count);
+ var presentationTarget = PresentationTargetImage(imageIndex);
var toTransferDst = new ImageMemoryBarrier
{
@@ -5105,11 +4746,11 @@ internal static unsafe class VulkanVideoPresenter
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = 0,
DstAccessMask = AccessFlags.TransferWriteBit,
- OldLayout = ImageLayout.PresentSrcKhr,
+ OldLayout = PresentationTargetFinalLayout,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
var preBlitBarriers = stackalloc ImageMemoryBarrier[2] { toTransferSrc, swapchainToDst };
@@ -5140,28 +4781,30 @@ internal static unsafe class VulkanVideoPresenter
_commandBuffer,
_overlayImage,
ImageLayout.TransferSrcOptimal,
- _swapchainImages[imageIndex],
+ presentationTarget,
ImageLayout.TransferDstOptimal,
1,
©);
- var swapchainToPresent = new ImageMemoryBarrier
+ var presentationTargetToFinal = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
- DstAccessMask = 0,
+ DstAccessMask = _hdrOutputActive ? AccessFlags.ShaderReadBit : 0,
OldLayout = ImageLayout.TransferDstOptimal,
- NewLayout = ImageLayout.PresentSrcKhr,
+ NewLayout = PresentationTargetFinalLayout,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
- PipelineStageFlags.BottomOfPipeBit,
- 0, 0, null, 0, null, 1, &swapchainToPresent);
+ _hdrOutputActive
+ ? PipelineStageFlags.FragmentShaderBit
+ : PipelineStageFlags.BottomOfPipeBit,
+ 0, 0, null, 0, null, 1, &presentationTargetToFinal);
_overlayImageInitialized = true;
}
@@ -5387,9 +5030,12 @@ internal static unsafe class VulkanVideoPresenter
var submitLabel = string.IsNullOrEmpty(submitContext)
? "vkQueueSubmit(guest)"
: $"vkQueueSubmit(guest) during {submitContext}";
- Check(
- _vk.QueueSubmit(_queue, 1, &submitInfo, fence),
- submitLabel);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.QueueSubmit))
+ {
+ Check(
+ _vk.QueueSubmit(_queue, 1, &submitInfo, fence),
+ submitLabel);
+ }
}
catch
{
@@ -5778,11 +5424,13 @@ internal static unsafe class VulkanVideoPresenter
{
if (!TryMakeActiveGuestQueueSubmissionsCpuVisible())
{
+ RenderPhaseProfile.RecordOrderedAction(work.DebugName, completed: false);
return false;
}
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
work.Action();
+ RenderPhaseProfile.RecordOrderedAction(work.DebugName, completed: true);
if (_traceVulkanShaderEnabled)
{
TraceVulkanShader(
@@ -5931,6 +5579,12 @@ internal static unsafe class VulkanVideoPresenter
lock (_gate)
{
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
+ var isHdr =
+ VideoOutExports.TryGetDisplayBufferInfo(
+ work.VideoOutHandle,
+ work.DisplayBufferIndex,
+ out var displayBuffer) &&
+ VideoOutExports.IsHdrPixelFormat(displayBuffer.PixelFormat);
var presentation = new Presentation(
null,
work.Width,
@@ -5941,7 +5595,8 @@ internal static unsafe class VulkanVideoPresenter
RequiredGuestWorkSequence: _activeGuestWorkSequence,
IsSplash: false,
GuestImageAddress: work.Address,
- GuestImageVersion: work.Version);
+ GuestImageVersion: work.Version,
+ IsHdr: isHdr);
_latestPresentation = presentation;
_pendingGuestImagePresentations.Enqueue(presentation);
while (_pendingGuestImagePresentations.Count > MaxPendingGuestFlipVersions)
@@ -6298,16 +5953,17 @@ internal static unsafe class VulkanVideoPresenter
private void CreateGuestDrawResources()
{
+ var presentationFormat = PresentationTargetFormat;
var colorAttachment = new AttachmentDescription
{
- Format = _swapchainFormat,
+ Format = presentationFormat,
Samples = SampleCountFlags.Count1Bit,
LoadOp = AttachmentLoadOp.Clear,
StoreOp = AttachmentStoreOp.Store,
StencilLoadOp = AttachmentLoadOp.DontCare,
StencilStoreOp = AttachmentStoreOp.DontCare,
InitialLayout = ImageLayout.Undefined,
- FinalLayout = ImageLayout.PresentSrcKhr,
+ FinalLayout = PresentationTargetFinalLayout,
};
var colorReference = new AttachmentReference
{
@@ -6355,6 +6011,13 @@ internal static unsafe class VulkanVideoPresenter
_swapchainImageViews = new ImageView[_swapchainImages.Length];
_framebuffers = new Framebuffer[_swapchainImages.Length];
+ if (_hdrOutputActive)
+ {
+ _presentationImages = new Image[_swapchainImages.Length];
+ _presentationImageMemory = new DeviceMemory[_swapchainImages.Length];
+ _presentationImageViews = new ImageView[_swapchainImages.Length];
+ _presentationSampleViews = new ImageView[_swapchainImages.Length];
+ }
for (var index = 0; index < _swapchainImages.Length; index++)
{
var viewInfo = new ImageViewCreateInfo
@@ -6375,6 +6038,11 @@ internal static unsafe class VulkanVideoPresenter
"vkCreateImageView");
var imageView = _swapchainImageViews[index];
+ if (_hdrOutputActive)
+ {
+ CreatePresentationImage(index);
+ imageView = _presentationImageViews[index];
+ }
var framebufferInfo = new FramebufferCreateInfo
{
SType = StructureType.FramebufferCreateInfo,
@@ -6398,6 +6066,457 @@ internal static unsafe class VulkanVideoPresenter
_vk.CreatePipelineLayout(_device, &layoutInfo, null, out _pipelineLayout),
"vkCreatePipelineLayout");
CreateBarycentricPipeline();
+ if (_hdrOutputActive)
+ {
+ CreateHdrPresentationResources();
+ }
+ }
+
+ private Format PresentationTargetFormat =>
+ _hdrOutputActive ? Format.B8G8R8A8Unorm : _swapchainFormat;
+
+ private ImageLayout PresentationTargetFinalLayout =>
+ _hdrOutputActive ? ImageLayout.ShaderReadOnlyOptimal : ImageLayout.PresentSrcKhr;
+
+ private Image PresentationTargetImage(uint imageIndex) =>
+ _hdrOutputActive ? _presentationImages[imageIndex] : _swapchainImages[imageIndex];
+
+ private void CreatePresentationImage(int index)
+ {
+ var imageInfo = new ImageCreateInfo
+ {
+ SType = StructureType.ImageCreateInfo,
+ Flags = ImageCreateFlags.CreateMutableFormatBit,
+ ImageType = ImageType.Type2D,
+ Format = Format.B8G8R8A8Unorm,
+ Extent = new Extent3D(_extent.Width, _extent.Height, 1),
+ MipLevels = 1,
+ ArrayLayers = 1,
+ Samples = SampleCountFlags.Count1Bit,
+ Tiling = ImageTiling.Optimal,
+ Usage = ImageUsageFlags.ColorAttachmentBit |
+ ImageUsageFlags.TransferDstBit |
+ ImageUsageFlags.TransferSrcBit |
+ ImageUsageFlags.SampledBit,
+ SharingMode = SharingMode.Exclusive,
+ InitialLayout = ImageLayout.Undefined,
+ };
+ Check(
+ _vk.CreateImage(_device, &imageInfo, null, out _presentationImages[index]),
+ "vkCreateImage(HDR presentation source)");
+ _vk.GetImageMemoryRequirements(
+ _device,
+ _presentationImages[index],
+ out var requirements);
+ var allocationInfo = new MemoryAllocateInfo
+ {
+ SType = StructureType.MemoryAllocateInfo,
+ AllocationSize = requirements.Size,
+ MemoryTypeIndex = FindMemoryType(
+ requirements.MemoryTypeBits,
+ MemoryPropertyFlags.DeviceLocalBit),
+ };
+ Check(
+ _vk.AllocateMemory(
+ _device,
+ &allocationInfo,
+ null,
+ out _presentationImageMemory[index]),
+ "vkAllocateMemory(HDR presentation source)");
+ Check(
+ _vk.BindImageMemory(
+ _device,
+ _presentationImages[index],
+ _presentationImageMemory[index],
+ 0),
+ "vkBindImageMemory(HDR presentation source)");
+
+ _presentationImageViews[index] = CreatePresentationImageView(
+ _presentationImages[index],
+ Format.B8G8R8A8Unorm);
+ _presentationSampleViews[index] = CreatePresentationImageView(
+ _presentationImages[index],
+ Format.B8G8R8A8Srgb);
+ }
+
+ private ImageView CreatePresentationImageView(Image image, Format format)
+ {
+ var viewInfo = new ImageViewCreateInfo
+ {
+ SType = StructureType.ImageViewCreateInfo,
+ Image = image,
+ ViewType = ImageViewType.Type2D,
+ Format = format,
+ Components = new ComponentMapping(
+ ComponentSwizzle.Identity,
+ ComponentSwizzle.Identity,
+ ComponentSwizzle.Identity,
+ ComponentSwizzle.Identity),
+ SubresourceRange = ColorSubresourceRange(),
+ };
+ Check(
+ _vk.CreateImageView(_device, &viewInfo, null, out var view),
+ "vkCreateImageView(HDR presentation source)");
+ return view;
+ }
+
+ private void CreateHdrPresentationResources()
+ {
+ var colorAttachment = new AttachmentDescription
+ {
+ Format = _swapchainFormat,
+ Samples = SampleCountFlags.Count1Bit,
+ LoadOp = AttachmentLoadOp.Clear,
+ StoreOp = AttachmentStoreOp.Store,
+ StencilLoadOp = AttachmentLoadOp.DontCare,
+ StencilStoreOp = AttachmentStoreOp.DontCare,
+ InitialLayout = ImageLayout.Undefined,
+ FinalLayout = ImageLayout.PresentSrcKhr,
+ };
+ var colorReference = new AttachmentReference(0, ImageLayout.ColorAttachmentOptimal);
+ var subpass = new SubpassDescription
+ {
+ PipelineBindPoint = PipelineBindPoint.Graphics,
+ ColorAttachmentCount = 1,
+ PColorAttachments = &colorReference,
+ };
+ var dependency = new SubpassDependency
+ {
+ SrcSubpass = Vk.SubpassExternal,
+ DstSubpass = 0,
+ SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit,
+ DstStageMask = PipelineStageFlags.ColorAttachmentOutputBit,
+ DstAccessMask = AccessFlags.ColorAttachmentWriteBit,
+ };
+ var renderPassInfo = new RenderPassCreateInfo
+ {
+ SType = StructureType.RenderPassCreateInfo,
+ AttachmentCount = 1,
+ PAttachments = &colorAttachment,
+ SubpassCount = 1,
+ PSubpasses = &subpass,
+ DependencyCount = 1,
+ PDependencies = &dependency,
+ };
+ Check(
+ _vk.CreateRenderPass(_device, &renderPassInfo, null, out _hdrRenderPass),
+ "vkCreateRenderPass(HDR presentation)");
+
+ _hdrFramebuffers = new Framebuffer[_swapchainImageViews.Length];
+ for (var index = 0; index < _swapchainImageViews.Length; index++)
+ {
+ var view = _swapchainImageViews[index];
+ var framebufferInfo = new FramebufferCreateInfo
+ {
+ SType = StructureType.FramebufferCreateInfo,
+ RenderPass = _hdrRenderPass,
+ AttachmentCount = 1,
+ PAttachments = &view,
+ Width = _extent.Width,
+ Height = _extent.Height,
+ Layers = 1,
+ };
+ Check(
+ _vk.CreateFramebuffer(
+ _device,
+ &framebufferInfo,
+ null,
+ out _hdrFramebuffers[index]),
+ "vkCreateFramebuffer(HDR presentation)");
+ }
+
+ var binding = new DescriptorSetLayoutBinding
+ {
+ Binding = 1,
+ DescriptorType = DescriptorType.CombinedImageSampler,
+ DescriptorCount = 1,
+ StageFlags = ShaderStageFlags.FragmentBit,
+ };
+ var descriptorLayoutInfo = new DescriptorSetLayoutCreateInfo
+ {
+ SType = StructureType.DescriptorSetLayoutCreateInfo,
+ BindingCount = 1,
+ PBindings = &binding,
+ };
+ Check(
+ _vk.CreateDescriptorSetLayout(
+ _device,
+ &descriptorLayoutInfo,
+ null,
+ out _hdrDescriptorSetLayout),
+ "vkCreateDescriptorSetLayout(HDR presentation)");
+
+ var descriptorPoolSize = new DescriptorPoolSize
+ {
+ Type = DescriptorType.CombinedImageSampler,
+ DescriptorCount = checked((uint)_presentationSampleViews.Length * 2),
+ };
+ var descriptorPoolInfo = new DescriptorPoolCreateInfo
+ {
+ SType = StructureType.DescriptorPoolCreateInfo,
+ MaxSets = checked((uint)_presentationSampleViews.Length * 2),
+ PoolSizeCount = 1,
+ PPoolSizes = &descriptorPoolSize,
+ };
+ Check(
+ _vk.CreateDescriptorPool(
+ _device,
+ &descriptorPoolInfo,
+ null,
+ out _hdrDescriptorPool),
+ "vkCreateDescriptorPool(HDR presentation)");
+
+ var samplerInfo = new SamplerCreateInfo
+ {
+ SType = StructureType.SamplerCreateInfo,
+ MagFilter = Filter.Linear,
+ MinFilter = Filter.Linear,
+ MipmapMode = SamplerMipmapMode.Linear,
+ AddressModeU = SamplerAddressMode.ClampToEdge,
+ AddressModeV = SamplerAddressMode.ClampToEdge,
+ AddressModeW = SamplerAddressMode.ClampToEdge,
+ MaxLod = 1f,
+ };
+ Check(
+ _vk.CreateSampler(_device, &samplerInfo, null, out _hdrSampler),
+ "vkCreateSampler(HDR presentation)");
+
+ _hdrDescriptorSets = new DescriptorSet[_presentationSampleViews.Length];
+ _hdrPqDescriptorSets = new DescriptorSet[_presentationSampleViews.Length];
+ for (var pq = 0; pq < 2; pq++)
+ {
+ var descriptorSets = pq == 0 ? _hdrDescriptorSets : _hdrPqDescriptorSets;
+ var imageViews = pq == 0 ? _presentationSampleViews : _presentationImageViews;
+ for (var index = 0; index < descriptorSets.Length; index++)
+ {
+ var layout = _hdrDescriptorSetLayout;
+ var allocateInfo = new DescriptorSetAllocateInfo
+ {
+ SType = StructureType.DescriptorSetAllocateInfo,
+ DescriptorPool = _hdrDescriptorPool,
+ DescriptorSetCount = 1,
+ PSetLayouts = &layout,
+ };
+ Check(
+ _vk.AllocateDescriptorSets(
+ _device,
+ &allocateInfo,
+ out descriptorSets[index]),
+ "vkAllocateDescriptorSets(HDR presentation)");
+ var imageInfo = new DescriptorImageInfo
+ {
+ Sampler = _hdrSampler,
+ ImageView = imageViews[index],
+ ImageLayout = ImageLayout.ShaderReadOnlyOptimal,
+ };
+ var write = new WriteDescriptorSet
+ {
+ SType = StructureType.WriteDescriptorSet,
+ DstSet = descriptorSets[index],
+ DstBinding = 1,
+ DescriptorCount = 1,
+ DescriptorType = DescriptorType.CombinedImageSampler,
+ PImageInfo = &imageInfo,
+ };
+ _vk.UpdateDescriptorSets(_device, 1, &write, 0, null);
+ }
+ }
+
+ var descriptorSetLayout = _hdrDescriptorSetLayout;
+ var pipelineLayoutInfo = new PipelineLayoutCreateInfo
+ {
+ SType = StructureType.PipelineLayoutCreateInfo,
+ SetLayoutCount = 1,
+ PSetLayouts = &descriptorSetLayout,
+ };
+ Check(
+ _vk.CreatePipelineLayout(
+ _device,
+ &pipelineLayoutInfo,
+ null,
+ out _hdrPipelineLayout),
+ "vkCreatePipelineLayout(HDR presentation)");
+ CreateHdrPresentationPipelines();
+ }
+
+ private void CreateHdrPresentationPipelines()
+ {
+ CreateHdrPresentationPipeline(
+ SpirvFixedShaders.CreateCopyFragment(_hdrSdrWhiteLevel),
+ "SDR",
+ out _hdrPipeline);
+ CreateHdrPresentationPipeline(
+ SpirvFixedShaders.CreatePqToScRgbFragment(),
+ "PQ",
+ out _hdrPqPipeline);
+ }
+
+ private void CreateHdrPresentationPipeline(
+ byte[] fragmentBytes,
+ string label,
+ out Pipeline pipeline)
+ {
+ var vertexModule = CreateShaderModule(SpirvFixedShaders.CreateFullscreenVertex(1));
+ var fragmentModule = CreateShaderModule(fragmentBytes);
+ var entryPoint = (byte*)SilkMarshal.StringToPtr("main");
+ try
+ {
+ var stages = stackalloc PipelineShaderStageCreateInfo[2];
+ stages[0] = new PipelineShaderStageCreateInfo
+ {
+ SType = StructureType.PipelineShaderStageCreateInfo,
+ Stage = ShaderStageFlags.VertexBit,
+ Module = vertexModule,
+ PName = entryPoint,
+ };
+ stages[1] = new PipelineShaderStageCreateInfo
+ {
+ SType = StructureType.PipelineShaderStageCreateInfo,
+ Stage = ShaderStageFlags.FragmentBit,
+ Module = fragmentModule,
+ PName = entryPoint,
+ };
+ var vertexInput = new PipelineVertexInputStateCreateInfo
+ {
+ SType = StructureType.PipelineVertexInputStateCreateInfo,
+ };
+ var inputAssembly = new PipelineInputAssemblyStateCreateInfo
+ {
+ SType = StructureType.PipelineInputAssemblyStateCreateInfo,
+ Topology = PrimitiveTopology.TriangleList,
+ };
+ var viewport = new Viewport(0, 0, _extent.Width, _extent.Height, 0, 1);
+ var scissor = new Rect2D(new Offset2D(0, 0), _extent);
+ var viewportState = new PipelineViewportStateCreateInfo
+ {
+ SType = StructureType.PipelineViewportStateCreateInfo,
+ ViewportCount = 1,
+ PViewports = &viewport,
+ ScissorCount = 1,
+ PScissors = &scissor,
+ };
+ var rasterization = new PipelineRasterizationStateCreateInfo
+ {
+ SType = StructureType.PipelineRasterizationStateCreateInfo,
+ PolygonMode = PolygonMode.Fill,
+ CullMode = CullModeFlags.None,
+ FrontFace = FrontFace.CounterClockwise,
+ LineWidth = 1,
+ };
+ var multisample = new PipelineMultisampleStateCreateInfo
+ {
+ SType = StructureType.PipelineMultisampleStateCreateInfo,
+ RasterizationSamples = SampleCountFlags.Count1Bit,
+ };
+ var blendAttachment = new PipelineColorBlendAttachmentState
+ {
+ ColorWriteMask = ColorComponentFlags.RBit |
+ ColorComponentFlags.GBit |
+ ColorComponentFlags.BBit |
+ ColorComponentFlags.ABit,
+ };
+ var blend = new PipelineColorBlendStateCreateInfo
+ {
+ SType = StructureType.PipelineColorBlendStateCreateInfo,
+ AttachmentCount = 1,
+ PAttachments = &blendAttachment,
+ };
+ var pipelineInfo = new GraphicsPipelineCreateInfo
+ {
+ SType = StructureType.GraphicsPipelineCreateInfo,
+ StageCount = 2,
+ PStages = stages,
+ PVertexInputState = &vertexInput,
+ PInputAssemblyState = &inputAssembly,
+ PViewportState = &viewportState,
+ PRasterizationState = &rasterization,
+ PMultisampleState = &multisample,
+ PColorBlendState = &blend,
+ Layout = _hdrPipelineLayout,
+ RenderPass = _hdrRenderPass,
+ };
+ Check(
+ _vk.CreateGraphicsPipelines(
+ _device,
+ _pipelineCache,
+ 1,
+ &pipelineInfo,
+ null,
+ out pipeline),
+ $"vkCreateGraphicsPipelines(HDR {label} presentation)");
+ MarkPipelineCacheDirty();
+ }
+ finally
+ {
+ SilkMarshal.Free((nint)entryPoint);
+ _vk.DestroyShaderModule(_device, fragmentModule, null);
+ _vk.DestroyShaderModule(_device, vertexModule, null);
+ }
+ }
+
+ private void RecordHdrPresentation(uint imageIndex, bool isHdr)
+ {
+ var sourceBarrier = new ImageMemoryBarrier
+ {
+ SType = StructureType.ImageMemoryBarrier,
+ SrcAccessMask = AccessFlags.MemoryWriteBit |
+ AccessFlags.TransferWriteBit |
+ AccessFlags.ColorAttachmentWriteBit,
+ DstAccessMask = AccessFlags.ShaderReadBit,
+ OldLayout = ImageLayout.ShaderReadOnlyOptimal,
+ NewLayout = ImageLayout.ShaderReadOnlyOptimal,
+ SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
+ DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
+ Image = _presentationImages[imageIndex],
+ SubresourceRange = ColorSubresourceRange(),
+ };
+ _vk.CmdPipelineBarrier(
+ _commandBuffer,
+ PipelineStageFlags.AllCommandsBit,
+ PipelineStageFlags.FragmentShaderBit,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 1,
+ &sourceBarrier);
+
+ var clearValue = new ClearValue
+ {
+ Color = new ClearColorValue(0f, 0f, 0f, 1f),
+ };
+ var renderPassInfo = new RenderPassBeginInfo
+ {
+ SType = StructureType.RenderPassBeginInfo,
+ RenderPass = _hdrRenderPass,
+ Framebuffer = _hdrFramebuffers[imageIndex],
+ RenderArea = new Rect2D(new Offset2D(0, 0), _extent),
+ ClearValueCount = 1,
+ PClearValues = &clearValue,
+ };
+ _vk.CmdBeginRenderPass(
+ _commandBuffer,
+ &renderPassInfo,
+ SubpassContents.Inline);
+ _vk.CmdBindPipeline(
+ _commandBuffer,
+ PipelineBindPoint.Graphics,
+ isHdr ? _hdrPqPipeline : _hdrPipeline);
+ var descriptorSet = isHdr
+ ? _hdrPqDescriptorSets[imageIndex]
+ : _hdrDescriptorSets[imageIndex];
+ _vk.CmdBindDescriptorSets(
+ _commandBuffer,
+ PipelineBindPoint.Graphics,
+ _hdrPipelineLayout,
+ 0,
+ 1,
+ &descriptorSet,
+ 0,
+ null);
+ _vk.CmdDraw(_commandBuffer, 3, 1, 0, 0);
+ _vk.CmdEndRenderPass(_commandBuffer);
}
private void CreateBarycentricPipeline()
@@ -12438,9 +12557,16 @@ internal static unsafe class VulkanVideoPresenter
}
}
+ // Non-Vulkan failures here are emulator bugs rather than device
+ // state, and the message alone ("overflow", "index out of range")
+ // names neither the guest draw nor the code that rejected it.
Console.Error.WriteLine(
$"[LOADER][ERROR] Vulkan offscreen draw failed " +
- $"mrt={work.Targets.Count}: {exception.Message}");
+ $"mrt={work.Targets.Count} vs=0x{work.ShaderAddress:X16} " +
+ $"size={work.Targets[0].Width}x{work.Targets[0].Height} " +
+ $"format={work.Targets[0].Format}/{work.Targets[0].NumberType} " +
+ $"textures={work.Draw.Textures.Count} " +
+ $"vertices={work.Draw.VertexCount}: {exception}");
}
finally
{
@@ -12605,7 +12731,7 @@ internal static unsafe class VulkanVideoPresenter
{
if (pixels.Length > 0)
{
- UploadGuestImageInitialData(target, pixels);
+ UploadGuestImageInitialData(target, pixels, work.RowOffset);
}
return;
@@ -12744,7 +12870,10 @@ internal static unsafe class VulkanVideoPresenter
private static uint AlignUp(uint value, uint alignment) =>
(value + alignment - 1) / alignment * alignment;
- private void UploadGuestImageInitialData(GuestImageResource target, byte[] pixels)
+ private void UploadGuestImageInitialData(
+ GuestImageResource target,
+ byte[] pixels,
+ uint rowOffset = 0)
{
var guestDataFormat = (target.GuestFormat & 0x8000_0000u) != 0
? (target.GuestFormat >> 8) & 0x1FFu
@@ -12758,6 +12887,37 @@ internal static unsafe class VulkanVideoPresenter
target.Height,
target.Depth);
+ // A band upload only makes sense on an image that already holds the
+ // rest of the surface. Uninitialized images enter the barrier below
+ // with OldLayout=Undefined, which discards every existing texel — a
+ // partial upload there would leave everything outside the band black
+ // and, because only rewritten rows are ever sent afterwards, it would
+ // stay that way. Refuse the band and take the full path instead.
+ if (rowOffset != 0 && !target.Initialized)
+ {
+ return;
+ }
+
+ // A band upload covers rows [rowOffset, rowOffset + rowCount) of an
+ // otherwise-correct image, so validate it against one row's worth of
+ // the surface rather than the whole thing.
+ var uploadHeight = target.Height;
+ if (rowOffset != 0 || (ulong)uploadPixels.Length != expectedByteCount)
+ {
+ var rowBytes = target.Height == 0 ? 0 : expectedByteCount / target.Height;
+ if (rowBytes != 0 &&
+ target.Depth <= 1 &&
+ (ulong)uploadPixels.Length % rowBytes == 0)
+ {
+ var rows = (uint)((ulong)uploadPixels.Length / rowBytes);
+ if (rows != 0 && rowOffset + rows <= target.Height)
+ {
+ uploadHeight = rows;
+ expectedByteCount = (ulong)uploadPixels.Length;
+ }
+ }
+ }
+
// The guest can hand us linear pixel data whose rows are padded out
// to a hardware pitch wider than the image, so the byte count runs
// past the tightly packed width*height*bpp we compute. Recover the
@@ -12850,10 +13010,10 @@ internal static unsafe class VulkanVideoPresenter
0,
0,
1),
- ImageOffset = default,
+ ImageOffset = new Offset3D(0, (int)rowOffset, 0),
ImageExtent = new Extent3D(
target.Width,
- target.Height,
+ uploadHeight,
target.Depth),
};
_vk.CmdCopyBufferToImage(
@@ -13981,6 +14141,7 @@ internal static unsafe class VulkanVideoPresenter
private void WaitForRenderWork()
{
+ using var profileScope = RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Idle);
var gpuWorkInFlight = _pendingGuestSubmissions.Count > 0 ||
Array.Exists(_frameFencePending, static pending => pending);
lock (_gate)
@@ -13998,30 +14159,6 @@ internal static unsafe class VulkanVideoPresenter
}
}
- private bool _overlayHotkeyWasDown;
-
- private void PollPerfOverlayHotkey()
- {
- // POSIX hosts route F1 through the GLFW window's keyboard events
- // (HostWindowInput.Attach). Windows never attaches that path — its
- // input comes from user32 polling — so sample the hotkey here for
- // both the standalone window and the embedded host surface.
- if (!OperatingSystem.IsWindows())
- {
- return;
- }
-
- const int VkF1 = 0x70;
- var input = SharpEmu.HLE.Host.HostPlatform.Current.Input;
- var down = input.IsKeyDown(VkF1) && input.IsHostWindowFocused();
- if (down && !_overlayHotkeyWasDown)
- {
- PerfOverlay.Toggle();
- }
-
- _overlayHotkeyWasDown = down;
- }
-
private void Render(double _)
{
try
@@ -14044,30 +14181,10 @@ internal static unsafe class VulkanVideoPresenter
if (Volatile.Read(ref _presenterCloseRequested))
{
Console.Error.WriteLine("[LOADER][WARN] Vulkan VideoOut closing on host shutdown request.");
- if (_window is not null)
- {
- _window.Close();
- }
- else
- {
- _embeddedLoopClosed = true;
- }
+ _window.Close();
return;
}
- PollPerfOverlayHotkey();
-
- if (_hostSurface is not null)
- {
- _hostSurface.RefreshChildProcessPixelSize();
- if (_lastHostResizeGeneration != _hostSurface.ResizeGeneration)
- {
- _lastHostResizeGeneration = _hostSurface.ResizeGeneration;
- RecreateSwapchainResources("embedded host resize", Result.SuboptimalKhr);
- _pendingHostSplashReplay = _lastHostSplashPresentation;
- }
- }
-
if (!_vulkanReady)
{
return;
@@ -14088,7 +14205,13 @@ internal static unsafe class VulkanVideoPresenter
// Reuse of a frame slot waits only on that slot's fence, keeping
// up to MaxFramesInFlight frames pipelined between CPU and GPU.
var frameSlot = _currentFrameSlot;
- if (!TryWaitFrameSlot(frameSlot, _frameSlotWaitBudgetNs))
+ bool frameSlotReady;
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.FrameSlotWait))
+ {
+ frameSlotReady = TryWaitFrameSlot(frameSlot, _frameSlotWaitBudgetNs);
+ }
+
+ if (!frameSlotReady)
{
// The GPU is still finishing this slot's previous frame (slow
// compute backlog). Don't block the macOS main thread — return
@@ -14102,10 +14225,15 @@ internal static unsafe class VulkanVideoPresenter
_commandBuffer = _presentationCommandBuffer;
if (!_deviceLost)
{
+ using var collectScope = RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Collect);
CollectCompletedGuestSubmissions(waitForOldest: false);
}
- DrainGuestImageCpuSync();
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Evict))
+ {
+ DrainGuestImageCpuSync();
+ }
+
var completedWork = 0;
HashSet? deferredOrderedQueues = null;
var workBudgetTicks = _renderWorkBudgetTicks;
@@ -14126,17 +14254,28 @@ internal static unsafe class VulkanVideoPresenter
// backlog), stop processing and let the event pump run; the
// remaining queued work is picked up on later frames as the GPU
// completions free up capacity (collected non-blockingly here).
- CollectCompletedGuestSubmissions(waitForOldest: false);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Collect))
+ {
+ CollectCompletedGuestSubmissions(waitForOldest: false);
+ }
+
if (OperatingSystem.IsMacOS() &&
_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
{
break;
}
- if (!TryTakeGuestWork(
- out var pendingGuestWork,
+ PendingGuestWork pendingGuestWork;
+ bool tookGuestWork;
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.TakeWork))
+ {
+ tookGuestWork = TryTakeGuestWork(
+ out pendingGuestWork,
deferredOrderedQueues,
- preferSyncWork))
+ preferSyncWork);
+ }
+
+ if (!tookGuestWork)
{
break;
}
@@ -14165,10 +14304,14 @@ internal static unsafe class VulkanVideoPresenter
_enqueueAsImmediateQueueFollowup = true;
_immediateFollowupTail = null;
var work = pendingGuestWork.Work;
- _activeGuestWorkLabel = DescribeGuestWork(
- work,
- pendingGuestWork.Queue,
- pendingGuestWork.Sequence);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Describe))
+ {
+ _activeGuestWorkLabel = DescribeGuestWork(
+ work,
+ pendingGuestWork.Queue,
+ pendingGuestWork.Sequence);
+ }
+
_lastGuestWorkLabel = _activeGuestWorkLabel;
var deferGuestWork = false;
@@ -14197,34 +14340,66 @@ internal static unsafe class VulkanVideoPresenter
switch (work)
{
case VulkanOffscreenGuestDraw offscreenDraw:
- ExecuteOffscreenDraw(offscreenDraw);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Draw))
+ {
+ ExecuteOffscreenDraw(offscreenDraw);
+ }
+
break;
case VulkanOffscreenColorClear colorClear:
- ExecuteOffscreenColorClear(colorClear);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.ColorClear))
+ {
+ ExecuteOffscreenColorClear(colorClear);
+ }
+
break;
case VulkanComputeGuestDispatch computeDispatch:
- ExecuteComputeDispatch(computeDispatch);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Compute))
+ {
+ ExecuteComputeDispatch(computeDispatch);
+ }
+
break;
case VulkanGuestImageWrite guestImageWrite:
- ExecuteGuestImageWrite(guestImageWrite);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.ImageWrite))
+ {
+ ExecuteGuestImageWrite(guestImageWrite);
+ }
+
break;
case VulkanOrderedGuestAction orderedAction:
- deferGuestWork = !TryExecuteOrderedGuestAction(orderedAction);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.OrderedAction))
+ {
+ deferGuestWork = !TryExecuteOrderedGuestAction(orderedAction);
+ }
+
break;
case VulkanOrderedGuestFlip orderedFlip:
- ExecuteOrderedGuestFlip(orderedFlip);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Flip))
+ {
+ ExecuteOrderedGuestFlip(orderedFlip);
+ }
+
break;
case VulkanOrderedGuestFlipWait flipWait:
- ExecuteOrderedGuestFlipWait(flipWait);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Flip))
+ {
+ ExecuteOrderedGuestFlipWait(flipWait);
+ }
+
break;
}
}
finally
{
- if (!deferGuestWork || !RequeueGuestWorkFront(pendingGuestWork))
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.CompleteWork))
{
- CompleteGuestWork(pendingGuestWork);
+ if (!deferGuestWork || !RequeueGuestWorkFront(pendingGuestWork))
+ {
+ CompleteGuestWork(pendingGuestWork);
+ }
}
+
_enqueueAsImmediateQueueFollowup = false;
_immediateFollowupTail = null;
_activeGuestWorkLabel = string.Empty;
@@ -14285,21 +14460,61 @@ internal static unsafe class VulkanVideoPresenter
}
}
- FlushBatchedGuestCommands();
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Flush))
+ {
+ FlushBatchedGuestCommands();
+ }
+
CollectAbandonedGuestImageVersions();
Presentation presentation;
- if (!TryTakePresentation(_presentedSequence, out presentation))
+ if (_window.IsMinimized)
{
- if (_pendingHostSplashReplay is { } splash)
- {
- presentation = splash;
- _pendingHostSplashReplay = null;
- }
- else
- {
- // A render-loop tick with no newer flip is normal. Warn only when
- // an actual queued presentation is waiting on unfinished guest work.
+ return;
+ }
+
+ var framebufferSize = GetFramebufferSize();
+ var drawableSizeChanged =
+ (uint)Math.Max(framebufferSize.X, 1) != _extent.Width ||
+ (uint)Math.Max(framebufferSize.Y, 1) != _extent.Height;
+ var hdrStateChanged = _window.ConsumeHdrStateChange();
+ var guestHdrRequestChanged =
+ _videoOptions.HdrMode == HostHdrMode.Auto &&
+ _hdrRequestedForSwapchain !=
+ (_window.HdrState.Enabled && VideoOutExports.IsHdrOutputRequested);
+ if (_window.ConsumeSurfaceRestore() ||
+ drawableSizeChanged ||
+ _swapchainRecreateDeferred ||
+ hdrStateChanged && _videoOptions.HdrMode != HostHdrMode.Off ||
+ guestHdrRequestChanged)
+ {
+ RecreateSwapchainResources(
+ guestHdrRequestChanged
+ ? "guest HDR output change"
+ : hdrStateChanged
+ ? "SDL HDR state change"
+ : drawableSizeChanged
+ ? "SDL drawable resize"
+ : "restored SDL window",
+ Result.SuboptimalKhr);
+ return;
+ }
+
+ bool tookPresentation;
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.TakePresentation))
+ {
+ tookPresentation = TryTakePresentation(_presentedSequence, out presentation);
+ }
+
+ if (!tookPresentation)
+ {
+ // Upstream also replays the last host splash here after an
+ // embedded-surface resize. That path is gone with the SDL
+ // window: there is no host surface to resize around, and the
+ // swapchain recreate above already covers SDL's own resize.
+ //
+ // A render-loop tick with no newer flip is normal. Warn only when
+ // an actual queued presentation is waiting on unfinished guest work.
var hasPendingPresentation =
HasPendingGuestPresentation(_presentedSequence);
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TracePresentNotTaken(
@@ -14307,7 +14522,7 @@ internal static unsafe class VulkanVideoPresenter
hasPendingPresentation);
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot();
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
- hasPendingPresentation &&
+ hasPendingPresentation &&
_presentNotTakenLoggedSequence != _presentedSequence)
{
_presentNotTakenLoggedSequence = _presentedSequence;
@@ -14317,19 +14532,6 @@ internal static unsafe class VulkanVideoPresenter
}
return;
- }
- }
- if (_hostSurface is not null)
- {
- if (presentation.IsSplash && presentation.Pixels is not null)
- {
- _lastHostSplashPresentation = presentation;
- }
- else
- {
- _lastHostSplashPresentation = null;
- _pendingHostSplashReplay = null;
- }
}
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TracePresentTaken(
@@ -14358,13 +14560,6 @@ internal static unsafe class VulkanVideoPresenter
{
pixels = presentation.Width == _extent.Width && presentation.Height == _extent.Height
? sourcePixels
- : _hostSurface is not null
- ? ScaleBgraCoverBilinear(
- sourcePixels,
- presentation.Width,
- presentation.Height,
- _extent.Width,
- _extent.Height)
: ScaleBgra(
sourcePixels,
presentation.Width,
@@ -14444,7 +14639,7 @@ internal static unsafe class VulkanVideoPresenter
translatedResources = CreateTranslatedDrawResources(
translatedDraw,
_renderPass,
- [_swapchainFormat],
+ [PresentationTargetFormat],
_extent);
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
!_firstGuestDrawPresented &&
@@ -14467,33 +14662,35 @@ internal static unsafe class VulkanVideoPresenter
}
uint imageIndex;
- var acquireResult = _swapchainApi.AcquireNextImage(
- _device,
- _swapchain,
- ulong.MaxValue,
- _frameImageAvailable[frameSlot],
- default,
- &imageIndex);
+ Result acquireResult;
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Acquire))
+ {
+ acquireResult = _swapchainApi.AcquireNextImage(
+ _device,
+ _swapchain,
+ SwapchainAcquireTimeoutNs,
+ _frameImageAvailable[frameSlot],
+ default,
+ &imageIndex);
+ }
+ if (acquireResult == Result.Timeout)
+ {
+ ReleaseUnsubmittedPresentationResources(
+ frameSlot,
+ translatedResources,
+ ownsPresentedGuestImageVersion,
+ presentedGuestImage);
+ return;
+ }
+
if (acquireResult == Result.ErrorOutOfDateKhr)
{
RecreateSwapchainResources("vkAcquireNextImageKHR", acquireResult);
- if (translatedResources is not null)
- {
- DestroyTranslatedDrawResources(translatedResources);
- }
- if (ownsPresentedGuestImageVersion && presentedGuestImage is not null)
- {
- if (_frameGuestImageVersions.Length > frameSlot &&
- ReferenceEquals(
- _frameGuestImageVersions[frameSlot],
- presentedGuestImage))
- {
- _frameGuestImageVersions[frameSlot] = null;
- _capturedGuestFlipVersions.Remove(
- presentedGuestImage.FlipVersion);
- DestroyGuestImage(presentedGuestImage);
- }
- }
+ ReleaseUnsubmittedPresentationResources(
+ frameSlot,
+ translatedResources,
+ ownsPresentedGuestImageVersion,
+ presentedGuestImage);
return;
}
@@ -14510,6 +14707,7 @@ internal static unsafe class VulkanVideoPresenter
}
}
+ using var presentScope = RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Present);
Check(_vk.ResetCommandBuffer(_commandBuffer, 0), "vkResetCommandBuffer");
var beginInfo = new CommandBufferBeginInfo
{
@@ -14569,6 +14767,12 @@ internal static unsafe class VulkanVideoPresenter
RecordOverlayBlit(imageIndex, frameSlot);
}
+ if (_hdrOutputActive)
+ {
+ RecordHdrPresentation(imageIndex, presentation.IsHdr);
+ waitStage = PipelineStageFlags.ColorAttachmentOutputBit;
+ }
+
Check(_vk.EndCommandBuffer(_commandBuffer), "vkEndCommandBuffer");
var imageAvailable = _frameImageAvailable[frameSlot];
@@ -14585,9 +14789,13 @@ internal static unsafe class VulkanVideoPresenter
SignalSemaphoreCount = 1,
PSignalSemaphores = &renderFinished,
};
- Check(
- _vk.QueueSubmit(_queue, 1, &submitInfo, _frameFences[frameSlot]),
- "vkQueueSubmit");
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.QueueSubmit))
+ {
+ Check(
+ _vk.QueueSubmit(_queue, 1, &submitInfo, _frameFences[frameSlot]),
+ "vkQueueSubmit");
+ }
+
_submitTimeline++;
_frameTimelines[frameSlot] = _submitTimeline;
_frameFencePending[frameSlot] = true;
@@ -14611,7 +14819,12 @@ internal static unsafe class VulkanVideoPresenter
PSwapchains = &swapchain,
PImageIndices = &imageIndex,
};
- var presentResult = _swapchainApi.QueuePresent(_queue, &presentInfo);
+ Result presentResult;
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.QueuePresent))
+ {
+ presentResult = _swapchainApi.QueuePresent(_queue, &presentInfo);
+ }
+
if (presentResult == Result.ErrorOutOfDateKhr)
{
// The submitted frame still executes; RecreateSwapchainResources
@@ -14624,11 +14837,7 @@ internal static unsafe class VulkanVideoPresenter
recreateAfterPresent |= presentResult == Result.SuboptimalKhr;
VideoOutExports.ReportPresentedFrame();
PerfOverlay.RecordPresent();
- if (_hostSurface is not null && !_firstHostFramePresented)
- {
- _firstHostFramePresented = true;
- NotifyFirstHostFramePresented(_hostSurface);
- }
+ RenderPhaseProfile.RecordFrame();
if (_swapchainReadbackPending || !_pendingAliasImageDumps.IsEmpty)
{
// Diagnostics read back GPU memory and need this frame done.
@@ -14682,6 +14891,29 @@ internal static unsafe class VulkanVideoPresenter
}
}
+ private void ReleaseUnsubmittedPresentationResources(
+ int frameSlot,
+ TranslatedDrawResources? translatedResources,
+ bool ownsPresentedGuestImageVersion,
+ GuestImageResource? presentedGuestImage)
+ {
+ if (translatedResources is not null)
+ {
+ DestroyTranslatedDrawResources(translatedResources);
+ }
+
+ if (!ownsPresentedGuestImageVersion || presentedGuestImage is null ||
+ _frameGuestImageVersions.Length <= frameSlot ||
+ !ReferenceEquals(_frameGuestImageVersions[frameSlot], presentedGuestImage))
+ {
+ return;
+ }
+
+ _frameGuestImageVersions[frameSlot] = null;
+ _capturedGuestFlipVersions.Remove(presentedGuestImage.FlipVersion);
+ DestroyGuestImage(presentedGuestImage);
+ }
+
private void TraceGuestImageContents(GuestImageResource image)
{
var bytesPerPixel = GetReadbackBytesPerPixel(image.Format);
@@ -16638,25 +16870,32 @@ internal static unsafe class VulkanVideoPresenter
private void RecordUpload(uint imageIndex, int frameSlot)
{
+ var presentationTarget = PresentationTargetImage(imageIndex);
var oldLayout = _imageInitialized[imageIndex]
- ? ImageLayout.PresentSrcKhr
+ ? PresentationTargetFinalLayout
: ImageLayout.Undefined;
var toTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
- SrcAccessMask = _imageInitialized[imageIndex] ? AccessFlags.MemoryReadBit : 0,
+ SrcAccessMask = _imageInitialized[imageIndex]
+ ? _hdrOutputActive
+ ? AccessFlags.ShaderReadBit
+ : AccessFlags.MemoryReadBit
+ : 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = oldLayout,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
_imageInitialized[imageIndex]
- ? PipelineStageFlags.BottomOfPipeBit
+ ? _hdrOutputActive
+ ? PipelineStageFlags.FragmentShaderBit
+ : PipelineStageFlags.BottomOfPipeBit
: PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
@@ -16679,34 +16918,38 @@ internal static unsafe class VulkanVideoPresenter
_vk.CmdCopyBufferToImage(
_commandBuffer,
_frameUploadBuffers[frameSlot],
- _swapchainImages[imageIndex],
+ presentationTarget,
ImageLayout.TransferDstOptimal,
1,
©Region);
- var toPresent = new ImageMemoryBarrier
+ var toFinalLayout = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
- DstAccessMask = AccessFlags.MemoryReadBit,
+ DstAccessMask = _hdrOutputActive
+ ? AccessFlags.ShaderReadBit
+ : AccessFlags.MemoryReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
- NewLayout = ImageLayout.PresentSrcKhr,
+ NewLayout = PresentationTargetFinalLayout,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
- PipelineStageFlags.BottomOfPipeBit,
+ _hdrOutputActive
+ ? PipelineStageFlags.FragmentShaderBit
+ : PipelineStageFlags.BottomOfPipeBit,
0,
0,
null,
0,
null,
1,
- &toPresent);
+ &toFinalLayout);
}
// PS5 float VideoOut buffers (A16B16G16R16F flips) hold linear scRGB
@@ -16806,6 +17049,7 @@ internal static unsafe class VulkanVideoPresenter
uint imageIndex,
GuestImageResource source)
{
+ var presentationTarget = PresentationTargetImage(imageIndex);
var presentedCount = Interlocked.Increment(ref _presentedSwapchainCount);
var periodicDumpInterval = SwapchainDumpInterval();
var traceDestination =
@@ -16841,16 +17085,18 @@ internal static unsafe class VulkanVideoPresenter
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = _imageInitialized[imageIndex]
- ? AccessFlags.MemoryReadBit
+ ? _hdrOutputActive
+ ? AccessFlags.ShaderReadBit
+ : AccessFlags.MemoryReadBit
: 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = _imageInitialized[imageIndex]
- ? ImageLayout.PresentSrcKhr
+ ? PresentationTargetFinalLayout
: ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
// Linear-float flips need a linear->sRGB encode on the way to a
@@ -16896,11 +17142,13 @@ internal static unsafe class VulkanVideoPresenter
var sourceY = 0u;
var sourceWidth = source.Width;
var sourceHeight = source.Height;
- if (_hostSurface is not null)
+ var destinationX = 0u;
+ var destinationY = 0u;
+ var destinationWidth = _extent.Width;
+ var destinationHeight = _extent.Height;
+ var scalingMode = _videoOptions.ScalingMode;
+ if (scalingMode == HostScalingMode.Cover)
{
- // The embedded GUI fills its game surface like CSS
- // object-fit: cover. Preserve the guest aspect ratio and crop
- // only the excess instead of distorting every video frame.
var sourceIsWider = (ulong)sourceWidth * _extent.Height >
(ulong)_extent.Width * sourceHeight;
if (sourceIsWider)
@@ -16914,6 +17162,44 @@ internal static unsafe class VulkanVideoPresenter
sourceY = (source.Height - sourceHeight) / 2;
}
}
+ else if (scalingMode is HostScalingMode.Fit or HostScalingMode.Integer)
+ {
+ var useIntegerScale = scalingMode == HostScalingMode.Integer &&
+ sourceWidth <= _extent.Width && sourceHeight <= _extent.Height;
+ if (useIntegerScale)
+ {
+ var scale = Math.Max(1u, Math.Min(_extent.Width / sourceWidth, _extent.Height / sourceHeight));
+ destinationWidth = sourceWidth * scale;
+ destinationHeight = sourceHeight * scale;
+ }
+ else if ((ulong)sourceWidth * _extent.Height > (ulong)_extent.Width * sourceHeight)
+ {
+ destinationWidth = _extent.Width;
+ destinationHeight = Math.Max(1u, (uint)((ulong)_extent.Width * sourceHeight / sourceWidth));
+ }
+ else
+ {
+ destinationHeight = _extent.Height;
+ destinationWidth = Math.Max(1u, (uint)((ulong)_extent.Height * sourceWidth / sourceHeight));
+ }
+
+ destinationX = (_extent.Width - destinationWidth) / 2;
+ destinationY = (_extent.Height - destinationHeight) / 2;
+ }
+
+ if (destinationX != 0 || destinationY != 0 ||
+ destinationWidth != _extent.Width || destinationHeight != _extent.Height)
+ {
+ var clearColor = new ClearColorValue(0f, 0f, 0f, 1f);
+ var clearRange = ColorSubresourceRange();
+ _vk.CmdClearColorImage(
+ _commandBuffer,
+ presentationTarget,
+ ImageLayout.TransferDstOptimal,
+ &clearColor,
+ 1,
+ &clearRange);
+ }
var sourceOffsets = new ImageBlit.SrcOffsetsBuffer
{
@@ -16925,10 +17211,10 @@ internal static unsafe class VulkanVideoPresenter
};
var destinationOffsets = new ImageBlit.DstOffsetsBuffer
{
- Element0 = new Offset3D(0, 0, 0),
+ Element0 = new Offset3D(checked((int)destinationX), checked((int)destinationY), 0),
Element1 = new Offset3D(
- checked((int)_extent.Width),
- checked((int)_extent.Height),
+ checked((int)(destinationX + destinationWidth)),
+ checked((int)(destinationY + destinationHeight)),
1),
};
var region = new ImageBlit
@@ -16952,13 +17238,13 @@ internal static unsafe class VulkanVideoPresenter
// row/column, which shreds 1-2px features in the guest frame.
var isIntegerUpscale =
sourceWidth != 0 && sourceHeight != 0 &&
- _extent.Width >= sourceWidth && _extent.Height >= sourceHeight &&
- _extent.Width % sourceWidth == 0 && _extent.Height % sourceHeight == 0;
+ destinationWidth >= sourceWidth && destinationHeight >= sourceHeight &&
+ destinationWidth % sourceWidth == 0 && destinationHeight % sourceHeight == 0;
_vk.CmdBlitImage(
_commandBuffer,
source.Image,
ImageLayout.TransferSrcOptimal,
- encodeForPresent ? encodeImage : _swapchainImages[imageIndex],
+ encodeForPresent ? encodeImage : presentationTarget,
ImageLayout.TransferDstOptimal,
1,
®ion,
@@ -17021,7 +17307,7 @@ internal static unsafe class VulkanVideoPresenter
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
@@ -17047,7 +17333,7 @@ internal static unsafe class VulkanVideoPresenter
};
_vk.CmdCopyImageToBuffer(
_commandBuffer,
- _swapchainImages[imageIndex],
+ presentationTarget,
ImageLayout.TransferSrcOptimal,
_stagingBuffer,
1,
@@ -17067,28 +17353,32 @@ internal static unsafe class VulkanVideoPresenter
Image = source.Image,
SubresourceRange = ColorSubresourceRange(),
};
- var destinationToPresent = new ImageMemoryBarrier
+ var destinationToFinalLayout = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = traceDestination
? AccessFlags.TransferReadBit
: AccessFlags.TransferWriteBit,
- DstAccessMask = AccessFlags.MemoryReadBit,
+ DstAccessMask = _hdrOutputActive
+ ? AccessFlags.ShaderReadBit
+ : AccessFlags.MemoryReadBit,
OldLayout = traceDestination
? ImageLayout.TransferSrcOptimal
: ImageLayout.TransferDstOptimal,
- NewLayout = ImageLayout.PresentSrcKhr,
+ NewLayout = PresentationTargetFinalLayout,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
barriers[0] = sourceToShaderRead;
- barriers[1] = destinationToPresent;
+ barriers[1] = destinationToFinalLayout;
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
- PipelineStageFlags.AllCommandsBit,
+ _hdrOutputActive
+ ? PipelineStageFlags.FragmentShaderBit
+ : PipelineStageFlags.AllCommandsBit,
0,
0,
null,
@@ -17179,10 +17469,10 @@ internal static unsafe class VulkanVideoPresenter
{
var fallbackWidth = _extent.Width != 0
? _extent.Width
- : DefaultWindowWidth;
+ : (uint)_videoOptions.Width;
var fallbackHeight = _extent.Height != 0
? _extent.Height
- : DefaultWindowHeight;
+ : (uint)_videoOptions.Height;
return new Extent2D(
ClampSurfaceExtent(
capabilities.CurrentExtent.Width,
@@ -17200,29 +17490,20 @@ internal static unsafe class VulkanVideoPresenter
return new Extent2D(
ClampSurfaceExtent(
(uint)Math.Max(size.X, 1),
- DefaultWindowWidth,
+ (uint)_videoOptions.Width,
capabilities.MinImageExtent.Width,
capabilities.MaxImageExtent.Width),
ClampSurfaceExtent(
(uint)Math.Max(size.Y, 1),
- DefaultWindowHeight,
+ (uint)_videoOptions.Height,
capabilities.MinImageExtent.Height,
capabilities.MaxImageExtent.Height));
}
- private Vector2D GetFramebufferSize()
+ private (int X, int Y) GetFramebufferSize()
{
- if (_window is not null)
- {
- return _window.FramebufferSize;
- }
-
- if (_hostSurface is not null)
- {
- return new Vector2D(_hostSurface.PixelWidth, _hostSurface.PixelHeight);
- }
-
- return new Vector2D((int)DefaultWindowWidth, (int)DefaultWindowHeight);
+ var size = _window.PixelSize;
+ return (size.Width, size.Height);
}
private static uint ClampSurfaceExtent(
@@ -17244,8 +17525,25 @@ internal static unsafe class VulkanVideoPresenter
return Math.Clamp(value, minimum, maximum);
}
- private static SurfaceFormatKHR ChooseSurfaceFormat(IReadOnlyList formats)
+ private static SurfaceFormatKHR ChooseSurfaceFormat(
+ IReadOnlyList formats,
+ bool requestHdr,
+ out bool hdrActive)
{
+ if (requestHdr)
+ {
+ foreach (var format in formats)
+ {
+ if (format.Format == Format.R16G16B16A16Sfloat &&
+ format.ColorSpace == ColorSpaceKHR.SpaceExtendedSrgbLinearExt)
+ {
+ hdrActive = true;
+ return format;
+ }
+ }
+ }
+
+ hdrActive = false;
foreach (var format in formats)
{
if (format.Format is Format.B8G8R8A8Srgb or Format.B8G8R8A8Unorm &&
@@ -17309,88 +17607,6 @@ internal static unsafe class VulkanVideoPresenter
return destination;
}
- private static byte[] ScaleBgraCoverBilinear(
- byte[] source,
- uint sourceWidth,
- uint sourceHeight,
- uint width,
- uint height)
- {
- var destination = new byte[checked((int)(width * height * 4))];
- var sourceIsWider = (ulong)sourceWidth * height > (ulong)width * sourceHeight;
- var cropWidth = sourceIsWider
- ? Math.Max(1u, (uint)((ulong)sourceHeight * width / height))
- : sourceWidth;
- var cropHeight = sourceIsWider
- ? sourceHeight
- : Math.Max(1u, (uint)((ulong)sourceWidth * height / width));
- var offsetX = (sourceWidth - cropWidth) / 2;
- var offsetY = (sourceHeight - cropHeight) / 2;
- var maxSourceX = offsetX + cropWidth - 1;
- var maxSourceY = offsetY + cropHeight - 1;
-
- for (uint y = 0; y < height; y++)
- {
- for (uint x = 0; x < width; x++)
- {
- var destinationOffset = checked((int)(((ulong)y * width + x) * 4));
- float blue = 0;
- float green = 0;
- float red = 0;
- float alpha = 0;
- for (var sampleY = 0; sampleY < 2; sampleY++)
- {
- var scaledY = offsetY + (((y + ((sampleY + 0.5f) / 2)) * cropHeight) / height) - 0.5f;
- var sourceY0 = (uint)Math.Clamp((int)MathF.Floor(scaledY), (int)offsetY, (int)maxSourceY);
- var sourceY1 = Math.Min(sourceY0 + 1, maxSourceY);
- var fractionY = scaledY - MathF.Floor(scaledY);
- for (var sampleX = 0; sampleX < 2; sampleX++)
- {
- var scaledX = offsetX + (((x + ((sampleX + 0.5f) / 2)) * cropWidth) / width) - 0.5f;
- var sourceX0 = (uint)Math.Clamp((int)MathF.Floor(scaledX), (int)offsetX, (int)maxSourceX);
- var sourceX1 = Math.Min(sourceX0 + 1, maxSourceX);
- var fractionX = scaledX - MathF.Floor(scaledX);
- var sourceOffset00 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX0) * 4));
- var sourceOffset10 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX1) * 4));
- var sourceOffset01 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX0) * 4));
- var sourceOffset11 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX1) * 4));
-
- var topBlue = source[sourceOffset00] +
- ((source[sourceOffset10] - source[sourceOffset00]) * fractionX);
- var bottomBlue = source[sourceOffset01] +
- ((source[sourceOffset11] - source[sourceOffset01]) * fractionX);
- blue += topBlue + ((bottomBlue - topBlue) * fractionY);
-
- var topGreen = source[sourceOffset00 + 1] +
- ((source[sourceOffset10 + 1] - source[sourceOffset00 + 1]) * fractionX);
- var bottomGreen = source[sourceOffset01 + 1] +
- ((source[sourceOffset11 + 1] - source[sourceOffset01 + 1]) * fractionX);
- green += topGreen + ((bottomGreen - topGreen) * fractionY);
-
- var topRed = source[sourceOffset00 + 2] +
- ((source[sourceOffset10 + 2] - source[sourceOffset00 + 2]) * fractionX);
- var bottomRed = source[sourceOffset01 + 2] +
- ((source[sourceOffset11 + 2] - source[sourceOffset01 + 2]) * fractionX);
- red += topRed + ((bottomRed - topRed) * fractionY);
-
- var topAlpha = source[sourceOffset00 + 3] +
- ((source[sourceOffset10 + 3] - source[sourceOffset00 + 3]) * fractionX);
- var bottomAlpha = source[sourceOffset01 + 3] +
- ((source[sourceOffset11 + 3] - source[sourceOffset01 + 3]) * fractionX);
- alpha += topAlpha + ((bottomAlpha - topAlpha) * fractionY);
- }
- }
-
- destination[destinationOffset] = (byte)MathF.Round(blue * 0.25f);
- destination[destinationOffset + 1] = (byte)MathF.Round(green * 0.25f);
- destination[destinationOffset + 2] = (byte)MathF.Round(red * 0.25f);
- destination[destinationOffset + 3] = (byte)MathF.Round(alpha * 0.25f);
- }
- }
-
- return destination;
- }
-
private void DisposeVulkan()
{
if (!_vulkanReady)
@@ -17550,11 +17766,33 @@ internal static unsafe class VulkanVideoPresenter
CreateSwapchain();
CreateCommandResources();
CreateGuestDrawResources();
+ QueueSplashReplayAfterSwapchainRecreate();
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan VideoOut recreated swapchain: " +
$"{_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
}
+ private void QueueSplashReplayAfterSwapchainRecreate()
+ {
+ lock (_gate)
+ {
+ if (_latestPresentation is not
+ {
+ IsSplash: true,
+ Pixels: not null,
+ } splash ||
+ splash.Sequence > _presentedSequence)
+ {
+ return;
+ }
+
+ _latestPresentation = splash with
+ {
+ Sequence = _presentedSequence + 1,
+ };
+ }
+ }
+
private void DestroySwapchainResources()
{
DestroyHostMovieImage();
@@ -17645,6 +17883,51 @@ internal static unsafe class VulkanVideoPresenter
{
_vk.DestroyFence(_device, recycledFence, null);
}
+ if (_hdrPipeline.Handle != 0)
+ {
+ _vk.DestroyPipeline(_device, _hdrPipeline, null);
+ _hdrPipeline = default;
+ }
+ if (_hdrPqPipeline.Handle != 0)
+ {
+ _vk.DestroyPipeline(_device, _hdrPqPipeline, null);
+ _hdrPqPipeline = default;
+ }
+ if (_hdrPipelineLayout.Handle != 0)
+ {
+ _vk.DestroyPipelineLayout(_device, _hdrPipelineLayout, null);
+ _hdrPipelineLayout = default;
+ }
+ if (_hdrDescriptorPool.Handle != 0)
+ {
+ _vk.DestroyDescriptorPool(_device, _hdrDescriptorPool, null);
+ _hdrDescriptorPool = default;
+ }
+ _hdrDescriptorSets = [];
+ _hdrPqDescriptorSets = [];
+ if (_hdrDescriptorSetLayout.Handle != 0)
+ {
+ _vk.DestroyDescriptorSetLayout(_device, _hdrDescriptorSetLayout, null);
+ _hdrDescriptorSetLayout = default;
+ }
+ if (_hdrSampler.Handle != 0)
+ {
+ _vk.DestroySampler(_device, _hdrSampler, null);
+ _hdrSampler = default;
+ }
+ foreach (var framebuffer in _hdrFramebuffers)
+ {
+ if (framebuffer.Handle != 0)
+ {
+ _vk.DestroyFramebuffer(_device, framebuffer, null);
+ }
+ }
+ _hdrFramebuffers = [];
+ if (_hdrRenderPass.Handle != 0)
+ {
+ _vk.DestroyRenderPass(_device, _hdrRenderPass, null);
+ _hdrRenderPass = default;
+ }
if (_barycentricPipeline.Handle != 0)
{
_vk.DestroyPipeline(_device, _barycentricPipeline, null);
@@ -17674,6 +17957,38 @@ internal static unsafe class VulkanVideoPresenter
_vk.DestroyImageView(_device, imageView, null);
}
}
+ foreach (var imageView in _presentationSampleViews)
+ {
+ if (imageView.Handle != 0)
+ {
+ _vk.DestroyImageView(_device, imageView, null);
+ }
+ }
+ _presentationSampleViews = [];
+ foreach (var imageView in _presentationImageViews)
+ {
+ if (imageView.Handle != 0)
+ {
+ _vk.DestroyImageView(_device, imageView, null);
+ }
+ }
+ _presentationImageViews = [];
+ foreach (var image in _presentationImages)
+ {
+ if (image.Handle != 0)
+ {
+ _vk.DestroyImage(_device, image, null);
+ }
+ }
+ _presentationImages = [];
+ foreach (var memory in _presentationImageMemory)
+ {
+ if (memory.Handle != 0)
+ {
+ _vk.FreeMemory(_device, memory, null);
+ }
+ }
+ _presentationImageMemory = [];
if (_commandPool.Handle != 0)
{
// Destroying the pool frees every command buffer allocated
@@ -17695,6 +18010,10 @@ internal static unsafe class VulkanVideoPresenter
_swapchainImageViews = [];
_framebuffers = [];
_imageInitialized = [];
+ _hdrOutputActive = false;
+ _hdrRequestedForSwapchain = false;
+ _hdrSdrWhiteLevel = 1f;
+ _hdrHeadroom = 1f;
}
private static void CheckSwapchainResult(Result result, string operation)
diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/SpirvFixedShaders.cs b/src/SharpEmu.ShaderCompiler.Vulkan/SpirvFixedShaders.cs
index 3b2493c1..c35e265c 100644
--- a/src/SharpEmu.ShaderCompiler.Vulkan/SpirvFixedShaders.cs
+++ b/src/SharpEmu.ShaderCompiler.Vulkan/SpirvFixedShaders.cs
@@ -92,7 +92,7 @@ public static class SpirvFixedShaders
return module.Build();
}
- public static byte[] CreateCopyFragment()
+ public static byte[] CreateCopyFragment(float colorScale = 1f)
{
var module = new SpirvModuleBuilder();
module.AddCapability(SpirvCapability.Shader);
@@ -152,6 +152,16 @@ public static class SpirvFixedShaders
coordinates,
2,
lod);
+ if (colorScale != 1f)
+ {
+ var scale = module.ConstantComposite(
+ vec4Type,
+ module.ConstantFloat(floatType, colorScale),
+ module.ConstantFloat(floatType, colorScale),
+ module.ConstantFloat(floatType, colorScale),
+ module.ConstantFloat(floatType, 1f));
+ color = module.AddInstruction(SpirvOp.FMul, vec4Type, color, scale);
+ }
module.AddStatement(SpirvOp.Store, output, color);
module.AddStatement(SpirvOp.Return);
module.EndFunction();
@@ -165,6 +175,155 @@ public static class SpirvFixedShaders
return module.Build();
}
+ public static byte[] CreatePqToScRgbFragment()
+ {
+ const float inverseM1 = 16384f / 2610f;
+ const float inverseM2 = 32f / 2523f;
+ const float c1 = 3424f / 4096f;
+ const float c2 = 2413f / 128f;
+ const float c3 = 2392f / 128f;
+
+ var module = new SpirvModuleBuilder();
+ module.AddCapability(SpirvCapability.Shader);
+ var glsl = module.ImportExtInst("GLSL.std.450");
+ var voidType = module.TypeVoid();
+ var floatType = module.TypeFloat(32);
+ var vec2Type = module.TypeVector(floatType, 2);
+ var vec3Type = module.TypeVector(floatType, 3);
+ var vec4Type = module.TypeVector(floatType, 4);
+ var inputVec4Pointer = module.TypePointer(SpirvStorageClass.Input, vec4Type);
+ var outputVec4Pointer = module.TypePointer(SpirvStorageClass.Output, vec4Type);
+ var imageType = module.TypeImage(
+ floatType,
+ SpirvImageDim.Dim2D,
+ depth: false,
+ arrayed: false,
+ multisampled: false,
+ sampled: 1,
+ SpirvImageFormat.Unknown);
+ var sampledImageType = module.TypeSampledImage(imageType);
+ var sampledImagePointer =
+ module.TypePointer(SpirvStorageClass.UniformConstant, sampledImageType);
+ var attribute = module.AddGlobalVariable(inputVec4Pointer, SpirvStorageClass.Input);
+ module.AddDecoration(attribute, SpirvDecoration.Location, 0);
+ var texture = module.AddGlobalVariable(
+ sampledImagePointer,
+ SpirvStorageClass.UniformConstant);
+ module.AddDecoration(texture, SpirvDecoration.DescriptorSet, 0);
+ module.AddDecoration(texture, SpirvDecoration.Binding, 1);
+ var output = module.AddGlobalVariable(outputVec4Pointer, SpirvStorageClass.Output);
+ module.AddDecoration(output, SpirvDecoration.Location, 0);
+
+ uint Float(float value) => module.ConstantFloat(floatType, value);
+ uint Vec3(float value) => module.ConstantComposite(
+ vec3Type,
+ Float(value),
+ Float(value),
+ Float(value));
+ uint Ext(uint operation, uint resultType, params uint[] operands)
+ {
+ var values = new uint[2 + operands.Length];
+ values[0] = glsl;
+ values[1] = operation;
+ operands.CopyTo(values, 2);
+ return module.AddInstruction(SpirvOp.ExtInst, resultType, values);
+ }
+ uint Component(uint vector, uint index) =>
+ module.AddInstruction(SpirvOp.CompositeExtract, floatType, vector, index);
+ uint Multiply(uint left, float right) =>
+ module.AddInstruction(SpirvOp.FMul, floatType, left, Float(right));
+ uint Add3(uint first, uint second, uint third) =>
+ module.AddInstruction(
+ SpirvOp.FAdd,
+ floatType,
+ module.AddInstruction(SpirvOp.FAdd, floatType, first, second),
+ third);
+
+ var functionType = module.TypeFunction(voidType);
+ var main = module.BeginFunction(voidType, functionType);
+ module.AddLabel();
+ var attributeValue = module.AddInstruction(SpirvOp.Load, vec4Type, attribute);
+ var coordinates = module.AddInstruction(
+ SpirvOp.VectorShuffle,
+ vec2Type,
+ attributeValue,
+ attributeValue,
+ 0,
+ 1);
+ var sampledImage = module.AddInstruction(SpirvOp.Load, sampledImageType, texture);
+ var color = module.AddInstruction(
+ SpirvOp.ImageSampleExplicitLod,
+ vec4Type,
+ sampledImage,
+ coordinates,
+ 2,
+ Float(0));
+ var pq = module.AddInstruction(
+ SpirvOp.VectorShuffle,
+ vec3Type,
+ color,
+ color,
+ 0,
+ 1,
+ 2);
+
+ // SMPTE ST 2084 converts normalized PQ code values to absolute luminance.
+ var powered = Ext(26, vec3Type, pq, Vec3(inverseM2));
+ var numerator = Ext(
+ 40,
+ vec3Type,
+ module.AddInstruction(SpirvOp.FSub, vec3Type, powered, Vec3(c1)),
+ Vec3(0));
+ var denominator = module.AddInstruction(
+ SpirvOp.FSub,
+ vec3Type,
+ Vec3(c2),
+ module.AddInstruction(SpirvOp.FMul, vec3Type, Vec3(c3), powered));
+ var normalizedLuminance = Ext(
+ 26,
+ vec3Type,
+ module.AddInstruction(SpirvOp.FDiv, vec3Type, numerator, denominator),
+ Vec3(inverseM1));
+ var scRgb2020 = module.AddInstruction(
+ SpirvOp.FMul,
+ vec3Type,
+ normalizedLuminance,
+ Vec3(10000f / 80f));
+
+ var red2020 = Component(scRgb2020, 0);
+ var green2020 = Component(scRgb2020, 1);
+ var blue2020 = Component(scRgb2020, 2);
+ var red = Add3(
+ Multiply(red2020, 1.660491f),
+ Multiply(green2020, -0.587641f),
+ Multiply(blue2020, -0.072850f));
+ var green = Add3(
+ Multiply(red2020, -0.124550f),
+ Multiply(green2020, 1.132900f),
+ Multiply(blue2020, -0.008349f));
+ var blue = Add3(
+ Multiply(red2020, -0.018151f),
+ Multiply(green2020, -0.100579f),
+ Multiply(blue2020, 1.118730f));
+ var converted = module.AddInstruction(
+ SpirvOp.CompositeConstruct,
+ vec4Type,
+ red,
+ green,
+ blue,
+ Component(color, 3));
+ module.AddStatement(SpirvOp.Store, output, converted);
+ module.AddStatement(SpirvOp.Return);
+ module.EndFunction();
+ module.AddEntryPoint(
+ SpirvExecutionModel.Fragment,
+ main,
+ "main",
+ [attribute, texture, output]);
+ module.AddExecutionMode(main, SpirvExecutionMode.OriginUpperLeft);
+ return module.Build();
+ }
+
public static byte[] CreateSolidFragment(float red, float green, float blue, float alpha)
{
var module = new SpirvModuleBuilder();
diff --git a/tests/SharpEmu.Libs.Tests/Audio/AcmExportsTests.cs b/tests/SharpEmu.Libs.Tests/Audio/AcmExportsTests.cs
new file mode 100644
index 00000000..12e57c08
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Audio/AcmExportsTests.cs
@@ -0,0 +1,130 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using SharpEmu.HLE;
+using SharpEmu.Libs.Acm;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Audio;
+
+[CollectionDefinition("AcmState", DisableParallelization = true)]
+public sealed class AcmStateCollection
+{
+ public const string Name = "AcmState";
+}
+
+[Collection(AcmStateCollection.Name)]
+public sealed class AcmExportsTests : IDisposable
+{
+ private const ulong MemoryBase = 0x1_1000_0000;
+ private const ulong ContextAddress = MemoryBase + 0x100;
+ private const ulong InfoArrayAddress = MemoryBase + 0x200;
+ private const ulong ErrorAddress = MemoryBase + 0x300;
+ private const ulong BatchAddress = MemoryBase + 0x400;
+
+ private readonly FakeCpuMemory _memory = new(MemoryBase, 0x1000);
+ private readonly CpuContext _ctx;
+
+ public AcmExportsTests()
+ {
+ AcmExports.ResetForTests();
+ _ctx = new CpuContext(_memory, Generation.Gen5);
+ }
+
+ [Fact]
+ public void ContextAndBatchLifecycle_UsesFourByteHandlesAndClearsErrors()
+ {
+ Span contextSentinel = stackalloc byte[8];
+ contextSentinel.Fill(0xCC);
+ Assert.True(_memory.TryWrite(ContextAddress, contextSentinel));
+
+ _ctx[CpuRegister.Rdi] = ContextAddress;
+ Assert.Equal(0, AcmExports.AcmContextCreate(_ctx));
+ var context = ReadUInt32(ContextAddress);
+ Assert.Equal(1u, context);
+ Assert.Equal(0xCCCCCCCCu, ReadUInt32(ContextAddress + 4));
+
+ Span errorSentinel = stackalloc byte[32];
+ errorSentinel.Fill(0xCC);
+ Assert.True(_memory.TryWrite(ErrorAddress, errorSentinel));
+ WriteUInt64(InfoArrayAddress, MemoryBase + 0x500);
+
+ _ctx[CpuRegister.Rdi] = context;
+ _ctx[CpuRegister.Rsi] = 1;
+ _ctx[CpuRegister.Rdx] = InfoArrayAddress;
+ _ctx[CpuRegister.Rcx] = ErrorAddress;
+ _ctx[CpuRegister.R8] = BatchAddress;
+ Assert.Equal(0, AcmExports.AcmBatchStartBuffers(_ctx));
+ Assert.Equal(1u, ReadUInt32(BatchAddress));
+ Assert.All(ReadBytes(ErrorAddress, 32), value => Assert.Equal(0, value));
+
+ _ctx[CpuRegister.Rdi] = context;
+ _ctx[CpuRegister.Rsi] = 1;
+ _ctx[CpuRegister.Rdx] = 0;
+ Assert.Equal(0, AcmExports.AcmBatchWait(_ctx));
+ }
+
+ [Fact]
+ public void BatchStartBuffers_RejectsUnknownContextAndMissingOutputs()
+ {
+ _ctx[CpuRegister.Rdi] = 99;
+ _ctx[CpuRegister.Rsi] = 1;
+ _ctx[CpuRegister.Rdx] = InfoArrayAddress;
+ _ctx[CpuRegister.R8] = BatchAddress;
+ Assert.Equal(
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
+ AcmExports.AcmBatchStartBuffers(_ctx));
+
+ _ctx[CpuRegister.Rdi] = ContextAddress;
+ Assert.Equal(0, AcmExports.AcmContextCreate(_ctx));
+ _ctx[CpuRegister.Rdi] = ReadUInt32(ContextAddress);
+ _ctx[CpuRegister.Rsi] = 1;
+ _ctx[CpuRegister.Rdx] = 0;
+ _ctx[CpuRegister.R8] = BatchAddress;
+ Assert.Equal(
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
+ AcmExports.AcmBatchStartBuffers(_ctx));
+ }
+
+ [Fact]
+ public void AcmBatchExports_RegisterForBothGenerations()
+ {
+ foreach (var generation in new[] { Generation.Gen4, Generation.Gen5 })
+ {
+ var manager = new ModuleManager();
+ manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(generation));
+
+ Assert.True(manager.TryGetExport("8fe55ktlNVo", out var start));
+ Assert.Equal("sceAcmBatchStartBuffers", start.Name);
+ Assert.True(manager.TryGetExport("RLN3gRlXJLE", out var wait));
+ Assert.Equal("sceAcmBatchWait", wait.Name);
+ }
+ }
+
+ public void Dispose()
+ {
+ AcmExports.ResetForTests();
+ }
+
+ private uint ReadUInt32(ulong address)
+ {
+ Span value = stackalloc byte[sizeof(uint)];
+ Assert.True(_memory.TryRead(address, value));
+ return BinaryPrimitives.ReadUInt32LittleEndian(value);
+ }
+
+ private byte[] ReadBytes(ulong address, int length)
+ {
+ var value = new byte[length];
+ Assert.True(_memory.TryRead(address, value));
+ return value;
+ }
+
+ private void WriteUInt64(ulong address, ulong value)
+ {
+ Span bytes = stackalloc byte[sizeof(ulong)];
+ BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
+ Assert.True(_memory.TryWrite(address, bytes));
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Audio/AjmExportsTests.cs b/tests/SharpEmu.Libs.Tests/Audio/AjmExportsTests.cs
index 1669c289..ef030b82 100644
--- a/tests/SharpEmu.Libs.Tests/Audio/AjmExportsTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Audio/AjmExportsTests.cs
@@ -25,6 +25,9 @@ public sealed class AjmExportsTests : IDisposable
private const ulong MemoryBase = 0x1_0000_0000;
private const ulong ContextAddress = MemoryBase + 0x100;
private const ulong InstanceAddress = MemoryBase + 0x200;
+ private const ulong BatchInfoAddress = MemoryBase + 0x300;
+ private const ulong StatisticsAddress = MemoryBase + 0x400;
+ private const ulong BatchBufferAddress = MemoryBase + 0x500;
private readonly FakeCpuMemory _memory = new(MemoryBase, 0x1000);
private readonly CpuContext _ctx;
@@ -80,6 +83,171 @@ public sealed class AjmExportsTests : IDisposable
Assert.Equal(InvalidContext, RegisterCodec(contextId + 1, 1));
}
+ [Fact]
+ public void MemoryRegistration_TracksValidContextAndToleratesRepeatedUnregister()
+ {
+ var contextId = Initialize();
+ const ulong address = 0x4_D4E0_0000;
+
+ Assert.Equal(0, RegisterMemory(contextId, address, 4));
+ Assert.Equal(0, UnregisterMemory(contextId, address));
+ Assert.Equal(0, UnregisterMemory(contextId, address));
+ Assert.Equal(InvalidContext, RegisterMemory(contextId + 1, address, 4));
+ Assert.Equal(InvalidContext, UnregisterMemory(contextId + 1, address));
+ Assert.Equal(InvalidParameter, RegisterMemory(contextId, 0, 4));
+ Assert.Equal(InvalidParameter, RegisterMemory(contextId, address, 0));
+ Assert.Equal(InvalidParameter, UnregisterMemory(contextId, 0));
+ }
+
+ [Fact]
+ public void BatchInitializeAndStatistics_WriteExpectedAbiStructures()
+ {
+ Span sentinel = stackalloc byte[48];
+ sentinel.Fill(0xCC);
+ Assert.True(_memory.TryWrite(StatisticsAddress, sentinel));
+
+ _ctx[CpuRegister.Rdi] = BatchBufferAddress;
+ _ctx[CpuRegister.Rsi] = 0x200;
+ _ctx[CpuRegister.Rdx] = BatchInfoAddress;
+ Assert.Equal(0, AjmExports.AjmBatchInitialize(_ctx));
+
+ Assert.Equal(BatchBufferAddress, ReadUInt64(BatchInfoAddress));
+ Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 8));
+ Assert.Equal(0x200ul, ReadUInt64(BatchInfoAddress + 16));
+ Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 24));
+ Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 32));
+
+ _ctx[CpuRegister.Rdi] = BatchInfoAddress;
+ _ctx[CpuRegister.Rsi] = StatisticsAddress;
+ _ctx.SetXmmRegister(0, BitConverter.SingleToUInt32Bits(0.25f), 0);
+ Assert.Equal(0, AjmExports.AjmBatchJobGetStatistics(_ctx));
+
+ Assert.Equal(88ul, ReadUInt64(BatchInfoAddress + 8));
+ Assert.Equal(BatchBufferAddress, ReadUInt64(BatchInfoAddress + 24));
+ Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 32));
+ Assert.All(ReadBytes(StatisticsAddress, 48), value => Assert.Equal(0, value));
+ Assert.All(ReadBytes(BatchBufferAddress, 88), value => Assert.Equal(0, value));
+ }
+
+ ///
+ /// Demon's Souls creates ATRAC9 voices with low flag words 1, 2, 4 and 8 —
+ /// the channel counts of the streams they carry. Rejecting any of them left
+ /// the title holding SCE_AJM_INSTANCE_INVALID for that voice, so its 4- and
+ /// 8-channel movie stems played silence.
+ ///
+ [Theory]
+ [InlineData(0x1_0000_0001ul)]
+ [InlineData(0x1_0000_0002ul)]
+ [InlineData(0x1_0000_0004ul)]
+ [InlineData(0x1_0000_0008ul)]
+ public void InstanceCreate_AcceptsEveryChannelCountFlagWord(ulong flags)
+ {
+ var contextId = Initialize();
+ Assert.Equal(0, RegisterCodec(contextId, 1));
+
+ Assert.Equal(0, CreateInstance(contextId, 1, flags, InstanceAddress));
+ Assert.Equal(0x4001u, ReadUInt32(InstanceAddress));
+ }
+
+ ///
+ /// sceAjmBatchJobRunSplit gathers arrays of AjmBuffer descriptors rather than
+ /// a single pointer/size pair, and its sideband layout is positional: result,
+ /// then only the blocks the job flags asked for.
+ ///
+ [Fact]
+ public void BatchJobRunSplit_GathersDescriptorsAndWritesFlaggedSideband()
+ {
+ const ulong inputDescriptors = MemoryBase + 0x600;
+ const ulong outputDescriptors = MemoryBase + 0x640;
+ const ulong inputData = MemoryBase + 0x700;
+ const ulong outputData = MemoryBase + 0x800;
+ const ulong sideband = MemoryBase + 0x900;
+ const ulong streamSideband = 1ul << 47;
+ const ulong multipleFrames = 1ul << 12;
+
+ var contextId = Initialize();
+ Assert.Equal(0, RegisterCodec(contextId, 2));
+ Assert.Equal(0, CreateInstance(contextId, 2, 0x2_0000_0002, InstanceAddress));
+ var instanceId = ReadUInt32(InstanceAddress);
+
+ InitializeBatch(BatchBufferAddress, 0x200, BatchInfoAddress);
+
+ // Two input descriptors totalling 0x30 bytes, one output of 0x40.
+ WriteUInt64(inputDescriptors, inputData);
+ WriteUInt64(inputDescriptors + 8, 0x20);
+ WriteUInt64(inputDescriptors + 16, inputData + 0x20);
+ WriteUInt64(inputDescriptors + 24, 0x10);
+ WriteUInt64(outputDescriptors, outputData);
+ WriteUInt64(outputDescriptors + 8, 0x40);
+
+ Span dirty = stackalloc byte[0x40];
+ dirty.Fill(0xAB);
+ Assert.True(_memory.TryWrite(outputData, dirty));
+
+ _ctx[CpuRegister.Rdi] = BatchInfoAddress;
+ _ctx[CpuRegister.Rsi] = instanceId;
+ _ctx[CpuRegister.Rdx] = streamSideband | multipleFrames;
+ _ctx[CpuRegister.Rcx] = inputDescriptors;
+ _ctx[CpuRegister.R8] = 2;
+ _ctx[CpuRegister.R9] = outputDescriptors;
+ WriteStackArgs(outputCount: 1, sidebandAddress: sideband, sidebandSize: 0x20);
+
+ Assert.Equal(0, AjmExports.AjmBatchJobRunSplit(_ctx));
+
+ // A non-ATRAC9 instance stays a silence stub: the output is cleared and
+ // the whole gathered input is reported consumed.
+ Assert.All(ReadBytes(outputData, 0x40), value => Assert.Equal(0, value));
+
+ var result = ReadBytes(sideband, 0x20);
+ Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(result)); // AjmSidebandResult
+ Assert.Equal(0x30, BinaryPrimitives.ReadInt32LittleEndian(result.AsSpan(8))); // stream.input_consumed
+ Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(result.AsSpan(12))); // stream.output_written
+ Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(result.AsSpan(24))); // mframe.num_frames
+
+ // The batch cursor advanced past the job plus its descriptors.
+ Assert.True(ReadUInt64(BatchInfoAddress + 8) > 0);
+ }
+
+ [Fact]
+ public void BatchJobRunSplit_OmitsSidebandBlocksTheJobFlagsDidNotRequest()
+ {
+ const ulong inputDescriptors = MemoryBase + 0x600;
+ const ulong outputDescriptors = MemoryBase + 0x640;
+ const ulong inputData = MemoryBase + 0x700;
+ const ulong outputData = MemoryBase + 0x800;
+ const ulong sideband = MemoryBase + 0x900;
+
+ var contextId = Initialize();
+ Assert.Equal(0, RegisterCodec(contextId, 2));
+ Assert.Equal(0, CreateInstance(contextId, 2, 0x2_0000_0002, InstanceAddress));
+ var instanceId = ReadUInt32(InstanceAddress);
+
+ InitializeBatch(BatchBufferAddress, 0x200, BatchInfoAddress);
+ WriteUInt64(inputDescriptors, inputData);
+ WriteUInt64(inputDescriptors + 8, 0x20);
+ WriteUInt64(outputDescriptors, outputData);
+ WriteUInt64(outputDescriptors + 8, 0x40);
+
+ Span sentinel = stackalloc byte[0x20];
+ sentinel.Fill(0x5A);
+ Assert.True(_memory.TryWrite(sideband, sentinel));
+
+ _ctx[CpuRegister.Rdi] = BatchInfoAddress;
+ _ctx[CpuRegister.Rsi] = instanceId;
+ _ctx[CpuRegister.Rdx] = 0; // No stream sideband, no multiple-frames.
+ _ctx[CpuRegister.Rcx] = inputDescriptors;
+ _ctx[CpuRegister.R8] = 1;
+ _ctx[CpuRegister.R9] = outputDescriptors;
+ WriteStackArgs(outputCount: 1, sidebandAddress: sideband, sidebandSize: 0x20);
+
+ Assert.Equal(0, AjmExports.AjmBatchJobRunSplit(_ctx));
+
+ // Only the 8-byte result block is written; the rest keeps its sentinel.
+ var written = ReadBytes(sideband, 0x20);
+ Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(written));
+ Assert.All(written.AsSpan(8).ToArray(), value => Assert.Equal(0x5A, value));
+ }
+
[Theory]
[InlineData(23u)]
[InlineData(24u)]
@@ -155,6 +323,12 @@ public sealed class AjmExportsTests : IDisposable
Assert.Equal("sceAjmInstanceCreate", create.Name);
Assert.True(manager.TryGetExport("RbLbuKv8zho", out var destroy));
Assert.Equal("sceAjmInstanceDestroy", destroy.Name);
+ Assert.True(manager.TryGetExport("bkRHEYG6lEM", out var memoryRegister));
+ Assert.Equal("sceAjmMemoryRegister", memoryRegister.Name);
+ Assert.True(manager.TryGetExport("pIpGiaYkHkM", out var memoryUnregister));
+ Assert.Equal("sceAjmMemoryUnregister", memoryUnregister.Name);
+ Assert.True(manager.TryGetExport("3cAg7xN995U", out var statistics));
+ Assert.Equal("sceAjmBatchJobGetStatistics", statistics.Name);
}
}
@@ -179,6 +353,21 @@ public sealed class AjmExportsTests : IDisposable
return AjmExports.AjmModuleRegister(_ctx);
}
+ private int RegisterMemory(uint contextId, ulong address, ulong pages)
+ {
+ _ctx[CpuRegister.Rdi] = contextId;
+ _ctx[CpuRegister.Rsi] = address;
+ _ctx[CpuRegister.Rdx] = pages;
+ return AjmExports.AjmMemoryRegister(_ctx);
+ }
+
+ private int UnregisterMemory(uint contextId, ulong address)
+ {
+ _ctx[CpuRegister.Rdi] = contextId;
+ _ctx[CpuRegister.Rsi] = address;
+ return AjmExports.AjmMemoryUnregister(_ctx);
+ }
+
private int CreateInstance(uint contextId, uint codecType, ulong flags, ulong outputAddress)
{
_ctx[CpuRegister.Rdi] = contextId;
@@ -202,6 +391,48 @@ public sealed class AjmExportsTests : IDisposable
return BinaryPrimitives.ReadUInt32LittleEndian(value);
}
+ private void InitializeBatch(ulong bufferAddress, ulong bufferSize, ulong infoAddress)
+ {
+ _ctx[CpuRegister.Rdi] = bufferAddress;
+ _ctx[CpuRegister.Rsi] = bufferSize;
+ _ctx[CpuRegister.Rdx] = infoAddress;
+ Assert.Equal(0, AjmExports.AjmBatchInitialize(_ctx));
+ }
+
+ ///
+ /// Places the seventh, eighth and ninth SysV arguments where the export reads
+ /// them: just past the return address slot at [rsp].
+ ///
+ private void WriteStackArgs(ulong outputCount, ulong sidebandAddress, ulong sidebandSize)
+ {
+ const ulong stackAddress = MemoryBase + 0xA00;
+ _ctx[CpuRegister.Rsp] = stackAddress;
+ WriteUInt64(stackAddress + 8, outputCount);
+ WriteUInt64(stackAddress + 16, sidebandAddress);
+ WriteUInt64(stackAddress + 24, sidebandSize);
+ }
+
+ private void WriteUInt64(ulong address, ulong value)
+ {
+ Span bytes = stackalloc byte[sizeof(ulong)];
+ BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
+ Assert.True(_memory.TryWrite(address, bytes));
+ }
+
+ private ulong ReadUInt64(ulong address)
+ {
+ Span value = stackalloc byte[sizeof(ulong)];
+ Assert.True(_memory.TryRead(address, value));
+ return BinaryPrimitives.ReadUInt64LittleEndian(value);
+ }
+
+ private byte[] ReadBytes(ulong address, int length)
+ {
+ var value = new byte[length];
+ Assert.True(_memory.TryRead(address, value));
+ return value;
+ }
+
private void WriteUInt32(ulong address, uint value)
{
Span bytes = stackalloc byte[sizeof(uint)];
diff --git a/tests/SharpEmu.Libs.Tests/GUI/GuiSettingsTests.cs b/tests/SharpEmu.Libs.Tests/GUI/GuiSettingsTests.cs
index baac02e2..ebfb7d11 100644
--- a/tests/SharpEmu.Libs.Tests/GUI/GuiSettingsTests.cs
+++ b/tests/SharpEmu.Libs.Tests/GUI/GuiSettingsTests.cs
@@ -30,6 +30,45 @@ public sealed class GuiSettingsTests
Assert.Empty(settings.GameFolders);
Assert.Empty(settings.ExcludedGames);
Assert.Empty(settings.EnvironmentToggles);
+ Assert.Equal("Windowed", settings.WindowMode);
+ Assert.Equal("1920x1080", settings.Resolution);
+ Assert.Equal("Fit", settings.ScalingMode);
+ Assert.Equal("Auto", settings.HdrMode);
+ Assert.True(settings.VSync);
+ }
+
+ [Fact]
+ public void NormalizeFromJson_InvalidVideoValues_FallBackAndClamp()
+ {
+ const string json = """
+ {
+ "WindowMode": "not-a-mode",
+ "Resolution": "not-a-resolution",
+ "ScalingMode": "nearest-ish",
+ "HdrMode": "maybe",
+ "DisplayIndex": -4,
+ "RefreshRate": 5000
+ }
+ """;
+
+ var settings = GuiSettings.NormalizeFromJson(json);
+
+ Assert.Equal("Windowed", settings.WindowMode);
+ Assert.Equal("1920x1080", settings.Resolution);
+ Assert.Equal("Fit", settings.ScalingMode);
+ Assert.Equal("Auto", settings.HdrMode);
+ Assert.Equal(0, settings.DisplayIndex);
+ Assert.Equal(1000, settings.RefreshRate);
+ }
+
+ [Fact]
+ public void NormalizeFromJson_CustomResolution_IsPreserved()
+ {
+ const string json = """{ "Resolution": "3440x1440" }""";
+
+ var settings = GuiSettings.NormalizeFromJson(json);
+
+ Assert.Equal("3440x1440", settings.Resolution);
}
[Fact]
@@ -97,4 +136,44 @@ public sealed class GuiSettingsTests
Assert.Empty(settings.ExcludedGames);
Assert.Empty(settings.EnvironmentToggles);
}
+
+ [Fact]
+ public void EffectiveLaunchSettings_PerGameVideoValuesOverrideOnlySelectedFields()
+ {
+ var global = new GuiSettings
+ {
+ WindowMode = "Windowed",
+ Resolution = "1920x1080",
+ DisplayIndex = 1,
+ RefreshRate = 60,
+ ScalingMode = "Fit",
+ HdrMode = "Auto",
+ VSync = true,
+ };
+ var perGame = new PerGameSettings
+ {
+ Resolution = "2560x1440",
+ DisplayIndex = 2,
+ HdrMode = "On",
+ VSync = false,
+ };
+
+ var effective = EffectiveLaunchSettings.Resolve(global, perGame);
+
+ Assert.Equal("Windowed", effective.WindowMode);
+ Assert.Equal("2560x1440", effective.Resolution);
+ Assert.Equal(2, effective.DisplayIndex);
+ Assert.Equal(60, effective.RefreshRate);
+ Assert.Equal("Fit", effective.ScalingMode);
+ Assert.Equal("On", effective.HdrMode);
+ Assert.False(effective.VSync);
+ }
+
+ [Fact]
+ public void PerGameSettings_VideoOverridesParticipateInEmptyCheck()
+ {
+ Assert.True(new PerGameSettings().IsEmpty);
+ Assert.False(new PerGameSettings { ScalingMode = "Integer" }.IsEmpty);
+ Assert.False(new PerGameSettings { HdrMode = "Off" }.IsEmpty);
+ }
}
diff --git a/tests/SharpEmu.Libs.Tests/GUI/HostDisplayOptionsTests.cs b/tests/SharpEmu.Libs.Tests/GUI/HostDisplayOptionsTests.cs
new file mode 100644
index 00000000..67e001c6
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/GUI/HostDisplayOptionsTests.cs
@@ -0,0 +1,51 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.GUI;
+using SharpEmu.Libs.VideoOut;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.GUI;
+
+public sealed class HostDisplayOptionsTests
+{
+ private static readonly HostDisplayInfo Display = new(
+ 1,
+ "Test monitor",
+ [
+ new HostDisplayMode(2560, 1440, 144),
+ new HostDisplayMode(2560, 1440, 60),
+ new HostDisplayMode(1920, 1080, 120),
+ new HostDisplayMode(1920, 1080, 60),
+ ]);
+
+ [Fact]
+ public void BuildDisplays_PreservesUnavailableSavedIndex()
+ {
+ var displays = HostDisplayOptions.BuildDisplays([Display], 3);
+
+ Assert.Equal([1, 3], displays.Select(display => display.Index));
+ Assert.Equal(3, HostDisplayOptions.SelectDisplay(displays, 3).Index);
+ }
+
+ [Fact]
+ public void BuildResolutions_DeduplicatesModesAndPreservesCustomValue()
+ {
+ var display = new HostDisplayOption(Display);
+
+ var resolutions = HostDisplayOptions.BuildResolutions(display, "3440x1440");
+
+ Assert.Equal(["3440x1440", "2560x1440", "1920x1080"], resolutions);
+ }
+
+ [Fact]
+ public void BuildRefreshRates_FiltersResolutionAndKeepsAutomaticFirst()
+ {
+ var display = new HostDisplayOption(Display);
+
+ var rates = HostDisplayOptions.BuildRefreshRates(display, "1920x1080", 75, "Automatic");
+
+ Assert.Equal([0, 120, 75, 60], rates.Select(rate => rate.Value));
+ Assert.Equal("Automatic", rates[0].Label);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelGameLogPathTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelGameLogPathTests.cs
new file mode 100644
index 00000000..90a2d5b0
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelGameLogPathTests.cs
@@ -0,0 +1,96 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.Libs.Kernel;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Kernel;
+
+[Collection(KernelMemoryCompatStateCollection.Name)]
+public sealed class KernelGameLogPathTests : IDisposable
+{
+ private readonly string? _previousHostappRoot =
+ Environment.GetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR");
+ private readonly string? _previousDevlogRoot =
+ Environment.GetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR");
+
+ public KernelGameLogPathTests()
+ {
+ Environment.SetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR", null);
+ Environment.SetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR", null);
+ KernelMemoryCompatExports.ConfigureApplicationInfo("ppsa/01:342");
+ }
+
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR", _previousHostappRoot);
+ Environment.SetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR", _previousDevlogRoot);
+ KernelMemoryCompatExports.ConfigureApplicationInfo(null);
+
+ var testRoot = Path.Combine(
+ AppContext.BaseDirectory,
+ "user",
+ "game_logs",
+ "PPSA_01_342");
+ if (Directory.Exists(testRoot))
+ {
+ Directory.Delete(testRoot, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void HostappUsesPerTitleGameLogDirectory()
+ {
+ var path = KernelMemoryCompatExports.ResolveGuestPath("/hostapp/logs/game.log");
+
+ Assert.Equal(
+ Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "user",
+ "game_logs",
+ "PPSA_01_342",
+ "hostapp",
+ "logs",
+ "game.log")),
+ path);
+ }
+
+ [Fact]
+ public void DevlogUsesPerTitleGameLogDirectory()
+ {
+ var path = KernelMemoryCompatExports.ResolveGuestPath("/devlog/app/debug.log");
+
+ Assert.Equal(
+ Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "user",
+ "game_logs",
+ "PPSA_01_342",
+ "devlog",
+ "app",
+ "debug.log")),
+ path);
+ }
+
+ [Fact]
+ public void ExplicitHostappOverrideIsPreserved()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N"));
+ try
+ {
+ Environment.SetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR", root);
+
+ Assert.Equal(
+ Path.Combine(Path.GetFullPath(root), "logs", "game.log"),
+ KernelMemoryCompatExports.ResolveGuestPath("/hostapp/logs/game.log"));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR", null);
+ if (Directory.Exists(root))
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Pad/HostWindowInputTests.cs b/tests/SharpEmu.Libs.Tests/Pad/HostWindowInputTests.cs
index 158c3071..d9e54f5b 100644
--- a/tests/SharpEmu.Libs.Tests/Pad/HostWindowInputTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Pad/HostWindowInputTests.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Pad;
+using SharpEmu.HLE.Host;
using Xunit;
namespace SharpEmu.Libs.Tests.Pad;
@@ -9,11 +10,161 @@ namespace SharpEmu.Libs.Tests.Pad;
public sealed class HostWindowInputTests
{
[Theory]
- [InlineData(-1.0f, 0)]
- [InlineData(0.0f, 128)]
- [InlineData(1.0f, 255)]
- public void ToStickByteMapsFullSilkRange(float value, int expected)
+ [InlineData(short.MinValue, 0)]
+ [InlineData(0, 128)]
+ [InlineData(short.MaxValue, 255)]
+ public void ToStickByteMapsFullSdlRange(short value, int expected)
{
Assert.Equal((byte)expected, HostWindowInput.ToStickByte(value));
}
+
+ [Fact]
+ public void ConnectPublishesWindowKeyboardState()
+ {
+ HostWindowInput.Connect();
+ try
+ {
+ HostWindowInput.SetKey(0x57, true);
+
+ var source = Assert.IsAssignableFrom(HostWindowInputSource.Current);
+ Assert.True(source.HasKeyboardFocus);
+ Assert.True(source.IsKeyDown(0x57));
+ }
+ finally
+ {
+ HostWindowInput.Disconnect();
+ }
+
+ Assert.Null(HostWindowInputSource.Current);
+ }
+
+ [Fact]
+ public void ConnectedGamepadStateIsExposedByWindowSource()
+ {
+ var expected = new HostGamepadState(
+ true,
+ HostGamepadButtons.Cross | HostGamepadButtons.Options,
+ 64,
+ 128,
+ 192,
+ 128,
+ 0,
+ 255);
+ HostWindowInput.Connect();
+ try
+ {
+ HostWindowInput.SetGamepad("SDL test pad", expected);
+ Span states = stackalloc HostGamepadState[1];
+
+ var source = Assert.IsAssignableFrom(HostWindowInputSource.Current);
+ Assert.Equal(1, source.GetGamepadStates(states));
+ Assert.Equal(expected, states[0]);
+ Assert.Equal("SDL test pad", source.DescribeConnectedGamepad());
+ }
+ finally
+ {
+ HostWindowInput.Disconnect();
+ }
+ }
+
+ [Fact]
+ public void ControllerOutputIsForwardedToSdlOwner()
+ {
+ var output = new RecordingGamepadOutput();
+ var effect = new HostAdaptiveTriggerEffect(
+ 0x21, 0x04, 0, 0x07, 0, 0, 0, 0, 0, 0, 0, 255);
+ HostWindowInput.Connect(output);
+ try
+ {
+ var source = Assert.IsAssignableFrom(HostWindowInputSource.Current);
+ source.SetRumble(10, 20);
+ source.SetTriggerRumble(30, 40);
+ source.SetAdaptiveTriggerEffect(effect, null);
+ source.SetLightbar(50, 60, 70);
+ source.ResetLightbar();
+
+ Assert.Equal((10, 20), output.Rumble);
+ Assert.Equal(((byte?)30, (byte?)40), output.TriggerRumble);
+ Assert.Equal(effect, output.LeftTriggerEffect);
+ Assert.Null(output.RightTriggerEffect);
+ Assert.Equal((50, 60, 70), output.Lightbar);
+ Assert.True(output.LightbarReset);
+ }
+ finally
+ {
+ HostWindowInput.Disconnect();
+ }
+ }
+
+ [Fact]
+ public void CommonHostInputUsesPublishedWindowSource()
+ {
+ var output = new RecordingGamepadOutput();
+ var expected = new HostGamepadState(
+ true,
+ HostGamepadButtons.Cross,
+ 32,
+ 64,
+ 96,
+ 128,
+ 160,
+ 192);
+ var input = new WindowHostInput();
+ HostWindowInput.Connect(output);
+ try
+ {
+ HostWindowInput.SetKey(0x57, true);
+ HostWindowInput.SetGamepad("SDL test pad", expected);
+ Span states = stackalloc HostGamepadState[1];
+
+ Assert.Equal(1, input.GetGamepadStates(states));
+ Assert.Equal(expected, states[0]);
+ Assert.Equal("SDL test pad", input.DescribeConnectedGamepad());
+ Assert.True(input.IsHostWindowFocused());
+ Assert.True(input.IsKeyDown(0x57));
+
+ input.SetRumble(10, 20);
+ input.SetLightbar(30, 40, 50);
+ Assert.Equal((10, 20), output.Rumble);
+ Assert.Equal((30, 40, 50), output.Lightbar);
+ }
+ finally
+ {
+ HostWindowInput.Disconnect();
+ }
+ }
+
+ private sealed class RecordingGamepadOutput : IHostGamepadOutput
+ {
+ public (byte Large, byte Small) Rumble { get; private set; }
+
+ public (byte? Left, byte? Right) TriggerRumble { get; private set; }
+
+ public HostAdaptiveTriggerEffect? LeftTriggerEffect { get; private set; }
+
+ public HostAdaptiveTriggerEffect? RightTriggerEffect { get; private set; }
+
+ public (byte Red, byte Green, byte Blue) Lightbar { get; private set; }
+
+ public bool LightbarReset { get; private set; }
+
+ public void SetRumble(byte largeMotor, byte smallMotor) =>
+ Rumble = (largeMotor, smallMotor);
+
+ public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) =>
+ TriggerRumble = (leftTrigger, rightTrigger);
+
+ public void SetAdaptiveTriggerEffect(
+ HostAdaptiveTriggerEffect? leftTrigger,
+ HostAdaptiveTriggerEffect? rightTrigger)
+ {
+ LeftTriggerEffect = leftTrigger;
+ RightTriggerEffect = rightTrigger;
+ }
+
+ public void SetLightbar(byte red, byte green, byte blue) =>
+ Lightbar = (red, green, blue);
+
+ public void ResetLightbar() => LightbarReset = true;
+ }
}
diff --git a/tests/SharpEmu.Libs.Tests/Pthread/PthreadMutexSemanticsTests.cs b/tests/SharpEmu.Libs.Tests/Pthread/PthreadMutexSemanticsTests.cs
index 90a8f65a..05661082 100644
--- a/tests/SharpEmu.Libs.Tests/Pthread/PthreadMutexSemanticsTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Pthread/PthreadMutexSemanticsTests.cs
@@ -198,6 +198,89 @@ public sealed class PthreadMutexSemanticsTests
Assert.Equal(workerCount * iterationsPerWorker, protectedCounter);
}
+ ///
+ /// A ScePthreadMutex variable is storage the guest owns and reuses — stack
+ /// frames recycle the slot, and a slot can be reassigned to another mutex —
+ /// so the handle the slot currently holds outranks anything cached under the
+ /// slot's own address. Resolving to the stale entry silently released the
+ /// wrong mutex and left the real one owned forever.
+ ///
+ [Fact]
+ public void ReusedHandleSlot_UnlockReleasesTheMutexTheSlotNowNames()
+ {
+ const ulong memoryBase = 0x1_0010_0000;
+ const ulong slotAddress = memoryBase + 0x100;
+ const ulong realMutexAddress = memoryBase + 0x200;
+ var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
+ var context = new CpuContext(memory, Generation.Gen5);
+
+ // First use of the slot happens while it still reads as zero, which
+ // implicitly registers a mutex keyed by the slot address itself.
+ Assert.True(context.TryWriteUInt64(slotAddress, 0));
+ context[CpuRegister.Rdi] = slotAddress;
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
+
+ // The frame is reused: the slot now holds a different, real handle.
+ context[CpuRegister.Rdi] = realMutexAddress;
+ context[CpuRegister.Rsi] = 0;
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexInit(context));
+ Assert.True(context.TryReadUInt64(realMutexAddress, out var realHandle));
+ Assert.NotEqual(0ul, realHandle);
+ Assert.True(context.TryWriteUInt64(slotAddress, realHandle));
+
+ // Locking by handle and releasing through the slot must hit one mutex.
+ context[CpuRegister.Rdi] = realHandle;
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
+
+ context[CpuRegister.Rdi] = slotAddress;
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
+
+ // Released for real: an unrelated acquisition now succeeds.
+ context[CpuRegister.Rdi] = realHandle;
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexTrylock(context));
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
+ }
+
+ [Fact]
+ public void ReusedHandleSlot_RecursiveMutexUnwindsThroughEitherAlias()
+ {
+ const ulong memoryBase = 0x1_0011_0000;
+ const ulong attrAddress = memoryBase + 0x100;
+ const ulong slotAddress = memoryBase + 0x200;
+ const ulong realMutexAddress = memoryBase + 0x300;
+ var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
+ var context = new CpuContext(memory, Generation.Gen5);
+
+ Assert.True(context.TryWriteUInt64(slotAddress, 0));
+ context[CpuRegister.Rdi] = slotAddress;
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
+
+ context[CpuRegister.Rdi] = attrAddress;
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexattrInit(context));
+ context[CpuRegister.Rsi] = 2; // Recursive.
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexattrSettype(context));
+
+ context[CpuRegister.Rdi] = realMutexAddress;
+ context[CpuRegister.Rsi] = attrAddress;
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexInit(context));
+ Assert.True(context.TryReadUInt64(realMutexAddress, out var realHandle));
+ Assert.True(context.TryWriteUInt64(slotAddress, realHandle));
+
+ context[CpuRegister.Rdi] = realHandle;
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
+
+ context[CpuRegister.Rdi] = slotAddress;
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
+
+ context[CpuRegister.Rdi] = realHandle;
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexTrylock(context));
+ Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
+ }
+
private sealed class AllocatingCpuMemory : ICpuMemory, IGuestMemoryAllocator
{
private readonly ulong _baseAddress;
diff --git a/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs b/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs
index 2dc1e8ab..9f6e7385 100644
--- a/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs
+++ b/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs
@@ -8,19 +8,20 @@ using Xunit;
namespace SharpEmu.Libs.Tests;
///
-/// Save data lives under ~/SharpEmu/Saves/<titleId>/<dirName>/ with UI
+/// Save data lives under user/savedata/<titleId>/<dirName>/ with UI
/// metadata in <slot>/sce_sys/param.json. These guard the pure path and
/// metadata logic that the SaveData HLE exports build on.
///
public sealed class SaveDataStorageTests
{
[Fact]
- public void RootHonorsOverrideAndFallsBackToUserProfile()
+ public void RootHonorsOverrideAndFallsBackToPortableDirectory()
{
Assert.Equal(Path.GetFullPath("/tmp/custom-saves"), SaveDataStorage.Root("/tmp/custom-saves"));
- var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
- Assert.Equal(Path.Combine(home, "SharpEmu", "Saves"), SaveDataStorage.Root());
+ Assert.Equal(
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "user", "savedata")),
+ SaveDataStorage.Root());
}
[Fact]
@@ -112,4 +113,36 @@ public sealed class SaveDataStorageTests
}
}
}
+
+ [Fact]
+ public void LegacyMigrationKeepsTheNewestSaveAndFlattensNumericUsers()
+ {
+ var testRoot = Path.Combine(Path.GetTempPath(), "sharpemu-savemigrate-" + Path.GetRandomFileName());
+ var destination = Path.Combine(testRoot, "portable");
+ var profile = Path.Combine(testRoot, "profile");
+ try
+ {
+ var stale = Path.Combine(destination, "268435456", "PPSA02929", "SAVEDATA00", "save.dat");
+ Directory.CreateDirectory(Path.GetDirectoryName(stale)!);
+ File.WriteAllText(stale, "stale");
+ File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddMinutes(-2));
+
+ var current = Path.Combine(profile, "PPSA02929", "SAVEDATA00", "save.dat");
+ Directory.CreateDirectory(Path.GetDirectoryName(current)!);
+ File.WriteAllText(current, "current");
+
+ SaveDataStorage.MigrateLegacyLayout(destination, profile);
+
+ Assert.Equal(
+ "current",
+ File.ReadAllText(Path.Combine(destination, "PPSA02929", "SAVEDATA00", "save.dat")));
+ }
+ finally
+ {
+ if (Directory.Exists(testRoot))
+ {
+ Directory.Delete(testRoot, recursive: true);
+ }
+ }
+ }
}
diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs
new file mode 100644
index 00000000..4d81175b
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs
@@ -0,0 +1,29 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.Libs.VideoOut;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.VideoOut;
+
+public sealed class HostVideoOptionsTests
+{
+ [Fact]
+ public void NormalizeClampsUnsafeHostValues()
+ {
+ var options = new HostVideoOptions
+ {
+ Width = 1,
+ Height = 99_999,
+ DisplayIndex = -2,
+ RefreshRate = 5000,
+ HdrMode = (HostHdrMode)99,
+ }.Normalize();
+
+ Assert.Equal(640, options.Width);
+ Assert.Equal(16384, options.Height);
+ Assert.Equal(0, options.DisplayIndex);
+ Assert.Equal(1000, options.RefreshRate);
+ Assert.Equal(HostHdrMode.Auto, options.HdrMode);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutPixelFormatTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutPixelFormatTests.cs
index cd6c9bf2..08a3bad4 100644
--- a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutPixelFormatTests.cs
+++ b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutPixelFormatTests.cs
@@ -97,6 +97,24 @@ public sealed class VideoOutPixelFormatTests
Assert.Equal(56u, InvokeMapPixelFormat(0xDEADBEEFUL));
}
+ [Theory]
+ [InlineData(0x88740000UL)]
+ [InlineData(0x8100070422000000UL)]
+ [InlineData(0x8100070400000000UL)]
+ public void IsHdrPixelFormat_PqFormats_ReturnTrue(ulong pixelFormat)
+ {
+ Assert.True(VideoOutExports.IsHdrPixelFormat(pixelFormat));
+ }
+
+ [Theory]
+ [InlineData(0x80000000UL)]
+ [InlineData(0x88060000UL)]
+ [InlineData(0x8100000622000000UL)]
+ public void IsHdrPixelFormat_SdrFormats_ReturnFalse(ulong pixelFormat)
+ {
+ Assert.False(VideoOutExports.IsHdrPixelFormat(pixelFormat));
+ }
+
// ---- Self-check activation ----
[Fact]
diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineCacheStorageTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineCacheStorageTests.cs
new file mode 100644
index 00000000..5b069388
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineCacheStorageTests.cs
@@ -0,0 +1,74 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.Libs.VideoOut;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.VideoOut;
+
+public sealed class VulkanPipelineCacheStorageTests
+{
+ [Fact]
+ public void ResolvePathUsesExecutableUserDirectoryAndTitleId()
+ {
+ var path = VulkanPipelineCacheStorage.ResolvePath("PPSA02929", configuredPath: null);
+
+ Assert.Equal(
+ Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "user",
+ "pipeline_cache",
+ "PPSA02929",
+ "vulkan-pipeline-cache.bin")),
+ path);
+ }
+
+ [Fact]
+ public void ResolvePathSanitizesTitleId()
+ {
+ var path = VulkanPipelineCacheStorage.ResolvePath("ppsa/02:929", configuredPath: null);
+
+ Assert.EndsWith(
+ Path.Combine("PPSA_02_929", "vulkan-pipeline-cache.bin"),
+ path,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ResolvePathPreservesConfiguredOverride()
+ {
+ var configured = Path.Combine(Path.GetTempPath(), "SharpEmuTests", "custom-cache.bin");
+
+ Assert.Equal(
+ Path.GetFullPath(configured),
+ VulkanPipelineCacheStorage.ResolvePath("PPSA02929", configured));
+ }
+
+ [Fact]
+ public void ImportLegacyCacheDoesNotOverwriteExistingTitleCache()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N"));
+ var legacyPath = Path.Combine(root, "legacy.bin");
+ var destinationPath = Path.Combine(root, "user", "pipeline_cache", "PPSA02929", "cache.bin");
+ try
+ {
+ Directory.CreateDirectory(root);
+ File.WriteAllBytes(legacyPath, [1, 2, 3]);
+
+ Assert.True(VulkanPipelineCacheStorage.ImportLegacyCache(legacyPath, destinationPath));
+ Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(destinationPath));
+ Assert.False(File.Exists(legacyPath));
+
+ File.WriteAllBytes(legacyPath, [4, 5, 6]);
+ Assert.False(VulkanPipelineCacheStorage.ImportLegacyCache(legacyPath, destinationPath));
+ Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(destinationPath));
+ }
+ finally
+ {
+ if (Directory.Exists(root))
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+ }
+}