mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-28 13:40:58 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6be44dd750 | |||
| 2b6bd5a532 | |||
| b4cc5f88ca | |||
| db4339f698 | |||
| 0535783f46 | |||
| 99004a3ccd |
@@ -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</SharpEmuVersion>
|
||||||
<Version>$(SharpEmuVersion)</Version>
|
<Version>$(SharpEmuVersion)</Version>
|
||||||
|
|
||||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||||
|
|||||||
@@ -7,23 +7,23 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Avalonia" Version="11.3.18" />
|
<PackageVersion Include="Avalonia" Version="12.1.0" />
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.18" />
|
<PackageVersion Include="Avalonia.Desktop" Version="12.1.0" />
|
||||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.18" />
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.0" />
|
||||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.18" />
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.0" />
|
||||||
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
|
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
|
||||||
<PackageVersion Include="Iced" Version="1.21.0" />
|
<PackageVersion Include="Iced" Version="1.21.0" />
|
||||||
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
|
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||||
<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="Silk.NET.Input" Version="2.23.0" />
|
<PackageVersion Include="NLayer" Version="1.14.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 to fix GHSA-xrw6-gwf8-vvr9 -->
|
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.94.1" />
|
||||||
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.21.3" />
|
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.1" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -13,16 +13,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
An experimental PlayStation 5 emulator for Windows, Linux and macOS.
|
An experimental PlayStation 5 emulator for Windows, Linux and macOS.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://discord.gg/6GejPEDqpc">
|
|
||||||
<img src="https://img.shields.io/badge/Discord-Join%20our%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<strong>Join our Discord for development updates, compatibility discussions, support, and community chat.</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ path = [
|
|||||||
"global.json",
|
"global.json",
|
||||||
"**/packages.lock.json",
|
"**/packages.lock.json",
|
||||||
"scripts/ps5_names.txt",
|
"scripts/ps5_names.txt",
|
||||||
|
"src/SharpEmu.LibAtrac9/**",
|
||||||
"src/SharpEmu.GUI/Languages/**",
|
"src/SharpEmu.GUI/Languages/**",
|
||||||
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
|
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
|
||||||
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
|
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
|
||||||
|
|||||||
@@ -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
@@ -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) ||
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -55,6 +64,7 @@ public sealed partial class DirectExecutionBackend
|
|||||||
}
|
}
|
||||||
_exceptionHandler = (nint)AddVectoredExceptionHandler(1u, _exceptionHandlerStub);
|
_exceptionHandler = (nint)AddVectoredExceptionHandler(1u, _exceptionHandlerStub);
|
||||||
Console.Error.WriteLine($"[LOADER][INFO] Exception handler installed: 0x{_exceptionHandler:X16}");
|
Console.Error.WriteLine($"[LOADER][INFO] Exception handler installed: 0x{_exceptionHandler:X16}");
|
||||||
|
SharpEmu.HLE.GuestImageWriteTracker.WarmUp();
|
||||||
|
|
||||||
_unhandledFilterDelegate = UnhandledExceptionFilter;
|
_unhandledFilterDelegate = UnhandledExceptionFilter;
|
||||||
_unhandledFilterHandle = GCHandle.Alloc(_unhandledFilterDelegate);
|
_unhandledFilterHandle = GCHandle.Alloc(_unhandledFilterDelegate);
|
||||||
@@ -117,6 +127,13 @@ public sealed partial class DirectExecutionBackend
|
|||||||
{
|
{
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
if (exceptionCode == 3221225477u &&
|
||||||
|
exceptionRecord->NumberParameters >= 2 &&
|
||||||
|
SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
|
||||||
|
exceptionRecord->ExceptionInformation[1]))
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
if (TryRecoverAuxiliaryThreadExecuteFault(exceptionRecord, contextRecord, rip))
|
if (TryRecoverAuxiliaryThreadExecuteFault(exceptionRecord, contextRecord, rip))
|
||||||
{
|
{
|
||||||
return -1;
|
return -1;
|
||||||
|
|||||||
@@ -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,9 +72,45 @@ public sealed partial class DirectExecutionBackend
|
|||||||
|
|
||||||
private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo)
|
private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo)
|
||||||
{
|
{
|
||||||
|
if (TryHandleGuestImageWriteFault(exceptionInfo))
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -165,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)
|
||||||
{
|
{
|
||||||
@@ -178,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);
|
||||||
@@ -1444,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) &&
|
||||||
@@ -1461,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 &&
|
||||||
@@ -1469,7 +1519,8 @@ public sealed partial class DirectExecutionBackend
|
|||||||
!expectedPollSemaBusy &&
|
!expectedPollSemaBusy &&
|
||||||
!expectedNetAcceptWouldBlock &&
|
!expectedNetAcceptWouldBlock &&
|
||||||
!expectedUserServiceNoEvent &&
|
!expectedUserServiceNoEvent &&
|
||||||
!expectedPrivacyInvalidParameter)
|
!expectedPrivacyInvalidParameter &&
|
||||||
|
!expectedPlayGoChunkEnumerationEnd)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using SharpEmu.Core.Cpu.Debugging;
|
|||||||
using SharpEmu.Core.Loader;
|
using SharpEmu.Core.Loader;
|
||||||
using SharpEmu.Core.Memory;
|
using SharpEmu.Core.Memory;
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
|
using SharpEmu.Libs.Diagnostics;
|
||||||
|
|
||||||
namespace SharpEmu.Core.Cpu.Native;
|
namespace SharpEmu.Core.Cpu.Native;
|
||||||
|
|
||||||
@@ -3651,6 +3652,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
$"[LOADER][INFO] Scheduled guest thread '{thread.Name}' handle=0x{thread.ThreadHandle:X16} " +
|
$"[LOADER][INFO] Scheduled guest thread '{thread.Name}' handle=0x{thread.ThreadHandle:X16} " +
|
||||||
$"entry=0x{thread.EntryPoint:X16} arg=0x{thread.Argument:X16} priority={thread.Priority} " +
|
$"entry=0x{thread.EntryPoint:X16} arg=0x{thread.Argument:X16} priority={thread.Priority} " +
|
||||||
$"host_priority={MapGuestThreadPriority(thread.Priority)} affinity=0x{thread.AffinityMask:X}");
|
$"host_priority={MapGuestThreadPriority(thread.Priority)} affinity=0x{thread.AffinityMask:X}");
|
||||||
|
LoadProgressDiagnostics.ArmIfNorthAudioThread(thread.Name);
|
||||||
Pump(creatorContext, "pthread_create");
|
Pump(creatorContext, "pthread_create");
|
||||||
// Pump is suppressed while another cooperative dispatch is active. The
|
// Pump is suppressed while another cooperative dispatch is active. The
|
||||||
// background dispatcher would eventually observe this thread, but an
|
// background dispatcher would eventually observe this thread, but an
|
||||||
@@ -5312,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)
|
||||||
{
|
{
|
||||||
@@ -5323,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)
|
||||||
|
|||||||
@@ -238,15 +238,22 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
|||||||
var alignedSize = (size + 0xFFF) & ~0xFFFUL;
|
var alignedSize = (size + 0xFFF) & ~0xFFFUL;
|
||||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||||
|
var allowLazyReserve = !executable &&
|
||||||
// Reserve address space only for very large non-executable regions; commit is done lazily later.
|
|
||||||
var reservedOnly = !executable &&
|
|
||||||
alignedSize >= LargeDataReserveThreshold &&
|
alignedSize >= LargeDataReserveThreshold &&
|
||||||
alignedSize > FullCommitRegionLimit;
|
alignedSize > FullCommitRegionLimit;
|
||||||
|
|
||||||
var result = reservedOnly
|
// Commit first so titles that walk guest memory via raw host pointers
|
||||||
? _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite)
|
// (GTA post-RenderThread workers) keep fully backed pages. Fall back to
|
||||||
: _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
// reserve-only + lazy commit only when a huge non-exec commit fails —
|
||||||
|
// that is the Poppy / large-reservation path #608 was aiming for.
|
||||||
|
var reservedOnly = false;
|
||||||
|
var result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||||
|
if (result == 0 && allowLazyReserve)
|
||||||
|
{
|
||||||
|
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||||
|
reservedOnly = result != 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (result == 0)
|
if (result == 0)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -260,7 +267,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var state = reservedOnly ? ReserveRegion(actualAddress, alignedSize) : "n/a";
|
var lazyPrimeState = reservedOnly ? PrimeLazyReserveRegion(actualAddress, alignedSize) : "n/a";
|
||||||
|
|
||||||
_gate.EnterWriteLock();
|
_gate.EnterWriteLock();
|
||||||
try
|
try
|
||||||
@@ -279,9 +286,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
|||||||
_gate.ExitWriteLock();
|
_gate.ExitWriteLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var allocationKind = reservedOnly
|
||||||
var allocationKind = executable ? "executable memory" : "data memory";
|
? "reserved data memory (lazy commit)"
|
||||||
TraceVmem($"Allocated exact {allocationKind}: 0x{actualAddress:X16} - 0x{actualAddress + alignedSize:X16} ({alignedSize} bytes)");
|
: (executable ? "executable memory" : "data memory");
|
||||||
|
TraceVmem(
|
||||||
|
$"Allocated exact {allocationKind}: 0x{actualAddress:X16} - 0x{actualAddress + alignedSize:X16} " +
|
||||||
|
$"({alignedSize} bytes) lazy_prime={lazyPrimeState}");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,55 +322,44 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
|||||||
|
|
||||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||||
var reservedOnly = false;
|
var allowLazyReserve = !executable &&
|
||||||
var preferReserveOnly = !executable &&
|
|
||||||
alignedSize >= LargeDataReserveThreshold &&
|
alignedSize >= LargeDataReserveThreshold &&
|
||||||
alignedSize > FullCommitRegionLimit;
|
alignedSize > FullCommitRegionLimit;
|
||||||
|
var reservedOnly = false;
|
||||||
|
|
||||||
ulong result = 0;
|
// Prefer a full commit. Only fall back to reserve-only when a large
|
||||||
if (preferReserveOnly)
|
// non-executable commit cannot be satisfied (see TryAllocateAtExact).
|
||||||
{
|
ulong result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
|
||||||
if (result == 0 && allowAlternative)
|
|
||||||
{
|
|
||||||
result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result != 0)
|
|
||||||
{
|
|
||||||
reservedOnly = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result == 0)
|
|
||||||
{
|
|
||||||
result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result == 0)
|
if (result == 0)
|
||||||
{
|
{
|
||||||
if (!allowAlternative)
|
if (!allowAlternative)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"Failed to allocate exact mapping at 0x{desiredAddress:X16} ({alignedSize} bytes)");
|
if (allowLazyReserve)
|
||||||
}
|
|
||||||
|
|
||||||
TraceVmem($"Could not allocate at 0x{desiredAddress:X16}, trying any address...");
|
|
||||||
result = _hostMemory.Allocate(0, alignedSize, hostProtection);
|
|
||||||
|
|
||||||
if (result == 0)
|
|
||||||
{
|
|
||||||
if (!executable)
|
|
||||||
{
|
{
|
||||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||||
if (result == 0 && allowAlternative)
|
reservedOnly = result != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to allocate exact mapping at 0x{desiredAddress:X16} ({alignedSize} bytes)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TraceVmem($"Could not allocate at 0x{desiredAddress:X16}, trying any address...");
|
||||||
|
result = _hostMemory.Allocate(0, alignedSize, hostProtection);
|
||||||
|
|
||||||
|
if (result == 0 && allowLazyReserve)
|
||||||
|
{
|
||||||
|
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||||
|
if (result == 0)
|
||||||
{
|
{
|
||||||
result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
|
result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result != 0)
|
reservedOnly = result != 0;
|
||||||
{
|
|
||||||
reservedOnly = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result == 0)
|
if (result == 0)
|
||||||
@@ -371,8 +370,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
|||||||
}
|
}
|
||||||
|
|
||||||
var actualAddress = result;
|
var actualAddress = result;
|
||||||
|
var lazyPrimeState = reservedOnly ? PrimeLazyReserveRegion(actualAddress, alignedSize) : "n/a";
|
||||||
var lazyPrimeState = reservedOnly ? ReserveRegion(actualAddress, alignedSize) : "n/a";
|
|
||||||
|
|
||||||
_gate.EnterWriteLock();
|
_gate.EnterWriteLock();
|
||||||
try
|
try
|
||||||
@@ -399,7 +397,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
|||||||
return actualAddress;
|
return actualAddress;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ReserveRegion(ulong actualAddress, ulong alignedSize)
|
/// <summary>
|
||||||
|
/// Commits the leading slice of a reserve-only region so early guest touches
|
||||||
|
/// succeed before on-demand <see cref="EnsureRangeCommitted"/> runs.
|
||||||
|
/// </summary>
|
||||||
|
private string PrimeLazyReserveRegion(ulong actualAddress, ulong alignedSize)
|
||||||
{
|
{
|
||||||
var primeBytes = Math.Min(alignedSize, LazyReservePrimeBytes);
|
var primeBytes = Math.Min(alignedSize, LazyReservePrimeBytes);
|
||||||
if (primeBytes == 0)
|
if (primeBytes == 0)
|
||||||
@@ -426,11 +428,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
|||||||
var state = committedBytes == primeBytes
|
var state = committedBytes == primeBytes
|
||||||
? $"ok:{committedBytes:X}"
|
? $"ok:{committedBytes:X}"
|
||||||
: $"partial:{committedBytes:X}/{primeBytes:X}";
|
: $"partial:{committedBytes:X}/{primeBytes:X}";
|
||||||
TraceVmem($"region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
|
TraceVmem($"Primed lazy region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
TraceVmem($"Failed to reserve region at 0x{actualAddress:X16} ({primeBytes} bytes)!");
|
TraceVmem($"Failed to prime lazy region at 0x{actualAddress:X16} ({primeBytes} bytes), continuing with on-demand commit");
|
||||||
return $"fail:{primeBytes:X}";
|
return $"fail:{primeBytes:X}";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1316,12 +1318,26 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var region = FindRegion(virtualAddress, 1);
|
var region = FindRegion(virtualAddress, 1);
|
||||||
if (region is null ||
|
if (region is null)
|
||||||
(region.IsReservedOnly && !EnsureRangeCommitted(virtualAddress, 1, region)))
|
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raw host pointers are walked by native/JIT code without further
|
||||||
|
// EnsureRangeCommitted calls. For reserve-only regions, commit a
|
||||||
|
// leading working-set chunk from this address so the common case
|
||||||
|
// does not immediately AV on the next page.
|
||||||
|
if (region.IsReservedOnly)
|
||||||
|
{
|
||||||
|
var regionEnd = region.VirtualAddress + region.Size;
|
||||||
|
var remaining = regionEnd > virtualAddress ? regionEnd - virtualAddress : 0;
|
||||||
|
var commitBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
|
||||||
|
if (commitBytes == 0 || !EnsureRangeCommitted(virtualAddress, commitBytes, region))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (void*)virtualAddress;
|
return (void*)virtualAddress;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Avalonia.Collections;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Templates;
|
using Avalonia.Controls.Templates;
|
||||||
using Avalonia.Data;
|
using Avalonia.Data;
|
||||||
|
using Avalonia.Input.Platform;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
@@ -40,7 +41,7 @@ public sealed class ConsoleWindow : Window
|
|||||||
|
|
||||||
_searchBox = new TextBox
|
_searchBox = new TextBox
|
||||||
{
|
{
|
||||||
Watermark = loc.Get("Console.SearchWatermark"),
|
PlaceholderText = loc.Get("Console.SearchWatermark"),
|
||||||
Width = 320,
|
Width = 320,
|
||||||
Margin = new Thickness(0, 0, 12, 0),
|
Margin = new Thickness(0, 0, 12, 0),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ using System.Text.Json;
|
|||||||
|
|
||||||
namespace SharpEmu.GUI;
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
public sealed record LanguageInfo(string Code, string NativeName);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads UI strings for the launcher. Every language ships embedded in the
|
/// Loads UI strings for the launcher. Every language ships embedded in the
|
||||||
/// assembly (see SharpEmu.GUI.csproj) so a release build is fully
|
/// assembly (see SharpEmu.GUI.csproj) so a release build is fully
|
||||||
@@ -16,8 +18,6 @@ public sealed class Localization
|
|||||||
{
|
{
|
||||||
public static Localization Instance { get; } = new();
|
public static Localization Instance { get; } = new();
|
||||||
|
|
||||||
public sealed record LanguageInfo(string Code, string NativeName);
|
|
||||||
|
|
||||||
private const string EmbeddedResourcePrefix = "Languages.";
|
private const string EmbeddedResourcePrefix = "Languages.";
|
||||||
private const string EmbeddedResourceSuffix = ".json";
|
private const string EmbeddedResourceSuffix = ".json";
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ public sealed class Localization
|
|||||||
result = loaded;
|
result = loaded;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryLoad(string code, string json)
|
private bool TryLoad(string code, string json)
|
||||||
{
|
{
|
||||||
if (TryLoad(json, out var dict))
|
if (TryLoad(json, out var dict))
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
Background="{StaticResource BgBrush}"
|
Background="{StaticResource BgBrush}"
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
ExtendClientAreaChromeHints="PreferSystemChrome"
|
WindowDecorations="Full"
|
||||||
ExtendClientAreaTitleBarHeightHint="44"
|
ExtendClientAreaTitleBarHeightHint="44"
|
||||||
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
|
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
|
||||||
KeyDown="OnKeyDown">
|
KeyDown="OnKeyDown">
|
||||||
@@ -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
|
||||||
@@ -84,7 +78,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
|
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<TextBox x:Name="SearchBox" Watermark="Search library…" Width="240" VerticalAlignment="Center" />
|
<TextBox x:Name="SearchBox" PlaceholderText="Search library…" Width="240" VerticalAlignment="Center" />
|
||||||
<Button x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder" VerticalAlignment="Center" />
|
<Button x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder" VerticalAlignment="Center" />
|
||||||
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" VerticalAlignment="Center" />
|
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" VerticalAlignment="Center" />
|
||||||
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
|
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
|
||||||
@@ -148,7 +142,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</ItemsPanelTemplate>
|
</ItemsPanelTemplate>
|
||||||
</ListBox.ItemsPanel>
|
</ListBox.ItemsPanel>
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate x:DataType="local:GameEntry" x:CompileBindings="True">
|
||||||
<StackPanel Width="128" Height="172" Spacing="7">
|
<StackPanel Width="128" Height="172" Spacing="7">
|
||||||
<Border Classes="coverShadow" Width="128" Height="128">
|
<Border Classes="coverShadow" Width="128" Height="128">
|
||||||
<Border Classes="coverClip">
|
<Border Classes="coverClip">
|
||||||
@@ -273,8 +267,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<local:SettingRow x:Name="LanguageRow" Label="Emulator language"
|
<local:SettingRow x:Name="LanguageRow" Label="Emulator language"
|
||||||
Description="Language used throughout the launcher. Applies immediately.">
|
Description="Language used throughout the launcher. Applies immediately.">
|
||||||
<ComboBox x:Name="LanguageBox" Width="160"
|
<ComboBox x:Name="LanguageBox" Width="160"
|
||||||
VerticalAlignment="Center" CornerRadius="8"
|
VerticalAlignment="Center" CornerRadius="8">
|
||||||
DisplayMemberBinding="{Binding NativeName}" />
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="local:LanguageInfo">
|
||||||
|
<TextBlock Text="{Binding NativeName}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="TitleMusicRow" Label="Title music"
|
<local:SettingRow x:Name="TitleMusicRow" Label="Title music"
|
||||||
@@ -403,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" />
|
||||||
@@ -420,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>
|
||||||
@@ -481,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. Enabled by default for compatibility. 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>
|
||||||
|
|
||||||
@@ -498,7 +542,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
|
||||||
<TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
<TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
||||||
<TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox"
|
<TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox"
|
||||||
Watermark="Search..." Width="320" />
|
PlaceholderText="Search..." Width="320" />
|
||||||
<CheckBox Grid.Column="2" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
|
<CheckBox Grid.Column="2" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
|
||||||
FontSize="12" Margin="0,0,12,0" />
|
FontSize="12" Margin="0,0,12,0" />
|
||||||
<Button Grid.Column="3" x:Name="DetachConsoleButton" Classes="ghost" Content="Split" FontSize="12"
|
<Button Grid.Column="3" x:Name="DetachConsoleButton" Classes="ghost" Content="Split" FontSize="12"
|
||||||
@@ -511,7 +555,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
|
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
|
||||||
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12">
|
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate x:DataType="local:LogLine" x:CompileBindings="True">
|
||||||
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
|
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
@@ -527,7 +571,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<!-- Selected game cover thumbnail -->
|
<!-- Selected game cover thumbnail -->
|
||||||
<Border Grid.Column="0" Classes="coverClip" Width="56" Height="56" CornerRadius="8"
|
<Border Grid.Column="0" Classes="coverClip" Width="56" Height="56" CornerRadius="8"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<Panel x:Name="SelectedCoverPanel">
|
<Panel x:Name="SelectedCoverPanel"
|
||||||
|
x:DataType="local:GameEntry"
|
||||||
|
x:CompileBindings="True">
|
||||||
<Border Background="{Binding PlaceholderBrush, FallbackValue={x:Null}}"
|
<Border Background="{Binding PlaceholderBrush, FallbackValue={x:Null}}"
|
||||||
IsVisible="{Binding !HasCover, FallbackValue=False}">
|
IsVisible="{Binding !HasCover, FallbackValue=False}">
|
||||||
<TextBlock Text="{Binding Initials}" FontSize="20" FontWeight="Bold"
|
<TextBlock Text="{Binding Initials}" FontSize="20" FontWeight="Bold"
|
||||||
@@ -548,7 +594,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<!-- Title id / version / size badges, right next to the
|
<!-- Title id / version / size badges, right next to the
|
||||||
title. The title's own MaxWidth (not a "*" column) is
|
title. The title's own MaxWidth (not a "*" column) is
|
||||||
what keeps them from drifting to the far right. -->
|
what keeps them from drifting to the far right. -->
|
||||||
<StackPanel Grid.Column="1" x:Name="SelectedBadgesRow" Orientation="Horizontal" Spacing="6"
|
<StackPanel Grid.Column="1" x:Name="SelectedBadgesRow"
|
||||||
|
x:DataType="local:GameEntry"
|
||||||
|
x:CompileBindings="True"
|
||||||
|
Orientation="Horizontal" Spacing="6"
|
||||||
IsVisible="False" VerticalAlignment="Center">
|
IsVisible="False" VerticalAlignment="Center">
|
||||||
<Border Classes="pill" IsVisible="{Binding HasTitleId, FallbackValue=False}">
|
<Border Classes="pill" IsVisible="{Binding HasTitleId, FallbackValue=False}">
|
||||||
<TextBlock Text="{Binding TitleId}" FontSize="10" FontWeight="SemiBold"
|
<TextBlock Text="{Binding TitleId}" FontSize="10" FontWeight="SemiBold"
|
||||||
@@ -585,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}"
|
||||||
@@ -643,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>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Avalonia;
|
|||||||
using Avalonia.Collections;
|
using Avalonia.Collections;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Input.Platform;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
@@ -15,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;
|
||||||
@@ -61,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;
|
||||||
@@ -113,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()
|
||||||
@@ -159,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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,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);
|
||||||
@@ -220,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 += (_, _) =>
|
||||||
@@ -238,6 +241,8 @@ 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 += (_, _) =>
|
||||||
|
SetGuestImageCpuSync(EnvGuestImageCpuSyncToggle.IsChecked == true);
|
||||||
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
|
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
|
||||||
|
|
||||||
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
||||||
@@ -254,8 +259,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),
|
||||||
@@ -426,8 +430,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;
|
||||||
@@ -443,9 +446,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;
|
||||||
}
|
}
|
||||||
@@ -591,7 +593,7 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void OnLanguageChanged()
|
private void OnLanguageChanged()
|
||||||
{
|
{
|
||||||
if (LanguageBox.SelectedItem is not Localization.LanguageInfo language)
|
if (LanguageBox.SelectedItem is not LanguageInfo language)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -613,7 +615,7 @@ public partial class MainWindow : Window
|
|||||||
LibraryTabButton.Content = loc.Get("Page.Library");
|
LibraryTabButton.Content = loc.Get("Page.Library");
|
||||||
OptionsTabButton.Content = loc.Get("Page.Options");
|
OptionsTabButton.Content = loc.Get("Page.Options");
|
||||||
|
|
||||||
SearchBox.Watermark = loc.Get("Library.SearchWatermark");
|
SearchBox.PlaceholderText = loc.Get("Library.SearchWatermark");
|
||||||
AddFolderButton.Content = loc.Get("Library.AddFolder");
|
AddFolderButton.Content = loc.Get("Library.AddFolder");
|
||||||
RescanButton.Content = loc.Get("Library.Rescan");
|
RescanButton.Content = loc.Get("Library.Rescan");
|
||||||
OpenFileButton.Content = loc.Get("Library.OpenFile");
|
OpenFileButton.Content = loc.Get("Library.OpenFile");
|
||||||
@@ -684,14 +686,32 @@ 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
ConsoleSectionTitle.Text = loc.Get("Console.Title");
|
ConsoleSectionTitle.Text = loc.Get("Console.Title");
|
||||||
ConsoleSearchBox.Watermark = loc.Get("Console.SearchWatermark");
|
ConsoleSearchBox.PlaceholderText = loc.Get("Console.SearchWatermark");
|
||||||
AutoScrollCheck.Content = loc.Get("Console.AutoScroll");
|
AutoScrollCheck.Content = loc.Get("Console.AutoScroll");
|
||||||
DetachConsoleButton.Content = loc.Get("Console.Split");
|
DetachConsoleButton.Content = loc.Get("Console.Split");
|
||||||
CopyLogButton.Content = loc.Get("Console.Copy");
|
CopyLogButton.Content = loc.Get("Console.Copy");
|
||||||
@@ -757,91 +777,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;
|
|
||||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
|
|
||||||
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;
|
|
||||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
|
|
||||||
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()
|
||||||
@@ -850,6 +800,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();
|
||||||
@@ -902,9 +853,143 @@ 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 = IsEnvironmentEnabled(
|
||||||
|
_settings.EnvironmentToggles,
|
||||||
|
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
|
||||||
|
defaultValue: true);
|
||||||
|
WindowModeBox.SelectedIndex = ChoiceIndex(_settings.WindowMode, "Windowed", "Borderless", "Exclusive");
|
||||||
|
LoadHostDisplayOptions();
|
||||||
|
ScalingModeBox.SelectedIndex = ChoiceIndex(_settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
|
||||||
|
VSyncToggle.IsChecked = _settings.VSync;
|
||||||
|
HdrModeBox.SelectedIndex = ChoiceIndex(_settings.HdrMode, "Auto", "On", "Off");
|
||||||
UpdateLogFilePathText();
|
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)
|
||||||
@@ -998,6 +1083,40 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetGuestImageCpuSync(bool enabled)
|
||||||
|
{
|
||||||
|
const string name = "SHARPEMU_GUEST_IMAGE_CPU_SYNC";
|
||||||
|
_settings.EnvironmentToggles.RemoveAll(entry =>
|
||||||
|
string.Equals(entry, name, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(entry, name + "=0", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (!enabled)
|
||||||
|
{
|
||||||
|
_settings.EnvironmentToggles.Add(name + "=0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEnvironmentEnabled(
|
||||||
|
IEnumerable<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;
|
||||||
|
}
|
||||||
|
|
||||||
private string SelectedLogLevel()
|
private string SelectedLogLevel()
|
||||||
{
|
{
|
||||||
return LogLevelBox.SelectedIndex switch
|
return LogLevelBox.SelectedIndex switch
|
||||||
@@ -1794,16 +1913,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1827,7 +1953,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;
|
||||||
@@ -1836,18 +1961,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>
|
||||||
@@ -1876,9 +2008,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;
|
||||||
@@ -1886,7 +2015,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1929,8 +2057,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
|
||||||
{
|
{
|
||||||
@@ -1963,7 +2090,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)
|
||||||
{
|
{
|
||||||
@@ -1983,7 +2110,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(
|
||||||
@@ -2005,12 +2132,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)
|
||||||
{
|
{
|
||||||
@@ -2021,16 +2148,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;
|
||||||
@@ -2039,8 +2163,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;
|
||||||
}
|
}
|
||||||
@@ -2049,143 +2173,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)
|
||||||
@@ -2257,7 +2263,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;
|
||||||
}
|
}
|
||||||
@@ -2270,33 +2289,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)
|
||||||
@@ -2355,16 +2352,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()
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: name == "SHARPEMU_GUEST_IMAGE_CPU_SYNC");
|
||||||
}
|
}
|
||||||
|
|
||||||
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: name == "SHARPEMU_GUEST_IMAGE_CPU_SYNC");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ChoiceOrDefault(string[] choices, string? value, string fallback) =>
|
||||||
|
choices.FirstOrDefault(choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
|
||||||
|
|
||||||
|
private void OnHostDisplayChanged()
|
||||||
|
{
|
||||||
|
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updatingHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PopulateHostModes(
|
||||||
|
display,
|
||||||
|
_resolution.SelectedItem as string ?? "1920x1080",
|
||||||
|
SelectedRefreshRate());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingHostDisplayOptions = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHostResolutionChanged()
|
||||||
|
{
|
||||||
|
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedRefreshRate = SelectedRefreshRate();
|
||||||
|
_updatingHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingHostDisplayOptions = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateHostModes(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string selectedResolution,
|
||||||
|
int selectedRefreshRate)
|
||||||
|
{
|
||||||
|
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
|
||||||
|
_resolution.ItemsSource = resolutions;
|
||||||
|
_resolution.SelectedItem = resolutions.FirstOrDefault(resolution =>
|
||||||
|
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
|
||||||
|
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateRefreshRates(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string? resolution,
|
||||||
|
int selectedRefreshRate)
|
||||||
|
{
|
||||||
|
var rates = HostDisplayOptions.BuildRefreshRates(
|
||||||
|
display,
|
||||||
|
resolution,
|
||||||
|
selectedRefreshRate,
|
||||||
|
Localization.Instance.Get("Options.RefreshRate.Automatic"));
|
||||||
|
_refreshRate.ItemsSource = rates;
|
||||||
|
_refreshRate.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private int SelectedRefreshRate() =>
|
||||||
|
_refreshRate.SelectedItem is HostRefreshRateOption refreshRate ? refreshRate.Value : 0;
|
||||||
|
|
||||||
private void Persist()
|
private void Persist()
|
||||||
{
|
{
|
||||||
var settings = new PerGameSettings
|
var settings = new PerGameSettings
|
||||||
@@ -196,10 +380,54 @@ 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()
|
||||||
|
{
|
||||||
|
const string guestImageCpuSync = "SHARPEMU_GUEST_IMAGE_CPU_SYNC";
|
||||||
|
var entries = _envBoxes
|
||||||
|
.Where(entry => entry.Name != guestImageCpuSync && entry.Box.IsChecked == true)
|
||||||
|
.Select(entry => entry.Name)
|
||||||
|
.ToList();
|
||||||
|
if (_envBoxes.First(entry => entry.Name == guestImageCpuSync).Box.IsChecked != true)
|
||||||
|
{
|
||||||
|
entries.Add(guestImageCpuSync + "=0");
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEnvironmentEnabled(
|
||||||
|
IEnumerable<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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// 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.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
@@ -30,6 +31,12 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
public ulong End;
|
public ulong End;
|
||||||
public int Dirty;
|
public int Dirty;
|
||||||
public int Armed;
|
public int Armed;
|
||||||
|
/// <summary>
|
||||||
|
/// When false the range is watch-only: managed writes still dirty it via
|
||||||
|
/// <see cref="NotifyManagedWrite"/>, but pages are never write-protected
|
||||||
|
/// so native CPU stores do not fault.
|
||||||
|
/// </summary>
|
||||||
|
public bool Protect;
|
||||||
public int FirstCpuWriteSeen;
|
public int FirstCpuWriteSeen;
|
||||||
public int PendingFirstCpuWrite;
|
public int PendingFirstCpuWrite;
|
||||||
public long WriteGeneration;
|
public long WriteGeneration;
|
||||||
@@ -80,8 +87,14 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
|
|
||||||
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
|
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
|
||||||
|
|
||||||
private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
|
// CPU-written guest image synchronization is the compatible default. A few
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
|
// titles (currently GTA V) require the lower-overhead watch-only path and
|
||||||
|
// opt out explicitly with SHARPEMU_GUEST_IMAGE_CPU_SYNC=0.
|
||||||
|
private static readonly bool _enabled =
|
||||||
|
!string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"),
|
||||||
|
"0",
|
||||||
|
StringComparison.Ordinal);
|
||||||
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 =
|
||||||
@@ -95,14 +108,67 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
_enabled && _lifetimeTraceEnabled ? GetMonotonicNanoseconds() : 0;
|
_enabled && _lifetimeTraceEnabled ? GetMonotonicNanoseconds() : 0;
|
||||||
private static long _lifetimeTraceSequence;
|
private static long _lifetimeTraceSequence;
|
||||||
|
|
||||||
|
private const uint PageReadonly = 0x02;
|
||||||
|
private const uint PageReadWrite = 0x04;
|
||||||
|
|
||||||
[DllImport("libc", EntryPoint = "mprotect", SetLastError = true)]
|
[DllImport("libc", EntryPoint = "mprotect", SetLastError = true)]
|
||||||
private static extern int Mprotect(nint address, nuint length, int protection);
|
private static extern int Mprotect(nint address, nuint length, int protection);
|
||||||
|
|
||||||
[DllImport("libc", EntryPoint = "clock_gettime", SetLastError = false)]
|
[DllImport("libc", EntryPoint = "clock_gettime", SetLastError = false)]
|
||||||
private static extern int ClockGetTime(int clockId, Timespec* time);
|
private static extern int ClockGetTime(int clockId, Timespec* time);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern int VirtualProtect(
|
||||||
|
nint lpAddress,
|
||||||
|
nuint dwSize,
|
||||||
|
uint flNewProtect,
|
||||||
|
out uint lpflOldProtect);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern nint VirtualAlloc(
|
||||||
|
nint lpAddress,
|
||||||
|
nuint dwSize,
|
||||||
|
uint flAllocationType,
|
||||||
|
uint flProtect);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern int VirtualFree(nint lpAddress, nuint dwSize, uint dwFreeType);
|
||||||
|
|
||||||
|
private const uint MemCommit = 0x1000;
|
||||||
|
private const uint MemReserve = 0x2000;
|
||||||
|
private const uint MemRelease = 0x8000;
|
||||||
|
|
||||||
public static bool Enabled => _enabled;
|
public static bool Enabled => _enabled;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test/diagnostics helper: whether <paramref name="address"/> is tracked
|
||||||
|
/// with write protection armed (watch-only ranges report protect=false).
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGetProtectionState(
|
||||||
|
ulong address,
|
||||||
|
out bool protect,
|
||||||
|
out bool armed)
|
||||||
|
{
|
||||||
|
protect = false;
|
||||||
|
armed = false;
|
||||||
|
if (!_enabled)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_rangesByAddress.TryGetValue(address, out var range))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protect = range.Protect;
|
||||||
|
armed = Volatile.Read(ref range.Armed) != 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exercises the fault-handling path once outside signal context so every
|
/// Exercises the fault-handling path once outside signal context so every
|
||||||
/// branch is JIT-compiled (and, under Rosetta 2, translated) before a real
|
/// branch is JIT-compiled (and, under Rosetta 2, translated) before a real
|
||||||
@@ -115,7 +181,17 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var scratch = NativeMemory.AllocZeroed(4096);
|
// VirtualProtect only belongs on VirtualAlloc/mmap pages. Warming on
|
||||||
|
// CRT heap memory makes neighbouring heap metadata read-only and
|
||||||
|
// crashes the process on Windows.
|
||||||
|
var scratch = OperatingSystem.IsWindows()
|
||||||
|
? VirtualAlloc(0, 4096, MemCommit | MemReserve, PageReadWrite)
|
||||||
|
: (nint)NativeMemory.AllocZeroed(4096);
|
||||||
|
if (scratch == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Warm the timestamp P/Invoke used by the signal-safe scalar
|
// Warm the timestamp P/Invoke used by the signal-safe scalar
|
||||||
@@ -129,16 +205,29 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
NativeMemory.Free(scratch);
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
_ = VirtualFree(scratch, 0, MemRelease);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
NativeMemory.Free((void*)scratch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Registers a range and arms write protection on it.</summary>
|
/// <summary>
|
||||||
|
/// Registers a range. When <paramref name="protect"/> is true, arms write
|
||||||
|
/// protection so native stores fault and mark the range dirty. When false,
|
||||||
|
/// the range is watch-only (managed HLE writes still dirty via
|
||||||
|
/// <see cref="NotifyManagedWrite"/>) and never <c>VirtualProtect</c>'d.
|
||||||
|
/// </summary>
|
||||||
public static void Track(
|
public static void Track(
|
||||||
ulong address,
|
ulong address,
|
||||||
ulong byteCount,
|
ulong byteCount,
|
||||||
long sourceSequence = 0,
|
long sourceSequence = 0,
|
||||||
string source = "unspecified")
|
string source = "unspecified",
|
||||||
|
bool protect = true)
|
||||||
{
|
{
|
||||||
if (!_enabled || address == 0 || byteCount == 0)
|
if (!_enabled || address == 0 || byteCount == 0)
|
||||||
{
|
{
|
||||||
@@ -159,6 +248,7 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
// a fresh immutable range, carrying the write generation so
|
// a fresh immutable range, carrying the write generation so
|
||||||
// resizes do not hide guest CPU rewrites from cache owners.
|
// resizes do not hide guest CPU rewrites from cache owners.
|
||||||
var writeGeneration = Volatile.Read(ref range.WriteGeneration);
|
var writeGeneration = Volatile.Read(ref range.WriteGeneration);
|
||||||
|
var keepProtect = range.Protect || protect;
|
||||||
DisarmLocked(range, "replace-range");
|
DisarmLocked(range, "replace-range");
|
||||||
_rangesByAddress.Remove(address);
|
_rangesByAddress.Remove(address);
|
||||||
range = new TrackedRange
|
range = new TrackedRange
|
||||||
@@ -167,6 +257,7 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
ByteCount = byteCount,
|
ByteCount = byteCount,
|
||||||
Start = start,
|
Start = start,
|
||||||
End = start + length,
|
End = start + length,
|
||||||
|
Protect = keepProtect,
|
||||||
WriteGeneration = writeGeneration,
|
WriteGeneration = writeGeneration,
|
||||||
};
|
};
|
||||||
_rangesByAddress[address] = range;
|
_rangesByAddress[address] = range;
|
||||||
@@ -181,6 +272,7 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
ByteCount = byteCount,
|
ByteCount = byteCount,
|
||||||
Start = start,
|
Start = start,
|
||||||
End = start + length,
|
End = start + length,
|
||||||
|
Protect = protect,
|
||||||
TraceLifetime =
|
TraceLifetime =
|
||||||
ShouldTraceRange(start, start + length) || ShouldTraceSource(source),
|
ShouldTraceRange(start, start + length) || ShouldTraceSource(source),
|
||||||
SourceSequence = sourceSequence,
|
SourceSequence = sourceSequence,
|
||||||
@@ -192,13 +284,22 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
FlushPendingFirstCpuWrite(range);
|
FlushPendingFirstCpuWrite(range);
|
||||||
|
// Protect is sticky: a later watch-only Track (texture cache)
|
||||||
|
// must not disarm an RT that already needs page faults.
|
||||||
|
if (protect && !range.Protect)
|
||||||
|
{
|
||||||
|
range.Protect = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
range.SourceSequence = sourceSequence;
|
range.SourceSequence = sourceSequence;
|
||||||
range.Source = source;
|
range.Source = source;
|
||||||
range.TraceLifetime =
|
range.TraceLifetime =
|
||||||
ShouldTraceRange(range.Start, range.End) || ShouldTraceSource(source);
|
ShouldTraceRange(range.Start, range.End) || ShouldTraceSource(source);
|
||||||
ArmLocked(range, "arm");
|
if (range.Protect)
|
||||||
|
{
|
||||||
|
ArmLocked(range, "arm");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +378,8 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (_rangesByAddress.TryGetValue(address, out var range))
|
if (_rangesByAddress.TryGetValue(address, out var range) &&
|
||||||
|
range.Protect)
|
||||||
{
|
{
|
||||||
ArmLocked(range, "rearm");
|
ArmLocked(range, "rearm");
|
||||||
}
|
}
|
||||||
@@ -445,10 +547,7 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (needsUnprotect &&
|
if (needsUnprotect &&
|
||||||
Mprotect(
|
!TrySetProtection(writableStart, writableEnd - writableStart, writable: true))
|
||||||
(nint)writableStart,
|
|
||||||
(nuint)(writableEnd - writableStart),
|
|
||||||
ProtRead | ProtWrite) != 0)
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -462,7 +561,11 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
}
|
}
|
||||||
|
|
||||||
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
|
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
|
||||||
if (wasArmed)
|
var wasDirty = Interlocked.Exchange(ref range.Dirty, 1) != 0;
|
||||||
|
// Protected ranges bump generation once per arm/fault cycle.
|
||||||
|
// Watch-only ranges never arm, so bump on the first dirty mark
|
||||||
|
// (NotifyManagedWrite) so cache owners still see a rewrite.
|
||||||
|
if (wasArmed || (!range.Protect && !wasDirty))
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref range.WriteGeneration);
|
Interlocked.Increment(ref range.WriteGeneration);
|
||||||
}
|
}
|
||||||
@@ -480,8 +583,6 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
Volatile.Write(ref range.PendingFirstCpuWrite, 1);
|
Volatile.Write(ref range.PendingFirstCpuWrite, 1);
|
||||||
Volatile.Write(ref range.FirstCpuWriteSeen, 2);
|
Volatile.Write(ref range.FirstCpuWriteSeen, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
Volatile.Write(ref range.Dirty, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -497,10 +598,7 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
|
|
||||||
// A new publication/rearm starts a new first-write lifetime.
|
// A new publication/rearm starts a new first-write lifetime.
|
||||||
Volatile.Write(ref range.FirstCpuWriteSeen, 0);
|
Volatile.Write(ref range.FirstCpuWriteSeen, 0);
|
||||||
var failed = Mprotect(
|
var failed = !TrySetProtection(range.Start, range.End - range.Start, writable: false);
|
||||||
(nint)range.Start,
|
|
||||||
(nuint)(range.End - range.Start),
|
|
||||||
ProtRead) != 0;
|
|
||||||
if (failed)
|
if (failed)
|
||||||
{
|
{
|
||||||
Volatile.Write(ref range.Armed, 0);
|
Volatile.Write(ref range.Armed, 0);
|
||||||
@@ -520,10 +618,7 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) == 1;
|
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) == 1;
|
||||||
if (wasArmed)
|
if (wasArmed)
|
||||||
{
|
{
|
||||||
_ = Mprotect(
|
_ = TrySetProtection(range.Start, range.End - range.Start, writable: true);
|
||||||
(nint)range.Start,
|
|
||||||
(nuint)(range.End - range.Start),
|
|
||||||
ProtRead | ProtWrite);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (range.TraceLifetime)
|
if (range.TraceLifetime)
|
||||||
@@ -534,7 +629,13 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
|
|
||||||
private static void RebuildSnapshotLocked()
|
private static void RebuildSnapshotLocked()
|
||||||
{
|
{
|
||||||
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray()));
|
// Fault / NotifyManagedWrite hot paths must only see protected ranges.
|
||||||
|
// Watch-only texture-cache registrations used to widen Start..End across
|
||||||
|
// nearly all GPU memory so every managed guest write walked this path.
|
||||||
|
var protectedRanges = _rangesByAddress.Values
|
||||||
|
.Where(static range => range.Protect)
|
||||||
|
.ToArray();
|
||||||
|
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(protectedRanges));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
|
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
|
||||||
@@ -679,8 +780,35 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
$"fault=0x{faultAddress:X16} page=0x{faultPage:X16}");
|
$"fault=0x{faultAddress:X16} page=0x{faultPage:X16}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TrySetProtection(ulong start, ulong length, bool writable)
|
||||||
|
{
|
||||||
|
if (length == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return VirtualProtect(
|
||||||
|
(nint)start,
|
||||||
|
(nuint)length,
|
||||||
|
writable ? PageReadWrite : PageReadonly,
|
||||||
|
out _) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Mprotect(
|
||||||
|
(nint)start,
|
||||||
|
(nuint)length,
|
||||||
|
writable ? ProtRead | ProtWrite : ProtRead) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
private static long GetMonotonicNanoseconds()
|
private static long GetMonotonicNanoseconds()
|
||||||
{
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return Stopwatch.GetTimestamp() * 1_000_000_000L / Stopwatch.Frequency;
|
||||||
|
}
|
||||||
|
|
||||||
Timespec time;
|
Timespec time;
|
||||||
return ClockGetTime(ClockMonotonicRaw, &time) == 0
|
return ClockGetTime(ClockMonotonicRaw, &time) == 0
|
||||||
? unchecked((time.Seconds * 1_000_000_000L) + time.Nanoseconds)
|
? unchecked((time.Seconds * 1_000_000_000L) + time.Nanoseconds)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,5 +19,11 @@ public interface IHostAudioOutput
|
|||||||
/// Throws when the host has no usable output device; callers degrade to a silent
|
/// Throws when the host has no usable output device; callers degrade to a silent
|
||||||
/// port and pace the guest instead.
|
/// port and pace the guest instead.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IHostAudioStream OpenStereoPcm16Stream(uint sampleRate);
|
/// <param name="sampleRate">Host stream sample rate in Hz.</param>
|
||||||
|
/// <param name="maxQueuedPcmBytes">
|
||||||
|
/// Soft backpressure cap for queued stereo PCM16. Default 32 KiB (~171 ms at
|
||||||
|
/// 48 kHz) matches classic AudioOut latency. Bursty AudioOut2 / FMOD feeders
|
||||||
|
/// may pass a deeper cap to avoid underruns.
|
||||||
|
/// </param>
|
||||||
|
IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -14,9 +14,6 @@ namespace SharpEmu.HLE.Host.Posix;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
||||||
{
|
{
|
||||||
// 32KB of stereo PCM16 at 48kHz is ~170ms; keep the same device-side
|
|
||||||
// queue depth the WinMM/CoreAudio ports enforce in managed code.
|
|
||||||
private const uint DeviceLatencyMicroseconds = 170_000;
|
|
||||||
private const int StreamPlayback = 0;
|
private const int StreamPlayback = 0;
|
||||||
private const int FormatS16LittleEndian = 2;
|
private const int FormatS16LittleEndian = 2;
|
||||||
private const int AccessReadWriteInterleaved = 3;
|
private const int AccessReadWriteInterleaved = 3;
|
||||||
@@ -27,7 +24,7 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
|||||||
private nint _pcm;
|
private nint _pcm;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public PosixAlsaAudioStream(uint sampleRate)
|
public PosixAlsaAudioStream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsLinux())
|
if (!OperatingSystem.IsLinux())
|
||||||
{
|
{
|
||||||
@@ -47,6 +44,14 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
|||||||
$"snd_pcm_open(\"{device}\") failed: {DescribeError(status)}.");
|
$"snd_pcm_open(\"{device}\") failed: {DescribeError(status)}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match WinMM/CoreAudio soft queue depth: 32 KiB stereo PCM16 @ 48 kHz
|
||||||
|
// is ~170 ms. AudioOut2 may request a deeper bed.
|
||||||
|
var queuedBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
|
||||||
|
var latencyMicroseconds = (uint)Math.Clamp(
|
||||||
|
(long)queuedBytes * 1_000_000L / Math.Max(sampleRate * 4u, 1u),
|
||||||
|
20_000L,
|
||||||
|
2_000_000L);
|
||||||
|
|
||||||
status = snd_pcm_set_params(
|
status = snd_pcm_set_params(
|
||||||
_pcm,
|
_pcm,
|
||||||
FormatS16LittleEndian,
|
FormatS16LittleEndian,
|
||||||
@@ -54,7 +59,7 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
|||||||
2,
|
2,
|
||||||
sampleRate,
|
sampleRate,
|
||||||
1,
|
1,
|
||||||
DeviceLatencyMicroseconds);
|
latencyMicroseconds);
|
||||||
if (status != 0)
|
if (status != 0)
|
||||||
{
|
{
|
||||||
_ = snd_pcm_close(_pcm);
|
_ = snd_pcm_close(_pcm);
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ namespace SharpEmu.HLE.Host.Posix;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
|
internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
|
||||||
{
|
{
|
||||||
private const int MaximumQueuedPcmBytes = 32 * 1024;
|
|
||||||
private const uint FormatLinearPcm = 0x6C70636D; // 'lpcm'
|
private const uint FormatLinearPcm = 0x6C70636D; // 'lpcm'
|
||||||
private const uint FlagIsSignedInteger = 0x4;
|
private const uint FlagIsSignedInteger = 0x4;
|
||||||
private const uint FlagIsPacked = 0x8;
|
private const uint FlagIsPacked = 0x8;
|
||||||
|
|
||||||
|
private readonly int _maximumQueuedPcmBytes;
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly AutoResetEvent _completion = new(false);
|
private readonly AutoResetEvent _completion = new(false);
|
||||||
private readonly Queue<nint> _freeBuffers = new();
|
private readonly Queue<nint> _freeBuffers = new();
|
||||||
@@ -27,13 +27,15 @@ internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
|
|||||||
private bool _started;
|
private bool _started;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public PosixCoreAudioStream(uint sampleRate)
|
public PosixCoreAudioStream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsMacOS())
|
if (!OperatingSystem.IsMacOS())
|
||||||
{
|
{
|
||||||
throw new PlatformNotSupportedException("CoreAudio is only available on macOS.");
|
throw new PlatformNotSupportedException("CoreAudio is only available on macOS.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_maximumQueuedPcmBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
|
||||||
|
|
||||||
var format = new AudioStreamBasicDescription
|
var format = new AudioStreamBasicDescription
|
||||||
{
|
{
|
||||||
SampleRate = sampleRate,
|
SampleRate = sampleRate,
|
||||||
@@ -73,7 +75,7 @@ internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
|
|||||||
|
|
||||||
var outputLength = stereoPcm16.Length;
|
var outputLength = stereoPcm16.Length;
|
||||||
while (_queuedPcmBytes != 0 &&
|
while (_queuedPcmBytes != 0 &&
|
||||||
_queuedPcmBytes + outputLength > MaximumQueuedPcmBytes)
|
_queuedPcmBytes + outputLength > _maximumQueuedPcmBytes)
|
||||||
{
|
{
|
||||||
Monitor.Exit(_gate);
|
Monitor.Exit(_gate);
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ internal sealed class PosixHostAudio : IHostAudioOutput
|
|||||||
{
|
{
|
||||||
public string BackendName => OperatingSystem.IsMacOS() ? "coreaudio" : "alsa";
|
public string BackendName => OperatingSystem.IsMacOS() ? "coreaudio" : "alsa";
|
||||||
|
|
||||||
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate)
|
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
|
||||||
{
|
{
|
||||||
return OperatingSystem.IsMacOS()
|
return OperatingSystem.IsMacOS()
|
||||||
? new PosixCoreAudioStream(sampleRate)
|
? new PosixCoreAudioStream(sampleRate, maxQueuedPcmBytes)
|
||||||
: new PosixAlsaAudioStream(sampleRate);
|
: new PosixAlsaAudioStream(sampleRate, maxQueuedPcmBytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
|
|||||||
{
|
{
|
||||||
public string BackendName => "winmm";
|
public string BackendName => "winmm";
|
||||||
|
|
||||||
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate) => new WaveOutStream(sampleRate);
|
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024) =>
|
||||||
|
new WaveOutStream(sampleRate, maxQueuedPcmBytes);
|
||||||
|
|
||||||
private sealed partial class WaveOutStream : IHostAudioStream
|
private sealed partial class WaveOutStream : IHostAudioStream
|
||||||
{
|
{
|
||||||
@@ -17,8 +18,8 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
|
|||||||
private const uint CallbackEvent = 0x0005_0000;
|
private const uint CallbackEvent = 0x0005_0000;
|
||||||
private const ushort WaveFormatPcm = 1;
|
private const ushort WaveFormatPcm = 1;
|
||||||
private const uint WaveHeaderDone = 0x0000_0001;
|
private const uint WaveHeaderDone = 0x0000_0001;
|
||||||
private const int MaximumQueuedPcmBytes = 32 * 1024;
|
|
||||||
|
|
||||||
|
private readonly int _maximumQueuedPcmBytes;
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly AutoResetEvent _completion = new(false);
|
private readonly AutoResetEvent _completion = new(false);
|
||||||
private readonly Queue<NativeBuffer> _buffers = new();
|
private readonly Queue<NativeBuffer> _buffers = new();
|
||||||
@@ -26,8 +27,9 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
|
|||||||
private int _queuedPcmBytes;
|
private int _queuedPcmBytes;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public WaveOutStream(uint sampleRate)
|
public WaveOutStream(uint sampleRate, int maxQueuedPcmBytes)
|
||||||
{
|
{
|
||||||
|
_maximumQueuedPcmBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
|
||||||
var format = new WaveFormat
|
var format = new WaveFormat
|
||||||
{
|
{
|
||||||
FormatTag = WaveFormatPcm,
|
FormatTag = WaveFormatPcm,
|
||||||
@@ -62,7 +64,7 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
|
|||||||
|
|
||||||
ReapCompletedBuffers();
|
ReapCompletedBuffers();
|
||||||
while (_queuedPcmBytes != 0 &&
|
while (_queuedPcmBytes != 0 &&
|
||||||
_queuedPcmBytes + stereoPcm16.Length > MaximumQueuedPcmBytes)
|
_queuedPcmBytes + stereoPcm16.Length > _maximumQueuedPcmBytes)
|
||||||
{
|
{
|
||||||
if (!_completion.WaitOne(TimeSpan.FromSeconds(1)))
|
if (!_completion.WaitOne(TimeSpan.FromSeconds(1)))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
+2
-1
@@ -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
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
+1
@@ -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;
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1403
-207
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Agc;
|
||||||
|
|
||||||
|
/// <summary>Prospero/AGC IndexType helpers (gpu_defs / renderDraw index8 expand).</summary>
|
||||||
|
internal static class AgcIndexHelpers
|
||||||
|
{
|
||||||
|
internal enum ProsperoIndexType : uint
|
||||||
|
{
|
||||||
|
Index16 = 0,
|
||||||
|
Index32 = 1,
|
||||||
|
Index8 = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static ProsperoIndexType Decode(uint raw) =>
|
||||||
|
(raw & 0x3u) switch
|
||||||
|
{
|
||||||
|
1 => ProsperoIndexType.Index32,
|
||||||
|
2 => ProsperoIndexType.Index8,
|
||||||
|
_ => ProsperoIndexType.Index16,
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static int GetGuestStrideBytes(ProsperoIndexType indexType) =>
|
||||||
|
indexType switch
|
||||||
|
{
|
||||||
|
ProsperoIndexType.Index32 => sizeof(uint),
|
||||||
|
ProsperoIndexType.Index8 => sizeof(byte),
|
||||||
|
_ => sizeof(ushort),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Expand guest u8 indices to host u16 (Vulkan/Metal bindable).</summary>
|
||||||
|
internal static void ExpandIndex8ToU16(ReadOnlySpan<byte> source, Span<byte> destination)
|
||||||
|
{
|
||||||
|
if (destination.Length < source.Length * sizeof(ushort))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("destination too small for u8->u16 expansion.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < source.Length; index++)
|
||||||
|
{
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(
|
||||||
|
destination.Slice(index * sizeof(ushort), sizeof(ushort)),
|
||||||
|
source[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Agc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared Prospero/AGC primitive-type helpers (renderDraw / gpu_defs).
|
||||||
|
/// </summary>
|
||||||
|
internal static class AgcPrimitiveHelpers
|
||||||
|
{
|
||||||
|
internal const uint PrimitiveRectList = 7;
|
||||||
|
internal const uint PrimitiveRectListLegacy = 0x11;
|
||||||
|
|
||||||
|
internal enum GsOutputPrimitiveType : uint
|
||||||
|
{
|
||||||
|
Points = 0,
|
||||||
|
Lines = 1,
|
||||||
|
Triangles = 2,
|
||||||
|
Rectangle2D = 3,
|
||||||
|
RectList = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsRectListPrimitive(uint primitiveType) =>
|
||||||
|
primitiveType is PrimitiveRectList or PrimitiveRectListLegacy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps draw prim type to VGT_GS_OUT_PRIM_TYPE when NGG is not enabled
|
||||||
|
/// on the GS (GraphicsPrimitiveTypeToGsOut).
|
||||||
|
/// </summary>
|
||||||
|
internal static uint PrimitiveTypeToGsOut(uint primitiveType) =>
|
||||||
|
primitiveType switch
|
||||||
|
{
|
||||||
|
1 => (uint)GsOutputPrimitiveType.Points, // PointList
|
||||||
|
2 or 3 or 10 or 11 or 18 => (uint)GsOutputPrimitiveType.Lines,
|
||||||
|
PrimitiveRectList => (uint)GsOutputPrimitiveType.Rectangle2D,
|
||||||
|
PrimitiveRectListLegacy => (uint)GsOutputPrimitiveType.RectList,
|
||||||
|
_ => (uint)GsOutputPrimitiveType.Triangles,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rect-list auto-draw topology selection.
|
||||||
|
/// NGG single-rect UI quads (DualSense prompts) submit count 1/3/4 and
|
||||||
|
/// must become a 4-vert triangle strip — even when the VS has embedded
|
||||||
|
/// vertex-buffer fetches (those still show up as host VBs). Indexed and
|
||||||
|
/// larger auto counts stay triangle list so the loading video is safe.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool ShouldDrawRectListAsTriangleStrip(
|
||||||
|
uint primitiveType,
|
||||||
|
bool indexed,
|
||||||
|
uint vertexCount,
|
||||||
|
bool hasVertexBuffers = false)
|
||||||
|
{
|
||||||
|
_ = hasVertexBuffers;
|
||||||
|
if (indexed || !IsRectListPrimitive(primitiveType))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primitiveType == PrimitiveRectListLegacy)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NGG kRectList: strip for auto + ngg_rectlist_draw.
|
||||||
|
// Restrict to the single-rect counts GTA UI actually submits.
|
||||||
|
return vertexCount is 1 or 3 or 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host vertex count for auto rect-list draws that expand to a strip.
|
||||||
|
/// NGG single-rect: always 4. Legacy 0x11: 3 -> 4.
|
||||||
|
/// </summary>
|
||||||
|
internal static uint GetRectListDrawVertexCount(
|
||||||
|
uint primitiveType,
|
||||||
|
uint vertexCount,
|
||||||
|
bool indexed,
|
||||||
|
bool hasVertexBuffers = false)
|
||||||
|
{
|
||||||
|
if (!ShouldDrawRectListAsTriangleStrip(
|
||||||
|
primitiveType,
|
||||||
|
indexed,
|
||||||
|
vertexCount,
|
||||||
|
hasVertexBuffers))
|
||||||
|
{
|
||||||
|
return vertexCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primitiveType == PrimitiveRectList)
|
||||||
|
{
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primitiveType == PrimitiveRectListLegacy && vertexCount == 3)
|
||||||
|
{
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return vertexCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legacy helper — prefer
|
||||||
|
/// <see cref="ShouldDrawRectListAsTriangleStrip"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsRectListTriangleStrip(uint primitiveType) =>
|
||||||
|
IsRectListPrimitive(primitiveType);
|
||||||
|
}
|
||||||
@@ -0,0 +1,788 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.HLE;
|
||||||
|
using SharpEmu.ShaderCompiler;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Agc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AGC embedded vertex metadata. Locates
|
||||||
|
/// PtrVertexBufferTable / PtrVertexAttribDescTable and builds authoritative
|
||||||
|
/// attribute layouts that draw translation merges onto IR-discovered fetches.
|
||||||
|
/// </summary>
|
||||||
|
internal static class AgcVertexMetadata
|
||||||
|
{
|
||||||
|
private const ushort IllegalDirectOffset = 0xFFFF;
|
||||||
|
private const ulong ShaderUserDataOffset = 0x08;
|
||||||
|
private const ulong ShaderInputSemanticsOffset = 0x30;
|
||||||
|
private const ulong ShaderNumInputSemanticsOffset = 0x50;
|
||||||
|
|
||||||
|
internal enum AgcDirectResourceType : uint
|
||||||
|
{
|
||||||
|
PtrVertexBufferTable = 8,
|
||||||
|
PtrVertexAttribDescTable = 10,
|
||||||
|
Last = PtrVertexAttribDescTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct VertexTableRegisters(
|
||||||
|
int VertexBufferReg,
|
||||||
|
int VertexAttribReg,
|
||||||
|
uint InputSemanticsCount,
|
||||||
|
ulong InputSemanticsAddress);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One AGC attrib-table resource.
|
||||||
|
/// Representation: <see cref="SharpBase"/> is the V# base; attribute byte
|
||||||
|
/// offset is applied as <see cref="OffsetBytes"/> (Vulkan bind offset),
|
||||||
|
/// not folded into the base — avoids double-counting when the IR prolog
|
||||||
|
/// already bumped the sharp address.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly record struct MetadataVertexResource(
|
||||||
|
uint Location,
|
||||||
|
uint Semantic,
|
||||||
|
uint HardwareMapping,
|
||||||
|
uint SizeInElements,
|
||||||
|
ulong SharpBase,
|
||||||
|
uint Stride,
|
||||||
|
uint OffsetBytes,
|
||||||
|
uint DataFormat,
|
||||||
|
uint NumberFormat,
|
||||||
|
uint ComponentCount,
|
||||||
|
bool PerInstance);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads AGC user-data direct-resource offsets for the ES header mapped to
|
||||||
|
/// <paramref name="shaderCodeAddress"/>. Returns false when the header is
|
||||||
|
/// unknown or the tables are absent (attribute-less clears).
|
||||||
|
/// </summary>
|
||||||
|
internal static bool TryGetVertexTableRegisters(
|
||||||
|
CpuContext ctx,
|
||||||
|
ulong shaderCodeAddress,
|
||||||
|
ulong shaderHeaderAddress,
|
||||||
|
out VertexTableRegisters registers)
|
||||||
|
{
|
||||||
|
registers = new VertexTableRegisters(-1, -1, 0, 0);
|
||||||
|
if (shaderHeaderAddress == 0 ||
|
||||||
|
!TryReadUInt64(ctx, shaderHeaderAddress + ShaderUserDataOffset, out var userDataAddress) ||
|
||||||
|
userDataAddress == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShaderUserData layout:
|
||||||
|
// 0x00: uint16_t* direct_resource_offset
|
||||||
|
// 0x08: sharp_resource_offset[4]
|
||||||
|
// 0x28: eud_size_dw, srt_size_dw
|
||||||
|
// 0x2C: direct_resource_count
|
||||||
|
if (!TryReadUInt64(ctx, userDataAddress, out var directResourceOffset) ||
|
||||||
|
!TryReadUInt16(ctx, userDataAddress + 0x2C, out var directResourceCount))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxTypes = (uint)AgcDirectResourceType.Last + 1u;
|
||||||
|
if (directResourceCount > maxTypes || directResourceOffset == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var vertexBufferReg = -1;
|
||||||
|
var vertexAttribReg = -1;
|
||||||
|
for (uint type = 0; type < directResourceCount; type++)
|
||||||
|
{
|
||||||
|
if (!TryReadUInt16(
|
||||||
|
ctx,
|
||||||
|
directResourceOffset + (type * sizeof(ushort)),
|
||||||
|
out var reg) ||
|
||||||
|
reg == IllegalDirectOffset)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ((AgcDirectResourceType)type)
|
||||||
|
{
|
||||||
|
case AgcDirectResourceType.PtrVertexBufferTable:
|
||||||
|
vertexBufferReg = reg;
|
||||||
|
break;
|
||||||
|
case AgcDirectResourceType.PtrVertexAttribDescTable:
|
||||||
|
vertexAttribReg = reg;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vertexBufferReg < 0 || vertexAttribReg < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryReadUInt64(
|
||||||
|
ctx,
|
||||||
|
shaderHeaderAddress + ShaderInputSemanticsOffset,
|
||||||
|
out var inputSemanticsAddress) ||
|
||||||
|
!TryReadUInt32(
|
||||||
|
ctx,
|
||||||
|
shaderHeaderAddress + ShaderNumInputSemanticsOffset,
|
||||||
|
out var inputSemanticsCount) ||
|
||||||
|
inputSemanticsCount == 0 ||
|
||||||
|
inputSemanticsAddress == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
registers = new VertexTableRegisters(
|
||||||
|
vertexBufferReg,
|
||||||
|
vertexAttribReg,
|
||||||
|
inputSemanticsCount,
|
||||||
|
inputSemanticsAddress);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds attrib resources from AGC input_semantics + tables.
|
||||||
|
/// ShaderSemantic packing:
|
||||||
|
/// bits [7:0] semantic → attrib table index
|
||||||
|
/// bits [15:8] hardware_mapping → VGPR destination
|
||||||
|
/// bits [19:16] size_in_elements
|
||||||
|
/// </summary>
|
||||||
|
internal static bool TryBuildVertexResourcesFromMetadata(
|
||||||
|
CpuContext ctx,
|
||||||
|
IReadOnlyList<uint> scalarRegisters,
|
||||||
|
VertexTableRegisters tables,
|
||||||
|
out IReadOnlyList<MetadataVertexResource> resources)
|
||||||
|
{
|
||||||
|
resources = Array.Empty<MetadataVertexResource>();
|
||||||
|
if (tables.VertexAttribReg < 0 ||
|
||||||
|
tables.VertexBufferReg < 0 ||
|
||||||
|
tables.VertexAttribReg + 1 >= scalarRegisters.Count ||
|
||||||
|
tables.VertexBufferReg + 1 >= scalarRegisters.Count ||
|
||||||
|
tables.InputSemanticsCount == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var attribTable =
|
||||||
|
((ulong)scalarRegisters[tables.VertexAttribReg + 1] << 32) |
|
||||||
|
scalarRegisters[tables.VertexAttribReg];
|
||||||
|
var bufferTable =
|
||||||
|
((ulong)scalarRegisters[tables.VertexBufferReg + 1] << 32) |
|
||||||
|
scalarRegisters[tables.VertexBufferReg];
|
||||||
|
if (attribTable == 0 || bufferTable == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var built = new List<MetadataVertexResource>((int)tables.InputSemanticsCount);
|
||||||
|
for (uint i = 0; i < tables.InputSemanticsCount; i++)
|
||||||
|
{
|
||||||
|
if (!TryReadUInt32(
|
||||||
|
ctx,
|
||||||
|
tables.InputSemanticsAddress + (i * sizeof(uint)),
|
||||||
|
out var semanticWord))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attrib index is semantic bits [7:0], not hardware_mapping.
|
||||||
|
var semantic = semanticWord & 0xFFu;
|
||||||
|
var hardwareMapping = (semanticWord >> 8) & 0xFFu;
|
||||||
|
var sizeInElements = (semanticWord >> 16) & 0xFu;
|
||||||
|
if (!TryReadUInt32(ctx, attribTable + (semantic * sizeof(uint)), out var attribWord))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attrib dword: buffer index [4:0], format [13:5], offset [25:14], fetch [26].
|
||||||
|
var bufferIndex = attribWord & 0x1Fu;
|
||||||
|
var format = (attribWord >> 5) & 0x1FFu;
|
||||||
|
var offset = (attribWord >> 14) & 0xFFFu;
|
||||||
|
var fetchIndex = (attribWord >> 26) & 0x1u;
|
||||||
|
var sharpAddress = bufferTable + (bufferIndex * 16u);
|
||||||
|
if (!TryReadUInt32(ctx, sharpAddress, out var sharp0) ||
|
||||||
|
!TryReadUInt32(ctx, sharpAddress + 4, out var sharp1))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sharpBase = sharp0 | ((ulong)(sharp1 & 0xFFFFu) << 32);
|
||||||
|
var stride = (sharp1 >> 16) & 0x3FFFu;
|
||||||
|
if (sharpBase == 0 || stride == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fallbackComponents = sizeInElements != 0 ? sizeInElements : 4u;
|
||||||
|
var (dataFormat, numberFormat, components) =
|
||||||
|
MapAttribFormat(format, fallbackComponents);
|
||||||
|
built.Add(new MetadataVertexResource(
|
||||||
|
Location: i,
|
||||||
|
Semantic: semantic,
|
||||||
|
HardwareMapping: hardwareMapping,
|
||||||
|
SizeInElements: sizeInElements,
|
||||||
|
SharpBase: sharpBase,
|
||||||
|
Stride: stride,
|
||||||
|
OffsetBytes: offset,
|
||||||
|
DataFormat: dataFormat,
|
||||||
|
NumberFormat: numberFormat,
|
||||||
|
ComponentCount: components,
|
||||||
|
PerInstance: fetchIndex != 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (built.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
resources = built;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Patch IR-discovered fetches from the attrib table onto the V# format/offset.
|
||||||
|
/// Prefer 1:1 Location pairing when counts match on one interleaved stream
|
||||||
|
/// (GTA UI glyphs). Otherwise match by stride + byte offset. Never rebases
|
||||||
|
/// BaseAddress/Data/Location/Pc/PerInstance.
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyList<Gen5VertexInputBinding> MergeVertexInputsFromMetadata(
|
||||||
|
CpuContext ctx,
|
||||||
|
IReadOnlyList<uint> scalarRegisters,
|
||||||
|
VertexTableRegisters tables,
|
||||||
|
IReadOnlyList<Gen5VertexInputBinding> discovered)
|
||||||
|
{
|
||||||
|
if (discovered.Count == 0 ||
|
||||||
|
!TryBuildVertexResourcesFromMetadata(
|
||||||
|
ctx,
|
||||||
|
scalarRegisters,
|
||||||
|
tables,
|
||||||
|
out var resources))
|
||||||
|
{
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryMergeByLocationPairing(discovered, resources, out var paired))
|
||||||
|
{
|
||||||
|
return paired;
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged = new List<Gen5VertexInputBinding>(discovered.Count);
|
||||||
|
var usedResources = new bool[resources.Count];
|
||||||
|
var changed = false;
|
||||||
|
foreach (var input in discovered)
|
||||||
|
{
|
||||||
|
if (!TryMatchMetadataResource(input, resources, usedResources, out var resource, out var fillOffset))
|
||||||
|
{
|
||||||
|
merged.Add(input);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var refined = ApplyMetadataFormat(input, resource, fillOffset);
|
||||||
|
changed |= refined != input;
|
||||||
|
merged.Add(refined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? merged : discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When discovery and metadata describe the same interleaved stream with
|
||||||
|
/// equal attribute counts, pair by sorted Location (semantic order).
|
||||||
|
/// Keeps each binding's Pc/Location for SPIR-V; overlays format + offset.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryMergeByLocationPairing(
|
||||||
|
IReadOnlyList<Gen5VertexInputBinding> discovered,
|
||||||
|
IReadOnlyList<MetadataVertexResource> resources,
|
||||||
|
out IReadOnlyList<Gen5VertexInputBinding> merged)
|
||||||
|
{
|
||||||
|
merged = discovered;
|
||||||
|
if (discovered.Count != resources.Count || discovered.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderedInputs = discovered.OrderBy(static input => input.Location).ToArray();
|
||||||
|
var orderedResources = resources.OrderBy(static resource => resource.Location).ToArray();
|
||||||
|
var streamBase = orderedResources[0].SharpBase;
|
||||||
|
var streamStride = orderedResources[0].Stride;
|
||||||
|
for (var index = 0; index < orderedResources.Length; index++)
|
||||||
|
{
|
||||||
|
var resource = orderedResources[index];
|
||||||
|
var input = orderedInputs[index];
|
||||||
|
if (resource.SharpBase != streamBase ||
|
||||||
|
resource.Stride != streamStride ||
|
||||||
|
(input.Stride != 0 && input.Stride != streamStride) ||
|
||||||
|
!IsSameVertexStream(input, resource))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var byPc = new Dictionary<uint, Gen5VertexInputBinding>(discovered.Count);
|
||||||
|
var changed = false;
|
||||||
|
for (var index = 0; index < orderedInputs.Length; index++)
|
||||||
|
{
|
||||||
|
var input = orderedInputs[index];
|
||||||
|
var resource = orderedResources[index];
|
||||||
|
var fillOffset = input.BaseAddress == resource.SharpBase ||
|
||||||
|
IsAddressInsideCapturedSpan(input, resource.SharpBase);
|
||||||
|
var refined = ApplyMetadataFormat(input, resource, fillOffset);
|
||||||
|
changed |= refined != input;
|
||||||
|
byPc[input.Pc] = refined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new Gen5VertexInputBinding[discovered.Count];
|
||||||
|
for (var index = 0; index < discovered.Count; index++)
|
||||||
|
{
|
||||||
|
result[index] = byPc[discovered[index].Pc];
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = result;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gen5VertexInputBinding ApplyMetadataFormat(
|
||||||
|
Gen5VertexInputBinding input,
|
||||||
|
MetadataVertexResource resource,
|
||||||
|
bool fillOffsetBytes)
|
||||||
|
{
|
||||||
|
var components = input.ComponentCount != 0 &&
|
||||||
|
input.ComponentCount < resource.ComponentCount
|
||||||
|
? input.ComponentCount
|
||||||
|
: resource.ComponentCount;
|
||||||
|
|
||||||
|
return input with
|
||||||
|
{
|
||||||
|
DataFormat = resource.DataFormat,
|
||||||
|
NumberFormat = resource.NumberFormat,
|
||||||
|
ComponentCount = components,
|
||||||
|
OffsetBytes = fillOffsetBytes ? resource.OffsetBytes : input.OffsetBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legacy entry point — forwards to <see cref="MergeVertexInputsFromMetadata"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyList<Gen5VertexInputBinding> RefineVertexInputs(
|
||||||
|
CpuContext ctx,
|
||||||
|
IReadOnlyList<uint> scalarRegisters,
|
||||||
|
VertexTableRegisters tables,
|
||||||
|
IReadOnlyList<Gen5VertexInputBinding> discovered) =>
|
||||||
|
MergeVertexInputsFromMetadata(ctx, scalarRegisters, tables, discovered);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collects SBufferLoad / SLoad PCs that read the AGC attrib or buffer
|
||||||
|
/// tables (embedded-fetch prolog). Those loads are executed on the
|
||||||
|
/// CPU during scalar evaluation; once vertex inputs are bound they must
|
||||||
|
/// not run again as live SSBOs on the GPU.
|
||||||
|
/// </summary>
|
||||||
|
internal static HashSet<uint> CollectFetchPrologPcs(
|
||||||
|
Gen5ShaderProgram program,
|
||||||
|
VertexTableRegisters tables)
|
||||||
|
{
|
||||||
|
var pcs = new HashSet<uint>();
|
||||||
|
if (tables.VertexAttribReg < 0 || tables.VertexBufferReg < 0)
|
||||||
|
{
|
||||||
|
return pcs;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tableRegs = new HashSet<uint>
|
||||||
|
{
|
||||||
|
(uint)tables.VertexAttribReg,
|
||||||
|
(uint)tables.VertexAttribReg + 1u,
|
||||||
|
(uint)tables.VertexBufferReg,
|
||||||
|
(uint)tables.VertexBufferReg + 1u,
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var instruction in program.Instructions)
|
||||||
|
{
|
||||||
|
var isScalarLoad =
|
||||||
|
instruction.Opcode.StartsWith("SBufferLoad", StringComparison.Ordinal) ||
|
||||||
|
instruction.Opcode.StartsWith("SLoad", StringComparison.Ordinal);
|
||||||
|
if (!isScalarLoad)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMEM loads encode the scalar base pointer in Sources[0].
|
||||||
|
if (instruction.Sources.Count > 0 &&
|
||||||
|
instruction.Sources[0] is
|
||||||
|
{
|
||||||
|
Kind: Gen5OperandKind.ScalarRegister,
|
||||||
|
Value: var scalarBase,
|
||||||
|
} &&
|
||||||
|
tableRegs.Contains(scalarBase))
|
||||||
|
{
|
||||||
|
pcs.Add(instruction.Pc);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instruction.Control is Gen5BufferMemoryControl buffer &&
|
||||||
|
tableRegs.Contains(buffer.ScalarResource))
|
||||||
|
{
|
||||||
|
pcs.Add(instruction.Pc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pcs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryMatchMetadataResource(
|
||||||
|
Gen5VertexInputBinding input,
|
||||||
|
IReadOnlyList<MetadataVertexResource> resources,
|
||||||
|
bool[] usedResources,
|
||||||
|
out MetadataVertexResource resource,
|
||||||
|
out bool fillOffsetBytes)
|
||||||
|
{
|
||||||
|
resource = default;
|
||||||
|
fillOffsetBytes = false;
|
||||||
|
var bestScore = int.MinValue;
|
||||||
|
var bestIndex = -1;
|
||||||
|
var bestFillOffset = false;
|
||||||
|
for (var index = 0; index < resources.Count; index++)
|
||||||
|
{
|
||||||
|
if (usedResources[index])
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = resources[index];
|
||||||
|
if (candidate.Stride != 0 &&
|
||||||
|
input.Stride != 0 &&
|
||||||
|
candidate.Stride != input.Stride)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsSameVertexStream(input, candidate))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var attrAddress = candidate.SharpBase + candidate.OffsetBytes;
|
||||||
|
var score = int.MinValue;
|
||||||
|
var fillOffset = false;
|
||||||
|
|
||||||
|
// Post-capture interleaved: shared BaseAddress, distinct OffsetBytes.
|
||||||
|
if (input.OffsetBytes == candidate.OffsetBytes &&
|
||||||
|
(input.BaseAddress == candidate.SharpBase ||
|
||||||
|
IsAddressInsideCapturedSpan(input, candidate.SharpBase)))
|
||||||
|
{
|
||||||
|
score = 400;
|
||||||
|
}
|
||||||
|
// IR prolog baked attrib offset into the V# base.
|
||||||
|
else if (input.BaseAddress == attrAddress)
|
||||||
|
{
|
||||||
|
score = 350;
|
||||||
|
}
|
||||||
|
// Discovery never saw the attrib offset — only safe when this
|
||||||
|
// resource's offset uniquely identifies it among unused entries.
|
||||||
|
else if (input.BaseAddress == candidate.SharpBase &&
|
||||||
|
input.OffsetBytes == 0 &&
|
||||||
|
candidate.OffsetBytes != 0 &&
|
||||||
|
IsUniqueUnusedOffset(resources, usedResources, candidate.OffsetBytes, index))
|
||||||
|
{
|
||||||
|
score = 300;
|
||||||
|
fillOffset = true;
|
||||||
|
}
|
||||||
|
else if (input.BaseAddress == candidate.SharpBase &&
|
||||||
|
input.OffsetBytes == 0 &&
|
||||||
|
candidate.OffsetBytes == 0)
|
||||||
|
{
|
||||||
|
score = 250;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > bestScore)
|
||||||
|
{
|
||||||
|
bestScore = score;
|
||||||
|
bestIndex = index;
|
||||||
|
bestFillOffset = fillOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require an offset-aware match. Bare SharpBase ties (score 250) are
|
||||||
|
// only accepted when a single unused resource remains for that stream.
|
||||||
|
if (bestIndex < 0 || bestScore < 300)
|
||||||
|
{
|
||||||
|
if (bestIndex < 0 || bestScore < 250)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var unusedSameStream = 0;
|
||||||
|
for (var index = 0; index < resources.Count; index++)
|
||||||
|
{
|
||||||
|
if (!usedResources[index] && IsSameVertexStream(input, resources[index]))
|
||||||
|
{
|
||||||
|
unusedSameStream++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unusedSameStream != 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usedResources[bestIndex] = true;
|
||||||
|
resource = resources[bestIndex];
|
||||||
|
fillOffsetBytes = bestFillOffset;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSameVertexStream(
|
||||||
|
Gen5VertexInputBinding input,
|
||||||
|
MetadataVertexResource resource)
|
||||||
|
{
|
||||||
|
if (input.BaseAddress == resource.SharpBase ||
|
||||||
|
input.BaseAddress == resource.SharpBase + resource.OffsetBytes)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsAddressInsideCapturedSpan(input, resource.SharpBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAddressInsideCapturedSpan(
|
||||||
|
Gen5VertexInputBinding input,
|
||||||
|
ulong address) =>
|
||||||
|
input.DataLength > 0 &&
|
||||||
|
address >= input.BaseAddress &&
|
||||||
|
address < input.BaseAddress + (ulong)input.DataLength;
|
||||||
|
|
||||||
|
private static bool IsUniqueUnusedOffset(
|
||||||
|
IReadOnlyList<MetadataVertexResource> resources,
|
||||||
|
bool[] usedResources,
|
||||||
|
uint offsetBytes,
|
||||||
|
int candidateIndex)
|
||||||
|
{
|
||||||
|
for (var index = 0; index < resources.Count; index++)
|
||||||
|
{
|
||||||
|
if (index == candidateIndex || usedResources[index])
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources[index].OffsetBytes == offsetBytes)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attrib-table format
|
||||||
|
/// fields are VertexAttribFormat; V# / Vulkan paths need BufferFormat.
|
||||||
|
/// Unknown values pass through (already BufferFormat).
|
||||||
|
/// </summary>
|
||||||
|
private static uint VertexAttribFormatToBufferFormat(uint format) =>
|
||||||
|
format switch
|
||||||
|
{
|
||||||
|
0 => 0, // Invalid
|
||||||
|
4 => 1, // k8UNorm
|
||||||
|
8 => 2, // k8SNorm
|
||||||
|
12 => 3, // k8UScaled
|
||||||
|
16 => 4, // k8SScaled
|
||||||
|
20 => 5, // k8UInt
|
||||||
|
24 => 6, // k8SInt
|
||||||
|
28 => 7, // k16UNorm
|
||||||
|
32 => 8, // k16SNorm
|
||||||
|
36 => 9, // k16UScaled
|
||||||
|
40 => 10, // k16SScaled
|
||||||
|
44 => 11, // k16UInt
|
||||||
|
48 => 12, // k16SInt
|
||||||
|
52 => 13, // k16Float
|
||||||
|
57 => 14, // k8_8UNorm
|
||||||
|
61 => 15, // k8_8SNorm
|
||||||
|
65 => 16, // k8_8UScaled
|
||||||
|
69 => 17, // k8_8SScaled
|
||||||
|
73 => 18, // k8_8UInt
|
||||||
|
77 => 19, // k8_8SInt
|
||||||
|
80 => 20, // k32UInt
|
||||||
|
84 => 21, // k32SInt
|
||||||
|
88 => 22, // k32Float
|
||||||
|
93 => 23, // k16_16UNorm
|
||||||
|
97 => 24, // k16_16SNorm
|
||||||
|
101 => 25, // k16_16UScaled
|
||||||
|
105 => 26, // k16_16SScaled
|
||||||
|
109 => 27, // k16_16UInt
|
||||||
|
113 => 28, // k16_16SInt
|
||||||
|
117 => 29, // k16_16Float
|
||||||
|
122 => 30, // k11_11_10UNorm
|
||||||
|
126 => 31,
|
||||||
|
130 => 32,
|
||||||
|
134 => 33,
|
||||||
|
138 => 34,
|
||||||
|
142 => 35,
|
||||||
|
146 => 36,
|
||||||
|
150 => 37, // k10_11_11UNorm
|
||||||
|
154 => 38,
|
||||||
|
158 => 39,
|
||||||
|
162 => 40,
|
||||||
|
166 => 41,
|
||||||
|
170 => 42,
|
||||||
|
174 => 43,
|
||||||
|
179 => 44, // k2_10_10_10UNorm
|
||||||
|
183 => 45,
|
||||||
|
187 => 46,
|
||||||
|
191 => 47,
|
||||||
|
195 => 48,
|
||||||
|
199 => 49,
|
||||||
|
203 => 50, // k10_10_10_2UNorm
|
||||||
|
207 => 51,
|
||||||
|
211 => 52,
|
||||||
|
215 => 53,
|
||||||
|
219 => 54,
|
||||||
|
223 => 55,
|
||||||
|
227 => 56, // k8_8_8_8UNorm
|
||||||
|
231 => 57,
|
||||||
|
235 => 58,
|
||||||
|
239 => 59,
|
||||||
|
243 => 60,
|
||||||
|
247 => 61,
|
||||||
|
249 => 62, // k32_32UInt
|
||||||
|
253 => 63,
|
||||||
|
257 => 64, // k32_32Float
|
||||||
|
263 => 65, // k16_16_16_16UNorm
|
||||||
|
267 => 66,
|
||||||
|
271 => 67,
|
||||||
|
275 => 68,
|
||||||
|
279 => 69,
|
||||||
|
283 => 70,
|
||||||
|
287 => 71, // k16_16_16_16Float
|
||||||
|
290 => 72, // k32_32_32UInt
|
||||||
|
294 => 73,
|
||||||
|
298 => 74,
|
||||||
|
303 => 75, // k32_32_32_32UInt
|
||||||
|
307 => 76,
|
||||||
|
311 => 77, // k32_32_32_32Float
|
||||||
|
_ => format,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps Prospero attrib-table formats onto GNM (DataFormat, NumberFormat,
|
||||||
|
/// Components) for <c>ToVkVertexFormat</c>. Accepts VertexAttribFormat
|
||||||
|
/// or BufferFormat (pass-through). NumberFormat: 0 Unorm, 1 SNorm,
|
||||||
|
/// 2 UScaled, 3 SScaled, 4 UInt, 5 SInt, 7 Float.
|
||||||
|
/// </summary>
|
||||||
|
private static (uint DataFormat, uint NumberFormat, uint Components) MapAttribFormat(
|
||||||
|
uint attribFormat,
|
||||||
|
uint fallbackComponents)
|
||||||
|
{
|
||||||
|
// Prospero VertexAttribFormat quirks before BufferFormat conversion.
|
||||||
|
if (attribFormat == 113)
|
||||||
|
{
|
||||||
|
return (14, 7, 4); // R32G32B32A32_SFLOAT
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attribFormat == 121)
|
||||||
|
{
|
||||||
|
return (5, 7, 2); // R16G16_SFLOAT
|
||||||
|
}
|
||||||
|
|
||||||
|
var bufferFormat = VertexAttribFormatToBufferFormat(attribFormat);
|
||||||
|
|
||||||
|
// Prospero::BufferFormat numeric values (gpu_defs.h).
|
||||||
|
return bufferFormat switch
|
||||||
|
{
|
||||||
|
1 => (1, 0, 1), // k8UNorm
|
||||||
|
2 => (1, 1, 1), // k8SNorm
|
||||||
|
3 => (1, 2, 1), // k8UScaled
|
||||||
|
4 => (1, 3, 1), // k8SScaled
|
||||||
|
5 => (1, 4, 1), // k8UInt
|
||||||
|
6 => (1, 5, 1), // k8SInt
|
||||||
|
7 => (2, 0, 1), // k16UNorm
|
||||||
|
8 => (2, 1, 1), // k16SNorm
|
||||||
|
9 => (2, 2, 1), // k16UScaled
|
||||||
|
10 => (2, 3, 1), // k16SScaled
|
||||||
|
11 => (2, 4, 1), // k16UInt
|
||||||
|
12 => (2, 5, 1), // k16SInt
|
||||||
|
13 => (2, 7, 1), // k16Float
|
||||||
|
14 => (3, 0, 2), // k8_8UNorm
|
||||||
|
15 => (3, 1, 2), // k8_8SNorm
|
||||||
|
16 => (3, 2, 2), // k8_8UScaled
|
||||||
|
17 => (3, 3, 2), // k8_8SScaled
|
||||||
|
18 => (3, 4, 2), // k8_8UInt
|
||||||
|
19 => (3, 5, 2), // k8_8SInt
|
||||||
|
20 => (4, 4, 1), // k32UInt
|
||||||
|
21 => (4, 5, 1), // k32SInt
|
||||||
|
22 => (4, 7, 1), // k32Float
|
||||||
|
23 => (5, 0, 2), // k16_16UNorm
|
||||||
|
24 => (5, 1, 2), // k16_16SNorm
|
||||||
|
25 => (5, 2, 2), // k16_16UScaled
|
||||||
|
26 => (5, 3, 2), // k16_16SScaled
|
||||||
|
27 => (5, 4, 2), // k16_16UInt
|
||||||
|
28 => (5, 5, 2), // k16_16SInt
|
||||||
|
29 => (5, 7, 2), // k16_16Float
|
||||||
|
50 => (9, 0, 4), // k10_10_10_2UNorm
|
||||||
|
51 => (9, 1, 4), // k10_10_10_2SNorm
|
||||||
|
56 => (10, 0, 4), // k8_8_8_8UNorm
|
||||||
|
57 => (10, 1, 4), // k8_8_8_8SNorm
|
||||||
|
58 => (10, 2, 4), // k8_8_8_8UScaled
|
||||||
|
59 => (10, 3, 4), // k8_8_8_8SScaled
|
||||||
|
60 => (10, 4, 4), // k8_8_8_8UInt
|
||||||
|
61 => (10, 5, 4), // k8_8_8_8SInt
|
||||||
|
62 => (11, 4, 2), // k32_32UInt
|
||||||
|
63 => (11, 5, 2), // k32_32SInt
|
||||||
|
64 => (11, 7, 2), // k32_32Float
|
||||||
|
65 => (12, 0, 4), // k16_16_16_16UNorm
|
||||||
|
66 => (12, 1, 4), // k16_16_16_16SNorm
|
||||||
|
67 => (12, 2, 4), // k16_16_16_16UScaled
|
||||||
|
68 => (12, 3, 4), // k16_16_16_16SScaled
|
||||||
|
69 => (12, 4, 4), // k16_16_16_16UInt
|
||||||
|
70 => (12, 5, 4), // k16_16_16_16SInt
|
||||||
|
71 => (12, 7, 4), // k16_16_16_16Float
|
||||||
|
72 => (13, 4, 3), // k32_32_32UInt
|
||||||
|
73 => (13, 5, 3), // k32_32_32SInt
|
||||||
|
74 => (13, 7, 3), // k32_32_32Float
|
||||||
|
75 => (14, 4, 4), // k32_32_32_32UInt
|
||||||
|
76 => (14, 5, 4), // k32_32_32_32SInt
|
||||||
|
77 => (14, 7, 4), // k32_32_32_32Float
|
||||||
|
_ => (14, 7, Math.Clamp(fallbackComponents, 1u, 4u)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadUInt16(CpuContext ctx, ulong address, out ushort value)
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[2];
|
||||||
|
if (!ctx.Memory.TryRead(address, buffer))
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(buffer);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value)
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[4];
|
||||||
|
if (!ctx.Memory.TryRead(address, buffer))
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadUInt64(CpuContext ctx, ulong address, out ulong value)
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[8];
|
||||||
|
if (!ctx.Memory.TryRead(address, buffer))
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ internal enum DetileEquation
|
|||||||
/// <summary>Unsupported mode/format; caller must use the CPU path or raw upload.</summary>
|
/// <summary>Unsupported mode/format; caller must use the CPU path or raw upload.</summary>
|
||||||
None,
|
None,
|
||||||
|
|
||||||
/// <summary>Exact AddrLib XOR equation (RDNA2 modes 5/9/24/27): factored X/Y terms.</summary>
|
/// <summary>Exact AddrLib XOR equation (RDNA2 modes 1/5/9/24/27): factored X/Y terms.</summary>
|
||||||
ExactXor,
|
ExactXor,
|
||||||
|
|
||||||
/// <summary>Other modes: a precomputed in-block Morton/standard element-offset table.</summary>
|
/// <summary>Other modes: a precomputed in-block Morton/standard element-offset table.</summary>
|
||||||
@@ -145,6 +145,18 @@ internal static unsafe class GnmTiling
|
|||||||
Y(2), X(2), Y(3), X(3), Y(4), X(4), Y(5), X(5)],
|
Y(2), X(2), Y(3), X(3), Y(4), X(4), Y(5), X(5)],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// GFX10 256B_S: 8-bit micro-tile equation (low octet of the 4K_S pattern).
|
||||||
|
// The generic StandardSwizzle bit-interleave is a different layout and leaves
|
||||||
|
// a broken grid on Gen5 UI atlases that ship as Standard256B.
|
||||||
|
private static readonly AddressBit[][] Standard256 =
|
||||||
|
[
|
||||||
|
[X(0), X(1), X(2), X(3), Y(0), Y(1), Y(2), Y(3)],
|
||||||
|
[Zero, X(0), X(1), X(2), Y(0), Y(1), Y(2), X(3)],
|
||||||
|
[Zero, Zero, X(0), X(1), Y(0), Y(1), Y(2), X(2)],
|
||||||
|
[Zero, Zero, Zero, X(0), Y(0), Y(1), X(1), X(2)],
|
||||||
|
[Zero, Zero, Zero, Zero, Y(0), Y(1), X(0), X(1)],
|
||||||
|
];
|
||||||
|
|
||||||
// GFX10 4K_S has a separate 12-bit micro-tile equation. It is not the
|
// GFX10 4K_S has a separate 12-bit micro-tile equation. It is not the
|
||||||
// generic x/y interleave used by the 64K standard block; using that larger
|
// generic x/y interleave used by the 64K standard block; using that larger
|
||||||
// equation leaves a regular grid in linearized atlases.
|
// equation leaves a regular grid in linearized atlases.
|
||||||
@@ -789,6 +801,7 @@ internal static unsafe class GnmTiling
|
|||||||
|
|
||||||
pattern = swizzleMode switch
|
pattern = swizzleMode switch
|
||||||
{
|
{
|
||||||
|
1 => Standard256[bytesPerElementLog2],
|
||||||
5 => Standard4K[bytesPerElementLog2],
|
5 => Standard4K[bytesPerElementLog2],
|
||||||
9 => RbPlus64KStandard[bytesPerElementLog2],
|
9 => RbPlus64KStandard[bytesPerElementLog2],
|
||||||
24 => RbPlus64KDepthX[bytesPerElementLog2],
|
24 => RbPlus64KDepthX[bytesPerElementLog2],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 System.Diagnostics;
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Agc;
|
namespace SharpEmu.Libs.Agc;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -92,6 +94,64 @@ internal static class GpuWaitRegistry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public readonly record struct OutstandingSnapshot(
|
||||||
|
int Outstanding,
|
||||||
|
int Latched,
|
||||||
|
long OldestAgeMs,
|
||||||
|
ulong SampleWaitAddress,
|
||||||
|
string? SampleQueueName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diagnostics snapshot of suspended WAIT_REG_MEM / dims waiters.
|
||||||
|
/// </summary>
|
||||||
|
public static OutstandingSnapshot SnapshotOutstanding(object? memory = null)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var outstanding = 0;
|
||||||
|
var latched = 0;
|
||||||
|
var oldestTicks = long.MaxValue;
|
||||||
|
ulong sampleAddress = 0;
|
||||||
|
string? sampleQueue = null;
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
foreach (var (_, list) in _waiters)
|
||||||
|
{
|
||||||
|
foreach (var waiter in list)
|
||||||
|
{
|
||||||
|
if (memory is not null &&
|
||||||
|
!ReferenceEquals(waiter.Memory, memory))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
outstanding++;
|
||||||
|
if (waiter.Latched)
|
||||||
|
{
|
||||||
|
latched++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (waiter.RegisteredTicks != 0 &&
|
||||||
|
waiter.RegisteredTicks < oldestTicks)
|
||||||
|
{
|
||||||
|
oldestTicks = waiter.RegisteredTicks;
|
||||||
|
sampleAddress = waiter.WaitAddress;
|
||||||
|
sampleQueue = waiter.QueueName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldestAgeMs = oldestTicks == long.MaxValue || oldestTicks == 0
|
||||||
|
? 0L
|
||||||
|
: (now - oldestTicks) * 1000L / Stopwatch.Frequency;
|
||||||
|
return new OutstandingSnapshot(
|
||||||
|
outstanding,
|
||||||
|
latched,
|
||||||
|
oldestAgeMs,
|
||||||
|
sampleAddress,
|
||||||
|
sampleQueue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void Register(ulong address, WaitingDcb waiter)
|
public static void Register(ulong address, WaitingDcb waiter)
|
||||||
{
|
{
|
||||||
waiter.WaitAddress = address;
|
waiter.WaitAddress = address;
|
||||||
|
|||||||
@@ -275,6 +275,19 @@ public static class AmprExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Offset -1 means "continue after the previous read of this file id".
|
||||||
|
// #216 dropped this wiring; without it sequential pack/streamer reads
|
||||||
|
// fail as INVALID_ARGUMENT and RAGE load jobs never complete while the
|
||||||
|
// North Yankton UI keeps flipping.
|
||||||
|
if (fileOffset == unchecked((ulong)(long)-1))
|
||||||
|
{
|
||||||
|
fileOffset = PakDirectoryTracker.ResolveSequentialOffset(fileId, size);
|
||||||
|
}
|
||||||
|
else if (fileOffset > long.MaxValue)
|
||||||
|
{
|
||||||
|
fileOffset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
var result = TryReadFileToGuestMemory(ctx, hostPath, fileOffset, destination, size, out var bytesRead);
|
var result = TryReadFileToGuestMemory(ctx, hostPath, fileOffset, destination, size, out var bytesRead);
|
||||||
if (result != (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
if (result != (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||||
{
|
{
|
||||||
@@ -282,6 +295,8 @@ public static class AmprExports
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PakDirectoryTracker.OnReadCompleted(ctx, fileId, destination, fileOffset, bytesRead);
|
||||||
|
|
||||||
if (!AppendReadFileRecord(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead))
|
if (!AppendReadFileRecord(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead))
|
||||||
{
|
{
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using NLayer;
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stateful AJM MP3 (codec 0) decoder. GTA menu music arrives as ~960-byte
|
||||||
|
/// packets that NLayer must decode with a persistent bit-reservoir.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AjmMp3Decoder
|
||||||
|
{
|
||||||
|
private static readonly Type? MpegStreamReaderType =
|
||||||
|
typeof(MpegFrameDecoder).Assembly.GetType("NLayer.Decoder.MpegStreamReader");
|
||||||
|
|
||||||
|
private static readonly MethodInfo? NextFrameMethod =
|
||||||
|
MpegStreamReaderType?.GetMethod(
|
||||||
|
"NextFrame",
|
||||||
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
private static readonly MethodInfo? ClearBufferMethod =
|
||||||
|
typeof(MpegFrameDecoder).Assembly.GetType("NLayer.Decoder.FrameBase")
|
||||||
|
?.GetMethod("ClearBuffer", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
private readonly MpegFrameDecoder _decoder = new();
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private byte[] _pending = Array.Empty<byte>();
|
||||||
|
private readonly float[] _floatScratch = new float[1152 * 2];
|
||||||
|
|
||||||
|
public ulong TotalDecodedSamples { get; private set; }
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_decoder.Reset();
|
||||||
|
_pending = Array.Empty<byte>();
|
||||||
|
TotalDecodedSamples = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DecodeResult Decode(ReadOnlySpan<byte> input, Span<byte> output, bool pcm16)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (MpegStreamReaderType is null || NextFrameMethod is null)
|
||||||
|
{
|
||||||
|
return DecodeResult.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged = new byte[_pending.Length + input.Length];
|
||||||
|
if (_pending.Length != 0)
|
||||||
|
{
|
||||||
|
_pending.CopyTo(merged, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.CopyTo(merged.AsSpan(_pending.Length));
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(merged, writable: false);
|
||||||
|
object? reader;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reader = Activator.CreateInstance(
|
||||||
|
MpegStreamReaderType,
|
||||||
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
|
||||||
|
binder: null,
|
||||||
|
args: [stream],
|
||||||
|
culture: null);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return DecodeResult.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader is null)
|
||||||
|
{
|
||||||
|
return DecodeResult.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputOffset = 0;
|
||||||
|
var inputConsumed = 0;
|
||||||
|
var frames = 0u;
|
||||||
|
var samplesThisCall = 0u;
|
||||||
|
|
||||||
|
while (outputOffset < output.Length)
|
||||||
|
{
|
||||||
|
object? frameObj;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
frameObj = NextFrameMethod.Invoke(reader, null);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frameObj is not IMpegFrame frame)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var frameOffset = GetFrameOffset(frameObj);
|
||||||
|
var frameLength = frame.FrameLength;
|
||||||
|
if (frameLength <= 0 || frameOffset + frameLength > merged.Length)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sampleCount;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sampleCount = _decoder.DecodeFrame(frame, _floatScratch, 0);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_decoder.Reset();
|
||||||
|
inputConsumed = frameOffset + frameLength;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sampleCount <= 0)
|
||||||
|
{
|
||||||
|
inputConsumed = frameOffset + frameLength;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var channels = frame.ChannelMode == MpegChannelMode.Mono ? 1 : 2;
|
||||||
|
var bytesPerSample = pcm16 ? 2 : 4;
|
||||||
|
var byteCount = sampleCount * bytesPerSample;
|
||||||
|
if (outputOffset + byteCount > output.Length)
|
||||||
|
{
|
||||||
|
// Not enough room for this frame — leave it for next job.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pcm16)
|
||||||
|
{
|
||||||
|
WritePcm16(_floatScratch.AsSpan(0, sampleCount), output[outputOffset..]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WriteFloat(_floatScratch.AsSpan(0, sampleCount), output[outputOffset..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
outputOffset += byteCount;
|
||||||
|
inputConsumed = frameOffset + frameLength;
|
||||||
|
frames++;
|
||||||
|
samplesThisCall += (uint)(sampleCount / Math.Max(channels, 1));
|
||||||
|
TotalDecodedSamples += (ulong)(sampleCount / Math.Max(channels, 1));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ClearBufferMethod?.Invoke(frameObj, null);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pending = inputConsumed < merged.Length
|
||||||
|
? merged[inputConsumed..]
|
||||||
|
: Array.Empty<byte>();
|
||||||
|
|
||||||
|
// Consume the portion of *this* input that left the pending window.
|
||||||
|
var pendingBefore = merged.Length - input.Length;
|
||||||
|
var consumedFromInput = Math.Clamp(inputConsumed - pendingBefore, 0, input.Length);
|
||||||
|
|
||||||
|
return new DecodeResult(
|
||||||
|
Success: frames > 0 || consumedFromInput > 0,
|
||||||
|
InputConsumed: consumedFromInput,
|
||||||
|
OutputWritten: outputOffset,
|
||||||
|
Frames: frames,
|
||||||
|
SamplesThisCall: samplesThisCall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetFrameOffset(object frameObj)
|
||||||
|
{
|
||||||
|
for (var type = frameObj.GetType(); type is not null; type = type.BaseType)
|
||||||
|
{
|
||||||
|
var prop = type.GetProperty(
|
||||||
|
"Offset",
|
||||||
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
|
||||||
|
if (prop?.GetValue(frameObj) is long offset)
|
||||||
|
{
|
||||||
|
return checked((int)offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WritePcm16(ReadOnlySpan<float> samples, Span<byte> destination)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < samples.Length; i++)
|
||||||
|
{
|
||||||
|
var sample = samples[i];
|
||||||
|
var scaled = sample < 0f ? sample * 32768f : sample * 32767f;
|
||||||
|
var value = (short)Math.Clamp(MathF.Round(scaled), short.MinValue, short.MaxValue);
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(destination[(i * 2)..], value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteFloat(ReadOnlySpan<float> samples, Span<byte> destination)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < samples.Length; i++)
|
||||||
|
{
|
||||||
|
var bits = BitConverter.SingleToInt32Bits(samples[i]);
|
||||||
|
BinaryPrimitives.WriteInt32LittleEndian(destination[(i * 4)..], bits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct DecodeResult(
|
||||||
|
bool Success,
|
||||||
|
int InputConsumed,
|
||||||
|
int OutputWritten,
|
||||||
|
uint Frames,
|
||||||
|
uint SamplesThisCall)
|
||||||
|
{
|
||||||
|
public static DecodeResult Failed { get; } = new(false, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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, " +
|
||||||
@@ -212,6 +228,14 @@ public static class AudioOutExports
|
|||||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same rule as AudioOut2 PortGetState: never bulk-write onto the caller
|
||||||
|
// stack. Some titles place small locals next to the canary; a full
|
||||||
|
// SceAudioOutPortState write smashes it.
|
||||||
|
if (IsGuestStackAddress(stateAddress))
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(0);
|
||||||
|
}
|
||||||
|
|
||||||
// SceAudioOutPortState: report a connected primary output at full volume
|
// SceAudioOutPortState: report a connected primary output at full volume
|
||||||
// so pacing/mixing code sees a live port. We do no host rerouting, so
|
// so pacing/mixing code sees a live port. We do no host rerouting, so
|
||||||
// rerouteCounter and flag stay zero.
|
// rerouteCounter and flag stay zero.
|
||||||
@@ -229,6 +253,9 @@ public static class AudioOutExports
|
|||||||
return ctx.SetReturn(0);
|
return ctx.SetReturn(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsGuestStackAddress(ulong value) =>
|
||||||
|
value >= 0x0000_7FF0_0000_0000UL && value <= 0x0000_7FFF_FFFF_FFFFUL;
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "w3PdaSTSwGE",
|
Nid = "w3PdaSTSwGE",
|
||||||
ExportName = "sceAudioOutOutputs",
|
ExportName = "sceAudioOutOutputs",
|
||||||
@@ -310,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();
|
||||||
@@ -438,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
|
||||||
{
|
{
|
||||||
@@ -502,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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Threading;
|
||||||
|
using SharpEmu.Libs.Agc;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rate-limited progress probes armed when GTA's 'North Audio Update' thread
|
||||||
|
/// starts. Used to classify North Yankton freezes (flip vs present vs GPU wait)
|
||||||
|
/// without enabling full AGC/VideoOut trace.
|
||||||
|
/// </summary>
|
||||||
|
public static class LoadProgressDiagnostics
|
||||||
|
{
|
||||||
|
// Keep probes live long enough to cover a stuck Yankton session.
|
||||||
|
private const long ActiveWindowMs = 120_000;
|
||||||
|
|
||||||
|
private static long _armedTicks;
|
||||||
|
private static long _flipSubmitTraceCount;
|
||||||
|
private static long _orderedFlipEnqueueTraceCount;
|
||||||
|
private static long _presentTakenTraceCount;
|
||||||
|
private static long _presentNotTakenTraceCount;
|
||||||
|
private static long _gpuWaitSnapshotTraceCount;
|
||||||
|
|
||||||
|
public static void ArmIfNorthAudioThread(string? threadName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(threadName) ||
|
||||||
|
threadName.IndexOf("North Audio", StringComparison.OrdinalIgnoreCase) < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Interlocked.CompareExchange(
|
||||||
|
ref _armedTicks,
|
||||||
|
Stopwatch.GetTimestamp(),
|
||||||
|
0) == 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"[LOADER][TRACE] load_progress.armed reason=north_audio " +
|
||||||
|
$"window_ms={ActiveWindowMs}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsActive
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var armed = Volatile.Read(ref _armedTicks);
|
||||||
|
if (armed == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var elapsedMs = (Stopwatch.GetTimestamp() - armed) * 1000L /
|
||||||
|
Stopwatch.Frequency;
|
||||||
|
return elapsedMs <= ActiveWindowMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void TraceFlipSubmit(
|
||||||
|
int handle,
|
||||||
|
int bufferIndex,
|
||||||
|
int flipMode,
|
||||||
|
bool submitGpuImage,
|
||||||
|
bool guestImageSubmitted,
|
||||||
|
ulong guestImageAddress,
|
||||||
|
int flipEventCount)
|
||||||
|
{
|
||||||
|
if (!IsActive || !ShouldTrace(ref _flipSubmitTraceCount, out var count))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][TRACE] load_progress.flip_submit count={count} " +
|
||||||
|
$"handle={handle} index={bufferIndex} mode={flipMode} " +
|
||||||
|
$"gpu_image={submitGpuImage} submitted={guestImageSubmitted} " +
|
||||||
|
$"addr=0x{guestImageAddress:X16} events={flipEventCount}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void TraceOrderedFlipEnqueue(
|
||||||
|
int videoOutHandle,
|
||||||
|
int displayBufferIndex,
|
||||||
|
ulong address,
|
||||||
|
long version,
|
||||||
|
bool enqueued)
|
||||||
|
{
|
||||||
|
if (!IsActive ||
|
||||||
|
!ShouldTrace(ref _orderedFlipEnqueueTraceCount, out var count))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][TRACE] load_progress.ordered_flip count={count} " +
|
||||||
|
$"handle={videoOutHandle} index={displayBufferIndex} " +
|
||||||
|
$"addr=0x{address:X16} version={version} enqueued={enqueued}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void TracePresentTaken(
|
||||||
|
long presentedSequence,
|
||||||
|
ulong guestImageAddress,
|
||||||
|
long guestImageVersion)
|
||||||
|
{
|
||||||
|
if (!IsActive || !ShouldTrace(ref _presentTakenTraceCount, out var count))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][TRACE] load_progress.present_taken count={count} " +
|
||||||
|
$"seq={presentedSequence} addr=0x{guestImageAddress:X16} " +
|
||||||
|
$"version={guestImageVersion}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void TracePresentNotTaken(
|
||||||
|
long presentedSequence,
|
||||||
|
bool hasPendingPresentation)
|
||||||
|
{
|
||||||
|
if (!IsActive ||
|
||||||
|
!ShouldTrace(ref _presentNotTakenTraceCount, out var count))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][TRACE] load_progress.present_not_taken count={count} " +
|
||||||
|
$"seq={presentedSequence} pending={hasPendingPresentation}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void TraceGpuWaitSnapshot(object? memory = null)
|
||||||
|
{
|
||||||
|
if (!IsActive ||
|
||||||
|
!ShouldTrace(ref _gpuWaitSnapshotTraceCount, out var count))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = GpuWaitRegistry.SnapshotOutstanding(memory);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][TRACE] load_progress.gpu_waits count={count} " +
|
||||||
|
$"outstanding={snapshot.Outstanding} latched={snapshot.Latched} " +
|
||||||
|
$"oldest_ms={snapshot.OldestAgeMs} " +
|
||||||
|
$"sample_addr=0x{snapshot.SampleWaitAddress:X16} " +
|
||||||
|
$"sample_queue={snapshot.SampleQueueName ?? "-"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldTrace(ref long counter, out long count)
|
||||||
|
{
|
||||||
|
count = Interlocked.Increment(ref counter);
|
||||||
|
return count <= 16 || (count & (count - 1)) == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,7 +89,8 @@ internal sealed record GuestVertexBuffer(
|
|||||||
uint OffsetBytes,
|
uint OffsetBytes,
|
||||||
byte[] Data,
|
byte[] Data,
|
||||||
int Length,
|
int Length,
|
||||||
bool Pooled);
|
bool Pooled,
|
||||||
|
bool PerInstance = false);
|
||||||
|
|
||||||
internal sealed record GuestIndexBuffer(
|
internal sealed record GuestIndexBuffer(
|
||||||
byte[] Data,
|
byte[] Data,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ internal interface IGuestGpuBackend
|
|||||||
int scalarRegisterBufferIndex = -1,
|
int scalarRegisterBufferIndex = -1,
|
||||||
uint pixelInputEnable = 0,
|
uint pixelInputEnable = 0,
|
||||||
uint pixelInputAddress = 0,
|
uint pixelInputAddress = 0,
|
||||||
|
IReadOnlyList<uint>? pixelInputCntl = null,
|
||||||
ulong storageBufferOffsetAlignment = 1);
|
ulong storageBufferOffsetAlignment = 1);
|
||||||
|
|
||||||
bool TryCompileComputeShader(
|
bool TryCompileComputeShader(
|
||||||
@@ -108,7 +109,8 @@ internal interface IGuestGpuBackend
|
|||||||
GuestIndexBuffer? indexBuffer = null,
|
GuestIndexBuffer? indexBuffer = null,
|
||||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||||
GuestRenderState? renderState = null,
|
GuestRenderState? renderState = null,
|
||||||
ulong shaderAddress = 0);
|
ulong shaderAddress = 0,
|
||||||
|
int baseVertex = 0);
|
||||||
|
|
||||||
void SubmitOffscreenTranslatedDraw(
|
void SubmitOffscreenTranslatedDraw(
|
||||||
IGuestCompiledShader pixelShader,
|
IGuestCompiledShader pixelShader,
|
||||||
@@ -124,7 +126,8 @@ internal interface IGuestGpuBackend
|
|||||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||||
GuestRenderState? renderState = null,
|
GuestRenderState? renderState = null,
|
||||||
GuestDepthTarget? depthTarget = null,
|
GuestDepthTarget? depthTarget = null,
|
||||||
ulong shaderAddress = 0);
|
ulong shaderAddress = 0,
|
||||||
|
int baseVertex = 0);
|
||||||
|
|
||||||
void SubmitStorageTranslatedDraw(
|
void SubmitStorageTranslatedDraw(
|
||||||
IGuestCompiledShader pixelShader,
|
IGuestCompiledShader pixelShader,
|
||||||
@@ -234,7 +237,27 @@ 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>
|
||||||
|
/// Asks the presenter to refresh CPU-dirty guest images on its render/present
|
||||||
|
/// drain. Must not enqueue retained plane copies on the producer path.
|
||||||
|
/// </summary>
|
||||||
|
void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue);
|
||||||
|
|
||||||
bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount);
|
bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount);
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,13 @@ internal static class MetalGuestFormats
|
|||||||
{
|
{
|
||||||
var format = (dataFormat, numberType) switch
|
var format = (dataFormat, numberType) switch
|
||||||
{
|
{
|
||||||
|
// Early G-buffer / scene targets (R16 + RG32). Keep in sync with
|
||||||
|
// VulkanVideoPresenter.TryDecodeRenderTargetFormat.
|
||||||
|
(2, 0) => MtlPixelFormat.R16Unorm,
|
||||||
|
(2, 1) => MtlPixelFormat.R16Snorm,
|
||||||
|
(2, 4) => MtlPixelFormat.R16Uint,
|
||||||
|
(2, 5) => MtlPixelFormat.R16Sint,
|
||||||
|
(2, 7) => MtlPixelFormat.R16Float,
|
||||||
(4, 4) => MtlPixelFormat.R32Uint,
|
(4, 4) => MtlPixelFormat.R32Uint,
|
||||||
(4, 5) => MtlPixelFormat.R32Sint,
|
(4, 5) => MtlPixelFormat.R32Sint,
|
||||||
(4, 7) => MtlPixelFormat.R32Float,
|
(4, 7) => MtlPixelFormat.R32Float,
|
||||||
@@ -230,6 +237,8 @@ internal static class MetalGuestFormats
|
|||||||
(10, 5) => MtlPixelFormat.Rgba8Sint,
|
(10, 5) => MtlPixelFormat.Rgba8Sint,
|
||||||
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
|
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
|
||||||
(10, _) => MtlPixelFormat.Rgba8Unorm,
|
(10, _) => MtlPixelFormat.Rgba8Unorm,
|
||||||
|
(11, 4) => MtlPixelFormat.Rg32Uint,
|
||||||
|
(11, 5) => MtlPixelFormat.Rg32Sint,
|
||||||
(11, 7) => MtlPixelFormat.Rg32Float,
|
(11, 7) => MtlPixelFormat.Rg32Float,
|
||||||
(12, 4) => MtlPixelFormat.Rgba16Uint,
|
(12, 4) => MtlPixelFormat.Rgba16Uint,
|
||||||
(12, 5) => MtlPixelFormat.Rgba16Sint,
|
(12, 5) => MtlPixelFormat.Rgba16Sint,
|
||||||
@@ -258,10 +267,12 @@ internal static class MetalGuestFormats
|
|||||||
|
|
||||||
var outputKind = format switch
|
var outputKind = format switch
|
||||||
{
|
{
|
||||||
MtlPixelFormat.R8Uint or MtlPixelFormat.R32Uint or MtlPixelFormat.Rg16Uint or
|
MtlPixelFormat.R8Uint or MtlPixelFormat.R16Uint or MtlPixelFormat.R32Uint or
|
||||||
MtlPixelFormat.Rgba8Uint or MtlPixelFormat.Rgba16Uint => Gen5PixelOutputKind.Uint,
|
MtlPixelFormat.Rg16Uint or MtlPixelFormat.Rg32Uint or MtlPixelFormat.Rgba8Uint or
|
||||||
MtlPixelFormat.R32Sint or MtlPixelFormat.Rg16Sint or MtlPixelFormat.Rgba8Sint or
|
MtlPixelFormat.Rgba16Uint => Gen5PixelOutputKind.Uint,
|
||||||
MtlPixelFormat.Rgba16Sint => Gen5PixelOutputKind.Sint,
|
MtlPixelFormat.R16Sint or MtlPixelFormat.R32Sint or MtlPixelFormat.Rg16Sint or
|
||||||
|
MtlPixelFormat.Rg32Sint or MtlPixelFormat.Rgba8Sint or MtlPixelFormat.Rgba16Sint =>
|
||||||
|
Gen5PixelOutputKind.Sint,
|
||||||
_ => Gen5PixelOutputKind.Float,
|
_ => Gen5PixelOutputKind.Float,
|
||||||
};
|
};
|
||||||
result = new MetalRenderTargetFormat(format, outputKind);
|
result = new MetalRenderTargetFormat(format, outputKind);
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
|||||||
int scalarRegisterBufferIndex = -1,
|
int scalarRegisterBufferIndex = -1,
|
||||||
uint pixelInputEnable = 0,
|
uint pixelInputEnable = 0,
|
||||||
uint pixelInputAddress = 0,
|
uint pixelInputAddress = 0,
|
||||||
|
IReadOnlyList<uint>? pixelInputCntl = null,
|
||||||
ulong storageBufferOffsetAlignment = 1)
|
ulong storageBufferOffsetAlignment = 1)
|
||||||
{
|
{
|
||||||
shader = null;
|
shader = null;
|
||||||
@@ -85,6 +86,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
|||||||
scalarRegisterBufferIndex,
|
scalarRegisterBufferIndex,
|
||||||
pixelInputEnable,
|
pixelInputEnable,
|
||||||
pixelInputAddress,
|
pixelInputAddress,
|
||||||
|
pixelInputCntl,
|
||||||
storageBufferOffsetAlignment))
|
storageBufferOffsetAlignment))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -251,7 +253,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
|||||||
GuestIndexBuffer? indexBuffer = null,
|
GuestIndexBuffer? indexBuffer = null,
|
||||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||||
GuestRenderState? renderState = null,
|
GuestRenderState? renderState = null,
|
||||||
ulong shaderAddress = 0) =>
|
ulong shaderAddress = 0,
|
||||||
|
int baseVertex = 0) =>
|
||||||
MetalVideoPresenter.SubmitDepthOnlyTranslatedDraw(
|
MetalVideoPresenter.SubmitDepthOnlyTranslatedDraw(
|
||||||
Msl(pixelShader),
|
Msl(pixelShader),
|
||||||
textures,
|
textures,
|
||||||
@@ -265,7 +268,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
|||||||
indexBuffer,
|
indexBuffer,
|
||||||
vertexBuffers,
|
vertexBuffers,
|
||||||
renderState,
|
renderState,
|
||||||
shaderAddress);
|
shaderAddress,
|
||||||
|
baseVertex);
|
||||||
|
|
||||||
public void SubmitOffscreenTranslatedDraw(
|
public void SubmitOffscreenTranslatedDraw(
|
||||||
IGuestCompiledShader pixelShader,
|
IGuestCompiledShader pixelShader,
|
||||||
@@ -281,7 +285,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
|||||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||||
GuestRenderState? renderState = null,
|
GuestRenderState? renderState = null,
|
||||||
GuestDepthTarget? depthTarget = null,
|
GuestDepthTarget? depthTarget = null,
|
||||||
ulong shaderAddress = 0) =>
|
ulong shaderAddress = 0,
|
||||||
|
int baseVertex = 0) =>
|
||||||
MetalVideoPresenter.SubmitOffscreenTranslatedDraw(
|
MetalVideoPresenter.SubmitOffscreenTranslatedDraw(
|
||||||
Msl(pixelShader),
|
Msl(pixelShader),
|
||||||
textures,
|
textures,
|
||||||
@@ -296,7 +301,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
|||||||
vertexBuffers,
|
vertexBuffers,
|
||||||
renderState,
|
renderState,
|
||||||
depthTarget,
|
depthTarget,
|
||||||
shaderAddress);
|
shaderAddress,
|
||||||
|
baseVertex);
|
||||||
|
|
||||||
public void SubmitStorageTranslatedDraw(
|
public void SubmitStorageTranslatedDraw(
|
||||||
IGuestCompiledShader pixelShader,
|
IGuestCompiledShader pixelShader,
|
||||||
@@ -392,9 +398,12 @@ 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) =>
|
||||||
|
MetalVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
|
||||||
|
|
||||||
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
|
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
|
||||||
MetalVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
|
MetalVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,6 +1,7 @@
|
|||||||
// 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.Binary;
|
||||||
using SharpEmu.Libs.Agc;
|
using SharpEmu.Libs.Agc;
|
||||||
using SharpEmu.ShaderCompiler;
|
using SharpEmu.ShaderCompiler;
|
||||||
using SharpEmu.ShaderCompiler.Metal;
|
using SharpEmu.ShaderCompiler.Metal;
|
||||||
@@ -87,7 +88,8 @@ internal static partial class MetalVideoPresenter
|
|||||||
uint InstanceCount,
|
uint InstanceCount,
|
||||||
uint PrimitiveType,
|
uint PrimitiveType,
|
||||||
GuestIndexBuffer? IndexBuffer,
|
GuestIndexBuffer? IndexBuffer,
|
||||||
GuestRenderState RenderState);
|
GuestRenderState RenderState,
|
||||||
|
int BaseVertex = 0);
|
||||||
|
|
||||||
private sealed record OffscreenGuestDraw(
|
private sealed record OffscreenGuestDraw(
|
||||||
TranslatedGuestDraw Draw,
|
TranslatedGuestDraw Draw,
|
||||||
@@ -245,7 +247,8 @@ internal static partial class MetalVideoPresenter
|
|||||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers,
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers,
|
||||||
GuestRenderState? renderState,
|
GuestRenderState? renderState,
|
||||||
GuestDepthTarget? depthTarget,
|
GuestDepthTarget? depthTarget,
|
||||||
ulong shaderAddress)
|
ulong shaderAddress,
|
||||||
|
int baseVertex = 0)
|
||||||
{
|
{
|
||||||
if (targets.Count == 0)
|
if (targets.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -293,7 +296,8 @@ internal static partial class MetalVideoPresenter
|
|||||||
instanceCount,
|
instanceCount,
|
||||||
primitiveType,
|
primitiveType,
|
||||||
indexBuffer,
|
indexBuffer,
|
||||||
effectiveRenderState),
|
effectiveRenderState,
|
||||||
|
baseVertex),
|
||||||
ToArray(targets),
|
ToArray(targets),
|
||||||
depthTarget,
|
depthTarget,
|
||||||
PublishTarget: true,
|
PublishTarget: true,
|
||||||
@@ -321,7 +325,8 @@ internal static partial class MetalVideoPresenter
|
|||||||
GuestIndexBuffer? indexBuffer,
|
GuestIndexBuffer? indexBuffer,
|
||||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers,
|
IReadOnlyList<GuestVertexBuffer>? vertexBuffers,
|
||||||
GuestRenderState? renderState,
|
GuestRenderState? renderState,
|
||||||
ulong shaderAddress)
|
ulong shaderAddress,
|
||||||
|
int baseVertex = 0)
|
||||||
{
|
{
|
||||||
if (depthTarget.Address == 0 || depthTarget.Width == 0 || depthTarget.Height == 0)
|
if (depthTarget.Address == 0 || depthTarget.Width == 0 || depthTarget.Height == 0)
|
||||||
{
|
{
|
||||||
@@ -348,7 +353,8 @@ internal static partial class MetalVideoPresenter
|
|||||||
instanceCount,
|
instanceCount,
|
||||||
primitiveType,
|
primitiveType,
|
||||||
indexBuffer,
|
indexBuffer,
|
||||||
renderState ?? GuestRenderState.Default),
|
renderState ?? GuestRenderState.Default,
|
||||||
|
baseVertex),
|
||||||
[new GuestRenderTarget(Address: 0, depthTarget.Width, depthTarget.Height, Format: 10, NumberType: 0)],
|
[new GuestRenderTarget(Address: 0, depthTarget.Width, depthTarget.Height, Format: 10, NumberType: 0)],
|
||||||
depthTarget,
|
depthTarget,
|
||||||
PublishTarget: false,
|
PublishTarget: false,
|
||||||
@@ -981,17 +987,38 @@ internal static partial class MetalVideoPresenter
|
|||||||
|
|
||||||
private static void EncodeDrawCall(nint encoder, TranslatedGuestDraw draw)
|
private static void EncodeDrawCall(nint encoder, TranslatedGuestDraw draw)
|
||||||
{
|
{
|
||||||
var primitive = GetPrimitiveType(draw.PrimitiveType);
|
var indexed = draw.IndexBuffer is not null;
|
||||||
var vertexCount = draw.PrimitiveType == 0x11 && draw.IndexBuffer is null
|
var hasVertexBuffers = draw.VertexBuffers.Length > 0;
|
||||||
? 4u
|
var primitive = GetPrimitiveType(
|
||||||
: draw.VertexCount;
|
draw.PrimitiveType,
|
||||||
|
indexed,
|
||||||
|
draw.VertexCount,
|
||||||
|
hasVertexBuffers);
|
||||||
|
var vertexCount = AgcPrimitiveHelpers.GetRectListDrawVertexCount(
|
||||||
|
draw.PrimitiveType,
|
||||||
|
draw.VertexCount,
|
||||||
|
indexed,
|
||||||
|
hasVertexBuffers);
|
||||||
|
var baseVertex = (nuint)Math.Max(draw.BaseVertex, 0);
|
||||||
if (draw.IndexBuffer is { } indexBuffer)
|
if (draw.IndexBuffer is { } indexBuffer)
|
||||||
{
|
{
|
||||||
var device = MetalNative.Send(encoder, MetalNative.Selector("device"));
|
var device = MetalNative.Send(encoder, MetalNative.Selector("device"));
|
||||||
var slice = AllocateUpload(
|
var slice = AllocateUpload(
|
||||||
device, Math.Max(indexBuffer.Length, 1), out var buffer, out var offset);
|
device, Math.Max(indexBuffer.Length, 1), out var buffer, out var offset);
|
||||||
indexBuffer.Data.AsSpan(0, Math.Min(indexBuffer.Length, indexBuffer.Data.Length))
|
var source = indexBuffer.Data.AsSpan(
|
||||||
.CopyTo(slice);
|
0,
|
||||||
|
Math.Min(indexBuffer.Length, indexBuffer.Data.Length));
|
||||||
|
// Metal drawIndexed without baseVertex: bake GE_INDX_OFFSET into
|
||||||
|
// the uploaded indices so glyph batches still hit the right verts.
|
||||||
|
if (draw.BaseVertex != 0)
|
||||||
|
{
|
||||||
|
BakeBaseVertexIntoIndices(source, slice, indexBuffer.Is32Bit, draw.BaseVertex);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
source.CopyTo(slice);
|
||||||
|
}
|
||||||
|
|
||||||
MetalNative.SendDrawIndexedPrimitives(
|
MetalNative.SendDrawIndexedPrimitives(
|
||||||
encoder,
|
encoder,
|
||||||
MetalNative.Selector("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"),
|
MetalNative.Selector("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"),
|
||||||
@@ -1012,12 +1039,46 @@ internal static partial class MetalVideoPresenter
|
|||||||
encoder,
|
encoder,
|
||||||
MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:instanceCount:"),
|
MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:instanceCount:"),
|
||||||
primitive,
|
primitive,
|
||||||
0,
|
baseVertex,
|
||||||
vertexCount,
|
vertexCount,
|
||||||
Math.Max(draw.InstanceCount, 1));
|
Math.Max(draw.InstanceCount, 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void BakeBaseVertexIntoIndices(
|
||||||
|
ReadOnlySpan<byte> source,
|
||||||
|
Span<byte> destination,
|
||||||
|
bool is32Bit,
|
||||||
|
int baseVertex)
|
||||||
|
{
|
||||||
|
if (is32Bit)
|
||||||
|
{
|
||||||
|
var count = source.Length / sizeof(uint);
|
||||||
|
for (var index = 0; index < count; index++)
|
||||||
|
{
|
||||||
|
var value = BinaryPrimitives.ReadUInt32LittleEndian(
|
||||||
|
source.Slice(index * sizeof(uint), sizeof(uint)));
|
||||||
|
var adjusted = unchecked((uint)(value + baseVertex));
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||||
|
destination.Slice(index * sizeof(uint), sizeof(uint)),
|
||||||
|
adjusted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shortCount = source.Length / sizeof(ushort);
|
||||||
|
for (var index = 0; index < shortCount; index++)
|
||||||
|
{
|
||||||
|
var value = BinaryPrimitives.ReadUInt16LittleEndian(
|
||||||
|
source.Slice(index * sizeof(ushort), sizeof(ushort)));
|
||||||
|
var adjusted = unchecked((ushort)(value + baseVertex));
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(
|
||||||
|
destination.Slice(index * sizeof(ushort), sizeof(ushort)),
|
||||||
|
adjusted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryGetDrawPipeline(
|
private static bool TryGetDrawPipeline(
|
||||||
nint device,
|
nint device,
|
||||||
TranslatedGuestDraw draw,
|
TranslatedGuestDraw draw,
|
||||||
@@ -1226,8 +1287,11 @@ internal static partial class MetalVideoPresenter
|
|||||||
? vertexBuffer.Stride
|
? vertexBuffer.Stride
|
||||||
: Math.Max(vertexBuffer.ComponentCount, 1) * 4;
|
: Math.Max(vertexBuffer.ComponentCount, 1) * 4;
|
||||||
MetalNative.Send(layout, MetalNative.Selector("setStride:"), (nint)stride);
|
MetalNative.Send(layout, MetalNative.Selector("setStride:"), (nint)stride);
|
||||||
// MTLVertexStepFunction.PerVertex = 1.
|
// MTLVertexStepFunction: PerVertex = 1, PerInstance = 2.
|
||||||
MetalNative.Send(layout, MetalNative.Selector("setStepFunction:"), 1);
|
MetalNative.Send(
|
||||||
|
layout,
|
||||||
|
MetalNative.Selector("setStepFunction:"),
|
||||||
|
vertexBuffer.PerInstance ? 2 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return descriptor;
|
return descriptor;
|
||||||
@@ -2059,13 +2123,30 @@ internal static partial class MetalVideoPresenter
|
|||||||
|
|
||||||
return 3;
|
return 3;
|
||||||
case 6:
|
case 6:
|
||||||
case 0x11:
|
|
||||||
return 4;
|
return 4;
|
||||||
default:
|
default:
|
||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static nuint GetPrimitiveType(
|
||||||
|
uint guestPrimitiveType,
|
||||||
|
bool indexed,
|
||||||
|
uint vertexCount,
|
||||||
|
bool hasVertexBuffers)
|
||||||
|
{
|
||||||
|
if (AgcPrimitiveHelpers.ShouldDrawRectListAsTriangleStrip(
|
||||||
|
guestPrimitiveType,
|
||||||
|
indexed,
|
||||||
|
vertexCount,
|
||||||
|
hasVertexBuffers))
|
||||||
|
{
|
||||||
|
return 4; // MTLPrimitiveTypeTriangleStrip
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetPrimitiveType(guestPrimitiveType);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsIntegerFormat(Gen5PixelOutputKind kind) =>
|
private static bool IsIntegerFormat(Gen5PixelOutputKind kind) =>
|
||||||
kind is Gen5PixelOutputKind.Uint or Gen5PixelOutputKind.Sint;
|
kind is Gen5PixelOutputKind.Uint or Gen5PixelOutputKind.Sint;
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user