Compare commits

...

6 Commits

Author SHA1 Message Date
Berk a3130e30ff chore: bump version to 0.0.3-hotfix-1 (#680) 2026-07-28 16:32:27 +03:00
Berk f095ed68a8 Fix ajm (#679)
* [audio] fixed demons souls audioout2 output path

* [video] restored texture cache bound and gated present diagnostics

* [cpu] made guest image cpu sync opt-in
2026-07-28 16:29:58 +03:00
ParantezTech ddcd285075 [reuse] correct license for AT9 decoder 2026-07-28 12:25:01 +03:00
Daniel Freak 6994538d87 [GUI] split shared theme into resource dictionaries (#669) 2026-07-28 12:16:43 +03:00
Berk 92e3abe752 chore: bump version to 0.0.3 (#671) 2026-07-28 03:38:49 +03:00
Berk 2b6bd5a532 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
2026-07-28 03:33:26 +03:00
122 changed files with 10257 additions and 4880 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<SharpEmuVersion>0.0.2-beta.5</SharpEmuVersion> <SharpEmuVersion>0.0.3-hotfix-1</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version> <Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot> <RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
+1 -2
View File
@@ -18,11 +18,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="NLayer" Version="1.14.0" /> <PackageVersion Include="NLayer" Version="1.14.0" />
<PackageVersion Include="Silk.NET.Input" Version="2.23.0" /> <PackageVersion Include="ppy.SDL3-CS" Version="2026.629.0" />
<PackageVersion Include="Silk.NET.Vulkan" Version="2.23.0" /> <PackageVersion Include="Silk.NET.Vulkan" Version="2.23.0" />
<PackageVersion Include="Silk.NET.Vulkan.Extensions.EXT" Version="2.23.0" /> <PackageVersion Include="Silk.NET.Vulkan.Extensions.EXT" Version="2.23.0" />
<PackageVersion Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.23.0" /> <PackageVersion Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.23.0" />
<PackageVersion Include="Silk.NET.Windowing" Version="2.23.0" />
<!-- Transitive of Avalonia.Desktop; pinned. Avalonia 12 requires 0.94.1+. --> <!-- Transitive of Avalonia.Desktop; pinned. Avalonia 12 requires 0.94.1+. -->
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.94.1" /> <PackageVersion Include="Tmds.DBus.Protocol" Version="0.94.1" />
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
+1 -1
View File
@@ -20,7 +20,7 @@ SPDX-FileCopyrightText = "SharpEmu Emulator Project"
SPDX-License-Identifier = "GPL-2.0-or-later" SPDX-License-Identifier = "GPL-2.0-or-later"
[[annotations]] [[annotations]]
path = "src/SharpEmu.GUI/Atrac9/**" path = "src/SharpEmu.LibAtrac9/**"
precedence = "aggregate" precedence = "aggregate"
SPDX-FileCopyrightText = "2018 Alex Barney" SPDX-FileCopyrightText = "2018 Alex Barney"
SPDX-License-Identifier = "MIT" SPDX-License-Identifier = "MIT"
+1
View File
@@ -5,6 +5,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Solution> <Solution>
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj" />
<Project Path="src/SharpEmu.CLI/SharpEmu.CLI.csproj" /> <Project Path="src/SharpEmu.CLI/SharpEmu.CLI.csproj" />
<Project Path="src/SharpEmu.Core/SharpEmu.Core.csproj" /> <Project Path="src/SharpEmu.Core/SharpEmu.Core.csproj" />
<Project Path="src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj" /> <Project Path="src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj" />
+270 -98
View File
@@ -8,6 +8,7 @@ using SharpEmu.HLE;
using SharpEmu.Libs.VideoOut; using SharpEmu.Libs.VideoOut;
using SharpEmu.Logging; using SharpEmu.Logging;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Loader;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@@ -45,6 +46,8 @@ internal static partial class Program
[STAThread] [STAThread]
private static int Main(string[] args) private static int Main(string[] args)
{ {
ConfigureManagedPluginResolution();
try try
{ {
return Run(args); 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) private static int Run(string[] args)
{ {
if (Updater.TryApply(args, out var updateExitCode)) if (Updater.TryApply(args, out var updateExitCode))
@@ -64,7 +86,6 @@ internal static partial class Program
} }
args = NormalizeInternalArguments(args, out var isMitigatedChild); args = NormalizeInternalArguments(args, out var isMitigatedChild);
PreloadGlfw();
if (args.Length == 0) if (args.Length == 0)
{ {
@@ -93,14 +114,9 @@ internal static partial class Program
PreloadMacVulkanLoader(); PreloadMacVulkanLoader();
} }
// GLFW requires window creation and event processing on the // SDL/AppKit window work belongs on the process main thread on
// process main thread: AppKit demands it on macOS, and X11 has a // macOS. Linux uses the same model for consistent X11/Wayland
// single event queue that must be serviced from the main thread // event ownership. Emulation remains on a worker 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.
var exitCode = 0; var exitCode = 0;
HostMainThread.Enable(); HostMainThread.Enable();
var emulation = new Thread(() => 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 /// 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 /// 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 /// osx-x64 under Rosetta 2 on Apple Silicon (Rosetta translates the
/// whole process, so it still reports as X64 here). An arm64 process /// whole process, so it still reports as X64 here). Failing up front on
/// (e.g. the osx-arm64 build) can browse the GUI but cannot run games; /// any other process architecture distinguishes that from MoltenVK,
/// failing up front distinguishes that from MoltenVK, signal-handler, /// signal-handler, or guest-memory startup problems.
/// or guest-memory startup problems.
/// </summary> /// </summary>
private static bool CheckHostArchitecture() private static bool CheckHostArchitecture()
{ {
@@ -178,11 +193,11 @@ internal static partial class Program
} }
/// <summary> /// <summary>
/// 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 /// Homebrew's Vulkan libraries are arm64-only and cannot load into this
/// x86-64 (Rosetta 2) process, so a universal libMoltenVK.dylib placed /// x86-64 (Rosetta 2) process, so a universal libMoltenVK.dylib placed
/// next to the executable (named libvulkan.1.dylib) is preloaded here; /// 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.
/// </summary> /// </summary>
private static void PreloadMacVulkanLoader() private static void PreloadMacVulkanLoader()
{ {
@@ -214,27 +229,6 @@ internal static partial class Program
"as libvulkan.1.dylib."); "as libvulkan.1.dylib.");
} }
/// <summary>
/// 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 <see cref="PreloadMacVulkanLoader"/> already relies on
/// for the Vulkan loader.
/// </summary>
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) private static int RunEmulator(string[] args, bool isMitigatedChild)
{ {
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args"); Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
@@ -244,17 +238,13 @@ internal static partial class Program
return childExitCode; return childExitCode;
} }
if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError)) if (!TryParseArguments(
{ args,
Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}"); out var ebootPath,
return 1; out var runtimeOptions,
} out var videoOptions,
out var logLevel,
HostSessionControl.SetEmbeddedHostSurface( out var logFilePath))
hostSurface?.WindowHandle ?? 0,
hostSurface?.DisplayHandle ?? 0);
if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
{ {
PrintUsage(); PrintUsage();
return 1; return 1;
@@ -266,6 +256,11 @@ internal static partial class Program
} }
SharpEmuLog.MinimumLevel = logLevel; 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(BuildInfo.Banner);
Log.Info(HostSystemInfo.Summary); Log.Info(HostSystemInfo.Summary);
@@ -309,12 +304,6 @@ internal static partial class Program
try 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); using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
OrbisGen2Result result; OrbisGen2Result result;
@@ -384,53 +373,9 @@ internal static partial class Program
debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult(); 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<string> args,
out string[] emulatorArgs,
out VulkanHostSurface? hostSurface,
out string? error)
{
const string hostSurfacePrefix = "--host-surface=";
var remaining = new List<string>(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() private static void EnsureCliConsole()
{ {
if (!OperatingSystem.IsWindows()) if (!OperatingSystem.IsWindows())
@@ -1042,7 +987,7 @@ internal static partial class Program
private static void PrintUsage() private static void PrintUsage()
{ {
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] [--debug-server[=host:port]] <path-to-eboot.bin>"); Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--debug-server[=host:port]] <path-to-eboot.bin>");
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin"""); 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."); 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, string[] args,
out string ebootPath, out string ebootPath,
out SharpEmuRuntimeOptions runtimeOptions, out SharpEmuRuntimeOptions runtimeOptions,
out HostVideoOptions videoOptions,
out LogLevel logLevel, out LogLevel logLevel,
out string? logFilePath) out string? logFilePath)
{ {
@@ -1094,6 +1040,7 @@ internal static partial class Program
{ {
ebootPath = string.Empty; ebootPath = string.Empty;
runtimeOptions = default; runtimeOptions = default;
videoOptions = HostVideoOptions.Default;
logLevel = SharpEmuLog.MinimumLevel; logLevel = SharpEmuLog.MinimumLevel;
logFilePath = null; logFilePath = null;
return false; return false;
@@ -1102,12 +1049,99 @@ internal static partial class Program
var strictDynlibResolution = false; var strictDynlibResolution = false;
var importTraceLimit = 0; var importTraceLimit = 0;
var cpuEngine = CpuExecutionEngine.NativeOnly; 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; logFilePath = null;
logLevel = SharpEmuLog.MinimumLevel; logLevel = SharpEmuLog.MinimumLevel;
var pathTokens = new List<string>(args.Length); var pathTokens = new List<string>(args.Length);
for (var i = 0; i < args.Length; i++) for (var i = 0; i < args.Length; i++)
{ {
var argument = args[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)) if (string.Equals(argument, "--strict", StringComparison.OrdinalIgnoreCase))
{ {
strictDynlibResolution = true; strictDynlibResolution = true;
@@ -1269,9 +1303,147 @@ internal static partial class Program
StrictDynlibResolution = strictDynlibResolution, StrictDynlibResolution = strictDynlibResolution,
ImportTraceLimit = importTraceLimit, 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; 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) private static bool TryParseCpuEngine(string valueText, out CpuExecutionEngine engine)
{ {
if (string.Equals(valueText, "native", StringComparison.OrdinalIgnoreCase) || if (string.Equals(valueText, "native", StringComparison.OrdinalIgnoreCase) ||
+29 -17
View File
@@ -77,35 +77,47 @@ SPDX-License-Identifier: GPL-2.0-or-later
<TargetPath>Languages\%(Filename)%(Extension)</TargetPath> <TargetPath>Languages\%(Filename)%(Extension)</TargetPath>
<Visible>False</Visible> <Visible>False</Visible>
</Content> </Content>
<Content Include="..\SharpEmu.LibAtrac9\LICENSE.txt">
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
<TargetPath>licenses\LibAtrac9.txt</TargetPath>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<Visible>False</Visible>
</Content>
</ItemGroup> </ItemGroup>
<!-- Native libraries (glfw, FFmpeg) publish into a subfolder next to the <Target Name="KeepLibAtrac9External" BeforeTargets="_ComputeFilesToBundle">
<ItemGroup>
<ResolvedFileToPublish Update="@(ResolvedFileToPublish)"
Condition="'%(Filename)%(Extension)' == 'SharpEmu.LibAtrac9.dll'">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<RelativePath>plugins\SharpEmu.LibAtrac9.dll</RelativePath>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
<!-- These are native debug symbols emitted by Skia/HarfBuzz, not managed
symbols that single-file publish can bundle. They are not needed at
runtime and would otherwise add more than 100 MB to every release. -->
<Target Name="RemoveNativeDebugSymbols" AfterTargets="Publish">
<ItemGroup>
<_NativeDebugSymbols Include="$(PublishDir)**\*.pdb" />
</ItemGroup>
<Delete Files="@(_NativeDebugSymbols)" />
</Target>
<!-- Native FFmpeg libraries publish into a subfolder next to the
executable instead of sitting loose beside it, so the publish executable instead of sitting loose beside it, so the publish
directory stays uncluttered as more native deps get added. The folder directory stays uncluttered as more native deps get added. The folder
name is a fixed constant, not derived from the RID/architecture: each name is a fixed constant, not derived from the RID/architecture: each
publish output only ever holds one architecture's binaries anyway, so publish output only ever holds one architecture's binaries anyway, so
varying the name added a class of bugs (RID resolution timing, host-OS varying the name added a class of bugs (RID resolution timing, host-OS
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
PreloadGlfw, FfmpegNativeBinkFrameSource's RootPath) uses the same FfmpegNativeBinkFrameSource uses the same literal "plugins" folder
literal "plugins" folder name. --> name. -->
<PropertyGroup> <PropertyGroup>
<NativeLibraryFolderName>plugins</NativeLibraryFolderName> <NativeLibraryFolderName>plugins</NativeLibraryFolderName>
</PropertyGroup> </PropertyGroup>
<!-- Keep glfw as a loose file in the native subfolder; every other native
library is embedded into the single-file bundle. -->
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
<ItemGroup>
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<RelativePath>$(NativeLibraryFolderName)/%(Filename)%(Extension)</RelativePath>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
<PropertyGroup> <PropertyGroup>
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag> <FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
<FfmpegRuntimeDir> <FfmpegRuntimeDir>
@@ -23,14 +23,71 @@ public sealed partial class DirectExecutionBackend
private static long _perfHleTotal; private static long _perfHleTotal;
private static long _perfHleDispatchTicks; private static long _perfHleDispatchTicks;
private sealed class PerfHleExportCost
{
public long Calls;
public long Ticks;
}
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, PerfHleExportCost> _perfHleCosts = new();
/// <summary>
/// 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.
/// </summary>
[System.ThreadStatic]
private static string? _perfHleCurrentExport;
private static long _perfHleFirstTimestamp;
private static void RecordPerfHleDispatchTime(long ticks) private static void RecordPerfHleDispatchTime(long ticks)
{ {
var total = System.Threading.Interlocked.Add(ref _perfHleDispatchTicks, ticks); var total = System.Threading.Interlocked.Add(ref _perfHleDispatchTicks, ticks);
var calls = System.Threading.Interlocked.Read(ref _perfHleTotal); 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) if (calls > 0 && calls % 500000 == 0)
{ {
var avgUs = (double)total / System.Diagnostics.Stopwatch.Frequency * 1_000_000.0 / calls; var frequency = (double)System.Diagnostics.Stopwatch.Frequency;
System.Console.Error.WriteLine($"[PERF][HLE] managed_dispatch_avg={avgUs:F3}us total_managed_s={(double)total / System.Diagnostics.Stopwatch.Frequency:F2}"); 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<System.Collections.Generic.KeyValuePair<string, PerfHleExportCost>>(_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) private static void RecordPerfHleCall(string name)
{ {
_perfHleCurrentExport = name;
var total = System.Threading.Interlocked.Increment(ref _perfHleTotal); var total = System.Threading.Interlocked.Increment(ref _perfHleTotal);
if (total == 1)
{
System.Threading.Interlocked.CompareExchange(
ref _perfHleFirstTimestamp,
System.Diagnostics.Stopwatch.GetTimestamp(),
0);
}
if (!_perfHleNoDict) if (!_perfHleNoDict)
{ {
_perfHleCounts.AddOrUpdate(name, 1, static (_, v) => v + 1); _perfHleCounts.AddOrUpdate(name, 1, static (_, v) => v + 1);
@@ -40,6 +40,15 @@ public sealed partial class DirectExecutionBackend
} }
_rawExceptionHandler = (nint)AddVectoredExceptionHandler(1u, _rawExceptionHandlerStub); _rawExceptionHandler = (nint)AddVectoredExceptionHandler(1u, _rawExceptionHandlerStub);
Console.Error.WriteLine($"[LOADER][INFO] Raw exception handler installed: 0x{_rawExceptionHandler:X16}"); 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 else
{ {
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<ulong, long> _guestRipSamples = new();
private readonly ConcurrentDictionary<string, long> _guestRipThreadSamples = new();
private readonly ConcurrentDictionary<string, long> _guestWaitSamples = new();
private readonly ConcurrentDictionary<string, long> _guestThreadWaitSamples = new();
private long _guestRipTotalSamples;
private long _guestWaitTotalSamples;
private long _guestRipCaptureFailures;
private long _guestRipSamplerErrors;
private int _guestRipSampleCursor;
/// <summary>
/// Names the HLE call a thread is parked in, using the guest RIP the import
/// dispatcher left on its context.
/// </summary>
private string ResolveWaitLabel(GuestThreadState thread)
{
var context = thread.Context;
if (context is null)
{
return "<no-context>";
}
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)
? "<idle-or-scheduler>"
: $"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) ? "<unnamed>" : 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) ? "<unnamed>" : 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<KeyValuePair<ulong, long>>(_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<ulong, long>();
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<KeyValuePair<string, long>>(_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<KeyValuePair<string, long>>(_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}%")));
}
/// <summary>
/// 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.
/// </summary>
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)";
}
}
@@ -54,10 +54,13 @@ public sealed partial class DirectExecutionBackend
var startTicks = System.Diagnostics.Stopwatch.GetTimestamp(); var startTicks = System.Diagnostics.Stopwatch.GetTimestamp();
var r = directExecutionBackend.DispatchImport(importIndex, argPackPtr); var r = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
RecordPerfHleDispatchTime(System.Diagnostics.Stopwatch.GetTimestamp() - startTicks); RecordPerfHleDispatchTime(System.Diagnostics.Stopwatch.GetTimestamp() - startTicks);
directExecutionBackend.ClearActiveImportIndex();
return r; return r;
} }
return directExecutionBackend.DispatchImport(importIndex, argPackPtr); var result = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
directExecutionBackend.ClearActiveImportIndex();
return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -69,11 +72,7 @@ public sealed partial class DirectExecutionBackend
private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo) private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo)
{ {
EXCEPTION_RECORD* exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord; if (TryHandleGuestImageWriteFault(exceptionInfo))
if (exceptionRecord->ExceptionCode == 3221225477u &&
exceptionRecord->NumberParameters >= 2 &&
SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
exceptionRecord->ExceptionInformation[1]))
{ {
return -1; return -1;
} }
@@ -81,6 +80,37 @@ public sealed partial class DirectExecutionBackend
return TryRecoverUnresolvedSentinel(exceptionInfo); return TryRecoverUnresolvedSentinel(exceptionInfo);
} }
/// <summary>
/// Windows counterpart of the POSIX SIGSEGV bridge into
/// <see cref="SharpEmu.HLE.GuestImageWriteTracker"/>. 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.
/// </summary>
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) private unsafe static int RawUnhandledFilterManaged(void* exceptionInfo)
{ {
return TryRecoverUnresolvedSentinel(exceptionInfo); return TryRecoverUnresolvedSentinel(exceptionInfo);
@@ -174,6 +204,10 @@ public sealed partial class DirectExecutionBackend
{ {
RecordPerfHleCall(importStubEntry.Export?.Name ?? importStubEntry.Nid); RecordPerfHleCall(importStubEntry.Export?.Name ?? importStubEntry.Nid);
} }
if (_profileGuestRip)
{
EnsureGuestRipSampler();
}
int num2 = Volatile.Read(in _rawSentinelRecoveries); int num2 = Volatile.Read(in _rawSentinelRecoveries);
if (num2 != _lastReportedRawSentinelRecoveries) if (num2 != _lastReportedRawSentinelRecoveries)
{ {
@@ -187,6 +221,10 @@ public sealed partial class DirectExecutionBackend
} }
cpuContext.Rip = importStubEntry.Address; cpuContext.Rip = importStubEntry.Address;
if (_profileGuestRip)
{
cpuContext.ActiveImportIndex = importIndex;
}
LoadImportVolatileArguments(cpuContext, argPackPtr); LoadImportVolatileArguments(cpuContext, argPackPtr);
cpuContext[CpuRegister.Rdi] = *(ulong*)argPackPtr; cpuContext[CpuRegister.Rdi] = *(ulong*)argPackPtr;
cpuContext[CpuRegister.Rsi] = *(ulong*)(argPackPtr + 8); cpuContext[CpuRegister.Rsi] = *(ulong*)(argPackPtr + 8);
@@ -1453,7 +1491,7 @@ public sealed partial class DirectExecutionBackend
string.Equals(nid, "fzyMKs9kim0", StringComparison.Ordinal) && string.Equals(nid, "fzyMKs9kim0", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT; result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
var expectedMutexTrylockBusy = var expectedMutexTrylockBusy =
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) && (nid is "K-jXhbt2gn4" or "upoVrzMHFeE") &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY; result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
var expectedSemaphoreTrywaitAgain = var expectedSemaphoreTrywaitAgain =
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) && string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
@@ -1470,6 +1508,9 @@ public sealed partial class DirectExecutionBackend
var expectedPrivacyInvalidParameter = var expectedPrivacyInvalidParameter =
string.Equals(nid, "D-CzAxQL0XI", StringComparison.Ordinal) && string.Equals(nid, "D-CzAxQL0XI", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80960009); resultValue == unchecked((int)0x80960009);
var expectedPlayGoChunkEnumerationEnd =
string.Equals(nid, "uWIYLFkkwqk", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80B2000C);
if (!expectedFileProbeMiss && if (!expectedFileProbeMiss &&
!expectedTimedWaitTimeout && !expectedTimedWaitTimeout &&
!expectedEqueueTimeout && !expectedEqueueTimeout &&
@@ -1478,7 +1519,8 @@ public sealed partial class DirectExecutionBackend
!expectedPollSemaBusy && !expectedPollSemaBusy &&
!expectedNetAcceptWouldBlock && !expectedNetAcceptWouldBlock &&
!expectedUserServiceNoEvent && !expectedUserServiceNoEvent &&
!expectedPrivacyInvalidParameter) !expectedPrivacyInvalidParameter &&
!expectedPlayGoChunkEnumerationEnd)
{ {
return true; return true;
} }
@@ -5314,7 +5314,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
var hostCpu = processorCount < 8 var hostCpu = processorCount < 8
? guestCpu % processorCount ? guestCpu % processorCount
: processorCount >= 16 : processorCount >= 16
? guestCpu * 2 ? MapGuestCpuAcrossSmtLanes(guestCpu, processorCount)
: guestCpu; : guestCpu;
if (hostCpu < processorCount) if (hostCpu < processorCount)
{ {
@@ -5325,6 +5325,45 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return hostAffinityMask; return hostAffinityMask;
} }
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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) public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority)
{ {
lock (_guestThreadGate) lock (_guestThreadGate)
@@ -143,6 +143,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
KernelModuleRegistry.Reset(); KernelModuleRegistry.Reset();
var image = LoadImage(normalizedEbootPath); var image = LoadImage(normalizedEbootPath);
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version); VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
KernelMemoryCompatExports.ConfigureApplicationInfo(image.TitleId);
SaveDataExports.ConfigureApplicationInfo(image.TitleId); SaveDataExports.ConfigureApplicationInfo(image.TitleId);
SystemServiceExports.ConfigureApplicationInfo(image.TitleId); SystemServiceExports.ConfigureApplicationInfo(image.TitleId);
_ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false); _ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
+14 -249
View File
@@ -1,267 +1,32 @@
<!-- <!--
Copyright (C) 2026 SharpEmu Emulator Project Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later SPDX-License-Identifier: GPL-2.0-or-later
Application composition root. Shared resources and styles are included in
cascade order so individual launcher views do not redefine global visuals.
--> -->
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharpEmu.GUI"
x:Class="SharpEmu.GUI.App" x:Class="SharpEmu.GUI.App"
RequestedThemeVariant="Dark"> RequestedThemeVariant="Dark">
<Application.Resources> <Application.Resources>
<Color x:Key="SystemAccentColor">#7C5CFC</Color> <ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%"> <ResourceInclude Source="avares://SharpEmu.GUI/Themes/Tokens.axaml" />
<GradientStop Offset="0" Color="#12151F" /> <ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/SettingRow.axaml" />
<GradientStop Offset="0.55" Color="#0D1017" /> </ResourceDictionary.MergedDictionaries>
<GradientStop Offset="1" Color="#0B0D14" /> </ResourceDictionary>
</LinearGradientBrush>
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
<SolidColorBrush x:Key="ElevatedBrush" Color="#1B2230" />
<SolidColorBrush x:Key="TextBrush" Color="#E8ECF4" />
<SolidColorBrush x:Key="MutedBrush" Color="#8B94A7" />
<SolidColorBrush x:Key="FaintBrush" Color="#5A6478" />
<SolidColorBrush x:Key="AccentBrush" Color="#7C5CFC" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#8F73FF" />
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
<ControlTheme x:Key="{x:Type local:SettingRow}" TargetType="local:SettingRow">
<Setter Property="Template">
<ControlTemplate>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="PART_Label" Text="{TemplateBinding Label}" FontSize="13" />
<TextBlock Text="{TemplateBinding Description}" FontSize="11"
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap"
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<ToggleSwitch OnContent="Override" OffContent="Override" MinWidth="0"
VerticalAlignment="Center"
IsVisible="{TemplateBinding ShowOverride}"
IsChecked="{Binding IsOverridden, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
<ContentPresenter x:Name="PART_Slot" Content="{TemplateBinding Content}" VerticalAlignment="Center" />
</StackPanel>
</Grid>
</ControlTemplate>
</Setter>
</ControlTheme>
</Application.Resources> </Application.Resources>
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<Style Selector="Window"> <StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Base.axaml" />
<Setter Property="FontFamily" Value="Inter, Segoe UI, sans-serif" /> <StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Surfaces.axaml" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" /> <StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Buttons.axaml" />
</Style> <StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Inputs.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Console.axaml" />
<Style Selector="Border.card"> <StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Library.axaml" />
<Setter Property="Background" Value="{StaticResource CardBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="16" />
</Style>
<Style Selector="Border.pill">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="CornerRadius" Value="999" />
<Setter Property="Padding" Value="10,3" />
</Style>
<!-- Session status/hotkey badges: the title-id pill geometry with a
tinted fill so state (RUNNING) and keys (F11) read at a glance. -->
<Style Selector="Border.badge">
<Setter Property="CornerRadius" Value="999" />
<Setter Property="Padding" Value="8,2" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Border.badge.running">
<Setter Property="Background" Value="#1E46C46B" />
<Setter Property="BorderBrush" Value="#5546C46B" />
</Style>
<Style Selector="Border.badge.key">
<Setter Property="Background" Value="#1E58A6FF" />
<Setter Property="BorderBrush" Value="#5558A6FF" />
</Style>
<Style Selector="TextBlock.sectionTitle">
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LetterSpacing" Value="1.5" />
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
</Style>
<Style Selector="TextBlock.fieldLabel">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
<Setter Property="Margin" Value="0,0,0,6" />
</Style>
<Style Selector="TextBox">
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.accent">
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Padding" Value="22,10" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentHoverBrush}" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.danger">
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Padding" Value="22,10" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource DangerHoverBrush}" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.ghost">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="Padding" Value="12,7" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ToggleButton.ghost">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="Padding" Value="12,7" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="ToggleButton.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ToggleButton.ghost:checked /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- Top-level page switcher (Library / Options): plain transparent
buttons, not TabItem, so there is no Fluent selected-tab underline.
The active page is conveyed by brightness alone; LB/RB gamepad
hints flank the pair. -->
<Style Selector="Button.segment">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
<Setter Property="FontSize" Value="22" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Padding" Value="6,4" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.segment:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Button.segment.active">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- Gamepad shoulder-button hint chip (LB/RB, L1/R1). -->
<Style Selector="Border.padHint">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="8,3" />
</Style>
<Style Selector="ContextMenu">
<Setter Property="Background" Value="{StaticResource CardBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="6" />
</Style>
<Style Selector="ContextMenu MenuItem">
<Setter Property="Padding" Value="10,7" />
<Setter Property="CornerRadius" Value="7" />
</Style>
<Style Selector="ContextMenu Separator">
<Setter Property="Background" Value="{StaticResource CardBorderBrush}" />
<Setter Property="Height" Value="1" />
<Setter Property="Margin" Value="8,4" />
</Style>
<Style Selector="ListBox.console">
<Setter Property="Background" Value="#0B0E14" />
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" />
<Setter Property="FontSize" Value="12" />
</Style>
<Style Selector="ListBox.console ListBoxItem">
<Setter Property="Padding" Value="10,1" />
<Setter Property="MinHeight" Value="0" />
</Style>
<!-- Cover-art library grid -->
<Style Selector="ListBox.tileGrid ListBoxItem">
<Setter Property="Padding" Value="10" />
<Setter Property="Margin" Value="5" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="RenderTransform" Value="translateY(0px)" />
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
</Transitions>
</Setter>
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover">
<Setter Property="RenderTransform" Value="translateY(-3px)" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" />
</Style>
<Style Selector="Border.coverShadow">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="BoxShadow" Value="0 6 14 0 #55000000" />
</Style>
<Style Selector="Border.coverClip">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="ClipToBounds" Value="True" />
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
</Style>
</Application.Styles> </Application.Styles>
</Application> </Application>
-620
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<VulkanHostSurface>? SurfaceAvailable;
public event EventHandler<VulkanHostSurface>? SurfaceDestroyed;
public VulkanHostSurface? Surface => _surface;
public void RefreshSurfaceSize() => UpdateSurfaceSize();
/// <summary>
/// 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.
/// </summary>
public void SetPresentationVisible(bool visible)
{
_presentationVisible = visible;
ApplyPresentationVisibility();
}
/// <summary>
/// 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.
/// </summary>
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<WndClassEx>(),
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);
}
+34
View File
@@ -50,6 +50,20 @@ public sealed class GuiSettings
public bool CheckForUpdatesOnStartup { get; set; } = true; 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";
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary> /// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
public List<string> EnvironmentToggles { get; set; } = new(); public List<string> EnvironmentToggles { get; set; } = new();
@@ -103,6 +117,12 @@ public sealed class GuiSettings
{ {
settings.RenderResolutionScale = 1.0; 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; return settings;
} }
@@ -118,6 +138,20 @@ public sealed class GuiSettings
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList(); 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() public void Save()
{ {
try try
+138
View File
@@ -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<HostDisplayMode> 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<HostDisplayOption> BuildDisplays(
IReadOnlyList<HostDisplayInfo> 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<HostDisplayOption> displays,
int selectedIndex) =>
displays.FirstOrDefault(display => display.Index == selectedIndex) ?? displays[0];
public static IReadOnlyList<string> 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<HostRefreshRateOption> 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<HostDisplayMode> CreateFallbackModes() =>
[
new HostDisplayMode(3840, 2160, 60),
new HostDisplayMode(2560, 1440, 60),
new HostDisplayMode(1920, 1080, 60),
new HostDisplayMode(1280, 720, 60),
];
}
+20
View File
@@ -41,6 +41,24 @@
"Options.Section.Emulation": "EMULATION", "Options.Section.Emulation": "EMULATION",
"Options.Section.Logging": "LOGGING", "Options.Section.Logging": "LOGGING",
"Options.Section.Launcher": "LAUNCHER", "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.Label": "CPU engine",
"Options.CpuEngine.Desc": "Execution engine used to run game code.", "Options.CpuEngine.Desc": "Execution engine used to run game code.",
@@ -87,6 +105,8 @@
"PerGame.Title": "Per-game settings — {0} ({1})", "PerGame.Title": "Per-game settings — {0} ({1})",
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.", "PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
"PerGame.Tab.General": "General",
"PerGame.Tab.Graphics": "Graphics",
"PerGame.EnvToggles.Label": "Environment toggles", "PerGame.EnvToggles.Label": "Environment toggles",
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.", "PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
+20
View File
@@ -29,6 +29,24 @@
"Options.Section.Emulation": "EMÜLASYON", "Options.Section.Emulation": "EMÜLASYON",
"Options.Section.Logging": "GÜNLÜKLEME", "Options.Section.Logging": "GÜNLÜKLEME",
"Options.Section.Launcher": "BAŞLATICI", "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.Label": "CPU motoru",
"Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.", "Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
@@ -159,6 +177,8 @@
"Common.Cancel": "İptal", "Common.Cancel": "İptal",
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})", "PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.", "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.Label": "Ortam anahtarları",
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.", "PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
"Options.About": "Hakkında", "Options.About": "Hakkında",
+49 -53
View File
@@ -60,12 +60,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<!-- Main content --> <!-- Main content -->
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto"> <Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
<!-- The game owns the full client area while running. Session controls
use a native popup so they can stay above this native child surface. -->
<Border x:Name="GameView" Grid.Row="0" Grid.RowSpan="4" IsVisible="False" Background="#000000" ClipToBounds="True">
<Grid x:Name="GameSurfaceContainer" />
</Border>
<!-- Library / Options page switcher, with the library toolbar sharing <!-- Library / Options page switcher, with the library toolbar sharing
the same row on the right. Plain buttons (not TabItem) so there is the same row on the right. Plain buttons (not TabItem) so there is
no underline; LB/RB hint chips flank the pair and the gamepad's no underline; LB/RB hint chips flank the pair and the gamepad's
@@ -408,7 +402,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15"> <TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left"> <StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
<Border Classes="card"> <Border Classes="card">
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" /> <TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
@@ -425,6 +418,46 @@ SPDX-License-Identifier: GPL-2.0-or-later
</local:SettingRow> </local:SettingRow>
</StackPanel> </StackPanel>
</Border> </Border>
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="DisplaySectionTitle" Classes="sectionTitle" Text="DISPLAY" />
<local:SettingRow x:Name="WindowModeRow" Label="Window mode" Description="Regular window, desktop borderless, or exclusive fullscreen.">
<ComboBox x:Name="WindowModeBox" Width="180" SelectedIndex="0" CornerRadius="8">
<ComboBoxItem Content="Windowed" />
<ComboBoxItem Content="Borderless" />
<ComboBoxItem Content="Exclusive" />
</ComboBox>
</local:SettingRow>
<local:SettingRow x:Name="ResolutionRow" Label="Resolution" Description="Initial window size or exclusive fullscreen resolution.">
<ComboBox x:Name="ResolutionBox" Width="180" CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="DisplayRow" Label="Display" Description="Monitor used for centering and fullscreen.">
<ComboBox x:Name="DisplayBox" Width="260" CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="RefreshRateRow" Label="Refresh rate" Description="Exclusive fullscreen refresh rate. Automatic selects the closest mode.">
<ComboBox x:Name="RefreshRateBox" Width="180" CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="ScalingRow" Label="Scaling" Description="Scale the native guest image without changing its internal resolution.">
<ComboBox x:Name="ScalingModeBox" Width="180" SelectedIndex="0" CornerRadius="8">
<ComboBoxItem Content="Fit" />
<ComboBoxItem Content="Cover" />
<ComboBoxItem Content="Stretch" />
<ComboBoxItem Content="Integer" />
</ComboBox>
</local:SettingRow>
<local:SettingRow x:Name="VSyncRow" Label="VSync" Description="Use FIFO presentation for tear-free output.">
<ToggleSwitch x:Name="VSyncToggle" IsChecked="True" OnContent="On" OffContent="Off" />
</local:SettingRow>
<local:SettingRow x:Name="HdrRow" Label="HDR" Description="Use HDR output when the selected display and graphics backend support it.">
<ComboBox x:Name="HdrModeBox" Width="180" SelectedIndex="0" CornerRadius="8">
<ComboBoxItem Content="Auto" />
<ComboBoxItem Content="On" />
<ComboBoxItem Content="Off" />
</ComboBox>
</local:SettingRow>
</StackPanel>
</Border>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
@@ -486,6 +519,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ToggleSwitch x:Name="EnvLogNpToggle" OnContent="On" OffContent="Off" <ToggleSwitch x:Name="EnvLogNpToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</local:SettingRow> </local:SettingRow>
<local:SettingRow x:Name="EnvGuestImageCpuSyncRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_GUEST_IMAGE_CPU_SYNC"
Description="Re-upload guest surfaces the game's own CPU code rewrites.&#10;Enabled by default for compatibility.&#10;Disable only for titles that regress with it, such as GTA V.">
<ToggleSwitch x:Name="EnvGuestImageCpuSyncToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</local:SettingRow>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -595,51 +634,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border> </Border>
</Grid> </Grid>
<!-- Avalonia's regular overlay layer cannot appear over a native child <!-- Keep launch progress above the blurred library while the SDL game
HWND/X11/Metal surface. Keep the running-session controls in a native process owns its independent top-level window. -->
popup so the game reaches the bottom status bar without losing Stop. -->
<primitives:Popup x:Name="SessionBarPopup"
IsOpen="False"
PlacementTarget="{Binding #GameView}"
Placement="Bottom"
VerticalOffset="-66"
Topmost="True"
ShouldUseOverlayLayer="False"
TakesFocusFromNativeControl="False"
IsLightDismissEnabled="False">
<Border Classes="card" Width="598" Height="58" CornerRadius="16" Padding="14,8">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock x:Name="SessionGameTitle" Text="GAME RUNNING" FontSize="13" FontWeight="SemiBold"
MaxWidth="240" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Classes="badge running" VerticalAlignment="Center">
<TextBlock Text="RUNNING" FontSize="9" FontWeight="Bold" LetterSpacing="1"
Foreground="{StaticResource SuccessBrush}" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="7">
<Border x:Name="SessionF11Badge" Classes="badge key" VerticalAlignment="Center">
<TextBlock Text="F11" FontSize="9" FontWeight="Bold"
Foreground="{StaticResource InfoBrush}" />
</Border>
<TextBlock x:Name="SessionHintText" Text="Fullscreen" FontSize="11"
Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button x:Name="SessionConsoleButton" Classes="ghost" Content="≡ Console" />
<Button x:Name="SessionStopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
</StackPanel>
</Grid>
</Border>
</primitives:Popup>
<!-- This is a native popup rather than an Avalonia overlay because the
emulated Vulkan surface is a native child window. -->
<!-- Anchored to MainContent, not GameView: the surface host is parked in
a 1x1 corner while loading/closing, which would pull a GameView-
anchored popup into the corner with it. -->
<primitives:Popup x:Name="SessionLoadingPopup" <primitives:Popup x:Name="SessionLoadingPopup"
IsOpen="False" IsOpen="False"
PlacementTarget="{Binding #MainContent}" PlacementTarget="{Binding #MainContent}"
@@ -653,7 +649,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" /> <TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12" <TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" /> Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<ProgressBar IsIndeterminate="True" Height="5" /> <ProgressBar x:Name="SessionLoadingProgress" IsIndeterminate="True" Height="5" />
</StackPanel> </StackPanel>
</Border> </Border>
</primitives:Popup> </primitives:Popup>
+236 -281
View File
@@ -16,7 +16,7 @@ using Avalonia.VisualTree;
using SharpEmu.Core.Cpu; using SharpEmu.Core.Cpu;
using SharpEmu.Core.Runtime; using SharpEmu.Core.Runtime;
using SharpEmu.HLE.Host; using SharpEmu.HLE.Host;
using SharpEmu.HLE.Host.Windows; using SharpEmu.Libs.Pad;
using SharpEmu.Libs.VideoOut; using SharpEmu.Libs.VideoOut;
using SharpEmu.Logging; using SharpEmu.Logging;
using System.Collections.Concurrent; using System.Collections.Concurrent;
@@ -62,18 +62,17 @@ public partial class MainWindow : Window
private bool _clearLibraryBlurWhenComplete; private bool _clearLibraryBlurWhenComplete;
private GuiSettings _settings = new(); private GuiSettings _settings = new();
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
private bool _updatingHostDisplayOptions;
private EmulatorProcess? _emulator; private EmulatorProcess? _emulator;
private GameSurfaceHost? _gameSurfaceHost;
private ConsoleWindow? _consoleWindow; private ConsoleWindow? _consoleWindow;
private GuiConsoleMirror? _consoleMirror; private GuiConsoleMirror? _consoleMirror;
private StreamWriter? _fileLog; private StreamWriter? _fileLog;
private readonly SndPreviewPlayer _sndPreview = new(); private readonly SndPreviewPlayer _sndPreview = new();
private string? _emulatorExePath; private string? _emulatorExePath;
private PendingLaunch? _pendingLaunch; private PendingLaunch? _pendingLaunch;
private bool _gameFullscreen;
private bool _isRunning; private bool _isRunning;
private bool _isStopping; private bool _isStopping;
private bool _awaitingFirstFrame;
private int _autoScrollTicks; private int _autoScrollTicks;
private int _activePageIndex; private int _activePageIndex;
private Updater.UpdateInfo? _availableUpdate; private Updater.UpdateInfo? _availableUpdate;
@@ -114,7 +113,7 @@ public partial class MainWindow : Window
string EbootPath, string EbootPath,
string DisplayName, string DisplayName,
string? TitleId, string? TitleId,
string LogLevel, EffectiveLaunchSettings Settings,
SharpEmuRuntimeOptions RuntimeOptions); SharpEmuRuntimeOptions RuntimeOptions);
public MainWindow() public MainWindow()
@@ -160,12 +159,10 @@ public partial class MainWindow : Window
// follow the launcher into the background or a minimized state. // follow the launcher into the background or a minimized state.
Activated += (_, _) => Activated += (_, _) =>
{ {
UpdateSessionBarVisibility();
SessionLoadingPopup.IsOpen = _sessionLoadingActive; SessionLoadingPopup.IsOpen = _sessionLoadingActive;
}; };
Deactivated += (_, _) => Deactivated += (_, _) =>
{ {
SessionBarPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = false; SessionLoadingPopup.IsOpen = false;
}; };
@@ -181,8 +178,6 @@ public partial class MainWindow : Window
LaunchButton.Click += (_, _) => LaunchSelected(); LaunchButton.Click += (_, _) => LaunchSelected();
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); }; ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
StopButton.Click += (_, _) => StopEmulator(); StopButton.Click += (_, _) => StopEmulator();
SessionStopButton.Click += (_, _) => StopEmulator();
SessionConsoleButton.Click += (_, _) => ShowConsoleWindow();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync(); CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow(); DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
LibraryTabButton.Click += (_, _) => SetActivePage(0); LibraryTabButton.Click += (_, _) => SetActivePage(0);
@@ -221,6 +216,13 @@ public partial class MainWindow : Window
}; };
AutoUpdateToggle.IsCheckedChanged += (_, _) => AutoUpdateToggle.IsCheckedChanged += (_, _) =>
_settings.CheckForUpdatesOnStartup = AutoUpdateToggle.IsChecked == true; _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(); UpdateButton.Click += async (_, _) => await OnUpdateButtonAsync();
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync(); SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
EnvBthidToggle.IsCheckedChanged += (_, _) => EnvBthidToggle.IsCheckedChanged += (_, _) =>
@@ -239,6 +241,10 @@ public partial class MainWindow : Window
SetEnvironmentToggle("SHARPEMU_LOG_IO", EnvLogIoToggle.IsChecked == true); SetEnvironmentToggle("SHARPEMU_LOG_IO", EnvLogIoToggle.IsChecked == true);
EnvLogNpToggle.IsCheckedChanged += (_, _) => EnvLogNpToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_LOG_NP", EnvLogNpToggle.IsChecked == true); SetEnvironmentToggle("SHARPEMU_LOG_NP", EnvLogNpToggle.IsChecked == true);
EnvGuestImageCpuSyncToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle(
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
EnvGuestImageCpuSyncToggle.IsChecked == true);
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged(); LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel); GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
@@ -255,8 +261,7 @@ public partial class MainWindow : Window
Opened += async (_, _) => await OnOpenedAsync(); Opened += async (_, _) => await OnOpenedAsync();
Closing += (_, _) => OnWindowClosing(); Closing += (_, _) => OnWindowClosing();
WindowsDualSenseReader.EnsureStarted(); SdlLauncherGamepad.EnsureStarted();
WindowsXInputReader.EnsureStarted();
_gamepadTimer = new DispatcherTimer _gamepadTimer = new DispatcherTimer
{ {
Interval = TimeSpan.FromMilliseconds(50), Interval = TimeSpan.FromMilliseconds(50),
@@ -427,8 +432,7 @@ public partial class MainWindow : Window
private void PollGamepad() private void PollGamepad()
{ {
// DualSense wins when both are connected; XInput covers Xbox pads. if (!SdlLauncherGamepad.TryGetState(out var pad))
if (!WindowsDualSenseReader.TryGetState(out var pad) && !WindowsXInputReader.TryGetState(out pad))
{ {
_previousPadButtons = HostGamepadButtons.None; _previousPadButtons = HostGamepadButtons.None;
return; return;
@@ -444,9 +448,8 @@ public partial class MainWindow : Window
if (_isRunning || _isStopping) if (_isRunning || _isStopping)
{ {
// The game renders inside the launcher window, so the launcher // The controller belongs to the separate game window while a
// stays active while playing. The controller belongs to the game // session is active; Circle/B must never stop the session.
// then: no navigation, and Circle/B must never stop the session.
_previousPadButtons = pad.Buttons; _previousPadButtons = pad.Buttons;
return; return;
} }
@@ -685,7 +688,25 @@ public partial class MainWindow : Window
AutoUpdateRow.Label = loc.Get("Updater.Auto.Label"); AutoUpdateRow.Label = loc.Get("Updater.Auto.Label");
AutoUpdateRow.Description = loc.Get("Updater.Auto.Desc"); 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.OnContent = loc.Get("Common.On");
toggle.OffContent = loc.Get("Common.Off"); toggle.OffContent = loc.Get("Common.Off");
@@ -758,91 +779,21 @@ public partial class MainWindow : Window
private void OnKeyDown(object sender, KeyEventArgs args) private void OnKeyDown(object sender, KeyEventArgs args)
{ {
args.Handled = true; if (args.Key == Key.F11 && !_isRunning)
switch (args.Key)
{ {
case Key.F11: WindowState = WindowState == WindowState.FullScreen
OnWindowFullScreen(this, new RoutedEventArgs()); ? WindowState.Maximized
break; : WindowState.FullScreen;
default: args.Handled = true;
args.Handled = false;
break;
} }
} }
private void OnPreviewKeyDown(object? sender, KeyEventArgs args) private void OnPreviewKeyDown(object? sender, KeyEventArgs args)
{ {
// While a session is on screen, Enter and Space are game input // The session runs in its own SDL window and takes keyboard focus with
// (Cross button). Keyboard focus stays on the launcher window, so a // it, so launcher buttons no longer see game input and nothing has to
// previously clicked, still-focused button (console toggle, session // be swallowed here. Kept as the wired handler because the launcher
// bar) would also activate and reshape the game view. Swallow the // still needs a preview hook for its own shortcuts.
// 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);
} }
private void OnWindowClosing() private void OnWindowClosing()
@@ -851,6 +802,7 @@ public partial class MainWindow : Window
_consoleFlushTimer.Stop(); _consoleFlushTimer.Stop();
_libraryBlurTimer.Stop(); _libraryBlurTimer.Stop();
_gamepadTimer.Stop(); _gamepadTimer.Stop();
SdlLauncherGamepad.Shutdown();
_sndPreview.Stop(); _sndPreview.Stop();
_discord?.Dispose(); _discord?.Dispose();
_consoleWindow?.Close(); _consoleWindow?.Close();
@@ -903,9 +855,141 @@ public partial class MainWindow : Window
EnvLogDirectMemoryToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_DIRECT_MEMORY"); EnvLogDirectMemoryToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_DIRECT_MEMORY");
EnvLogIoToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_IO"); EnvLogIoToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_IO");
EnvLogNpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_NP"); EnvLogNpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_NP");
EnvGuestImageCpuSyncToggle.IsChecked =
_settings.EnvironmentToggles.Contains("SHARPEMU_GUEST_IMAGE_CPU_SYNC");
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(); 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() private async Task OnUpdateButtonAsync()
{ {
if (_availableUpdate is null) if (_availableUpdate is null)
@@ -1795,16 +1879,23 @@ public partial class MainWindow : Window
// launcher process so every platform receives the same launch options. // launcher process so every platform receives the same launch options.
foreach (var staleName in _appliedEnvironmentVariables) 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); Environment.SetEnvironmentVariable(staleName, null);
} }
} }
_appliedEnvironmentVariables.Clear(); _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); _appliedEnvironmentVariables.Add(name);
} }
@@ -1828,7 +1919,6 @@ public partial class MainWindow : Window
_isRunning = true; _isRunning = true;
_runningGameName = displayName; _runningGameName = displayName;
SessionGameTitle.Text = displayName;
_runningGameTitleId = resolvedTitleId; _runningGameTitleId = resolvedTitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); _runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush; StatusDot.Fill = SuccessLineBrush;
@@ -1837,18 +1927,25 @@ public partial class MainWindow : Window
UpdateRunButtons(); UpdateRunButtons();
UpdateDiscordPresence(); UpdateDiscordPresence();
ShowGameView(); BeginSessionUi();
_pendingLaunch = new PendingLaunch( _pendingLaunch = new PendingLaunch(
Path.GetFullPath(ebootPath), Path.GetFullPath(ebootPath),
displayName, displayName,
_runningGameTitleId, _runningGameTitleId,
effective.LogLevel, effective,
runtimeOptions); runtimeOptions);
if (_gameSurfaceHost?.Surface is { } surface) StartPendingSession();
{ }
StartPendingSession(surface);
} 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;
} }
/// <summary> /// <summary>
@@ -1877,9 +1974,6 @@ public partial class MainWindow : Window
_isStopping = true; _isStopping = true;
StopButton.IsEnabled = false; 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..."); ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
_emulator.Stop(); _emulator.Stop();
_runningGameName = null; _runningGameName = null;
@@ -1887,7 +1981,6 @@ public partial class MainWindow : Window
StatusText.Text = Localization.Instance.Get("Launch.Stopping"); StatusText.Text = Localization.Instance.Get("Launch.Stopping");
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping"); StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
UpdateDiscordPresence(); UpdateDiscordPresence();
UpdateSessionBarVisibility();
ReturnToLibraryWhileStopping(); ReturnToLibraryWhileStopping();
} }
@@ -1930,8 +2023,7 @@ public partial class MainWindow : Window
_emulator?.Dispose(); _emulator?.Dispose();
_emulator = null; _emulator = null;
_pendingLaunch = null; _pendingLaunch = null;
DisposeGameSurfaceHost(); EndSessionUi();
HideGameView();
var meaningKey = exitCode switch var meaningKey = exitCode switch
{ {
@@ -1964,7 +2056,7 @@ public partial class MainWindow : Window
UpdateDiscordPresence(); UpdateDiscordPresence();
} }
private void StartPendingSession(VulkanHostSurface surface) private void StartPendingSession()
{ {
if (_pendingLaunch is not { } launch || _emulator is not null) if (_pendingLaunch is not { } launch || _emulator is not null)
{ {
@@ -1984,7 +2076,7 @@ public partial class MainWindow : Window
try try
{ {
var arguments = BuildEmulatorArguments(launch, surface); var arguments = BuildEmulatorArguments(launch);
_emulator = process; _emulator = process;
_pendingLaunch = null; _pendingLaunch = null;
process.Start( process.Start(
@@ -2006,12 +2098,12 @@ public partial class MainWindow : Window
} }
} }
private List<string> BuildEmulatorArguments(PendingLaunch launch, VulkanHostSurface surface) private List<string> BuildEmulatorArguments(PendingLaunch launch)
{ {
var arguments = new List<string> var arguments = new List<string>
{ {
"--cpu-engine=native", "--cpu-engine=native",
$"--log-level={launch.LogLevel}", $"--log-level={launch.Settings.LogLevel}",
}; };
if (launch.RuntimeOptions.StrictDynlibResolution) if (launch.RuntimeOptions.StrictDynlibResolution)
{ {
@@ -2022,16 +2114,13 @@ public partial class MainWindow : Window
arguments.Add($"--trace-imports={launch.RuntimeOptions.ImportTraceLimit}"); arguments.Add($"--trace-imports={launch.RuntimeOptions.ImportTraceLimit}");
} }
if (surface.TryGetChildProcessDescriptor(out var descriptor)) arguments.Add($"--window-mode={launch.Settings.WindowMode.ToLowerInvariant()}");
{ arguments.Add($"--resolution={launch.Settings.Resolution}");
arguments.Add($"--host-surface={descriptor}"); arguments.Add($"--display={launch.Settings.DisplayIndex}");
} arguments.Add($"--refresh-rate={launch.Settings.RefreshRate}");
else arguments.Add($"--scaling={launch.Settings.ScalingMode.ToLowerInvariant()}");
{ arguments.Add($"--vsync={(launch.Settings.VSync ? "on" : "off")}");
AppendConsoleLine( arguments.Add($"--hdr={launch.Settings.HdrMode.ToLowerInvariant()}");
"[GUI][WARN] Embedded child surfaces are unavailable on this platform; opening a game window instead.",
WarningLineBrush);
}
arguments.Add(launch.EbootPath); arguments.Add(launch.EbootPath);
return arguments; return arguments;
@@ -2040,8 +2129,8 @@ public partial class MainWindow : Window
private void OnEmulatorOutput(string line, bool isError) private void OnEmulatorOutput(string line, bool isError)
{ {
_pendingLines.Enqueue((line, isError)); _pendingLines.Enqueue((line, isError));
if (!line.Contains("[VIDEOOUT][INFO] Hosted splash ready.", StringComparison.Ordinal) && if (!line.Contains("Vulkan VideoOut presented first frame:", StringComparison.Ordinal) &&
!line.Contains("[VIDEOOUT][INFO] Hosted first frame presented.", StringComparison.Ordinal)) !line.Contains("Vulkan VideoOut ready:", StringComparison.Ordinal))
{ {
return; return;
} }
@@ -2050,143 +2139,25 @@ public partial class MainWindow : Window
{ {
if (_isRunning && !_isStopping) if (_isRunning && !_isStopping)
{ {
_awaitingFirstFrame = false; ShowSessionStatus("Game is running");
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);
});
} }
}); });
} }
private GameSurfaceHost EnsureGameSurfaceHost() private void BeginSessionUi()
{
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();
}
}
/// <summary>
/// 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.
/// </summary>
private void ParkGameViewOffscreen()
{
GameView.Margin = new Thickness(-20000, 0, 20000, 0);
}
private void RestoreGameViewToFull()
{
GameView.Margin = new Thickness(0);
}
private void ShowGameView()
{ {
_isStopping = false; _isStopping = false;
_awaitingFirstFrame = true;
var host = EnsureGameSurfaceHost();
ParkGameViewOffscreen();
GameView.IsVisible = true;
GameView.Background = Brushes.Transparent;
GameView.IsHitTestVisible = false;
host.SetPresentationVisible(false);
AnimateLibraryBlur(LaunchBlurRadius); AnimateLibraryBlur(LaunchBlurRadius);
SessionHintText.Text = "Fullscreen";
SessionF11Badge.IsVisible = true;
UpdateSessionBarVisibility();
ShowSessionLoading("Loading game", "Preparing the emulation session..."); 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(); HideSessionLoading();
AnimateLibraryBlur(0, clearWhenComplete: true); 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; LaunchBar.IsVisible = true;
LibraryPage.IsVisible = _activePageIndex == 0; ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
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;
} }
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false) private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
@@ -2258,7 +2229,20 @@ public partial class MainWindow : Window
private void ShowSessionLoading(string title, string detail) private void ShowSessionLoading(string title, string detail)
{ {
SessionLoadingTitle.Text = title; SessionLoadingTitle.Text = title;
SessionLoadingTitle.IsVisible = true;
SessionLoadingDetail.Text = detail; 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; _sessionLoadingActive = true;
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized; SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
} }
@@ -2271,33 +2255,11 @@ public partial class MainWindow : Window
private void ReturnToLibraryWhileStopping() 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); AnimateLibraryBlur(LaunchBlurRadius);
MainContent.Margin = new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = true;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null; ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true; 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(); 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) private void OpenFileLog(string? titleId)
@@ -2356,16 +2318,9 @@ public partial class MainWindow : Window
{ {
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry; LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
StopButton.IsEnabled = _isRunning && !_isStopping; StopButton.IsEnabled = _isRunning && !_isStopping;
SessionStopButton.IsEnabled = _isRunning && !_isStopping;
OpenFileButton.IsEnabled = !_isRunning; OpenFileButton.IsEnabled = !_isRunning;
} }
private void UpdateSessionBarVisibility()
{
SessionBarPopup.IsOpen = _isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible &&
!_gameFullscreen && WindowState != WindowState.FullScreen;
}
// ---- Console ---- // ---- Console ----
private void FlushPendingConsoleLines() private void FlushPendingConsoleLines()
+35
View File
@@ -21,6 +21,20 @@ public sealed class PerGameSettings
public bool? LogToFile { get; set; } 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<string>? EnvironmentToggles { get; set; } public List<string>? EnvironmentToggles { get; set; }
[JsonIgnore] [JsonIgnore]
@@ -29,6 +43,13 @@ public sealed class PerGameSettings
ImportTraceLimit is null && ImportTraceLimit is null &&
StrictDynlibResolution is null && StrictDynlibResolution is null &&
LogToFile 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; EnvironmentToggles is null;
public static string DirectoryPath => public static string DirectoryPath =>
@@ -116,6 +137,13 @@ public sealed record EffectiveLaunchSettings(
int ImportTraceLimit, int ImportTraceLimit,
bool StrictDynlibResolution, bool StrictDynlibResolution,
bool LogToFile, bool LogToFile,
string WindowMode,
string Resolution,
int DisplayIndex,
int RefreshRate,
string ScalingMode,
bool VSync,
string HdrMode,
IReadOnlyList<string> EnvironmentToggles) IReadOnlyList<string> EnvironmentToggles)
{ {
public static EffectiveLaunchSettings Resolve(GuiSettings global, PerGameSettings? perGame) => new( public static EffectiveLaunchSettings Resolve(GuiSettings global, PerGameSettings? perGame) => new(
@@ -123,5 +151,12 @@ public sealed record EffectiveLaunchSettings(
perGame?.ImportTraceLimit ?? global.ImportTraceLimit, perGame?.ImportTraceLimit ?? global.ImportTraceLimit,
perGame?.StrictDynlibResolution ?? global.StrictDynlibResolution, perGame?.StrictDynlibResolution ?? global.StrictDynlibResolution,
perGame?.LogToFile ?? global.LogToFile, 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); perGame?.EnvironmentToggles ?? global.EnvironmentToggles);
} }
+231 -10
View File
@@ -5,6 +5,7 @@ using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using SharpEmu.Libs.VideoOut;
namespace SharpEmu.GUI; namespace SharpEmu.GUI;
@@ -12,6 +13,9 @@ public sealed class PerGameSettingsDialog : Window
{ {
private static readonly string[] LogLevels = private static readonly string[] LogLevels =
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" }; { "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 = private static readonly string[] EnvToggles =
{ {
@@ -23,9 +27,12 @@ public sealed class PerGameSettingsDialog : Window
"SHARPEMU_LOG_DIRECT_MEMORY", "SHARPEMU_LOG_DIRECT_MEMORY",
"SHARPEMU_LOG_IO", "SHARPEMU_LOG_IO",
"SHARPEMU_LOG_NP", "SHARPEMU_LOG_NP",
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
}; };
private readonly string _titleId; private readonly string _titleId;
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
private bool _updatingHostDisplayOptions;
private readonly SettingRow _logLevelRow; private readonly SettingRow _logLevelRow;
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 }; private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
@@ -42,6 +49,27 @@ public sealed class PerGameSettingsDialog : Window
private readonly SettingRow _logToFileRow; private readonly SettingRow _logToFileRow;
private readonly ToggleSwitch _logToFile = new(); 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 SettingRow _envRow;
private readonly StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) }; 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(); private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
@@ -60,13 +88,20 @@ public sealed class PerGameSettingsDialog : Window
Background = new SolidColorBrush(Color.Parse("#0D1017")); Background = new SolidColorBrush(Color.Parse("#0D1017"));
_strict.OnContent = _logToFile.OnContent = loc.Get("Common.On"); _strict.OnContent = _logToFile.OnContent = _vsync.OnContent = loc.Get("Common.On");
_strict.OffContent = _logToFile.OffContent = loc.Get("Common.Off"); _strict.OffContent = _logToFile.OffContent = _vsync.OffContent = loc.Get("Common.Off");
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel); _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); _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); _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); _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 _envRow = new SettingRow
{ {
Label = loc.Get("PerGame.EnvToggles.Label"), Label = loc.Get("PerGame.EnvToggles.Label"),
@@ -81,6 +116,22 @@ public sealed class PerGameSettingsDialog : Window
_envList.Children.Add(box); _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) }; var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
content.Children.Add(new TextBlock content.Children.Add(new TextBlock
{ {
@@ -88,9 +139,14 @@ public sealed class PerGameSettingsDialog : Window
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")), Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
FontSize = 12, FontSize = 12,
}); });
content.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow)); content.Children.Add(new TabControl
content.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow)); {
content.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList)); 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 save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } }; var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
@@ -119,6 +175,8 @@ public sealed class PerGameSettingsDialog : Window
root.Children.Add(buttonBar); root.Children.Add(buttonBar);
Content = root; Content = root;
_displayIndex.SelectionChanged += (_, _) => OnHostDisplayChanged();
_resolution.SelectionChanged += (_, _) => OnHostResolutionChanged();
LoadValues(global); LoadValues(global);
_envRow.PropertyChanged += (_, e) => _envRow.PropertyChanged += (_, e) =>
{ {
@@ -154,16 +212,38 @@ public sealed class PerGameSettingsDialog : Window
private void LoadValues(GuiSettings global) 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"; _logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
_trace.Value = global.ImportTraceLimit; _trace.Value = global.ImportTraceLimit;
_strict.IsChecked = global.StrictDynlibResolution; _strict.IsChecked = global.StrictDynlibResolution;
_logToFile.IsChecked = global.LogToFile; _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) foreach (var (name, box) in _envBoxes)
{ {
box.IsChecked = global.EnvironmentToggles.Contains(name); box.IsChecked = IsEnvironmentEnabled(global.EnvironmentToggles, name, defaultValue: false);
} }
var existing = PerGameSettings.Load(_titleId);
if (existing is null) if (existing is null)
{ {
return; return;
@@ -178,16 +258,120 @@ public sealed class PerGameSettingsDialog : Window
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; } if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; } if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; } 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) if (existing.EnvironmentToggles is { } env)
{ {
_envRow.IsOverridden = true; _envRow.IsOverridden = true;
foreach (var (name, box) in _envBoxes) foreach (var (name, box) in _envBoxes)
{ {
box.IsChecked = env.Contains(name); box.IsChecked = IsEnvironmentEnabled(env, name, defaultValue: false);
} }
} }
} }
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() private void Persist()
{ {
var settings = new PerGameSettings var settings = new PerGameSettings
@@ -196,10 +380,47 @@ public sealed class PerGameSettingsDialog : Window
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null, ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null, StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null, LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
EnvironmentToggles = _envRow.IsOverridden WindowMode = _windowModeRow.IsOverridden ? _windowMode.SelectedItem as string : null,
? _envBoxes.Where(e => e.Box.IsChecked == true).Select(e => e.Name).ToList() Resolution = _resolutionRow.IsOverridden ? _resolution.SelectedItem as string : null,
DisplayIndex = _displayIndexRow.IsOverridden && _displayIndex.SelectedItem is HostDisplayOption display
? display.Index
: null, : 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); settings.Save(_titleId);
} }
private List<string> BuildEnvironmentEntries()
{
return _envBoxes
.Where(entry => entry.Box.IsChecked == true)
.Select(entry => entry.Name)
.ToList();
}
private static bool IsEnvironmentEnabled(
IEnumerable<string> 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;
}
} }
+3 -4
View File
@@ -9,16 +9,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
the executable is started without arguments. --> the executable is started without arguments. -->
<PropertyGroup> <PropertyGroup>
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- Required by the source-generated LibraryImport stubs in the linked
controller readers below. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<!-- Dependency-free; provides the BuildInfo provenance shown in the <!-- Dependency-free; provides the BuildInfo provenance shown in the
title bar. --> title bar. -->
<ItemGroup> <ItemGroup>
<!-- The GUI owns the native presentation control while each game runs in <!-- Games run in isolated SDL-window processes; the GUI owns launch and
an isolated emulator process. --> session controls only. -->
<ProjectReference Include="..\SharpEmu.LibAtrac9\SharpEmu.LibAtrac9.csproj" />
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" /> <ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
<ProjectReference Include="..\SharpEmu.Libs\SharpEmu.Libs.csproj" /> <ProjectReference Include="..\SharpEmu.Libs\SharpEmu.Libs.csproj" />
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" /> <ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
+26
View File
@@ -0,0 +1,26 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Window and text defaults shared by all launcher views.
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Window">
<Setter Property="FontFamily" Value="Inter, Segoe UI, sans-serif" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="TextBlock.sectionTitle">
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LetterSpacing" Value="1.5" />
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
</Style>
<Style Selector="TextBlock.fieldLabel">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
<Setter Property="Margin" Value="0,0,0,6" />
</Style>
</Styles>
@@ -0,0 +1,84 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Shared launcher button variants and page switcher styles.
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Button.accent">
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Padding" Value="22,10" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentHoverBrush}" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.danger">
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Padding" Value="22,10" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource DangerHoverBrush}" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.ghost">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="Padding" Value="12,7" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ToggleButton.ghost">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="Padding" Value="12,7" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="ToggleButton.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ToggleButton.ghost:checked /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- Top-level page switcher (Library / Options): plain transparent
buttons, not TabItem, so there is no Fluent selected-tab underline.
The active page is conveyed by brightness alone; LB/RB gamepad
hints flank the pair. -->
<Style Selector="Button.segment">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
<Setter Property="FontSize" Value="22" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Padding" Value="6,4" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.segment:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Button.segment.active">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
</Styles>
@@ -0,0 +1,18 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Console list typography and compact item spacing.
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="ListBox.console">
<Setter Property="Background" Value="#0B0E14" />
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" />
<Setter Property="FontSize" Value="12" />
</Style>
<Style Selector="ListBox.console ListBoxItem">
<Setter Property="Padding" Value="10,1" />
<Setter Property="MinHeight" Value="0" />
</Style>
</Styles>
@@ -0,0 +1,29 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Shared text input and context-menu styles.
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="TextBox">
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="ContextMenu">
<Setter Property="Background" Value="{StaticResource CardBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="6" />
</Style>
<Style Selector="ContextMenu MenuItem">
<Setter Property="Padding" Value="10,7" />
<Setter Property="CornerRadius" Value="7" />
</Style>
<Style Selector="ContextMenu Separator">
<Setter Property="Background" Value="{StaticResource CardBorderBrush}" />
<Setter Property="Height" Value="1" />
<Setter Property="Margin" Value="8,4" />
</Style>
</Styles>
@@ -0,0 +1,37 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Cover-art library item states and motion.
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="ListBox.tileGrid ListBoxItem">
<Setter Property="Padding" Value="10" />
<Setter Property="Margin" Value="5" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="RenderTransform" Value="translateY(0px)" />
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
</Transitions>
</Setter>
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover">
<Setter Property="RenderTransform" Value="translateY(-3px)" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" />
</Style>
</Styles>
@@ -0,0 +1,55 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Shared card, badge, hint and cover surfaces.
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Border.card">
<Setter Property="Background" Value="{StaticResource CardBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="16" />
</Style>
<Style Selector="Border.pill">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="CornerRadius" Value="999" />
<Setter Property="Padding" Value="10,3" />
</Style>
<!-- Session status/hotkey badges: the title-id pill geometry with a
tinted fill so state (RUNNING) and keys (F11) read at a glance. -->
<Style Selector="Border.badge">
<Setter Property="CornerRadius" Value="999" />
<Setter Property="Padding" Value="8,2" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Border.badge.running">
<Setter Property="Background" Value="#1E46C46B" />
<Setter Property="BorderBrush" Value="#5546C46B" />
</Style>
<Style Selector="Border.badge.key">
<Setter Property="Background" Value="#1E58A6FF" />
<Setter Property="BorderBrush" Value="#5558A6FF" />
</Style>
<!-- Gamepad shoulder-button hint chip (LB/RB, L1/R1). -->
<Style Selector="Border.padHint">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="8,3" />
</Style>
<Style Selector="Border.coverShadow">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="BoxShadow" Value="0 6 14 0 #55000000" />
</Style>
<Style Selector="Border.coverClip">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="ClipToBounds" Value="True" />
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
</Style>
</Styles>
@@ -0,0 +1,34 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Control theme for the shared launcher settings row.
-->
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharpEmu.GUI">
<ControlTheme x:Key="{x:Type local:SettingRow}" TargetType="local:SettingRow">
<Setter Property="Template">
<ControlTemplate>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="PART_Label" Text="{TemplateBinding Label}" FontSize="13" />
<TextBlock Text="{TemplateBinding Description}" FontSize="11"
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap"
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<ToggleSwitch OnContent="Override" OffContent="Override" MinWidth="0"
VerticalAlignment="Center"
IsVisible="{TemplateBinding ShowOverride}"
IsChecked="{Binding IsOverridden, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
<ContentPresenter x:Name="PART_Slot"
Content="{TemplateBinding Content}"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>
+32
View File
@@ -0,0 +1,32 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Shared colors and brushes used throughout the launcher.
-->
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
<GradientStop Offset="0" Color="#12151F" />
<GradientStop Offset="0.55" Color="#0D1017" />
<GradientStop Offset="1" Color="#0B0D14" />
</LinearGradientBrush>
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
<SolidColorBrush x:Key="ElevatedBrush" Color="#1B2230" />
<SolidColorBrush x:Key="TextBrush" Color="#E8ECF4" />
<SolidColorBrush x:Key="MutedBrush" Color="#8B94A7" />
<SolidColorBrush x:Key="FaintBrush" Color="#5A6478" />
<SolidColorBrush x:Key="AccentBrush" Color="#7C5CFC" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#8F73FF" />
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
</ResourceDictionary>
+9
View File
@@ -20,6 +20,15 @@ public sealed class CpuContext(ICpuMemory memory, Generation generation)
public ulong Rip { get; set; } public ulong Rip { get; set; }
/// <summary>
/// Index of the import this context is currently executing, or -1 when it is
/// running guest code. Only maintained while guest profiling is enabled;
/// <see cref="Rip"/> alone cannot answer "what is this thread inside right
/// now" because it keeps pointing at the last import stub after the call
/// returns.
/// </summary>
public int ActiveImportIndex { get; set; } = -1;
public ulong Rflags { get; set; } public ulong Rflags { get; set; }
public ulong FsBase { get; set; } public ulong FsBase { get; set; }
+4 -10
View File
@@ -87,17 +87,11 @@ public static unsafe class GuestImageWriteTracker
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty; 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).
private static readonly bool _enabled = private static readonly bool _enabled =
OperatingSystem.IsWindows() string.Equals(
? string.Equals( Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"),
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"), "1",
"1", StringComparison.Ordinal);
StringComparison.Ordinal)
: Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
private static readonly (bool Wildcard, ulong[] Addresses) _lifetimeTraceFilter = private static readonly (bool Wildcard, ulong[] Addresses) _lifetimeTraceFilter =
ParseAddressList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS")); ParseAddressList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
private static readonly (bool Wildcard, string[] Sources) _lifetimeSourceTraceFilter = private static readonly (bool Wildcard, string[] Sources) _lifetimeSourceTraceFilter =
+69
View File
@@ -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;
/// <summary>
/// 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 <see cref="Stopwatch"/>.
///
/// 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.
/// </summary>
public static class GuestAudioClock
{
private static long _playedMicroseconds;
private static long _lastAdvanceTimestamp;
/// <summary>Seconds of guest audio the device has played. Monotonic.</summary>
public static double PlayedSeconds =>
Interlocked.Read(ref _playedMicroseconds) / 1_000_000.0;
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
+90 -1
View File
@@ -28,8 +28,44 @@ public enum HostGamepadButtons : uint
R3 = 1 << 13, R3 = 1 << 13,
Options = 1 << 14, Options = 1 << 14,
TouchPad = 1 << 15, 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);
/// <summary> /// <summary>
/// Snapshot of one host gamepad: sticks are 0..255 with 128 centered and Y growing /// 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 /// 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 RightX,
byte RightY, byte RightY,
byte LeftTrigger, byte LeftTrigger,
byte RightTrigger); byte RightTrigger,
HostGamepadType Type = HostGamepadType.Generic,
HostGamepadConnection Connection = HostGamepadConnection.Unknown,
HostMotionState Motion = default,
HostTouchState Touch = default,
byte BatteryPercent = 0);
/// <summary>A complete 11-byte DualSense adaptive-trigger command.</summary>
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<byte> 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<byte> 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;
}
}
+13
View File
@@ -15,4 +15,17 @@ public interface IHostAudioStream : IDisposable
/// audio, in which case the caller paces the guest itself. /// audio, in which case the caller paces the guest itself.
/// </summary> /// </summary>
bool Submit(ReadOnlySpan<byte> stereoPcm16); bool Submit(ReadOnlySpan<byte> stereoPcm16);
/// <summary>
/// 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.
/// </summary>
int QueuedMilliseconds => -1;
} }
+5
View File
@@ -32,6 +32,11 @@ public interface IHostInput
/// </summary> /// </summary>
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger); void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
/// <summary>Applies native DualSense trigger effects when supported.</summary>
void SetAdaptiveTriggerEffect(
HostAdaptiveTriggerEffect? leftTrigger,
HostAdaptiveTriggerEffect? rightTrigger);
void SetLightbar(byte red, byte green, byte blue); void SetLightbar(byte red, byte green, byte blue);
void ResetLightbar(); void ResetLightbar();
@@ -0,0 +1,19 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host;
/// <summary>
/// Optional host-audio extension for backends that can accept the guest's
/// interleaved PCM layout directly and perform device conversion themselves.
/// </summary>
public interface IHostPcmAudioOutput : IHostAudioOutput
{
IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format);
}
public enum HostPcmFormat
{
Signed16,
Float32,
}
@@ -0,0 +1,42 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host;
/// <summary>Input snapshots produced by the active host window.</summary>
public interface IHostWindowInputSource
{
bool HasKeyboardFocus { get; }
bool IsKeyDown(int virtualKey);
int GetGamepadStates(Span<HostGamepadState> 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();
}
/// <summary>Process-wide bridge between the window layer and host input.</summary>
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);
}
@@ -1,186 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host.Posix;
/// <summary>
/// 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
/// <see cref="SetSource"/> 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.
/// </summary>
public interface IPosixWindowInputSource
{
/// <summary>True while the window's keyboard is delivering events.</summary>
bool HasKeyboardFocus { get; }
/// <summary>Windows virtual-key semantics; the source translates.</summary>
bool IsKeyDown(int virtualKey);
/// <summary>Same contract as <see cref="IHostInput.GetGamepadStates"/>.</summary>
int GetGamepadStates(Span<HostGamepadState> 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;
/// <summary>Called by the presenter's window layer when input is ready.</summary>
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<HostGamepadState> 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);
}
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host.Sdl;
namespace SharpEmu.HLE.Host.Posix; namespace SharpEmu.HLE.Host.Posix;
internal sealed class PosixHostPlatform : IHostPlatform internal sealed class PosixHostPlatform : IHostPlatform
@@ -11,7 +13,7 @@ internal sealed class PosixHostPlatform : IHostPlatform
public IHostSymbolResolver Symbols { get; } = new PosixHostSymbolResolver(); 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();
} }
+325
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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";
/// <summary>
/// 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.
/// </summary>
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
=> OpenStream(
sampleRate,
channels: 2,
HostPcmFormat.Signed16,
maxQueuedPcmBytes > 0 ? maxQueuedPcmBytes : 32 * 1024);
/// <summary>
/// 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.
/// </summary>
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<byte> 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;
}
}
/// <summary>
/// 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 <see cref="_gate"/>.
/// </summary>
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;
}
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host;
/// <summary>
/// Routes emulated input through the active cross-platform host window.
/// </summary>
internal sealed class WindowHostInput : IHostInput
{
public void EnsureStarted()
{
// SDL owns device discovery and pumps it on the window thread.
}
public int GetGamepadStates(Span<HostGamepadState> 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;
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
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;
}
}
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
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<byte> 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<byte> 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<byte> 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,
};
}
@@ -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;
/// <summary>
/// Minimal Win32 HID interop used to talk to a DualSense controller
/// directly, without any external input library.
/// </summary>
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);
/// <summary>
/// Enumerates the device paths of all present HID interfaces.
/// </summary>
internal static List<string> EnumerateHidDevicePaths()
{
var paths = new List<string>();
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<SpDeviceInterfaceData>(),
};
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;
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
internal sealed partial class WindowsHostInput : IHostInput
{
public void EnsureStarted()
{
WindowsDualSenseReader.EnsureStarted();
WindowsXInputReader.EnsureStarted();
}
public int GetGamepadStates(Span<HostGamepadState> 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;
}
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host.Sdl;
namespace SharpEmu.HLE.Host.Windows; namespace SharpEmu.HLE.Host.Windows;
internal sealed class WindowsHostPlatform : IHostPlatform internal sealed class WindowsHostPlatform : IHostPlatform
@@ -11,7 +13,7 @@ internal sealed class WindowsHostPlatform : IHostPlatform
public IHostSymbolResolver Symbols { get; } = new WindowsHostSymbolResolver(); 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();
} }
@@ -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;
/// <summary>
/// Reads Xbox 360 / Xbox One (and other XInput-compatible) controllers via
/// the Windows XInput API on a background thread, translated to
/// <see cref="HostGamepadState"/> conventions. Supports rumble and hot-plug
/// retry; the first connected slot (of four) is used.
/// </summary>
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;
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
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;
}
}
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
internal static void SetRumble(byte largeMotor, byte smallMotor)
{
lock (Gate)
{
if (_motorLeft == largeMotor && _motorRight == smallMotor)
{
return;
}
_motorLeft = largeMotor;
_motorRight = smallMotor;
SendRumbleLocked();
}
}
/// <summary>Approximates per-trigger vibration on the two XInput body motors.</summary>
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);
}
+2 -2
View File
@@ -6,8 +6,8 @@ using System.Collections.Concurrent;
namespace SharpEmu.HLE; namespace SharpEmu.HLE;
/// <summary> /// <summary>
/// Runs work on the real process main thread. macOS only allows AppKit (and /// Runs work on the real process main thread. macOS requires its windowing
/// therefore GLFW windowing) on that thread, so the CLI moves emulation onto /// event loop on that thread, so the CLI moves emulation onto
/// a worker thread, parks the main thread in <see cref="Pump"/>, and the /// a worker thread, parks the main thread in <see cref="Pump"/>, and the
/// video presenter posts its window loop here. On other platforms /// video presenter posts its window loop here. On other platforms
/// <see cref="IsAvailable"/> stays false and nothing changes. /// <see cref="IsAvailable"/> stays false and nothing changes.
-17
View File
@@ -12,8 +12,6 @@ public static class HostSessionControl
private static Action<string>? _shutdownHandler; private static Action<string>? _shutdownHandler;
private static string? _pendingShutdownReason; private static string? _pendingShutdownReason;
private static int _shutdownRequested; private static int _shutdownRequested;
private static long _embeddedHostWindow;
private static long _embeddedHostDisplay;
/// <summary> /// <summary>
/// Indicates that the active host session is being stopped. Runtime code /// Indicates that the active host session is being stopped. Runtime code
@@ -22,21 +20,6 @@ public static class HostSessionControl
/// </summary> /// </summary>
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0; public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
/// <summary>
/// Native GUI surface used by an isolated emulator child. Input backends
/// use it to treat the launcher window as the active game window.
/// </summary>
public static nint EmbeddedHostWindow => unchecked((nint)Interlocked.Read(ref _embeddedHostWindow));
/// <summary>X11 Display* paired with <see cref="EmbeddedHostWindow"/> when available.</summary>
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));
}
/// <summary> /// <summary>
/// Starts a fresh session after the previous guest has fully left its /// Starts a fresh session after the previous guest has fully left its
/// execution backend. /// execution backend.
+4
View File
@@ -21,6 +21,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" /> <ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.SDL3-CS" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Forces build ordering for the aerolib task below; loaded as a build component, <!-- Forces build ordering for the aerolib task below; loaded as a build component,
never a runtime dependency. --> never a runtime dependency. -->
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System.IO; using System.IO;
using LibAtrac9.Utilities; using LibAtrac9.Utilities;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using LibAtrac9.Utilities; using LibAtrac9.Utilities;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9 namespace LibAtrac9
{ {
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9 namespace LibAtrac9
{ {
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using LibAtrac9.Utilities; using LibAtrac9.Utilities;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9 namespace LibAtrac9
{ {
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9 namespace LibAtrac9
{ {
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using LibAtrac9.Utilities; using LibAtrac9.Utilities;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9 namespace LibAtrac9
{ {
@@ -1350,4 +1351,4 @@ namespace LibAtrac9
new byte[] {0, 0, 0, 0} new byte[] {0, 0, 0, 0}
}; };
} }
} }
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using System.IO; using System.IO;
@@ -0,0 +1,12 @@
<!--
Copyright (C) 2018 VGMToolbox Project
SPDX-License-Identifier: MIT
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>SharpEmu.LibAtrac9</AssemblyName>
<RootNamespace>LibAtrac9</RootNamespace>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
</Project>
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9 namespace LibAtrac9
{ {
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using static LibAtrac9.HuffmanCodebooks; using static LibAtrac9.HuffmanCodebooks;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using System.IO; using System.IO;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9.Utilities namespace LibAtrac9.Utilities
{ {
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using System.Diagnostics; using System.Diagnostics;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
+205 -7
View File
@@ -2,13 +2,17 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Collections.Concurrent;
using SharpEmu.HLE; using SharpEmu.HLE;
namespace SharpEmu.Libs.Acm; namespace SharpEmu.Libs.Acm;
public static class AcmExports public static class AcmExports
{ {
private const int AcmBatchErrorBytes = 32;
private static readonly ConcurrentDictionary<uint, byte> Contexts = new();
private static int _nextContextHandle; private static int _nextContextHandle;
private static int _nextBatchHandle;
[SysAbiExport( [SysAbiExport(
Nid = "ZIXln2K3XMk", Nid = "ZIXln2K3XMk",
@@ -23,12 +27,17 @@ public static class AcmExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle); var handle = unchecked((uint)Interlocked.Increment(ref _nextContextHandle));
Span<byte> handleBytes = stackalloc byte[sizeof(ulong)]; Span<byte> handleBytes = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle); BinaryPrimitives.WriteUInt32LittleEndian(handleBytes, handle);
return ctx.Memory.TryWrite(outContextAddress, handleBytes) if (!ctx.Memory.TryWrite(outContextAddress, handleBytes))
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK) {
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); 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( [SysAbiExport(
@@ -38,7 +47,196 @@ public static class AcmExports
LibraryName = "libSceAcm")] LibraryName = "libSceAcm")]
public static int AcmContextDestroy(CpuContext ctx) 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); 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<byte> 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<byte> 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<byte> 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}");
}
}
} }
+11
View File
@@ -4383,6 +4383,14 @@ public static partial class AgcExports
_tracedProducerlessWaits.Clear(); _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 && if (!stale && producer is null &&
!_tracedProducerlessWaits.Add( !_tracedProducerlessWaits.Add(
(memory, waiter.WaitAddress))) (memory, waiter.WaitAddress)))
@@ -5648,6 +5656,8 @@ public static partial class AgcExports
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot( SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot(
ctx.Memory); ctx.Memory);
GpuWaitProfile.RecordMonitorPoll(resumed != 0);
GpuWaitProfile.ReportIfDue(remaining);
if (remaining == 0) if (remaining == 0)
{ {
gpuState.WaitMonitorRunning = false; gpuState.WaitMonitorRunning = false;
@@ -5841,6 +5851,7 @@ public static partial class AgcExports
$"submission={waiter.SubmissionId} label=0x{waiter.WaitAddress:X16} " + $"submission={waiter.SubmissionId} label=0x{waiter.WaitAddress:X16} " +
$"resume=0x{waiter.ResumeAddress:X16} remaining_dwords={remainingDwords} " + $"resume=0x{waiter.ResumeAddress:X16} remaining_dwords={remainingDwords} " +
$"waited_ms={waitedMilliseconds:F3}"); $"waited_ms={waitedMilliseconds:F3}");
GpuWaitProfile.RecordResume(waiter.WaitAddress, waitedMilliseconds);
if (remainingDwords == 0) if (remainingDwords == 0)
{ {
state.IsSuspended = false; state.IsSuspended = false;
+160
View File
@@ -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;
/// <summary>
/// Aggregate accounting for suspended WAIT_REG_MEM packets, enabled with
/// SHARPEMU_PROFILE_GPU_WAIT=1.
///
/// The existing <c>agc.wait_suspended</c> 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.
/// </summary>
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<ulong, (long Count, double Milliseconds)> _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);
}
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
File diff suppressed because it is too large Load Diff
@@ -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<byte> 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<byte> input,
Span<byte> 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<byte> 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<byte> 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<byte> 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;
}
}
+107 -131
View File
@@ -42,6 +42,15 @@ public static class AudioOut2Exports
// with ContextMemoryAlignment (0x100). // with ContextMemoryAlignment (0x100).
private const int PortStateSize = 0x20; private const int PortStateSize = 0x20;
private const int SpeakerInfoSize = 0x20; private const int SpeakerInfoSize = 0x20;
private static readonly string _stackOutBufferModes =
Environment.GetEnvironmentVariable("SHARPEMU_AUDIO_OUT2_STACK_WRITES") ?? "1";
private static bool AllowStackOut(string which) =>
string.Equals(_stackOutBufferModes, "1", StringComparison.Ordinal) ||
_stackOutBufferModes
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Contains(which, StringComparer.OrdinalIgnoreCase);
private const int PortParamSize = 0x40; private const int PortParamSize = 0x40;
private const int AttributeEntrySize = 0x18; private const int AttributeEntrySize = 0x18;
private const uint PortAttributeIdPcm = 0; private const uint PortAttributeIdPcm = 0;
@@ -51,6 +60,7 @@ public static class AudioOut2Exports
private static int _nextPortId; private static int _nextPortId;
private static long _pushTraceCount; private static long _pushTraceCount;
private static long _submitTraceCount; private static long _submitTraceCount;
private static long _submitSkipTraceCount;
private static long _attributePcmTraceCount; private static long _attributePcmTraceCount;
private static readonly ConcurrentDictionary<ulong, byte> SpeakerArrays = new(); private static readonly ConcurrentDictionary<ulong, byte> SpeakerArrays = new();
@@ -126,6 +136,9 @@ public static class AudioOut2Exports
public uint SamplingFrequency { get; } public uint SamplingFrequency { get; }
public uint GrainSamples { get; } public uint GrainSamples { get; }
public ulong PcmAddress; public ulong PcmAddress;
public int PcmPending;
} }
// Two host streams: primary FMOD context (menus) and everything else // Two host streams: primary FMOD context (menus) and everything else
@@ -194,6 +207,19 @@ public static class AudioOut2Exports
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
var contextMemorySize = (ulong)AudioOut2ContextMemorySize;
Span<byte> param = stackalloc byte[AudioOut2ContextParamSize];
if (ctx.Memory.TryRead(paramAddress, param))
{
var queueDepth = BinaryPrimitives.ReadUInt32LittleEndian(param[0x0C..]);
if (queueDepth == 0)
{
queueDepth = 4;
}
contextMemorySize = checked(0x10000UL + (queueDepth * 0x590UL));
}
// Heap: {size, alignment} (16 bytes), matching sceAudioPropagationSystemQueryMemory. // Heap: {size, alignment} (16 bytes), matching sceAudioPropagationSystemQueryMemory.
// Stack: SIZE ONLY as a full ulong (8 bytes). Writing alignment at +8 is how // Stack: SIZE ONLY as a full ulong (8 bytes). Writing alignment at +8 is how
// [rbp-0x30] became 0x100 on GTA V Enhanced. Do NOT shrink this to uint32 — // [rbp-0x30] became 0x100 on GTA V Enhanced. Do NOT shrink this to uint32 —
@@ -202,10 +228,10 @@ public static class AudioOut2Exports
if (IsGuestStackAddress(memoryInfoAddress)) if (IsGuestStackAddress(memoryInfoAddress))
{ {
Span<byte> sizeOnly = stackalloc byte[sizeof(ulong)]; Span<byte> sizeOnly = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(sizeOnly, AudioOut2ContextMemorySize); BinaryPrimitives.WriteUInt64LittleEndian(sizeOnly, contextMemorySize);
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][TRACE] audio_out2.context-query-memory stack-size-only " + $"[LOADER][TRACE] audio_out2.context-query-memory stack-size-only " +
$"out=0x{memoryInfoAddress:X} size=0x{AudioOut2ContextMemorySize:X}"); $"out=0x{memoryInfoAddress:X} size=0x{contextMemorySize:X}");
return ctx.Memory.TryWrite(memoryInfoAddress, sizeOnly) return ctx.Memory.TryWrite(memoryInfoAddress, sizeOnly)
? SetReturn(ctx, 0) ? SetReturn(ctx, 0)
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); : SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
@@ -213,11 +239,11 @@ public static class AudioOut2Exports
Span<byte> memoryInfo = stackalloc byte[0x10]; Span<byte> memoryInfo = stackalloc byte[0x10];
memoryInfo.Clear(); memoryInfo.Clear();
BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x00..], AudioOut2ContextMemorySize); BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x00..], contextMemorySize);
BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x08..], AudioOut2ContextMemoryAlignment); BinaryPrimitives.WriteUInt64LittleEndian(memoryInfo[0x08..], AudioOut2ContextMemoryAlignment);
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][TRACE] audio_out2.context-query-memory out=0x{memoryInfoAddress:X} " + $"[LOADER][TRACE] audio_out2.context-query-memory out=0x{memoryInfoAddress:X} " +
$"size=0x{AudioOut2ContextMemorySize:X} align=0x{AudioOut2ContextMemoryAlignment:X}"); $"size=0x{contextMemorySize:X} align=0x{AudioOut2ContextMemoryAlignment:X}");
return ctx.Memory.TryWrite(memoryInfoAddress, memoryInfo) return ctx.Memory.TryWrite(memoryInfoAddress, memoryInfo)
? SetReturn(ctx, 0) ? SetReturn(ctx, 0)
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); : SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
@@ -325,7 +351,10 @@ public static class AudioOut2Exports
{ {
if (Contexts.TryGetValue(ctx[CpuRegister.Rdi], out var state)) if (Contexts.TryGetValue(ctx[CpuRegister.Rdi], out var state))
{ {
state.PaceAdvance(); if (!TrySubmitContextAudio(ctx, state))
{
state.PaceAdvance();
}
} }
return SetReturn(ctx, 0); return SetReturn(ctx, 0);
@@ -342,14 +371,16 @@ public static class AudioOut2Exports
// uint64 write into a stack slot at [rbp-0x14] next to the canary at // uint64 write into a stack slot at [rbp-0x14] next to the canary at
// [rbp-0x10] zeroed the canary low half and killed Bink Snd @ eboot+0xAE36. // [rbp-0x10] zeroed the canary low half and killed Bink Snd @ eboot+0xAE36.
var outLevelAddress = ctx[CpuRegister.Rsi]; var outLevelAddress = ctx[CpuRegister.Rsi];
var outAvailableAddress = ctx[CpuRegister.Rdx];
if (outLevelAddress == 0) if (outLevelAddress == 0)
{ {
outLevelAddress = ctx[CpuRegister.Rdx]; outLevelAddress = outAvailableAddress;
outAvailableAddress = 0;
} }
Span<byte> level = stackalloc byte[sizeof(uint)];
if (outLevelAddress != 0) if (outLevelAddress != 0)
{ {
Span<byte> level = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(level, 0); BinaryPrimitives.WriteUInt32LittleEndian(level, 0);
if (!ctx.Memory.TryWrite(outLevelAddress, level)) if (!ctx.Memory.TryWrite(outLevelAddress, level))
{ {
@@ -357,6 +388,20 @@ public static class AudioOut2Exports
} }
} }
if (outAvailableAddress != 0 &&
outAvailableAddress != outLevelAddress &&
IsWritableOutBuffer(outAvailableAddress))
{
var available = Contexts.TryGetValue(ctx[CpuRegister.Rdi], out var context)
? context.QueueDepth
: 4u;
BinaryPrimitives.WriteUInt32LittleEndian(level, available);
if (!ctx.Memory.TryWrite(outAvailableAddress, level))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
return SetReturn(ctx, 0); return SetReturn(ctx, 0);
} }
@@ -418,6 +463,7 @@ public static class AudioOut2Exports
} }
port.PcmAddress = BinaryPrimitives.ReadUInt64LittleEndian(pcm); port.PcmAddress = BinaryPrimitives.ReadUInt64LittleEndian(pcm);
Volatile.Write(ref port.PcmPending, port.PcmAddress != 0 ? 1 : 0);
var n = Interlocked.Increment(ref _attributePcmTraceCount); var n = Interlocked.Increment(ref _attributePcmTraceCount);
if (n <= 8 || n % 500 == 0) if (n <= 8 || n % 500 == 0)
{ {
@@ -475,13 +521,14 @@ public static class AudioOut2Exports
// Handle encodes only the low type byte; PortState keeps the full type // Handle encodes only the low type byte; PortState keeps the full type
// so object ports (0x01xx) can still be filtered at submit time. // so object ports (0x01xx) can still be filtered at submit time.
var handle = 0x2000_0000UL | ((ulong)(portType & 0xFF) << 16) | portId; var handle = 0x2000_0000UL | ((ulong)(portType & 0xFF) << 16) | portId;
Ports[handle] = new PortState( var portState = new PortState(
handle, handle,
contextHandle, contextHandle,
portType, portType,
dataFormat, dataFormat,
samplingFrequency, samplingFrequency,
grainSamples); grainSamples);
Ports[handle] = portState;
if (!TryWriteUInt64(ctx, outPortAddress, handle)) if (!TryWriteUInt64(ctx, outPortAddress, handle))
{ {
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
@@ -512,7 +559,8 @@ public static class AudioOut2Exports
// caller frames / canaries (state=0x7FFFDE1FF688 right before fail). // caller frames / canaries (state=0x7FFFDE1FF688 right before fail).
// Heap outs still get a real state blob even when the handle wasn't // Heap outs still get a real state blob even when the handle wasn't
// minted by PortCreate — this title synthesizes port ids itself. // minted by PortCreate — this title synthesizes port ids itself.
if (IsGuestStackAddress(stateAddress)) if (IsGuestStackAddress(stateAddress) &&
!(AllowStackOut("portstate") && Ports.ContainsKey(portHandle)))
{ {
TraceAudioOut2( TraceAudioOut2(
$"port-get-state skip-stack handle=0x{portHandle:X} state=0x{stateAddress:X}"); $"port-get-state skip-stack handle=0x{portHandle:X} state=0x{stateAddress:X}");
@@ -545,6 +593,48 @@ public static class AudioOut2Exports
return SetReturn(ctx, 0); return SetReturn(ctx, 0);
} }
[SysAbiExport(
Nid = "4dq2rblWlg0",
ExportName = "sceAudioOut2ContextSetAttributes",
Target = Generation.Gen5,
LibraryName = "libSceAudioOut2")]
public static int AudioOut2ContextSetAttributes(CpuContext ctx)
{
var attributeAddress = ctx[CpuRegister.Rsi];
var count = unchecked((uint)ctx[CpuRegister.Rdx]);
return SetReturn(
ctx,
count != 0 && attributeAddress == 0
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT
: 0);
}
[SysAbiExport(
Nid = "bkBN+CMLwRc",
ExportName = "sceAudioOut2GetSystemState",
Target = Generation.Gen5,
LibraryName = "libSceAudioOut2")]
public static int AudioOut2GetSystemState(CpuContext ctx)
{
var stateAddress = ctx[CpuRegister.Rdi];
if (stateAddress == 0)
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
if (IsGuestStackAddress(stateAddress) && !AllowStackOut("systemstate"))
{
TraceAudioOut2($"get-system-state skip-stack out=0x{stateAddress:X}");
return SetReturn(ctx, 0);
}
Span<byte> state = stackalloc byte[0x40];
state.Clear();
return ctx.Memory.TryWrite(stateAddress, state)
? SetReturn(ctx, 0)
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
// rdi=out buffer, rsi=type/flag (not a pointer). Fixed-size write only. // rdi=out buffer, rsi=type/flag (not a pointer). Fixed-size write only.
[SysAbiExport( [SysAbiExport(
Nid = "DImz2Ft9E2g", Nid = "DImz2Ft9E2g",
@@ -559,8 +649,7 @@ public static class AudioOut2Exports
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
// Same rule as PortGetState — never bulk-write speaker info onto the stack. if (IsGuestStackAddress(infoAddress) && !AllowStackOut("speaker"))
if (IsGuestStackAddress(infoAddress))
{ {
TraceAudioOut2($"get-speaker-info skip-stack out=0x{infoAddress:X}"); TraceAudioOut2($"get-speaker-info skip-stack out=0x{infoAddress:X}");
return SetReturn(ctx, 0); return SetReturn(ctx, 0);
@@ -788,25 +877,8 @@ public static class AudioOut2Exports
return false; return false;
} }
// Serialize submits per process so ArrayPool buffers stay private; primary
// and secondary devices still receive independent PCM (no digital sum).
lock (HostSubmitGate) lock (HostSubmitGate)
{ {
if (!TryPickMainBed(context.Handle, out var mainPort))
{
mainPort = null;
}
if (!TryPickAuxBed(context.Handle, out var auxPort))
{
auxPort = null;
}
if (mainPort is null && auxPort is null)
{
return false;
}
var mix = ArrayPool<float>.Shared.Rent(frames * 2); var mix = ArrayPool<float>.Shared.Rent(frames * 2);
var source = ArrayPool<byte>.Shared.Rent(frames * 16 * sizeof(float)); var source = ArrayPool<byte>.Shared.Rent(frames * 16 * sizeof(float));
var output = ArrayPool<byte>.Shared.Rent(frames * AudioPcmConversion.OutputFrameSize); var output = ArrayPool<byte>.Shared.Rent(frames * AudioPcmConversion.OutputFrameSize);
@@ -814,16 +886,11 @@ public static class AudioOut2Exports
{ {
mix.AsSpan(0, frames * 2).Clear(); mix.AsSpan(0, frames * 2).Clear();
var mixedPorts = 0; var mixedPorts = 0;
ulong lastPort = 0; foreach (var port in Ports.Values)
uint lastFormat = 0;
var lastChannels = 0;
// At most one MAIN/BGM + one AUX on this context. Menus stay on
// MAIN; intro/Bink rides AUX without stacking every bed.
for (var bed = 0; bed < 2; bed++)
{ {
var port = bed == 0 ? mainPort : auxPort; if (port.ContextHandle != context.Handle ||
if (port is null || port.PcmAddress == 0 ||
Interlocked.Exchange(ref port.PcmPending, 0) == 0 ||
!TryDecodeDataFormat(port.DataFormat, out var ch, out var bps, out var isFloat)) !TryDecodeDataFormat(port.DataFormat, out var ch, out var bps, out var isFloat))
{ {
continue; continue;
@@ -850,9 +917,6 @@ public static class AudioOut2Exports
isFloat, isFloat,
additive: mixedPorts > 0); additive: mixedPorts > 0);
mixedPorts++; mixedPorts++;
lastPort = port.Handle;
lastFormat = port.DataFormat;
lastChannels = ch;
} }
if (mixedPorts == 0) if (mixedPorts == 0)
@@ -862,22 +926,11 @@ public static class AudioOut2Exports
var outputSpan = output.AsSpan(0, frames * AudioPcmConversion.OutputFrameSize); var outputSpan = output.AsSpan(0, frames * AudioPcmConversion.OutputFrameSize);
var peak = 0f; var peak = 0f;
var any = false;
for (var frame = 0; frame < frames; frame++) for (var frame = 0; frame < frames; frame++)
{ {
var left = Math.Clamp(mix[frame * 2], -1f, 1f); var left = Math.Clamp(mix[frame * 2], -1f, 1f);
var right = Math.Clamp(mix[(frame * 2) + 1], -1f, 1f); var right = Math.Clamp(mix[(frame * 2) + 1], -1f, 1f);
var framePeak = Math.Max(Math.Abs(left), Math.Abs(right)); peak = Math.Max(peak, Math.Max(Math.Abs(left), Math.Abs(right)));
if (framePeak > peak)
{
peak = framePeak;
}
if (framePeak > 1e-7f)
{
any = true;
}
BinaryPrimitives.WriteInt16LittleEndian( BinaryPrimitives.WriteInt16LittleEndian(
outputSpan[(frame * AudioPcmConversion.OutputFrameSize)..], outputSpan[(frame * AudioPcmConversion.OutputFrameSize)..],
FloatToPcm16(left)); FloatToPcm16(left));
@@ -886,13 +939,6 @@ public static class AudioOut2Exports
FloatToPcm16(right)); FloatToPcm16(right));
} }
if (!any)
{
return false;
}
// Bind primary only after a real grain so silent early contexts
// do not steal the menu device.
var backend = ResolveContextBackend(context, out var backendName); var backend = ResolveContextBackend(context, out var backendName);
if (backend is null) if (backend is null)
{ {
@@ -900,12 +946,11 @@ public static class AudioOut2Exports
} }
var n = Interlocked.Increment(ref _submitTraceCount); var n = Interlocked.Increment(ref _submitTraceCount);
if (n <= 8 || n % 200 == 0) if (n <= 8 || n % 500 == 0)
{ {
TraceAudioOut2( TraceAudioOut2(
$"context-submit#{n} handle=0x{context.Handle:X} frames={frames} " + $"context-submit#{n} handle=0x{context.Handle:X} frames={frames} " +
$"ports={mixedPorts} lastPort=0x{lastPort:X} format=0x{lastFormat:X} " + $"ports={mixedPorts} peak={peak:F4} backend={backendName}");
$"ch={lastChannels} peak={peak:F4} backend={backendName}");
} }
return backend.Submit(outputSpan); return backend.Submit(outputSpan);
@@ -919,75 +964,6 @@ public static class AudioOut2Exports
} }
} }
private static bool TryPickMainBed(ulong contextHandle, out PortState? chosen)
{
chosen = null;
var chosenScore = int.MinValue;
foreach (var port in Ports.Values)
{
if (port.ContextHandle != contextHandle ||
port.PcmAddress == 0 ||
IsObjectPort(port.PortType) ||
!IsMainOrBgmPort(port.PortType) ||
!TryDecodeDataFormat(port.DataFormat, out var channels, out _, out _))
{
continue;
}
var score = channels switch
{
2 => 300,
1 => 200,
8 => 100,
_ => 50,
};
if ((port.PortType & 0xFF) == 0)
{
score += 20; // MAIN over BGM
}
if (score > chosenScore)
{
chosenScore = score;
chosen = port;
}
}
return chosen is not null;
}
private static bool TryPickAuxBed(ulong contextHandle, out PortState? chosen)
{
chosen = null;
var chosenScore = int.MinValue;
foreach (var port in Ports.Values)
{
if (port.ContextHandle != contextHandle ||
port.PcmAddress == 0 ||
IsObjectPort(port.PortType) ||
(port.PortType & 0xFF) != 6 ||
!TryDecodeDataFormat(port.DataFormat, out var channels, out _, out _))
{
continue;
}
var score = channels switch
{
2 => 300,
1 => 200,
8 => 100,
_ => 50,
};
if (score > chosenScore)
{
chosenScore = score;
chosen = port;
}
}
return chosen is not null;
}
private static bool IsMainOrBgmPort(ushort portType) private static bool IsMainOrBgmPort(ushort portType)
{ {
var kind = portType & 0xFF; var kind = portType & 0xFF;
+43 -20
View File
@@ -44,6 +44,7 @@ public static class AudioOutExports
int channels, int channels,
int bytesPerSample, int bytesPerSample,
bool isFloat, bool isFloat,
bool preservesGuestFormat,
IHostAudioStream? backend) IHostAudioStream? backend)
{ {
UserId = userId; UserId = userId;
@@ -54,6 +55,7 @@ public static class AudioOutExports
Channels = channels; Channels = channels;
BytesPerSample = bytesPerSample; BytesPerSample = bytesPerSample;
IsFloat = isFloat; IsFloat = isFloat;
PreservesGuestFormat = preservesGuestFormat;
Backend = backend; Backend = backend;
} }
@@ -65,6 +67,7 @@ public static class AudioOutExports
public int Channels { get; } public int Channels { get; }
public int BytesPerSample { get; } public int BytesPerSample { get; }
public bool IsFloat { get; } public bool IsFloat { get; }
public bool PreservesGuestFormat { get; }
public IHostAudioStream? Backend { get; } public IHostAudioStream? Backend { get; }
public object SubmissionGate { get; } = new(); public object SubmissionGate { get; } = new();
public volatile float Volume = 1.0f; public volatile float Volume = 1.0f;
@@ -140,6 +143,7 @@ public static class AudioOutExports
} }
IHostAudioStream? backend = null; IHostAudioStream? backend = null;
var preservesGuestFormat = false;
string backendName; string backendName;
try try
{ {
@@ -152,7 +156,18 @@ public static class AudioOutExports
else else
{ {
var audio = HostPlatform.Current.Audio; 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; backendName = audio.BackendName;
} }
} }
@@ -173,6 +188,7 @@ public static class AudioOutExports
channels, channels,
bytesPerSample, bytesPerSample,
isFloat, isFloat,
preservesGuestFormat,
backend); backend);
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][INFO] AudioOut port {handle}: {frequency} Hz, " + $"[LOADER][INFO] AudioOut port {handle}: {frequency} Hz, " +
@@ -321,18 +337,13 @@ public static class AudioOutExports
return ctx.SetReturn(0); 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<byte>.Shared.Rent(outputLength); var output = ArrayPool<byte>.Shared.Rent(outputLength);
try try
{ {
AudioPcmConversion.ConvertToStereoPcm16( ConvertForHost(port, source, output.AsSpan(0, outputLength));
source,
output.AsSpan(0, outputLength),
checked((int)port.BufferLength),
port.Channels,
port.BytesPerSample,
port.IsFloat,
port.Volume);
if (!port.Backend.Submit(output.AsSpan(0, outputLength))) if (!port.Backend.Submit(output.AsSpan(0, outputLength)))
{ {
port.PaceSilence(); port.PaceSilence();
@@ -449,17 +460,11 @@ public static class AudioOutExports
TraceOutput(output.Handle, output.Port, source); TraceOutput(output.Handle, output.Port, source);
output.HostBufferLength = checked( output.HostBufferLength = output.Port.PreservesGuestFormat
(int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize); ? output.Port.BufferByteLength
: checked((int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize);
output.HostBuffer = ArrayPool<byte>.Shared.Rent(output.HostBufferLength); output.HostBuffer = ArrayPool<byte>.Shared.Rent(output.HostBufferLength);
AudioPcmConversion.ConvertToStereoPcm16( ConvertForHost(output.Port, source, output.HostBuffer.AsSpan(0, output.HostBufferLength));
source,
output.HostBuffer.AsSpan(0, output.HostBufferLength),
checked((int)output.Port.BufferLength),
output.Port.Channels,
output.Port.BytesPerSample,
output.Port.IsFloat,
output.Port.Volume);
} }
finally finally
{ {
@@ -513,6 +518,24 @@ public static class AudioOutExports
(ulong)candidate.BufferLength * current.Frequency > (ulong)candidate.BufferLength * current.Frequency >
(ulong)current.BufferLength * candidate.Frequency; (ulong)current.BufferLength * candidate.Frequency;
private static void ConvertForHost(PortState port, ReadOnlySpan<byte> source, Span<byte> 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<byte> source) private static void TraceOutput(int handle, PortState port, ReadOnlySpan<byte> source)
{ {
if (!_traceOutput) if (!_traceOutput)
@@ -43,6 +43,46 @@ internal static class AudioPcmConversion
} }
} }
/// <summary>
/// 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.
/// </summary>
public static void CopyWithVolume(
ReadOnlySpan<byte> source,
Span<byte> 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( private static short ReadSample(
ReadOnlySpan<byte> frame, ReadOnlySpan<byte> frame,
int channel, int channel,
+3 -1
View File
@@ -133,10 +133,12 @@ internal static class Bink2MovieBridge
if (_playback.IsFinished) if (_playback.IsFinished)
{ {
var completedPath = _activePath; var completedPath = _activePath;
var progress = _playback.PlaybackProgress;
CloseActiveLocked(); CloseActiveLocked();
Console.Error.WriteLine( Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge completed: " + "[LOADER][INFO] Bink2 bridge completed: " +
Path.GetFileName(completedPath)); $"{Path.GetFileName(completedPath)} after " +
$"{progress.Seconds:F2}s at frame {progress.FrameIndex}");
AttachNextQueuedMovieLocked(); AttachNextQueuedMovieLocked();
} }
return false; return false;
+129 -7
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics; using System.Diagnostics;
using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Bink; namespace SharpEmu.Libs.Bink;
@@ -36,6 +37,8 @@ internal sealed class BinkFramePlayback : IDisposable
private long _currentFrameIndex = -1; private long _currentFrameIndex = -1;
private long _nextDecodedFrameIndex; private long _nextDecodedFrameIndex;
private long _playbackStartTimestamp; private long _playbackStartTimestamp;
private double _audioStartSeconds;
private long _lastSkewTraceTimestamp;
private bool _playbackClockStarted; private bool _playbackClockStarted;
private bool _decoderCompleted; private bool _decoderCompleted;
private bool _stopRequested; private bool _stopRequested;
@@ -83,6 +86,26 @@ internal sealed class BinkFramePlayback : IDisposable
} }
} }
/// <summary>
/// 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.
/// </summary>
internal (double Seconds, long FrameIndex) PlaybackProgress
{
get
{
lock (_gate)
{
return (
_playbackClockStarted
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
: 0,
_currentFrameIndex);
}
}
}
internal bool TryGetFrame( internal bool TryGetFrame(
bool advanceClock, bool advanceClock,
out byte[] pixels, out byte[] pixels,
@@ -118,14 +141,13 @@ internal sealed class BinkFramePlayback : IDisposable
if (advanceClock && !_playbackClockStarted) if (advanceClock && !_playbackClockStarted)
{ {
_playbackStartTimestamp = Stopwatch.GetTimestamp(); _playbackStartTimestamp = Stopwatch.GetTimestamp();
_audioStartSeconds = GuestAudioClock.PlayedSeconds;
_playbackClockStarted = true; _playbackClockStarted = true;
} }
var elapsedSeconds = _playbackClockStarted var elapsedSeconds = CurrentPlaybackSecondsLocked();
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds TraceClockSkewLocked();
: 0; var targetFrameIndex = CurrentTargetFrameIndexLocked();
var targetFrameIndex = (long)Math.Floor(
elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
DecodedFrame? replacement = null; DecodedFrame? replacement = null;
while (_decodedFrames.Count > 0 && while (_decodedFrames.Count > 0 &&
_decodedFrames.Peek().Index <= targetFrameIndex) _decodedFrames.Peek().Index <= targetFrameIndex)
@@ -166,6 +188,86 @@ internal sealed class BinkFramePlayback : IDisposable
} }
} }
/// <summary>
/// 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.
/// </summary>
private static readonly bool _followGuestAudioClock = !string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_MOVIE_CLOCK"),
"wall",
StringComparison.OrdinalIgnoreCase);
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// 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 <see cref="_gate"/>.
/// </summary>
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}");
}
/// <summary>
/// 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.
/// </summary>
private long CurrentTargetFrameIndexLocked() =>
_playbackClockStarted
? (long)Math.Floor(
CurrentPlaybackSecondsLocked() *
FramesPerSecondNumerator / FramesPerSecondDenominator)
: -1;
private void DecodeLoop() private void DecodeLoop()
{ {
try try
@@ -199,8 +301,28 @@ internal sealed class BinkFramePlayback : IDisposable
lock (_gate) lock (_gate)
{ {
_decodedFrames.Enqueue(new DecodedFrame( var frameIndex = _nextDecodedFrameIndex++;
_nextDecodedFrameIndex++, destination));
// 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); Monitor.PulseAll(_gate);
} }
} }
@@ -1,7 +1,9 @@
// Copyright (C) 2026 SharpEmu Emulator Project // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
using FFmpeg.AutoGen; using FFmpeg.AutoGen;
using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Bink; namespace SharpEmu.Libs.Bink;
@@ -13,13 +15,29 @@ namespace SharpEmu.Libs.Bink;
/// </summary> /// </summary>
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder 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 AVFormatContext* _formatContext;
private AVCodecContext* _codecContext; private AVCodecContext* _codecContext;
private AVCodecContext* _audioCodecContext;
private SwsContext* _swsContext; private SwsContext* _swsContext;
private SwrContext* _swrContext;
private AVFrame* _frame; private AVFrame* _frame;
private AVFrame* _audioFrame;
private AVPacket* _packet; private AVPacket* _packet;
private IHostAudioStream? _audioStream;
private readonly int _videoStreamIndex; 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 _draining;
private bool _audioDraining;
private bool _audioFailed;
private int _disposed; private int _disposed;
public uint Width { get; } public uint Width { get; }
@@ -34,6 +52,10 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
AVFormatContext* formatContext, AVFormatContext* formatContext,
AVCodecContext* codecContext, AVCodecContext* codecContext,
int videoStreamIndex, int videoStreamIndex,
AVCodecContext* audioCodecContext,
int audioStreamIndex,
IHostAudioStream? audioStream,
int audioOutputSampleRate,
uint width, uint width,
uint height, uint height,
uint framesPerSecondNumerator, uint framesPerSecondNumerator,
@@ -42,11 +64,16 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
_formatContext = formatContext; _formatContext = formatContext;
_codecContext = codecContext; _codecContext = codecContext;
_videoStreamIndex = videoStreamIndex; _videoStreamIndex = videoStreamIndex;
_audioCodecContext = audioCodecContext;
_audioStreamIndex = audioStreamIndex;
_audioStream = audioStream;
_audioOutputSampleRate = audioOutputSampleRate;
Width = width; Width = width;
Height = height; Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator; FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator; FramesPerSecondDenominator = framesPerSecondDenominator;
_frame = ffmpeg.av_frame_alloc(); _frame = ffmpeg.av_frame_alloc();
_audioFrame = audioCodecContext is null ? null : ffmpeg.av_frame_alloc();
_packet = ffmpeg.av_packet_alloc(); _packet = ffmpeg.av_packet_alloc();
} }
@@ -93,6 +120,8 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
AVFormatContext* formatContext = null; AVFormatContext* formatContext = null;
AVCodecContext* codecContext = null; AVCodecContext* codecContext = null;
AVCodecContext* audioCodecContext = null;
IHostAudioStream? audioStream = null;
try try
{ {
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0) 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 }; 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 outputWidth = (uint)codecContext->width;
var outputHeight = (uint)codecContext->height; var outputHeight = (uint)codecContext->height;
if (maximumWidth > 0 && maximumHeight > 0 && if (maximumWidth > 0 && maximumHeight > 0 &&
@@ -175,12 +226,18 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
formatContext, formatContext,
codecContext, codecContext,
videoStreamIndex, videoStreamIndex,
audioCodecContext,
audioStreamIndex,
audioStream,
audioOutputSampleRate,
outputWidth, outputWidth,
outputHeight, outputHeight,
(uint)frameRate.num, (uint)frameRate.num,
(uint)frameRate.den); (uint)frameRate.den);
formatContext = null; formatContext = null;
codecContext = null; codecContext = null;
audioCodecContext = null;
audioStream = null;
return true; return true;
} }
catch (DllNotFoundException) catch (DllNotFoundException)
@@ -194,6 +251,13 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
ffmpeg.avcodec_free_context(&codecContext); ffmpeg.avcodec_free_context(&codecContext);
} }
if (audioCodecContext is not null)
{
ffmpeg.avcodec_free_context(&audioCodecContext);
}
audioStream?.Dispose();
if (formatContext is not null) if (formatContext is not null)
{ {
ffmpeg.avformat_close_input(&formatContext); 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<byte> destination) public bool TryDecodeNextFrame(Span<byte> destination)
{ {
var stride = checked((int)(Width * 4)); lock (_decodeGate)
var required = (long)stride * Height;
if (destination.Length < required)
{ {
return false; if (Volatile.Read(ref _disposed) != 0)
} {
return false;
}
if (!TryReceiveFrame()) var stride = checked((int)(Width * 4));
{ var required = (long)stride * Height;
return false; if (destination.Length < required)
} {
return false;
}
_swsContext = ffmpeg.sws_getCachedContext( if (!TryReceiveFrame())
_swsContext, {
_frame->width, return false;
_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;
}
fixed (byte* destinationPointer = destination) _swsContext = ffmpeg.sws_getCachedContext(
{
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
var destinationStrides = new int[4] { stride, 0, 0, 0 };
var convertedRows = ffmpeg.sws_scale(
_swsContext, _swsContext,
_frame->data, _frame->width,
_frame->linesize,
0,
_frame->height, _frame->height,
destinationPlanes, (AVPixelFormat)_frame->format,
destinationStrides); (int)Width,
ffmpeg.av_frame_unref(_frame); (int)Height,
return convertedRows == (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; _draining = true;
ffmpeg.avcodec_send_packet(_codecContext, null); ffmpeg.avcodec_send_packet(_codecContext, null);
DrainAudioDecoder();
return true; return true;
} }
if (_packet->stream_index == _audioStreamIndex)
{
DecodeAudioPacket(_packet);
ffmpeg.av_packet_unref(_packet);
continue;
}
if (_packet->stream_index != _videoStreamIndex) if (_packet->stream_index != _videoStreamIndex)
{ {
ffmpeg.av_packet_unref(_packet); 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<byte>.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<byte>.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() public void Dispose()
{ {
if (Interlocked.Exchange(ref _disposed, 1) != 0) if (Interlocked.Exchange(ref _disposed, 1) != 0)
@@ -318,38 +682,59 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
return; return;
} }
if (_swsContext is not null) lock (_decodeGate)
{ {
ffmpeg.sws_freeContext(_swsContext); FreeAudioResampler();
_swsContext = null; _audioStream?.Dispose();
} _audioStream = null;
if (_packet is not null) if (_swsContext is not null)
{ {
var packet = _packet; ffmpeg.sws_freeContext(_swsContext);
ffmpeg.av_packet_free(&packet); _swsContext = null;
_packet = null; }
}
if (_frame is not null) if (_packet is not null)
{ {
var frame = _frame; var packet = _packet;
ffmpeg.av_frame_free(&frame); ffmpeg.av_packet_free(&packet);
_frame = null; _packet = null;
} }
if (_codecContext is not null) if (_frame is not null)
{ {
var codecContext = _codecContext; var frame = _frame;
ffmpeg.avcodec_free_context(&codecContext); ffmpeg.av_frame_free(&frame);
_codecContext = null; _frame = null;
} }
if (_formatContext is not null) if (_audioFrame is not null)
{ {
var formatContext = _formatContext; var frame = _audioFrame;
ffmpeg.avformat_close_input(&formatContext); ffmpeg.av_frame_free(&frame);
_formatContext = null; _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;
}
} }
} }
} }
+609 -15
View File
@@ -2,28 +2,84 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Libs.Audio;
using SharpEmu.Libs.Kernel; using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Threading; using System.Threading;
namespace SharpEmu.Libs.Codec; namespace SharpEmu.Libs.Codec;
/// <summary> /// <summary>
/// libSceVideodec / libSceAudiodec handle management. Actual H.264/HEVC and /// libSceVideodec / libSceAudiodec compatibility exports.
/// 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.
/// </summary> /// </summary>
public static class CodecExports public static class CodecExports
{ {
private const int Ok = 0; private const int Ok = 0;
private const int VideodecErrorInvalidArg = unchecked((int)0x80620801); private const int VideodecErrorInvalidArg = unchecked((int)0x80620801);
private const int AudiodecErrorInvalidType = unchecked((int)0x807F0001);
private const int AudiodecErrorInvalidArg = unchecked((int)0x807F0002); 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<ulong, byte> VideoDecoders = new(); private static readonly ConcurrentDictionary<ulong, byte> VideoDecoders = new();
private static readonly ConcurrentDictionary<ulong, byte> AudioDecoders = new(); private static readonly ConcurrentDictionary<int, AudioDecoderState> AudioDecoders = new();
private static readonly ConcurrentDictionary<uint, int> AudioCodecInitCounts = new();
private static readonly object AudioDecoderGate = new();
private static long _nextHandle = 1; 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 ---- // ---- Video decoder ----
@@ -65,34 +121,572 @@ public static class CodecExports
// ---- Audio decoder ---- // ---- 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", [SysAbiExport(Nid = "O3f1sLMWRvs", ExportName = "sceAudiodecCreateDecoder",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")] Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecCreateDecoder(CpuContext ctx) public static int AudiodecCreateDecoder(CpuContext ctx)
{ {
var handle = (ulong)Interlocked.Increment(ref _nextHandle); var controlAddress = ctx[CpuRegister.Rdi];
AudioDecoders[handle] = 1; var codecType = unchecked((uint)ctx[CpuRegister.Rsi]);
// sceAudiodec returns the handle directly (>= 0) or a negative error. if (!IsValidAudioCodecType(codecType))
ctx[CpuRegister.Rax] = handle; {
return unchecked((int)handle); 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", [SysAbiExport(Nid = "KHXHMDLkILw", ExportName = "sceAudiodecDecode",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")] Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecDecode(CpuContext ctx) public static int AudiodecDecode(CpuContext ctx)
{ {
// No decoder present: report success with zero output samples so the var handle = unchecked((int)ctx[CpuRegister.Rdi]);
// caller treats the frame as silent rather than erroring. if (!AudioDecoders.TryGetValue(handle, out var decoder))
return SetReturn(ctx, AudioDecoders.ContainsKey(ctx[CpuRegister.Rdi]) ? Ok : AudiodecErrorInvalidArg); {
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", [SysAbiExport(Nid = "Tp+ZEy69mLk", ExportName = "sceAudiodecDeleteDecoder",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")] Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecDeleteDecoder(CpuContext ctx) 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); 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<byte> 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<byte> auInfo = stackalloc byte[24];
if (!ctx.Memory.TryRead(auInfoAddress, auInfo))
{
return AudiodecErrorInvalidAuInfoPointer;
}
if (BinaryPrimitives.ReadUInt32LittleEndian(auInfo) != auInfo.Length)
{
return AudiodecErrorInvalidAuInfoSize;
}
Span<byte> 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<byte> 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<byte> 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<byte>.Shared.Rent(checked((int)control.AuSize));
var output = ArrayPool<byte>.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<byte>.Shared.Return(input);
ArrayPool<byte>.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<byte> 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<byte> 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<byte> 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<byte> 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<int> 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<byte> 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) => private static bool TryWriteHandle(CpuContext ctx, ulong address, ulong handle) =>
ctx.TryWriteUInt64(address, handle); ctx.TryWriteUInt64(address, handle);
+15 -1
View File
@@ -237,7 +237,21 @@ internal interface IGuestGpuBackend
void SubmitGuestImageFill(ulong address, uint fillValue); void SubmitGuestImageFill(ulong address, uint fillValue);
void SubmitGuestImageWrite(ulong address, byte[] pixels); /// <summary>
/// Uploads guest-authored pixels into a live guest image. <paramref name="rowOffset"/>
/// 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.
/// </summary>
void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0);
/// <summary>
/// Whether a non-zero <c>rowOffset</c> 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.
/// </summary>
bool SupportsPartialImageWrite => false;
/// <summary> /// <summary>
/// Asks the presenter to refresh CPU-dirty guest images on its render/present /// Asks the presenter to refresh CPU-dirty guest images on its render/present
@@ -398,7 +398,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageFill(ulong address, uint fillValue) => public void SubmitGuestImageFill(ulong address, uint fillValue) =>
MetalVideoPresenter.SubmitGuestImageFill(address, 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); MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) => public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
@@ -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;
/// <summary>
/// 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.
/// </summary>
internal static class MetalHostInput
{
private static readonly object Gate = new();
private static readonly HashSet<ushort> Pressed = new();
private static volatile bool _connected;
/// <summary>Registers this window's keyboard as the host input source.</summary>
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);
}
/// <summary>Called once per render frame; fires and releases scripted keys.</summary>
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<HostGamepadState> destination) => 0;
public string? DescribeConnectedGamepad() => null;
}
/// <summary>Windows virtual-key semantics (the seam's contract) to macOS
/// kVK virtual key codes, covering the keys pad emulation polls.</summary>
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;
}
}
+2 -89
View File
@@ -5,19 +5,6 @@ using System.Runtime.InteropServices;
namespace SharpEmu.Libs.Gpu.Metal; 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)] [StructLayout(LayoutKind.Sequential)]
internal struct CGSize internal struct CGSize
{ {
@@ -93,31 +80,21 @@ internal struct MtlViewport
} }
/// <summary> /// <summary>
/// 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 /// through objc_msgSend, with one LibraryImport overload per distinct native
/// signature. Dependency-free by design — this plus the OS frameworks is the entire /// signature. Dependency-free by design — this plus the OS frameworks is the entire
/// Metal path, which is what keeps it NativeAOT-clean. /// Metal path, which is what keeps it NativeAOT-clean.
/// </summary> /// </summary>
internal static partial class MetalNative 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 ObjCLibrary = "/usr/lib/libobjc.A.dylib";
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal"; 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 const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
private static bool _frameworksLoaded; private static bool _frameworksLoaded;
/// <summary> /// <summary>
/// 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. /// pulled in by its own LibraryImport. Call once before any Class() lookup.
/// </summary> /// </summary>
public static void EnsureFrameworksLoaded() public static void EnsureFrameworksLoaded()
@@ -127,7 +104,6 @@ internal static partial class MetalNative
return; return;
} }
NativeLibrary.Load(AppKitFramework);
NativeLibrary.Load(QuartzCoreFramework); NativeLibrary.Load(QuartzCoreFramework);
_frameworksLoaded = true; _frameworksLoaded = true;
} }
@@ -141,16 +117,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)] [LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint sel_registerName(string name); 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)] [LibraryImport(ObjCLibrary)]
public static partial nint objc_autoreleasePoolPush(); public static partial nint objc_autoreleasePoolPush();
@@ -169,14 +135,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument); public static partial nint Send(nint receiver, nint selector, nint argument);
/// <summary>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.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend_stret")]
public static partial void SendStretRect(out CGRect result, nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error); 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")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1); public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
/// <summary>performSelectorOnMainThread:withObject:waitUntilDone: — the SEL
/// to perform is itself an argument, followed by the object and the wait
/// flag.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidPerformSelector(
nint receiver,
nint selector,
nint performedSelector,
nint argument,
[MarshalAs(UnmanagedType.I1)] bool waitUntilDone);
/// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte /// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte
/// MTLTextureSwizzle values, passed packed like the framework expects.</summary> /// MTLTextureSwizzle values, passed packed like the framework expects.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
@@ -237,9 +184,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size); 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")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color); public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
@@ -328,37 +272,6 @@ internal static partial class MetalNative
nuint indexBufferOffset, nuint indexBufferOffset,
nuint instanceCount); 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")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTextureDescriptor( public static partial nint SendTextureDescriptor(
nint receiver, nint receiver,
@@ -1,8 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Libs.VideoOut; using SharpEmu.Libs.VideoOut;
using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler;
@@ -11,28 +9,12 @@ using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal; namespace SharpEmu.Libs.Gpu.Metal;
/// <summary> /// <summary>
/// The Metal presenter: an AppKit window hosting a CAMetalLayer. A CADisplayLink /// The Metal presenter uses SDL for its host window, events and input, then renders
/// requested from the content view drives <see cref="RenderFrame"/> in sync with the /// into the CAMetalLayer owned by SDL's Metal view. Windowing and input therefore
/// display refresh, on the main run loop — the loop whose Core Animation observer /// share the Vulkan path while all Metal command encoding remains native.
/// 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
/// <see cref="HostMainThread"/> (AppKit traps off-main), which the CLI parks for us.
/// </summary> /// </summary>
internal static partial class MetalVideoPresenter 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 PixelFormatBgra8Unorm = (nuint)MtlPixelFormat.Bgra8Unorm;
private const nuint LoadActionLoad = 1; private const nuint LoadActionLoad = 1;
private const nuint LoadActionClear = 2; private const nuint LoadActionClear = 2;
@@ -69,17 +51,14 @@ internal static partial class MetalVideoPresenter
private static readonly byte[] _overlayPixels = private static readonly byte[] _overlayPixels =
new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4]; new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4];
// Presenter objects and per-frame present state, shared between window setup // Presenter objects and per-frame present state, all used on the SDL host thread.
// and the display-link RenderFrame callback (both on the main thread).
private static nint _device; private static nint _device;
private static nint _commandQueue; private static nint _commandQueue;
private static nint _metalLayer; private static nint _metalLayer;
private static nint _presentPipeline; private static nint _presentPipeline;
private static nint _presentSampler; private static nint _presentSampler;
private static nint _window; private static SdlHostWindow? _hostWindow;
private static nint _application; private static HostVideoOptions _videoOptions = HostVideoOptions.Default;
private static nint _renderTimer;
private static nint _renderTimerTarget;
private static double _drawableWidth; private static double _drawableWidth;
private static double _drawableHeight; private static double _drawableHeight;
private static nint _frameTexture; private static nint _frameTexture;
@@ -91,10 +70,23 @@ internal static partial class MetalVideoPresenter
private static nint _ownedVersionTexture; private static nint _ownedVersionTexture;
private static ulong _presentGuestAddress; private static ulong _presentGuestAddress;
private static long _presentedSequence = -1; private static long _presentedSequence = -1;
private static bool _userClosed;
private static uint _windowWidth; private static uint _windowWidth;
private static uint _windowHeight; 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) public static void EnsureStarted(uint width, uint height)
{ {
if (width == 0 || height == 0) if (width == 0 || height == 0)
@@ -183,6 +175,7 @@ internal static partial class MetalVideoPresenter
public static void RequestClose() public static void RequestClose()
{ {
Volatile.Write(ref _closeRequested, true); Volatile.Write(ref _closeRequested, true);
Volatile.Read(ref _hostWindow)?.Close();
} }
private static void StartPresenterLocked() private static void StartPresenterLocked()
@@ -247,33 +240,23 @@ internal static partial class MetalVideoPresenter
VideoOutExports.SetSelectedGpuName(deviceName); VideoOutExports.SetSelectedGpuName(deviceName);
} }
// Fixed window like the Vulkan presenter: guest frames letterbox into HostVideoOptions videoOptions;
// it. Sizing the window from the guest's display mode (4K) exceeds the lock (_gate)
// screen — macOS clamps the window while the layer keeps the requested {
// geometry, leaving the visible region showing nothing but clear. videoOptions = _videoOptions;
const uint width = DefaultWindowWidth; }
const uint height = DefaultWindowHeight;
using var hostWindow = new SdlHostWindow(
VideoOutExports.GetWindowTitle(),
videoOptions,
SdlGraphicsApi.Metal,
ToggleMetalPerformanceHud);
Volatile.Write(ref _hostWindow, hostWindow);
var setupPool = MetalNative.objc_autoreleasePoolPush(); var setupPool = MetalNative.objc_autoreleasePoolPush();
try try
{ {
_application = MetalNative.Send( _metalLayer = hostWindow.CreateMetalLayer();
MetalNative.Class("NSApplication"), MetalNative.Selector("sharedApplication")); ConfigureMetalLayer(_device, _metalLayer);
// 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);
_commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue")); _commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue"));
if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError)) if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError))
{ {
@@ -282,31 +265,7 @@ internal static partial class MetalVideoPresenter
} }
_presentSampler = CreateLinearSampler(_device); _presentSampler = CreateLinearSampler(_device);
SyncDrawableSizeToLayer();
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"));
} }
finally finally
{ {
@@ -314,25 +273,20 @@ internal static partial class MetalVideoPresenter
} }
Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut presenter started."); 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 try
{ {
MetalNative.SendVoid(_window, MetalNative.Selector("close")); hostWindow.Run(
static () => { },
static _ => RenderFrameSafely(),
static () => { });
} }
finally finally
{ {
MetalNative.objc_autoreleasePoolPop(closePool); Volatile.Write(ref _hostWindow, null);
_metalLayer = 0;
} }
if (_userClosed) if (hostWindow.ClosedByUser)
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
"[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown."); "[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown.");
@@ -340,115 +294,8 @@ internal static partial class MetalVideoPresenter
} }
} }
/// <summary>
/// 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.
/// </summary>
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]<nint, nint, nint, void>)&OnKeyDown;
MetalNative.class_addMethod(cls, MetalNative.Selector("keyDown:"), keyDown, "v@:@");
var keyUp = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, void>)&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]<nint, nint, nint, byte>)&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]<nint, nint, byte>)&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; 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;
}
/// <summary> /// <summary>
/// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps /// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps
/// the built-in CPU-rasterized perf overlay). Configured per Apple's /// 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 /// mode=default|disabled and logging=default, plus any MTL_HUD_* environment
/// keys directly in the dictionary — all three HUD flags (enabled, per-frame /// keys directly in the dictionary — all three HUD flags (enabled, per-frame
/// logging, shader-compile logging) ride in one property set. Runs on the /// 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.
/// </summary> /// </summary>
private static void ToggleMetalPerformanceHud() private static void ToggleMetalPerformanceHud()
{ {
@@ -508,33 +355,13 @@ internal static partial class MetalVideoPresenter
$"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1)."); $"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1).");
} }
private static unsafe nint CreateRenderTimerTarget() /// <summary>The SDL host loop drains guest work continuously. The method
/// remains as the enqueue-side hook used by guest image synchronization.</summary>
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]<nint, nint, nint, void>)&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]<nint, nint, nint, void>)&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 RenderFrameSafely()
private static void OnRenderTimer(nint self, nint cmd, nint timer)
{ {
try try
{ {
@@ -546,78 +373,13 @@ internal static partial class MetalVideoPresenter
} }
} }
/// <summary>Set while an onGuestWork: wake is scheduled on the main run
/// loop; coalesces enqueue-side wake requests to one in-flight message.</summary>
private static int _guestWorkWakeScheduled;
/// <summary>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.</summary>
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() private static void RenderFrame()
{ {
MetalHostInput.PumpAutoKeys();
var pool = MetalNative.objc_autoreleasePoolPush(); var pool = MetalNative.objc_autoreleasePoolPush();
try try
{ {
// NSApp.run dispatches window events itself, so there is no manual if (Volatile.Read(ref _closeRequested))
// event drain here.
var visible = MetalNative.SendBool(_window, MetalNative.Selector("isVisible"));
if (Volatile.Read(ref _closeRequested) || !visible)
{ {
_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; return;
} }
@@ -715,8 +477,7 @@ internal static partial class MetalVideoPresenter
if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal)) if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal))
{ {
_lastWindowTitle = title; _lastWindowTitle = title;
MetalNative.SendVoid( Volatile.Read(ref _hostWindow)?.SetTitle(title);
_window, MetalNative.Selector("setTitle:"), MetalNative.NsString(title));
} }
} }
@@ -725,8 +486,20 @@ internal static partial class MetalVideoPresenter
// for a drawable, or nextDrawable keeps handing back the original // for a drawable, or nextDrawable keeps handing back the original
// resolution and Core Animation stretches it (blurry, mis-scaled // resolution and Core Animation stretches it (blurry, mis-scaled
// overlay). No-op when the size is unchanged, i.e. almost every tick. // 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(); SyncDrawableSizeToLayer();
if (hostWindow?.IsMinimized == true)
{
return;
}
var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable")); var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable"));
if (drawable == 0) if (drawable == 0)
{ {
@@ -846,43 +619,33 @@ internal static partial class MetalVideoPresenter
3); 3);
} }
private static nint CreateWindow(uint width, uint height) private static void ConfigureMetalLayer(nint device, nint layer)
{ {
var window = MetalNative.SendInitWindow( MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
MetalNative.Send(MetalNative.Class("NSWindow"), MetalNative.Selector("alloc")), MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
MetalNative.Selector("initWithContentRect:styleMask:backing:defer:"), MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
new CGRect { X = 0, Y = 0, Width = width, Height = height }, MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
WindowStyleMask,
BackingStoreBuffered, var setDisplaySync = MetalNative.Selector("setDisplaySyncEnabled:");
defer: false); if (MetalNative.SendBool(layer, MetalNative.Selector("respondsToSelector:"), setDisplaySync))
// The presenter owns the handle; AppKit must not free it on user close. {
MetalNative.SendVoidBool(window, MetalNative.Selector("setReleasedWhenClosed:"), false); MetalNative.SendVoidBool(layer, setDisplaySync, _videoOptions.VSync);
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;
} }
/// <summary>Keeps the CAMetalLayer's drawable size (pixels) matched to its /// <summary>Keeps the SDL-owned CAMetalLayer drawable in host pixels as the
/// current bounds (points) × scale as the window resizes or moves between /// window resizes or moves between displays with different scale factors.</summary>
/// displays. CAMetalLayer never updates drawableSize on its own, even as a
/// view's backing layer, so the render loop drives it.</summary>
private static void SyncDrawableSizeToLayer() private static void SyncDrawableSizeToLayer()
{ {
MetalNative.SendStretRect(out var bounds, _metalLayer, MetalNative.Selector("bounds")); var hostWindow = Volatile.Read(ref _hostWindow);
var scale = MetalNative.SendDouble(_metalLayer, MetalNative.Selector("contentsScale")); if (hostWindow is null || _metalLayer == 0)
if (scale <= 0)
{ {
scale = 1; return;
} }
var width = Math.Max(1, Math.Round(bounds.Width * scale)); var pixelSize = hostWindow.PixelSize;
var height = Math.Max(1, Math.Round(bounds.Height * scale)); var width = (double)pixelSize.Width;
var height = (double)pixelSize.Height;
if (width == _drawableWidth && height == _drawableHeight) if (width == _drawableWidth && height == _drawableHeight)
{ {
return; return;
@@ -896,57 +659,6 @@ internal static partial class MetalVideoPresenter
new CGSize { Width = width, Height = height }); 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) private static bool TryCreatePresentPipeline(nint device, out nint pipeline, out string error)
{ {
pipeline = 0; pipeline = 0;
@@ -1105,10 +817,24 @@ internal static partial class MetalVideoPresenter
{ {
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline); MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline);
// Aspect-fit letterbox: scale the frame into the drawable via the viewport. var scaleX = drawableWidth / frameWidth;
var scale = Math.Min(drawableWidth / frameWidth, drawableHeight / frameHeight); var scaleY = drawableHeight / frameHeight;
var viewportWidth = frameWidth * scale; var viewportWidth = drawableWidth;
var viewportHeight = frameHeight * scale; 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( MetalNative.SendVoidViewport(
encoder, encoder,
MetalNative.Selector("setViewport:"), MetalNative.Selector("setViewport:"),
@@ -378,8 +378,10 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageFill(ulong address, uint fillValue) => public void SubmitGuestImageFill(ulong address, uint fillValue) =>
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue); VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
public void SubmitGuestImageWrite(ulong address, byte[] pixels) => public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels); VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels, rowOffset);
public bool SupportsPartialImageWrite => true;
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) => public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
VulkanVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount); VulkanVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
@@ -129,6 +129,24 @@ public static partial class KernelMemoryCompatExports
private static readonly HashSet<string> _negativeStatCache = new(HostFsPathComparer); private static readonly HashSet<string> _negativeStatCache = new(HostFsPathComparer);
private static readonly ConcurrentDictionary<string, ulong> _aprFileSizeCache = new(HostFsPathComparer); private static readonly ConcurrentDictionary<string, ulong> _aprFileSizeCache = new(HostFsPathComparer);
private static long _nextFileDescriptor = 2; 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<char> 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() internal static int AllocateGuestFileDescriptor()
{ {
@@ -5350,7 +5368,7 @@ public static partial class KernelMemoryCompatExports
} }
else else
{ {
root = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "logs", "devlog", "app")); root = Path.Combine(ResolveGameLogRoot(), "devlog", "app");
} }
Directory.CreateDirectory(root); Directory.CreateDirectory(root);
@@ -5419,14 +5437,20 @@ public static partial class KernelMemoryCompatExports
} }
else else
{ {
root = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "logs", "hostapp")); root = Path.Combine(ResolveGameLogRoot(), "hostapp");
Environment.SetEnvironmentVariable(hostappVariableName, root);
} }
Directory.CreateDirectory(root); Directory.CreateDirectory(root);
return root; return root;
} }
private static string ResolveGameLogRoot() =>
Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"user",
"game_logs",
Volatile.Read(ref _applicationTitleId)));
private static string GetPerAppWritableRoot() private static string GetPerAppWritableRoot()
{ {
var app0Root = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR"); var app0Root = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
@@ -35,9 +35,13 @@ public static class KernelPthreadCompatExports
private static readonly bool _tracePthreadConds = private static readonly bool _tracePthreadConds =
_tracePthreads || _tracePthreads ||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_CONDS"), "1", StringComparison.Ordinal); 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<ulong>? _tracePthreadMutexFilter = ParseTraceAddressFilter( private static readonly HashSet<ulong>? _tracePthreadMutexFilter = ParseTraceAddressFilter(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_MUTEX_FILTER")); Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_MUTEX_FILTER"));
private static long _nextSynchronizationWaiterId; private static long _nextSynchronizationWaiterId;
private static int _pthreadFastPathTraceWritten;
private static readonly ConcurrentDictionary<ulong, byte> _pthreadFastPathBusyTraced = new();
private sealed class PthreadMutexState private sealed class PthreadMutexState
{ {
@@ -809,6 +813,7 @@ public static class KernelPthreadCompatExports
if (!TryResolveMutexState(ctx, mutexAddress, createIfZero: true, out var resolvedAddress, out var state)) 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); 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; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
} }
@@ -861,6 +866,7 @@ public static class KernelPthreadCompatExports
var ownedResult = tryOnly var ownedResult = tryOnly
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY ? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK; : (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); TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
return ownedResult; return ownedResult;
} }
@@ -949,6 +955,7 @@ public static class KernelPthreadCompatExports
if (tryOnly) 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); TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
return (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) if (state.RecursionCount <= 0)
{ {
TracePthreadFastPathUnlock(ctx, mutexAddress, resolvedAddress, state, currentThreadId);
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
return (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; 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; return mutexAddress;
@@ -1212,13 +1226,35 @@ public static class KernelPthreadCompatExports
return false; return false;
} }
var hasPointedHandle = KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress, out var pointedHandle);
if (_mutexStates.TryGetValue(mutexAddress, out state)) 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; resolvedAddress = mutexAddress;
return true; return true;
} }
if (!KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress, out var pointedHandle)) if (!hasPointedHandle)
{ {
return false; return false;
} }
@@ -2163,6 +2199,60 @@ public static class KernelPthreadCompatExports
$"recursion={(state?.RecursionCount ?? 0)} type={(state?.Type ?? 0)} result=0x{unchecked((uint)result):X8}"); $"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<byte> 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<ulong> 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) private static void TracePthreadCond(string operation, ulong condAddress, ulong mutexAddress, PthreadCondState? state, bool timed, int result)
{ {
if (!_tracePthreadConds) if (!_tracePthreadConds)
+45 -10
View File
@@ -30,7 +30,7 @@ public static class Ngs2Exports
// The grain length defaults to 256 frames (matching the 8192-byte AudioOut // The grain length defaults to 256 frames (matching the 8192-byte AudioOut
// buffers games copy it into) until the title overrides it. // buffers games copy it into) until the title overrides it.
private const int DefaultGrainSamples = 256; private const int DefaultGrainSamples = 256;
private const double OutputSampleRate = 48000.0; private const int DefaultSampleRate = 48000;
private sealed class SystemState private sealed class SystemState
{ {
@@ -38,6 +38,7 @@ public static class Ngs2Exports
public uint Uid { get; } public uint Uid { get; }
public int GrainSamples { get; set; } = DefaultGrainSamples; public int GrainSamples { get; set; } = DefaultGrainSamples;
public int SampleRate { get; set; } = DefaultSampleRate;
} }
private sealed record RackState(ulong SystemHandle, uint RackId); 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); return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
Span<byte> renderBufferInfo = stackalloc byte[RenderBufferInfoSize];
for (uint i = 0; i < bufferInfoCount; i++) for (uint i = 0; i < bufferInfoCount; i++)
{ {
var entryAddress = bufferInfoAddress + (i * RenderBufferInfoSize); var entryAddress = bufferInfoAddress + (i * RenderBufferInfoSize);
@@ -527,10 +529,9 @@ public static class Ngs2Exports
if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4) if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4)
{ {
Span<byte> rbi = stackalloc byte[RenderBufferInfoSize]; ctx.Memory.TryRead(entryAddress, renderBufferInfo);
ctx.Memory.TryRead(entryAddress, rbi);
Console.Error.WriteLine( 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) CpuContext ctx, ulong systemHandle, ulong bufferAddress, ulong bufferSize, int channels)
{ {
int grain; int grain;
int sampleRate;
lock (StateGate) lock (StateGate)
{ {
if (!Systems.TryGetValue(systemHandle, out var system)) if (!Systems.TryGetValue(systemHandle, out var system))
@@ -560,6 +562,7 @@ public static class Ngs2Exports
} }
grain = system.GrainSamples; grain = system.GrainSamples;
sampleRate = system.SampleRate;
} }
var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float))); var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float)));
@@ -590,7 +593,7 @@ public static class Ngs2Exports
continue; continue;
} }
MixOneVoice(accum, capacityFrames, channels, voice); MixOneVoice(accum, capacityFrames, channels, sampleRate, voice);
mixedAnything = true; 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 / // it to the front stereo pair. Advances the voice cursor and handles loop /
// one-shot end. Must be called under StateGate. // 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 pcm = voice.Pcm!;
var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length; var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length;
var loopStart = voice.LoopStart; var loopStart = voice.LoopStart;
var step = voice.SourceRate / OutputSampleRate; var step = voice.SourceRate / (double)outputSampleRate;
var gain = voice.Gain / 32768f; var gain = voice.Gain / 32768f;
var pos = voice.Position; var pos = voice.Position;
for (var f = 0; f < frames; f++) for (var f = 0; f < frames; f++)
@@ -640,7 +648,14 @@ public static class Ngs2Exports
break; 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; var baseIndex = f * channels;
accum[baseIndex] += sample; accum[baseIndex] += sample;
if (channels > 1) if (channels > 1)
@@ -736,7 +751,27 @@ public static class Ngs2Exports
ExportName = "sceNgs2SystemSetSampleRate", ExportName = "sceNgs2SystemSetSampleRate",
Target = Generation.Gen4 | Generation.Gen5, Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")] 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( [SysAbiExport(
Nid = "gThZqM5PYlQ", Nid = "gThZqM5PYlQ",
+152 -214
View File
@@ -2,138 +2,135 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host; using SharpEmu.HLE.Host;
using SharpEmu.HLE.Host.Posix;
using Silk.NET.Input;
namespace SharpEmu.Libs.Pad; namespace SharpEmu.Libs.Pad;
/// <summary> /// <summary>Cross-platform input state supplied by the SDL game window.</summary>
/// 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.
/// </summary>
public static class HostWindowInput public static class HostWindowInput
{ {
private static readonly object Gate = new(); private static readonly object Gate = new();
private static readonly HashSet<Key> Pressed = new(); private static readonly HashSet<int> PressedKeys = new();
private static volatile bool _connected; private static bool _focused;
// Latest window-gamepad snapshot in the host seam's conventions.
private static bool _gamepadConnected; private static bool _gamepadConnected;
private static string? _gamepadName; private static string? _gamepadName;
private static HostGamepadButtons _gamepadButtons; private static HostGamepadState _gamepadState;
private static byte _gamepadLeftX = 128; private static IHostGamepadOutput? _gamepadOutput;
private static byte _gamepadLeftY = 128; private static readonly WindowInputSource Source = new();
private static byte _gamepadRightX = 128;
private static byte _gamepadRightY = 128;
private static byte _gamepadL2;
private static byte _gamepadR2;
/// <summary>True once a window keyboard is delivering events.</summary> public static void Connect(IHostGamepadOutput? gamepadOutput = null)
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)
{ {
lock (Gate) 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) public bool IsKeyDown(int virtualKey)
{ {
return TryMapVirtualKey(virtualKey, out var key) && HostWindowInput.IsKeyDown(key); lock (Gate)
{
return PressedKeys.Contains(virtualKey);
}
} }
public int GetGamepadStates(Span<HostGamepadState> destination) public int GetGamepadStates(Span<HostGamepadState> destination)
{ {
lock (Gate) lock (Gate)
{ {
if (!_gamepadConnected || destination.Length == 0) if (!_gamepadConnected || destination.IsEmpty)
{ {
return 0; return 0;
} }
destination[0] = new HostGamepadState( destination[0] = _gamepadState;
Connected: true,
Buttons: _gamepadButtons,
LeftX: _gamepadLeftX,
LeftY: _gamepadLeftY,
RightX: _gamepadRightX,
RightY: _gamepadRightY,
LeftTrigger: _gamepadL2,
RightTrigger: _gamepadR2);
return 1; return 1;
} }
} }
@@ -142,139 +139,80 @@ public static class HostWindowInput
{ {
lock (Gate) lock (Gate)
{ {
return _gamepadConnected ? _gamepadName ?? "GLFW gamepad" : null; return _gamepadConnected ? _gamepadName ?? "SDL gamepad" : null;
} }
} }
}
private static bool TryMapVirtualKey(int vk, out Key key) public void SetRumble(byte largeMotor, byte smallMotor)
{
key = vk switch
{ {
0x08 => Key.Backspace, IHostGamepadOutput? output;
0x09 => Key.Tab, lock (Gate)
0x0D => Key.Enter, {
0x1B => Key.Escape, output = _gamepadOutput;
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;
}
private static void AttachGamepad(IGamepad gamepad) output?.SetRumble(largeMotor, smallMotor);
{
lock (Gate)
{
_gamepadConnected = true;
_gamepadName = gamepad.Name;
} }
gamepad.ButtonDown += (_, button) => public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
{ {
var bit = MapButton(button.Name); IHostGamepadOutput? output;
if (bit == HostGamepadButtons.None) lock (Gate)
{ {
return; output = _gamepadOutput;
} }
output?.SetTriggerRumble(leftTrigger, rightTrigger);
}
public void SetAdaptiveTriggerEffect(
HostAdaptiveTriggerEffect? leftTrigger,
HostAdaptiveTriggerEffect? rightTrigger)
{
IHostGamepadOutput? output;
lock (Gate) lock (Gate)
{ {
_gamepadButtons |= bit; output = _gamepadOutput;
}
};
gamepad.ButtonUp += (_, button) =>
{
var bit = MapButton(button.Name);
if (bit == HostGamepadButtons.None)
{
return;
} }
lock (Gate) output?.SetAdaptiveTriggerEffect(leftTrigger, rightTrigger);
{ }
_gamepadButtons &= ~bit;
} public void SetLightbar(byte red, byte green, byte blue)
};
gamepad.ThumbstickMoved += (_, thumbstick) =>
{ {
// Silk's GLFW backend reports sticks -1..1 with +Y pointing down, IHostGamepadOutput? output;
// matching the seam's 0..255 down-growing convention after biasing.
var x = ToStickByte(thumbstick.X);
var y = ToStickByte(thumbstick.Y);
lock (Gate) lock (Gate)
{ {
if (thumbstick.Index == 0) output = _gamepadOutput;
{
_gamepadLeftX = x;
_gamepadLeftY = y;
}
else
{
_gamepadRightX = x;
_gamepadRightY = y;
}
} }
};
gamepad.TriggerMoved += (_, trigger) => output?.SetLightbar(red, green, blue);
}
public void ResetLightbar()
{ {
// GLFW gamepad triggers rest at -1 and saturate at +1. IHostGamepadOutput? output;
var value = (byte)Math.Clamp((int)((trigger.Position + 1.0f) * 0.5f * 255.0f), 0, 255);
lock (Gate) lock (Gate)
{ {
if (trigger.Index == 0) output = _gamepadOutput;
{
_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?.ResetLightbar();
}
} }
}
internal static byte ToStickByte(float value)
{ public interface IHostGamepadOutput
return (byte)Math.Clamp((int)MathF.Round((value + 1.0f) * 127.5f), 0, 255); {
} void SetRumble(byte largeMotor, byte smallMotor);
private static HostGamepadButtons MapButton(ButtonName name) => name switch void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
{
// GLFW reports the Xbox layout: A=Cross, B=Circle, X=Square, Y=Triangle. void SetAdaptiveTriggerEffect(
ButtonName.A => HostGamepadButtons.Cross, HostAdaptiveTriggerEffect? leftTrigger,
ButtonName.B => HostGamepadButtons.Circle, HostAdaptiveTriggerEffect? rightTrigger);
ButtonName.X => HostGamepadButtons.Square,
ButtonName.Y => HostGamepadButtons.Triangle, void SetLightbar(byte red, byte green, byte blue);
ButtonName.LeftBumper => HostGamepadButtons.L1,
ButtonName.RightBumper => HostGamepadButtons.R1, void ResetLightbar();
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,
};
} }
+220 -14
View File
@@ -37,6 +37,7 @@ public static class PadExports
private static PadState _cachedInputState; private static PadState _cachedInputState;
private static bool _initialized; private static bool _initialized;
private static int _motionSensorEnabled;
private static int _controlsAnnouncementLogged; private static int _controlsAnnouncementLogged;
[SysAbiExport( [SysAbiExport(
@@ -151,9 +152,13 @@ public static class PadExports
public static int PadSetMotionSensorState(CpuContext ctx) public static int PadSetMotionSensorState(CpuContext ctx)
{ {
var handle = unchecked((int)ctx[CpuRegister.Rdi]); var handle = unchecked((int)ctx[CpuRegister.Rdi]);
return IsPrimaryPadHandle(handle) if (!IsPrimaryPadHandle(handle))
? ctx.SetReturn(0) {
: ctx.SetReturn(OrbisPadErrorInvalidHandle); return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
Volatile.Write(ref _motionSensorEnabled, ctx[CpuRegister.Rsi] != 0 ? 1 : 0);
return ctx.SetReturn(0);
} }
[SysAbiExport( [SysAbiExport(
@@ -362,24 +367,170 @@ public static class PadExports
} }
var triggerMask = parameter[0]; var triggerMask = parameter[0];
HostPlatform.Current.Input.SetTriggerRumble( HostPlatform.Current.Input.SetAdaptiveTriggerEffect(
(triggerMask & 0x01) != 0 ? DecodeTriggerVibration(parameter[8..64]) : null, (triggerMask & 0x01) != 0 ? DecodeTriggerEffect(parameter[8..64]) : null,
(triggerMask & 0x02) != 0 ? DecodeTriggerVibration(parameter[64..120]) : null); (triggerMask & 0x02) != 0 ? DecodeTriggerEffect(parameter[64..120]) : null);
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK); return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
} }
private static byte DecodeTriggerVibration(ReadOnlySpan<byte> command) private static HostAdaptiveTriggerEffect DecodeTriggerEffect(ReadOnlySpan<byte> command)
{ {
var mode = BinaryPrimitives.ReadUInt32LittleEndian(command); var mode = BinaryPrimitives.ReadUInt32LittleEndian(command);
var amplitude = mode switch var parameters = command[8..];
Span<byte> native = stackalloc byte[11];
native.Clear();
byte fallbackStrength = 0;
switch (mode)
{ {
3 when command[10] != 0 => command[9], case 1:
6 when command[8] != 0 => command[9..19].ToArray().Max(), EncodeFeedback(native, parameters[0], parameters[1]);
_ => (byte)0, fallbackStrength = ScaleTriggerStrength(parameters[1]);
}; break;
return (byte)(Math.Min(amplitude, (byte)8) * 255 / 8); 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<byte> destination, byte position, byte strength)
{
if (position > 9 || strength is 0 or > 8)
{
destination[0] = 0x05;
return;
}
Span<byte> strengths = stackalloc byte[10];
strengths[position..].Fill(strength);
EncodeZonedStrengths(destination, 0x21, strengths, 0);
}
private static void EncodeWeapon(Span<byte> 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<byte> destination,
byte nativeMode,
byte position,
byte strength,
byte frequency)
{
if (position > 9 || strength is 0 or > 8 || frequency == 0)
{
destination[0] = 0x05;
return;
}
Span<byte> strengths = stackalloc byte[10];
strengths[position..].Fill(strength);
EncodeZonedStrengths(destination, nativeMode, strengths, frequency);
}
private static void EncodeSlope(
Span<byte> 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<byte> 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<byte> destination,
byte nativeMode,
ReadOnlySpan<byte> 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<byte> 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( [SysAbiExport(
Nid = "yFVnOdGxvZY", Nid = "yFVnOdGxvZY",
ExportName = "scePadSetVibration", ExportName = "scePadSetVibration",
@@ -478,6 +629,17 @@ public static class PadExports
data[0x08] = l2; data[0x08] = l2;
data[0x09] = r2; data[0x09] = r2;
BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f); 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; data[0x4C] = 1;
var timestampTicks = Stopwatch.GetTimestamp(); var timestampTicks = Stopwatch.GetTimestamp();
var timestampMicroseconds = var timestampMicroseconds =
@@ -508,6 +670,10 @@ public static class PadExports
var rightY = acceptsKeyboardInput ? ReadAnalogStick(input.IsKeyDown(0x49), input.IsKeyDown(0x4B)) : (byte)128; var rightY = acceptsKeyboardInput ? ReadAnalogStick(input.IsKeyDown(0x49), input.IsKeyDown(0x4B)) : (byte)128;
var l2 = acceptsKeyboardInput && input.IsKeyDown(0x52) ? (byte)255 : (byte)0; var l2 = acceptsKeyboardInput && input.IsKeyDown(0x52) ? (byte)255 : (byte)0;
var r2 = acceptsKeyboardInput && input.IsKeyDown(0x46) ? (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<HostGamepadState> gamepads = stackalloc HostGamepadState[2]; Span<HostGamepadState> gamepads = stackalloc HostGamepadState[2];
var gamepadCount = input.GetGamepadStates(gamepads); var gamepadCount = input.GetGamepadStates(gamepads);
@@ -523,6 +689,13 @@ public static class PadExports
rightY = MergeAxis(pad.RightY, rightY); rightY = MergeAxis(pad.RightY, rightY);
l2 = Math.Max(l2, pad.LeftTrigger); l2 = Math.Max(l2, pad.LeftTrigger);
r2 = Math.Max(r2, pad.RightTrigger); r2 = Math.Max(r2, pad.RightTrigger);
if (index == 0)
{
gamepadType = pad.Type;
connection = pad.Connection;
motion = pad.Motion;
touch = pad.Touch;
}
} }
if (IsAutoCrossActive()) if (IsAutoCrossActive())
@@ -538,7 +711,11 @@ public static class PadExports
RightX: rightX, RightX: rightX,
RightY: rightY, RightY: rightY,
L2: l2, L2: l2,
R2: r2); R2: r2,
Type: gamepadType,
Connection: connection,
Motion: motion,
Touch: touch);
_lastInputSampleTicks = now; _lastInputSampleTicks = now;
return _cachedInputState; return _cachedInputState;
} }
@@ -592,6 +769,7 @@ public static class PadExports
private static uint ToOrbisButtons(HostGamepadButtons buttons) private static uint ToOrbisButtons(HostGamepadButtons buttons)
{ {
uint result = 0; uint result = 0;
if ((buttons & HostGamepadButtons.Create) != 0) result |= OrbisPadButton.Share;
if ((buttons & HostGamepadButtons.Up) != 0) result |= OrbisPadButton.Up; if ((buttons & HostGamepadButtons.Up) != 0) result |= OrbisPadButton.Up;
if ((buttons & HostGamepadButtons.Down) != 0) result |= OrbisPadButton.Down; if ((buttons & HostGamepadButtons.Down) != 0) result |= OrbisPadButton.Down;
if ((buttons & HostGamepadButtons.Left) != 0) result |= OrbisPadButton.Left; if ((buttons & HostGamepadButtons.Left) != 0) result |= OrbisPadButton.Left;
@@ -611,6 +789,34 @@ public static class PadExports
return result; return result;
} }
private static void WriteTouchData(Span<byte> data, HostTouchState touch)
{
Span<HostTouchPoint> 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) private static uint ReadKeyboardButtons(IHostInput input)
{ {
uint buttons = 0; uint buttons = 0;
+8 -1
View File
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Pad; namespace SharpEmu.Libs.Pad;
/// <summary> /// <summary>
@@ -16,11 +18,16 @@ internal readonly record struct PadState(
byte RightX, byte RightX,
byte RightY, byte RightY,
byte L2, byte L2,
byte R2); byte R2,
HostGamepadType Type = HostGamepadType.Generic,
HostGamepadConnection Connection = HostGamepadConnection.Unknown,
HostMotionState Motion = default,
HostTouchState Touch = default);
/// <summary>SCE_PAD_BUTTON bit values.</summary> /// <summary>SCE_PAD_BUTTON bit values.</summary>
internal static class OrbisPadButton internal static class OrbisPadButton
{ {
internal const uint Share = 0x0001;
internal const uint L3 = 0x0002; internal const uint L3 = 0x0002;
internal const uint R3 = 0x0004; internal const uint R3 = 0x0004;
internal const uint Options = 0x0008; internal const uint Options = 0x0008;
@@ -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;
}
}
}
@@ -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;
/// <summary>Cross-platform SDL gamepad polling for launcher navigation.</summary>
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();
}
}
+20 -2
View File
@@ -38,6 +38,7 @@ public static class SaveDataExports
private static readonly object _memoryGate = new(); private static readonly object _memoryGate = new();
private static readonly HashSet<int> _preparedTransactionResources = []; private static readonly HashSet<int> _preparedTransactionResources = [];
private static string? _titleId; private static string? _titleId;
private static int _legacySaveMigrationChecked;
public static void ConfigureApplicationInfo(string? titleId) 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 // Saves are keyed by title id only (single-user emulation) under
// ~/SharpEmu/Saves/<titleId>/; userId is accepted for API fidelity but not // user/savedata/<titleId>/; userId is accepted for API fidelity but not
// part of the host path. // part of the host path.
private static string ResolveTitleSaveRoot(int userId, string titleId) => private static string ResolveTitleSaveRoot(int userId, string titleId) =>
SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId); SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId);
@@ -1133,7 +1134,24 @@ public static class SaveDataExports
ctx.TryReadUInt64(address + 0x10, out offset); 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() private static string ResolveConfiguredTitleId()
{ {

Some files were not shown because too many files have changed in this diff Show More