From 2b6bd5a532da1af7287069a41d87d7a30bc57c31 Mon Sep 17 00:00:00 2001 From: Berk <12572227+par274@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:33:26 +0300 Subject: [PATCH] Sdl backend (#670) * [audio] added sdl audio backend and in-tree atrac9 decoder * [input] replaced per-platform pad readers with sdl gamepad input * [video] added sdl window and host display plumbing * [gui] added host display options and per-game render settings * [bink] synced host movie playback to the guest audio clock * [cpu] hooked windows write faults into guest image tracking * [perf] added guest and render profiling, reserved host cpu lanes * [kernel] fixed stale pthread mutex handle alias * [host] wired the sdl session, save-data paths and project references * [audio] hoisted ajm trace stackalloc out of its loop * [video] Add guest image sync setting * [build] Strip native symbols * reuse --- Directory.Packages.props | 3 +- REUSE.toml | 1 + SharpEmu.slnx | 1 + src/SharpEmu.CLI/Program.cs | 368 +++- src/SharpEmu.CLI/SharpEmu.CLI.csproj | 46 +- .../DirectExecutionBackend.Diagnostics.cs | 70 +- .../DirectExecutionBackend.Exceptions.cs | 9 + .../DirectExecutionBackend.GuestSampler.cs | 322 +++ .../Native/DirectExecutionBackend.Imports.cs | 58 +- .../Cpu/Native/DirectExecutionBackend.cs | 41 +- src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs | 1 + src/SharpEmu.GUI/GameSurfaceHost.cs | 620 ------ src/SharpEmu.GUI/GuiSettings.cs | 34 + src/SharpEmu.GUI/HostDisplayOptions.cs | 138 ++ src/SharpEmu.GUI/Languages/en.json | 20 + src/SharpEmu.GUI/Languages/tr.json | 20 + src/SharpEmu.GUI/MainWindow.axaml | 102 +- src/SharpEmu.GUI/MainWindow.axaml.cs | 551 +++-- src/SharpEmu.GUI/PerGameSettings.cs | 35 + src/SharpEmu.GUI/PerGameSettingsDialog.cs | 248 ++- src/SharpEmu.GUI/SharpEmu.GUI.csproj | 7 +- src/SharpEmu.HLE/CpuContext.cs | 9 + src/SharpEmu.HLE/GuestImageWriteTracker.cs | 17 +- src/SharpEmu.HLE/Host/GuestAudioClock.cs | 69 + src/SharpEmu.HLE/Host/HostGamepadState.cs | 91 +- src/SharpEmu.HLE/Host/IHostAudioStream.cs | 13 + src/SharpEmu.HLE/Host/IHostInput.cs | 5 + src/SharpEmu.HLE/Host/IHostPcmAudioOutput.cs | 19 + .../Host/IHostWindowInputSource.cs | 42 + src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs | 186 -- .../Host/Posix/PosixHostPlatform.cs | 6 +- src/SharpEmu.HLE/Host/Sdl/SdlHostAudio.cs | 325 +++ src/SharpEmu.HLE/Host/WindowHostInput.cs | 43 + .../Host/Windows/WindowsDualSenseReader.cs | 439 ---- .../Host/Windows/WindowsHidNative.cs | 141 -- .../Host/Windows/WindowsHostInput.cs | 101 - .../Host/Windows/WindowsHostPlatform.cs | 6 +- .../Host/Windows/WindowsXInputReader.cs | 277 --- src/SharpEmu.HLE/HostMainThread.cs | 4 +- src/SharpEmu.HLE/HostSessionControl.cs | 17 - src/SharpEmu.HLE/SharpEmu.HLE.csproj | 4 + .../Atrac9Config.cs | 1 + .../Atrac9Decoder.cs | 1 + .../Atrac9Rng.cs | 1 + .../BandExtension.cs | 1 + .../BitAllocation.cs | 1 + .../Atrac9 => SharpEmu.LibAtrac9}/Block.cs | 1 + .../Atrac9 => SharpEmu.LibAtrac9}/Channel.cs | 1 + .../ChannelConfig.cs | 1 + .../Atrac9 => SharpEmu.LibAtrac9}/Frame.cs | 1 + .../HuffmanCodebook.cs | 1 + .../HuffmanCodebooks.cs | 3 +- .../Atrac9 => SharpEmu.LibAtrac9}/LICENSE.txt | 0 .../Quantization.cs | 1 + .../ScaleFactors.cs | 1 + .../SharpEmu.LibAtrac9.csproj | 12 + .../Atrac9 => SharpEmu.LibAtrac9}/Stereo.cs | 1 + .../Atrac9 => SharpEmu.LibAtrac9}/Tables.cs | 1 + .../Atrac9 => SharpEmu.LibAtrac9}/Unpack.cs | 1 + .../Utilities/Bit.cs | 1 + .../Utilities/BitReader.cs | 1 + .../Utilities/Helpers.cs | 1 + .../Utilities/Mdct.cs | 1 + src/SharpEmu.Libs/Acm/AcmExports.cs | 212 +- src/SharpEmu.Libs/Agc/AgcExports.cs | 11 + src/SharpEmu.Libs/Agc/GpuWaitProfile.cs | 160 ++ src/SharpEmu.Libs/Audio/AjmExports.cs | 985 ++++++++- src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs | 409 ++++ src/SharpEmu.Libs/Audio/AudioOutExports.cs | 63 +- src/SharpEmu.Libs/Audio/AudioPcmConversion.cs | 40 + src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs | 4 +- src/SharpEmu.Libs/Bink/BinkFramePlayback.cs | 136 +- .../Bink/FfmpegNativeBinkFrameSource.cs | 515 ++++- src/SharpEmu.Libs/Codec/CodecExports.cs | 624 +++++- src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs | 16 +- .../Gpu/Metal/MetalGuestGpuBackend.cs | 2 +- src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs | 182 -- src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs | 91 +- .../Gpu/Metal/MetalVideoPresenter.cs | 470 +---- .../Gpu/Vulkan/VulkanGuestGpuBackend.cs | 6 +- .../Kernel/KernelMemoryCompatExports.cs | 30 +- .../Kernel/KernelPthreadCompatExports.cs | 106 +- src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs | 55 +- src/SharpEmu.Libs/Pad/HostWindowInput.cs | 366 ++-- src/SharpEmu.Libs/Pad/PadExports.cs | 234 +- src/SharpEmu.Libs/Pad/PadState.cs | 9 +- .../Pad/SdlGamepadStateReader.cs | 121 ++ src/SharpEmu.Libs/Pad/SdlLauncherGamepad.cs | 85 + src/SharpEmu.Libs/SaveData/SaveDataExports.cs | 22 +- src/SharpEmu.Libs/SaveData/SaveDataStorage.cs | 67 +- src/SharpEmu.Libs/SharpEmu.Libs.csproj | 5 +- .../VideoOut/HostDisplayCatalog.cs | 129 ++ .../VideoOut/HostVideoOptions.cs | 68 + .../VideoOut/RenderPhaseProfile.cs | 252 +++ src/SharpEmu.Libs/VideoOut/SdlHostWindow.cs | 832 ++++++++ src/SharpEmu.Libs/VideoOut/VideoOutExports.cs | 78 +- .../VideoOut/VulkanHostSurface.cs | 270 --- .../VideoOut/VulkanPipelineCacheStorage.cs | 96 + .../VideoOut/VulkanVideoPresenter.cs | 1879 ++++++++++------- .../SpirvFixedShaders.cs | 161 +- .../Audio/AcmExportsTests.cs | 130 ++ .../Audio/AjmExportsTests.cs | 231 ++ .../GUI/GuiSettingsTests.cs | 79 + .../GUI/HostDisplayOptionsTests.cs | 51 + .../Kernel/KernelGameLogPathTests.cs | 96 + .../Pad/HostWindowInputTests.cs | 159 +- .../Pthread/PthreadMutexSemanticsTests.cs | 83 + .../SaveDataStorageTests.cs | 41 +- .../VideoOut/HostVideoOptionsTests.cs | 29 + .../VideoOut/VideoOutPixelFormatTests.cs | 18 + .../VulkanPipelineCacheStorageTests.cs | 74 + 111 files changed, 9846 insertions(+), 4479 deletions(-) create mode 100644 src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.GuestSampler.cs delete mode 100644 src/SharpEmu.GUI/GameSurfaceHost.cs create mode 100644 src/SharpEmu.GUI/HostDisplayOptions.cs create mode 100644 src/SharpEmu.HLE/Host/GuestAudioClock.cs create mode 100644 src/SharpEmu.HLE/Host/IHostPcmAudioOutput.cs create mode 100644 src/SharpEmu.HLE/Host/IHostWindowInputSource.cs delete mode 100644 src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs create mode 100644 src/SharpEmu.HLE/Host/Sdl/SdlHostAudio.cs create mode 100644 src/SharpEmu.HLE/Host/WindowHostInput.cs delete mode 100644 src/SharpEmu.HLE/Host/Windows/WindowsDualSenseReader.cs delete mode 100644 src/SharpEmu.HLE/Host/Windows/WindowsHidNative.cs delete mode 100644 src/SharpEmu.HLE/Host/Windows/WindowsHostInput.cs delete mode 100644 src/SharpEmu.HLE/Host/Windows/WindowsXInputReader.cs rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Atrac9Config.cs (99%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Atrac9Decoder.cs (99%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Atrac9Rng.cs (96%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/BandExtension.cs (99%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/BitAllocation.cs (99%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Block.cs (98%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Channel.cs (98%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/ChannelConfig.cs (96%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Frame.cs (94%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/HuffmanCodebook.cs (98%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/HuffmanCodebooks.cs (99%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/LICENSE.txt (100%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Quantization.cs (98%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/ScaleFactors.cs (99%) create mode 100644 src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Stereo.cs (97%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Tables.cs (99%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Unpack.cs (99%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Utilities/Bit.cs (96%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Utilities/BitReader.cs (99%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Utilities/Helpers.cs (98%) rename src/{SharpEmu.GUI/Atrac9 => SharpEmu.LibAtrac9}/Utilities/Mdct.cs (99%) create mode 100644 src/SharpEmu.Libs/Agc/GpuWaitProfile.cs create mode 100644 src/SharpEmu.Libs/Audio/Atrac9DecodeState.cs delete mode 100644 src/SharpEmu.Libs/Gpu/Metal/MetalHostInput.cs create mode 100644 src/SharpEmu.Libs/Pad/SdlGamepadStateReader.cs create mode 100644 src/SharpEmu.Libs/Pad/SdlLauncherGamepad.cs create mode 100644 src/SharpEmu.Libs/VideoOut/HostDisplayCatalog.cs create mode 100644 src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs create mode 100644 src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs create mode 100644 src/SharpEmu.Libs/VideoOut/SdlHostWindow.cs delete mode 100644 src/SharpEmu.Libs/VideoOut/VulkanHostSurface.cs create mode 100644 src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs create mode 100644 tests/SharpEmu.Libs.Tests/Audio/AcmExportsTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/GUI/HostDisplayOptionsTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/Kernel/KernelGameLogPathTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineCacheStorageTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 267a07aa..56deb2ea 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,11 +18,10 @@ SPDX-License-Identifier: GPL-2.0-or-later - + - diff --git a/REUSE.toml b/REUSE.toml index b7162dd2..b789e0c2 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -7,6 +7,7 @@ path = [ "global.json", "**/packages.lock.json", "scripts/ps5_names.txt", + "src/SharpEmu.LibAtrac9/**", "src/SharpEmu.GUI/Languages/**", "src/SharpEmu.ShaderCompiler.Metal/Templates/**", "tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**", diff --git a/SharpEmu.slnx b/SharpEmu.slnx index a59b4889..f7eafb5a 100644 --- a/SharpEmu.slnx +++ b/SharpEmu.slnx @@ -5,6 +5,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index 868a7cb3..d673e43f 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -8,6 +8,7 @@ using SharpEmu.HLE; using SharpEmu.Libs.VideoOut; using SharpEmu.Logging; using System.Runtime.InteropServices; +using System.Runtime.Loader; using System.Text; using System.Text.Json; @@ -45,6 +46,8 @@ internal static partial class Program [STAThread] private static int Main(string[] args) { + ConfigureManagedPluginResolution(); + try { return Run(args); @@ -56,6 +59,25 @@ internal static partial class Program } } + private static void ConfigureManagedPluginResolution() + { + AssemblyLoadContext.Default.Resolving += static (loadContext, assemblyName) => + { + if (string.IsNullOrWhiteSpace(assemblyName.Name)) + { + return null; + } + + var assemblyPath = Path.Combine( + AppContext.BaseDirectory, + "plugins", + assemblyName.Name + ".dll"); + return File.Exists(assemblyPath) + ? loadContext.LoadFromAssemblyPath(assemblyPath) + : null; + }; + } + private static int Run(string[] args) { if (Updater.TryApply(args, out var updateExitCode)) @@ -64,7 +86,6 @@ internal static partial class Program } args = NormalizeInternalArguments(args, out var isMitigatedChild); - PreloadGlfw(); if (args.Length == 0) { @@ -93,14 +114,9 @@ internal static partial class Program PreloadMacVulkanLoader(); } - // GLFW requires window creation and event processing on the - // process main thread: AppKit demands it on macOS, and X11 has a - // single event queue that must be serviced from the main thread - // (a window created and polled off it may never map, which showed - // as a running game with no visible window on Linux). Emulation - // moves to a worker thread and the main thread services the window - // work the video presenter posts. Windows keeps a per-thread event - // queue, so its window stays on the presenter's own thread. + // SDL/AppKit window work belongs on the process main thread on + // macOS. Linux uses the same model for consistent X11/Wayland + // event ownership. Emulation remains on a worker thread. var exitCode = 0; HostMainThread.Enable(); var emulation = new Thread(() => @@ -131,10 +147,9 @@ internal static partial class Program /// starts: the CPU backend executes guest x86-64 code natively, so the /// host process must be x86-64 — win-x64/linux-x64 on x64 hardware, or /// osx-x64 under Rosetta 2 on Apple Silicon (Rosetta translates the - /// whole process, so it still reports as X64 here). An arm64 process - /// (e.g. the osx-arm64 build) can browse the GUI but cannot run games; - /// failing up front distinguishes that from MoltenVK, signal-handler, - /// or guest-memory startup problems. + /// whole process, so it still reports as X64 here). Failing up front on + /// any other process architecture distinguishes that from MoltenVK, + /// signal-handler, or guest-memory startup problems. /// private static bool CheckHostArchitecture() { @@ -178,11 +193,11 @@ internal static partial class Program } /// - /// Makes a Vulkan loader visible to GLFW's dlopen("libvulkan.1.dylib"). + /// Makes a Vulkan loader visible before SDL creates its Vulkan surface. /// Homebrew's Vulkan libraries are arm64-only and cannot load into this /// x86-64 (Rosetta 2) process, so a universal libMoltenVK.dylib placed /// next to the executable (named libvulkan.1.dylib) is preloaded here; - /// dyld then resolves GLFW's bare-name dlopen to the loaded image. + /// dyld can then resolve the loader for SDL and Silk.NET. /// private static void PreloadMacVulkanLoader() { @@ -214,27 +229,6 @@ internal static partial class Program "as libvulkan.1.dylib."); } - /// - /// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather - /// than flat next to the executable, which falls outside the default OS - /// DLL/dlopen search path. Preloading it here by full path first means - /// any later bare-name lookup (however Silk.NET/GLFW itself resolves the - /// library) finds it already loaded in the process and reuses it -- the - /// same technique already relies on - /// for the Vulkan loader. - /// - private static void PreloadGlfw() - { - var fileName = OperatingSystem.IsWindows() ? "glfw3.dll" - : OperatingSystem.IsMacOS() ? "libglfw.3.dylib" - : "libglfw.so.3"; - var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName); - if (File.Exists(candidate)) - { - NativeLibrary.TryLoad(candidate, out _); - } - } - private static int RunEmulator(string[] args, bool isMitigatedChild) { Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args"); @@ -244,17 +238,13 @@ internal static partial class Program return childExitCode; } - if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError)) - { - Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}"); - return 1; - } - - HostSessionControl.SetEmbeddedHostSurface( - hostSurface?.WindowHandle ?? 0, - hostSurface?.DisplayHandle ?? 0); - - if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath)) + if (!TryParseArguments( + args, + out var ebootPath, + out var runtimeOptions, + out var videoOptions, + out var logLevel, + out var logFilePath)) { PrintUsage(); return 1; @@ -266,6 +256,11 @@ internal static partial class Program } SharpEmuLog.MinimumLevel = logLevel; + if (!HostVideoHost.TryConfigureVideo(videoOptions)) + { + Console.Error.WriteLine("[LOADER][ERROR] Video options cannot change while a presenter is active."); + return 3; + } Log.Info(BuildInfo.Banner); Log.Info(HostSystemInfo.Summary); @@ -309,12 +304,6 @@ internal static partial class Program try { - if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface)) - { - Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active."); - return 3; - } - using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions); OrbisGen2Result result; @@ -384,53 +373,9 @@ internal static partial class Program debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult(); } - HostSessionControl.SetEmbeddedHostSurface(0); - if (hostSurface is not null) - { - VulkanVideoHost.RequestClose(); - VulkanVideoHost.DetachSurface(hostSurface); - hostSurface.Dispose(); - } } } - private static bool TryExtractHostSurfaceArgument( - IReadOnlyList args, - out string[] emulatorArgs, - out VulkanHostSurface? hostSurface, - out string? error) - { - const string hostSurfacePrefix = "--host-surface="; - var remaining = new List(args.Count); - hostSurface = null; - error = null; - foreach (var argument in args) - { - if (!argument.StartsWith(hostSurfacePrefix, StringComparison.OrdinalIgnoreCase)) - { - remaining.Add(argument); - continue; - } - - if (hostSurface is not null) - { - emulatorArgs = []; - error = "more than one GUI host surface was specified"; - return false; - } - - var descriptor = argument[hostSurfacePrefix.Length..]; - if (!VulkanHostSurface.TryCreateChildProcessSurface(descriptor, out hostSurface, out error)) - { - emulatorArgs = []; - return false; - } - } - - emulatorArgs = remaining.ToArray(); - return true; - } - private static void EnsureCliConsole() { if (!OperatingSystem.IsWindows()) @@ -1042,7 +987,7 @@ internal static partial class Program private static void PrintUsage() { - Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--log-level=] [--log-file[=]] [--debug-server[=host:port]] "); + Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--log-level=] [--log-file[=]] [--window-mode=] [--resolution=] [--display=] [--refresh-rate=] [--scaling=] [--vsync=] [--hdr=] [--debug-server[=host:port]] "); Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin"""); Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient."); } @@ -1087,6 +1032,7 @@ internal static partial class Program string[] args, out string ebootPath, out SharpEmuRuntimeOptions runtimeOptions, + out HostVideoOptions videoOptions, out LogLevel logLevel, out string? logFilePath) { @@ -1094,6 +1040,7 @@ internal static partial class Program { ebootPath = string.Empty; runtimeOptions = default; + videoOptions = HostVideoOptions.Default; logLevel = SharpEmuLog.MinimumLevel; logFilePath = null; return false; @@ -1102,12 +1049,99 @@ internal static partial class Program var strictDynlibResolution = false; var importTraceLimit = 0; var cpuEngine = CpuExecutionEngine.NativeOnly; + HostWindowMode? windowModeOverride = null; + HostScalingMode? scalingModeOverride = null; + int? windowWidthOverride = null; + int? windowHeightOverride = null; + int? displayIndexOverride = null; + int? refreshRateOverride = null; + bool? vsyncOverride = null; + HostHdrMode? hdrModeOverride = null; + videoOptions = HostVideoOptions.Default; logFilePath = null; logLevel = SharpEmuLog.MinimumLevel; var pathTokens = new List(args.Length); for (var i = 0; i < args.Length; i++) { var argument = args[i]; + if (TrySplitOption(argument, "--window-mode", out var windowModeText)) + { + if (!TryParseWindowMode(windowModeText, out var windowMode)) + { + ebootPath = string.Empty; + runtimeOptions = default; + return false; + } + windowModeOverride = windowMode; + continue; + } + if (TrySplitOption(argument, "--resolution", out var resolutionText)) + { + if (!TryParseResolution(resolutionText, out var windowWidth, out var windowHeight)) + { + ebootPath = string.Empty; + runtimeOptions = default; + return false; + } + windowWidthOverride = windowWidth; + windowHeightOverride = windowHeight; + continue; + } + if (TrySplitOption(argument, "--display", out var displayText)) + { + if (!int.TryParse(displayText, out var displayIndex) || displayIndex < 0) + { + ebootPath = string.Empty; + runtimeOptions = default; + return false; + } + displayIndexOverride = displayIndex; + continue; + } + if (TrySplitOption(argument, "--refresh-rate", out var refreshText)) + { + if (!int.TryParse(refreshText, out var refreshRate) || refreshRate < 0) + { + ebootPath = string.Empty; + runtimeOptions = default; + return false; + } + refreshRateOverride = refreshRate; + continue; + } + if (TrySplitOption(argument, "--scaling", out var scalingText)) + { + if (!TryParseScalingMode(scalingText, out var scalingMode)) + { + ebootPath = string.Empty; + runtimeOptions = default; + return false; + } + scalingModeOverride = scalingMode; + continue; + } + if (TrySplitOption(argument, "--vsync", out var vsyncText)) + { + if (!TryParseSwitch(vsyncText, out var vsync)) + { + ebootPath = string.Empty; + runtimeOptions = default; + return false; + } + vsyncOverride = vsync; + continue; + } + if (TrySplitOption(argument, "--hdr", out var hdrText)) + { + if (!TryParseHdrMode(hdrText, out var hdrMode)) + { + ebootPath = string.Empty; + runtimeOptions = default; + return false; + } + hdrModeOverride = hdrMode; + continue; + } if (string.Equals(argument, "--strict", StringComparison.OrdinalIgnoreCase)) { strictDynlibResolution = true; @@ -1269,9 +1303,147 @@ internal static partial class Program StrictDynlibResolution = strictDynlibResolution, ImportTraceLimit = importTraceLimit, }; + var configuredVideoOptions = LoadConfiguredVideoOptions(ebootPath); + videoOptions = (configuredVideoOptions with + { + WindowMode = windowModeOverride ?? configuredVideoOptions.WindowMode, + ScalingMode = scalingModeOverride ?? configuredVideoOptions.ScalingMode, + Width = windowWidthOverride ?? configuredVideoOptions.Width, + Height = windowHeightOverride ?? configuredVideoOptions.Height, + DisplayIndex = displayIndexOverride ?? configuredVideoOptions.DisplayIndex, + RefreshRate = refreshRateOverride ?? configuredVideoOptions.RefreshRate, + VSync = vsyncOverride ?? configuredVideoOptions.VSync, + HdrMode = hdrModeOverride ?? configuredVideoOptions.HdrMode, + }).Normalize(); return true; } + private static HostVideoOptions LoadConfiguredVideoOptions(string ebootPath) + { + var defaults = HostVideoOptions.Default; + try + { + var effective = EffectiveLaunchSettings.Resolve( + GuiSettings.Load(), + PerGameSettings.Load(TryReadTitleId(ebootPath))); + + var windowMode = TryParseWindowMode(effective.WindowMode, out var parsedWindowMode) + ? parsedWindowMode + : defaults.WindowMode; + var scalingMode = TryParseScalingMode(effective.ScalingMode, out var parsedScalingMode) + ? parsedScalingMode + : defaults.ScalingMode; + var hasResolution = TryParseResolution( + effective.Resolution, + out var configuredWidth, + out var configuredHeight); + var hdrMode = TryParseHdrMode(effective.HdrMode, out var parsedHdrMode) + ? parsedHdrMode + : defaults.HdrMode; + + return new HostVideoOptions + { + WindowMode = windowMode, + ScalingMode = scalingMode, + Width = hasResolution ? configuredWidth : defaults.Width, + Height = hasResolution ? configuredHeight : defaults.Height, + DisplayIndex = effective.DisplayIndex, + RefreshRate = effective.RefreshRate, + VSync = effective.VSync, + HdrMode = hdrMode, + }.Normalize(); + } + catch (Exception exception) + { + Console.Error.WriteLine( + $"[LOADER][WARN] GUI video settings could not be loaded; using defaults: {exception.Message}"); + return defaults; + } + } + + private static bool TrySplitOption(string argument, string name, out string value) + { + var prefix = name + "="; + if (argument.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + value = argument[prefix.Length..]; + return true; + } + + value = string.Empty; + return false; + } + + private static bool TryParseWindowMode(string value, out HostWindowMode mode) + { + mode = value.ToLowerInvariant() switch + { + "windowed" => HostWindowMode.Windowed, + "borderless" => HostWindowMode.Borderless, + "exclusive" or "fullscreen" => HostWindowMode.ExclusiveFullscreen, + _ => (HostWindowMode)(-1), + }; + return Enum.IsDefined(mode); + } + + private static bool TryParseScalingMode(string value, out HostScalingMode mode) + { + mode = value.ToLowerInvariant() switch + { + "fit" => HostScalingMode.Fit, + "cover" => HostScalingMode.Cover, + "stretch" => HostScalingMode.Stretch, + "integer" => HostScalingMode.Integer, + _ => (HostScalingMode)(-1), + }; + return Enum.IsDefined(mode); + } + + private static bool TryParseHdrMode(string value, out HostHdrMode mode) + { + mode = value.ToLowerInvariant() switch + { + "auto" => HostHdrMode.Auto, + "on" or "true" or "1" => HostHdrMode.On, + "off" or "false" or "0" => HostHdrMode.Off, + _ => (HostHdrMode)(-1), + }; + return Enum.IsDefined(mode); + } + + private static bool TryParseResolution(string value, out int width, out int height) + { + var parts = value.Split('x', 'X'); + if (parts.Length == 2 && int.TryParse(parts[0], out width) && int.TryParse(parts[1], out height) && + width >= 640 && height >= 360) + { + return true; + } + + width = 0; + height = 0; + return false; + } + + private static bool TryParseSwitch(string value, out bool enabled) + { + if (value is "1" || value.Equals("on", StringComparison.OrdinalIgnoreCase) || + value.Equals("true", StringComparison.OrdinalIgnoreCase)) + { + enabled = true; + return true; + } + if (value is "0" || value.Equals("off", StringComparison.OrdinalIgnoreCase) || + value.Equals("false", StringComparison.OrdinalIgnoreCase)) + { + enabled = false; + return true; + } + + enabled = false; + return false; + } + private static bool TryParseCpuEngine(string valueText, out CpuExecutionEngine engine) { if (string.Equals(valueText, "native", StringComparison.OrdinalIgnoreCase) || diff --git a/src/SharpEmu.CLI/SharpEmu.CLI.csproj b/src/SharpEmu.CLI/SharpEmu.CLI.csproj index d4ef9e8b..2714e437 100644 --- a/src/SharpEmu.CLI/SharpEmu.CLI.csproj +++ b/src/SharpEmu.CLI/SharpEmu.CLI.csproj @@ -77,35 +77,47 @@ SPDX-License-Identifier: GPL-2.0-or-later Languages\%(Filename)%(Extension) False + + Always + licenses\LibAtrac9.txt + true + False + - + + + <_NativeDebugSymbols Include="$(PublishDir)**\*.pdb" /> + + + + + + FfmpegNativeBinkFrameSource uses the same literal "plugins" folder + name. --> plugins - - - - <_GlfwPublishFiles Include="@(ResolvedFileToPublish)" - Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" /> - - - true - $(NativeLibraryFolderName)/%(Filename)%(Extension) - - - - 2c92585 diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Diagnostics.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Diagnostics.cs index c741472d..f226f3ba 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Diagnostics.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Diagnostics.cs @@ -23,14 +23,71 @@ public sealed partial class DirectExecutionBackend private static long _perfHleTotal; private static long _perfHleDispatchTicks; + private sealed class PerfHleExportCost + { + public long Calls; + public long Ticks; + } + + private static readonly System.Collections.Concurrent.ConcurrentDictionary _perfHleCosts = new(); + + /// + /// Name of the export currently being dispatched on this thread, so the + /// gateway can attribute its elapsed time once the call returns. Answering + /// "which export is worth optimising" needs cost per export, not just call + /// counts — a rare expensive call and a hot cheap one look identical in a + /// frequency histogram. + /// + [System.ThreadStatic] + private static string? _perfHleCurrentExport; + + private static long _perfHleFirstTimestamp; + private static void RecordPerfHleDispatchTime(long ticks) { var total = System.Threading.Interlocked.Add(ref _perfHleDispatchTicks, ticks); var calls = System.Threading.Interlocked.Read(ref _perfHleTotal); + + var name = _perfHleCurrentExport; + if (name is not null) + { + var cost = _perfHleCosts.GetOrAdd(name, static _ => new PerfHleExportCost()); + System.Threading.Interlocked.Increment(ref cost.Calls); + System.Threading.Interlocked.Add(ref cost.Ticks, ticks); + } + if (calls > 0 && calls % 500000 == 0) { - var avgUs = (double)total / System.Diagnostics.Stopwatch.Frequency * 1_000_000.0 / calls; - System.Console.Error.WriteLine($"[PERF][HLE] managed_dispatch_avg={avgUs:F3}us total_managed_s={(double)total / System.Diagnostics.Stopwatch.Frequency:F2}"); + var frequency = (double)System.Diagnostics.Stopwatch.Frequency; + var avgUs = (double)total / frequency * 1_000_000.0 / calls; + var first = System.Threading.Interlocked.CompareExchange(ref _perfHleFirstTimestamp, 0, 0); + var wallSeconds = first == 0 + ? 0 + : (double)(System.Diagnostics.Stopwatch.GetTimestamp() - first) / frequency; + System.Console.Error.WriteLine( + $"[PERF][HLE] managed_dispatch_avg={avgUs:F3}us " + + $"total_managed_s={(double)total / frequency:F2} " + + $"wall_s={wallSeconds:F2} " + + $"cores={(wallSeconds > 0 ? total / frequency / wallSeconds : 0):F2}"); + + var snapshot = new System.Collections.Generic.List>(_perfHleCosts.Count + 16); + foreach (var kvp in _perfHleCosts) + { + snapshot.Add(kvp); + } + + var top = snapshot + .OrderByDescending(kvp => System.Threading.Interlocked.Read(ref kvp.Value.Ticks)) + .Take(12) + .Select(kvp => + { + var seconds = System.Threading.Interlocked.Read(ref kvp.Value.Ticks) / frequency; + var callCount = System.Threading.Interlocked.Read(ref kvp.Value.Calls); + var cores = wallSeconds > 0 ? seconds / wallSeconds : 0; + var perCallUs = callCount > 0 ? seconds * 1_000_000.0 / callCount : 0; + return $"{kvp.Key}: {cores:F2}cores {seconds:F1}s n={callCount} {perCallUs:F2}us/call"; + }); + System.Console.Error.WriteLine($"[PERF][HLE] cost: {string.Join(" | ", top)}"); } } @@ -39,7 +96,16 @@ public sealed partial class DirectExecutionBackend private static void RecordPerfHleCall(string name) { + _perfHleCurrentExport = name; var total = System.Threading.Interlocked.Increment(ref _perfHleTotal); + if (total == 1) + { + System.Threading.Interlocked.CompareExchange( + ref _perfHleFirstTimestamp, + System.Diagnostics.Stopwatch.GetTimestamp(), + 0); + } + if (!_perfHleNoDict) { _perfHleCounts.AddOrUpdate(name, 1, static (_, v) => v + 1); diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs index b76069ee..fb3812e2 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs @@ -40,6 +40,15 @@ public sealed partial class DirectExecutionBackend } _rawExceptionHandler = (nint)AddVectoredExceptionHandler(1u, _rawExceptionHandlerStub); Console.Error.WriteLine($"[LOADER][INFO] Raw exception handler installed: 0x{_rawExceptionHandler:X16}"); + + // The raw handler carries the guest-image write-fault bridge, so the + // path must be compiled before the first protected-page store can + // reach it. Guest code has not started yet, so warming here cannot + // race a real fault. + SharpEmu.HLE.GuestImageWriteTracker.WarmUp(); + Console.Error.WriteLine( + "[LOADER][INFO] Guest image CPU write tracking: " + + $"{(SharpEmu.HLE.GuestImageWriteTracker.Enabled ? "enabled" : "disabled")}"); } else { diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.GuestSampler.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.GuestSampler.cs new file mode 100644 index 00000000..93b17445 --- /dev/null +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.GuestSampler.cs @@ -0,0 +1,322 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; + +namespace SharpEmu.Core.Cpu.Native; + +/// +/// Sampling profiler for guest code. Managed profilers only see the emulator's +/// own frames — once a guest thread is running translated code it is opaque to +/// them, so a title that burns its cores inside its own spin loops looks like +/// unattributed native time. This walks the guest thread registry and samples +/// each thread's host RIP, which lands directly on the guest instruction being +/// executed. +/// +public sealed partial class DirectExecutionBackend +{ + private static readonly bool _profileGuestRip = + string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP"), + "1", + StringComparison.Ordinal); + + private static readonly int _profileGuestRipIntervalMs = + int.TryParse( + Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP_INTERVAL_MS"), + out var interval) && interval > 0 + ? interval + : 2; + + private static readonly int _profileGuestRipReportSeconds = + int.TryParse( + Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP_REPORT_S"), + out var report) && report > 0 + ? report + : 15; + + private const ulong GuestImageBase = 0x0000_0008_0000_0000UL; + private const ulong GuestImageLimit = 0x0000_0009_0000_0000UL; + + private int _guestRipSamplerStarted; + private readonly ConcurrentDictionary _guestRipSamples = new(); + private readonly ConcurrentDictionary _guestRipThreadSamples = new(); + private readonly ConcurrentDictionary _guestWaitSamples = new(); + private readonly ConcurrentDictionary _guestThreadWaitSamples = new(); + private long _guestRipTotalSamples; + private long _guestWaitTotalSamples; + private long _guestRipCaptureFailures; + private long _guestRipSamplerErrors; + private int _guestRipSampleCursor; + + /// + /// Names the HLE call a thread is parked in, using the guest RIP the import + /// dispatcher left on its context. + /// + private string ResolveWaitLabel(GuestThreadState thread) + { + var context = thread.Context; + if (context is null) + { + return ""; + } + + var importIndex = context.ActiveImportIndex; + if ((uint)importIndex >= (uint)_importEntries.Length) + { + // Host code with no import in flight: the thread is parked by the + // emulator's own scheduler. The cooperative block records why, which + // is the part that actually identifies what the frame is waiting on. + var blockReason = thread.BlockReason; + return string.IsNullOrEmpty(blockReason) + ? "" + : $"blocked:{blockReason}"; + } + + var entry = _importEntries[importIndex]; + return entry.Export?.Name ?? entry.Nid; + } + + internal void ClearActiveImportIndex() + { + if (!_profileGuestRip) + { + return; + } + + var context = ActiveCpuContext; + if (context is not null) + { + context.ActiveImportIndex = -1; + } + } + + private void EnsureGuestRipSampler() + { + if (!_profileGuestRip || + !OperatingSystem.IsWindows() || + Interlocked.Exchange(ref _guestRipSamplerStarted, 1) != 0) + { + return; + } + + var sampler = new Thread(GuestRipSampleLoop) + { + IsBackground = true, + Name = "SharpEmu guest RIP sampler", + // Sampling suspends guest threads briefly. Keep this diagnostic below + // the title workers so it observes them without becoming the bottleneck. + Priority = ThreadPriority.BelowNormal, + }; + sampler.Start(); + Console.Error.WriteLine( + $"[PERF][GUEST] RIP sampler started: interval={_profileGuestRipIntervalMs}ms " + + $"report={_profileGuestRipReportSeconds}s"); + } + + private void GuestRipSampleLoop() + { + var clock = Stopwatch.StartNew(); + var lastReportMs = 0L; + var lastReportSamples = 0L; + + while (true) + { + try + { + var guestThreads = SnapshotGuestThreads(); + var sampleIndex = guestThreads.Length == 0 + ? 0 + : (int)((uint)Interlocked.Increment(ref _guestRipSampleCursor) % (uint)guestThreads.Length); + foreach (var thread in guestThreads.Skip(sampleIndex).Take(1)) + { + var hostThreadId = Volatile.Read(ref thread.HostThreadId); + if (hostThreadId == 0) + { + continue; + } + + if (!TryCaptureHostThreadContext(hostThreadId, out var snapshot) || + !snapshot.IsValid) + { + Interlocked.Increment(ref _guestRipCaptureFailures); + continue; + } + + _guestRipSamples.AddOrUpdate(snapshot.Rip, 1, static (_, value) => value + 1); + _guestRipThreadSamples.AddOrUpdate( + string.IsNullOrEmpty(thread.Name) ? "" : thread.Name, + 1, + static (_, value) => value + 1); + Interlocked.Increment(ref _guestRipTotalSamples); + + // A host RIP means the thread is inside the emulator rather + // than running translated code. DispatchImport parks the + // guest RIP on the import stub for the call being serviced, + // so the stub address names what the thread is waiting on — + // no hot-path bookkeeping needed to find out. + if (snapshot.Rip >= GuestImageBase && snapshot.Rip < GuestImageLimit) + { + continue; + } + + _guestWaitSamples.AddOrUpdate( + ResolveWaitLabel(thread), + 1, + static (_, value) => value + 1); + _guestThreadWaitSamples.AddOrUpdate( + string.IsNullOrEmpty(thread.Name) ? "" : thread.Name, + 1, + static (_, value) => value + 1); + Interlocked.Increment(ref _guestWaitTotalSamples); + } + + Thread.Sleep(_profileGuestRipIntervalMs); + + var elapsedMs = clock.ElapsedMilliseconds; + if (elapsedMs - lastReportMs < _profileGuestRipReportSeconds * 1000L) + { + continue; + } + + var samples = Interlocked.Read(ref _guestRipTotalSamples); + ReportGuestRipSamples(samples - lastReportSamples, (elapsedMs - lastReportMs) / 1000.0); + lastReportMs = elapsedMs; + lastReportSamples = samples; + } + catch (Exception exception) + { + // A title can tear down a thread or its context during a capture. + // The profiler must never silently die or affect guest execution. + if (Interlocked.Increment(ref _guestRipSamplerErrors) == 1) + { + Console.Error.WriteLine($"[PERF][GUEST] sampler recovery: {exception.GetType().Name}: {exception.Message}"); + } + } + } + } + + private void ReportGuestRipSamples(long windowSamples, double windowSeconds) + { + var total = Interlocked.Read(ref _guestRipTotalSamples); + if (total == 0) + { + return; + } + + var byRip = new List>(_guestRipSamples.Count + 16); + foreach (var pair in _guestRipSamples) + { + byRip.Add(pair); + } + + // A tight spin lands on a handful of instructions; grouping by 4 KB page + // as well shows which routine those instructions belong to. + var byPage = new Dictionary(); + foreach (var pair in byRip) + { + var page = pair.Key & ~0xFFFUL; + byPage[page] = byPage.TryGetValue(page, out var existing) + ? existing + pair.Value + : pair.Value; + } + + var byThread = new List>(_guestRipThreadSamples.Count + 16); + foreach (var pair in _guestRipThreadSamples) + { + byThread.Add(pair); + } + + Console.Error.WriteLine( + $"[PERF][GUEST] samples={total} window={windowSamples} in {windowSeconds:F1}s " + + $"capture_failures={Interlocked.Read(ref _guestRipCaptureFailures)}"); + + Console.Error.WriteLine( + "[PERF][GUEST] top_rip: " + + string.Join( + " | ", + byRip.OrderByDescending(pair => pair.Value) + .Take(12) + .Select(pair => + $"0x{pair.Key:X}{DescribeGuestAddress(pair.Key)}={pair.Value * 100.0 / total:F1}%"))); + + Console.Error.WriteLine( + "[PERF][GUEST] top_page: " + + string.Join( + " | ", + byPage.OrderByDescending(pair => pair.Value) + .Take(8) + .Select(pair => + $"0x{pair.Key:X}{DescribeGuestAddress(pair.Key)}={pair.Value * 100.0 / total:F1}%"))); + + var byWait = new List>(_guestWaitSamples.Count + 16); + foreach (var pair in _guestWaitSamples) + { + byWait.Add(pair); + } + + var waitTotal = Interlocked.Read(ref _guestWaitTotalSamples); + Console.Error.WriteLine( + $"[PERF][GUEST] waiting={waitTotal * 100.0 / total:F1}% of guest thread-time; top_wait: " + + string.Join( + " | ", + byWait.OrderByDescending(pair => pair.Value) + .Take(12) + .Select(pair => $"{pair.Key}={pair.Value * 100.0 / total:F1}%"))); + + // Per-thread spin/park split. The global wait share mixes the job pool in + // with a dozen dormant threads, which hides the number that matters: + // how much of a core each worker actually burns. + Console.Error.WriteLine( + "[PERF][GUEST] thread_split (running/parked): " + + string.Join( + " | ", + byThread.OrderByDescending(pair => pair.Value) + .Take(10) + .Select(pair => + { + var parked = _guestThreadWaitSamples.TryGetValue(pair.Key, out var wait) ? wait : 0; + var running = pair.Value - parked; + return $"{pair.Key}={running * 100.0 / pair.Value:F0}%/{parked * 100.0 / pair.Value:F0}%"; + }))); + + Console.Error.WriteLine( + "[PERF][GUEST] top_thread: " + + string.Join( + " | ", + byThread.OrderByDescending(pair => pair.Value) + .Take(10) + .Select(pair => $"{pair.Key}={pair.Value * 100.0 / total:F1}%"))); + } + + /// + /// Tags a sampled address with the region it belongs to. Guest module code + /// lives above the image base; anything else is emulator or system code that + /// the managed profiler already covers. + /// + private string DescribeGuestAddress(ulong address) + { + + + + if (address >= GuestImageBase && address < GuestImageLimit) + { + return $"(app+0x{address - GuestImageBase:X})"; + } + + for (var index = 0; index < _importEntries.Length; index++) + { + if (_importEntries[index].Address == (address & ~0xFUL)) + { + return $"(stub:{_importEntries[index].Nid})"; + } + } + + return "(host)"; + } +} diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs index c352b2c4..f6e5dd94 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs @@ -54,10 +54,13 @@ public sealed partial class DirectExecutionBackend var startTicks = System.Diagnostics.Stopwatch.GetTimestamp(); var r = directExecutionBackend.DispatchImport(importIndex, argPackPtr); RecordPerfHleDispatchTime(System.Diagnostics.Stopwatch.GetTimestamp() - startTicks); + directExecutionBackend.ClearActiveImportIndex(); return r; } - return directExecutionBackend.DispatchImport(importIndex, argPackPtr); + var result = directExecutionBackend.DispatchImport(importIndex, argPackPtr); + directExecutionBackend.ClearActiveImportIndex(); + return result; } catch (Exception ex) { @@ -69,11 +72,7 @@ public sealed partial class DirectExecutionBackend private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo) { - EXCEPTION_RECORD* exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord; - if (exceptionRecord->ExceptionCode == 3221225477u && - exceptionRecord->NumberParameters >= 2 && - SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault( - exceptionRecord->ExceptionInformation[1])) + if (TryHandleGuestImageWriteFault(exceptionInfo)) { return -1; } @@ -81,6 +80,37 @@ public sealed partial class DirectExecutionBackend return TryRecoverUnresolvedSentinel(exceptionInfo); } + /// + /// Windows counterpart of the POSIX SIGSEGV bridge into + /// . Guest code runs natively, + /// so a store into a surface the GPU backend has cached is an ordinary CPU + /// write with nothing to intercept — the page is write-protected instead and + /// the resulting fault is what tells the backend to re-upload. Without this + /// the cache serves the first upload forever, and anything the guest CPU + /// draws (a software-decoded movie frame, a memset fog layer) never reaches + /// the screen. + /// + private unsafe static bool TryHandleGuestImageWriteFault(void* exceptionInfo) + { + if (!SharpEmu.HLE.GuestImageWriteTracker.Enabled) + { + return false; + } + + var exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord; + // STATUS_ACCESS_VIOLATION, and only the write flavour: ExceptionInformation + // is [accessKind, address] with 0=read, 1=write, 8=DEP execute. + if (exceptionRecord->ExceptionCode != 3221225477u || + exceptionRecord->NumberParameters < 2 || + exceptionRecord->ExceptionInformation[0] != 1uL) + { + return false; + } + + return SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault( + exceptionRecord->ExceptionInformation[1]); + } + private unsafe static int RawUnhandledFilterManaged(void* exceptionInfo) { return TryRecoverUnresolvedSentinel(exceptionInfo); @@ -174,6 +204,10 @@ public sealed partial class DirectExecutionBackend { RecordPerfHleCall(importStubEntry.Export?.Name ?? importStubEntry.Nid); } + if (_profileGuestRip) + { + EnsureGuestRipSampler(); + } int num2 = Volatile.Read(in _rawSentinelRecoveries); if (num2 != _lastReportedRawSentinelRecoveries) { @@ -187,6 +221,10 @@ public sealed partial class DirectExecutionBackend } cpuContext.Rip = importStubEntry.Address; + if (_profileGuestRip) + { + cpuContext.ActiveImportIndex = importIndex; + } LoadImportVolatileArguments(cpuContext, argPackPtr); cpuContext[CpuRegister.Rdi] = *(ulong*)argPackPtr; cpuContext[CpuRegister.Rsi] = *(ulong*)(argPackPtr + 8); @@ -1453,7 +1491,7 @@ public sealed partial class DirectExecutionBackend string.Equals(nid, "fzyMKs9kim0", StringComparison.Ordinal) && result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT; var expectedMutexTrylockBusy = - string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) && + (nid is "K-jXhbt2gn4" or "upoVrzMHFeE") && result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY; var expectedSemaphoreTrywaitAgain = string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) && @@ -1470,6 +1508,9 @@ public sealed partial class DirectExecutionBackend var expectedPrivacyInvalidParameter = string.Equals(nid, "D-CzAxQL0XI", StringComparison.Ordinal) && resultValue == unchecked((int)0x80960009); + var expectedPlayGoChunkEnumerationEnd = + string.Equals(nid, "uWIYLFkkwqk", StringComparison.Ordinal) && + resultValue == unchecked((int)0x80B2000C); if (!expectedFileProbeMiss && !expectedTimedWaitTimeout && !expectedEqueueTimeout && @@ -1478,7 +1519,8 @@ public sealed partial class DirectExecutionBackend !expectedPollSemaBusy && !expectedNetAcceptWouldBlock && !expectedUserServiceNoEvent && - !expectedPrivacyInvalidParameter) + !expectedPrivacyInvalidParameter && + !expectedPlayGoChunkEnumerationEnd) { return true; } diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs index 54c7fb06..1309410d 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs @@ -5314,7 +5314,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I var hostCpu = processorCount < 8 ? guestCpu % processorCount : processorCount >= 16 - ? guestCpu * 2 + ? MapGuestCpuAcrossSmtLanes(guestCpu, processorCount) : guestCpu; if (hostCpu < processorCount) { @@ -5325,6 +5325,45 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I return hostAffinityMask; } + /// + /// Places guest CPUs on distinct physical cores first, then wraps onto the + /// SMT siblings. Doubling the index alone only works while the title stays + /// inside the first half of the guest CPU set: beyond that every mapped lane + /// lands past the host's processor count and gets dropped, which silently + /// leaves those threads unpinned. Demon's Souls asks for CPUs 0-12 and keeps + /// its renderer on 9 and 11, so dropping the overflow un-pinned both the + /// renderer and a third of its job pool onto every core at once. + /// + private static int MapGuestCpuAcrossSmtLanes(int guestCpu, int processorCount) + { + // Reserve the top lanes for the emulator itself. A title sized for a + // console's dedicated cores will happily keep a worker per guest CPU + // spinning on an empty queue — Demon's Souls' job pool runs ~90% busy + // doing nothing — and spreading those across every host lane leaves the + // GPU translation and present threads fighting them for a slice. Packing + // near-idle spinners tighter costs them almost nothing and buys back + // whole cores for the work that actually produces frames. + var usableLanes = Math.Max(processorCount - EmulatorReservedLanes, 2); + var physicalCores = usableLanes / 2; + var lane = guestCpu % usableLanes; + return lane < physicalCores + ? lane * 2 + : ((lane - physicalCores) * 2) + 1; + } + + /// + /// Host lanes kept away from guest threads. Measured on a 16-lane host with + /// Demon's Souls: reserving 0/4/6/8 lanes gave 6.08/6.78/7.20/5.62 fps, so + /// the useful range is a bit over a third of the machine — too few and the + /// emulator is crowded out, too many and the guest cannot make progress. + /// + private static readonly int EmulatorReservedLanes = + int.TryParse( + Environment.GetEnvironmentVariable("SHARPEMU_RESERVED_HOST_LANES"), + out var reserved) && reserved >= 0 + ? reserved + : Math.Max(2, Environment.ProcessorCount * 3 / 8); + public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority) { lock (_guestThreadGate) diff --git a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs index e1c853e2..671f3d57 100644 --- a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs +++ b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs @@ -143,6 +143,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime KernelModuleRegistry.Reset(); var image = LoadImage(normalizedEbootPath); VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version); + KernelMemoryCompatExports.ConfigureApplicationInfo(image.TitleId); SaveDataExports.ConfigureApplicationInfo(image.TitleId); SystemServiceExports.ConfigureApplicationInfo(image.TitleId); _ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false); diff --git a/src/SharpEmu.GUI/GameSurfaceHost.cs b/src/SharpEmu.GUI/GameSurfaceHost.cs deleted file mode 100644 index 7e3471f5..00000000 --- a/src/SharpEmu.GUI/GameSurfaceHost.cs +++ /dev/null @@ -1,620 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using Avalonia; -using Avalonia.Controls; -using Avalonia.Platform; -using Avalonia.Threading; -using SharpEmu.Libs.VideoOut; -using System.Runtime.InteropServices; - -namespace SharpEmu.GUI; - -/// -/// Native child surface owned by Avalonia. The isolated emulator process uses -/// its platform handle to create the Vulkan presentation surface, keeping the -/// guest address space out of the GUI process. -/// -public sealed class GameSurfaceHost : NativeControlHost -{ - private const uint SwpNoSize = 0x0001; - private const uint SwpNoMove = 0x0002; - private const uint SwpNoZOrder = 0x0004; - private const uint SwpNoActivate = 0x0010; - private const uint SwpShowWindow = 0x0040; - private const uint SwpHideWindow = 0x0080; - private const uint WsChild = 0x40000000; - private const uint WsVisible = 0x10000000; - private const uint WsClipSiblings = 0x04000000; - private const uint WsClipChildren = 0x02000000; - private const uint CsOwnDc = 0x0020; - private const uint WmSetCursor = 0x0020; - private const uint WmMouseMove = 0x0200; - private const int IdcArrow = 32512; - private const int CursorHideDelayMs = 2500; - - private VulkanHostSurface? _surface; - private nint _windowHandle; - private nint _x11Display; - private string? _win32ClassName; - private WindowProcedure? _windowProcedure; - private nint _metalLayer; - private bool _presentationVisible = true; - private DispatcherTimer? _cursorIdleTimer; - private bool _cursorAutoHide; - private bool _cursorHidden; - private long _lastPointerActivity; - - public GameSurfaceHost() - { - PropertyChanged += (_, change) => - { - if (change.Property == BoundsProperty) - { - UpdateSurfaceSize(); - } - }; - LayoutUpdated += (_, _) => - { - // Fullscreen can change a monitor's DPI scale without changing - // the logical Bounds. Refresh the native child from physical size. - UpdateSurfaceSize(); - - // NativeControlHost may make its HWND visible again as part of a - // later arrange pass. Keep a loading surface hidden until its - // child process reports a real first frame. - if (!_presentationVisible) - { - ApplyPresentationVisibility(); - } - }; - } - - public event EventHandler? SurfaceAvailable; - - public event EventHandler? SurfaceDestroyed; - - public VulkanHostSurface? Surface => _surface; - - public void RefreshSurfaceSize() => UpdateSurfaceSize(); - - /// - /// Hides the platform child without detaching the Vulkan surface. This - /// allows the launcher to return to its library while guest teardown is - /// still finishing on the render thread. - /// - public void SetPresentationVisible(bool visible) - { - _presentationVisible = visible; - ApplyPresentationVisibility(); - } - - /// - /// Auto-hides the mouse cursor over the game surface after a short idle - /// period; any pointer movement brings it back. Enabling (again) restarts - /// the idle countdown, so both "first frame presented" and "entered - /// fullscreen" can arm it. Windows-only; a no-op elsewhere. - /// - public void SetCursorAutoHide(bool enabled) - { - if (!OperatingSystem.IsWindows()) - { - return; - } - - _cursorAutoHide = enabled; - _lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp(); - if (enabled) - { - _cursorIdleTimer ??= CreateCursorIdleTimer(); - _cursorIdleTimer.Start(); - return; - } - - _cursorIdleTimer?.Stop(); - ShowCursorNow(); - } - - private DispatcherTimer CreateCursorIdleTimer() - { - var timer = new DispatcherTimer - { - Interval = TimeSpan.FromMilliseconds(250), - }; - timer.Tick += (_, _) => HideCursorWhenIdle(); - return timer; - } - - private void HideCursorWhenIdle() - { - if (!_cursorAutoHide || _cursorHidden || _windowHandle == 0) - { - return; - } - - var idleMs = (System.Diagnostics.Stopwatch.GetTimestamp() - _lastPointerActivity) * - 1000 / System.Diagnostics.Stopwatch.Frequency; - if (idleMs < CursorHideDelayMs) - { - return; - } - - // Only swallow the cursor while it is actually over the game surface; - // hovering launcher chrome (console, toolbar) must keep the arrow. - if (!GetCursorPos(out var point) || WindowFromPoint(point) != _windowHandle) - { - return; - } - - _cursorHidden = true; - _ = SetCursor(0); - } - - private void ShowCursorNow() - { - if (!_cursorHidden) - { - return; - } - - _cursorHidden = false; - _ = SetCursor(LoadCursorW(0, IdcArrow)); - } - - private void ApplyPresentationVisibility() - { - if (_windowHandle == 0) - { - return; - } - - var visible = _presentationVisible; - if (OperatingSystem.IsWindows()) - { - // SW_HIDE can be ignored for a window's initial show state. Force - // the state through SetWindowPos so an old child swapchain cannot - // remain composed while the next game is loading. - var flags = SwpNoSize | SwpNoMove | SwpNoZOrder | SwpNoActivate | - (visible ? SwpShowWindow : SwpHideWindow); - _ = SetWindowPos(_windowHandle, 0, 0, 0, 0, 0, flags); - } - else if (OperatingSystem.IsLinux() && _x11Display != 0) - { - _ = visible - ? XMapWindow(_x11Display, _windowHandle) - : XUnmapWindow(_x11Display, _windowHandle); - _ = XFlush(_x11Display); - } - else if (OperatingSystem.IsMacOS()) - { - SendBool(_windowHandle, "setHidden:", !visible); - } - } - - protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle control) - { - PlatformHandle handle; - if (OperatingSystem.IsWindows()) - { - handle = CreateWin32(control); - } - else if (OperatingSystem.IsLinux()) - { - handle = CreateX11(control); - } - else if (OperatingSystem.IsMacOS()) - { - handle = CreateMacOS(); - } - else - { - throw new PlatformNotSupportedException("SharpEmu's embedded Vulkan surface is unsupported on this platform."); - } - - UpdateSurfaceSize(); - if (_surface is { } surface) - { - SurfaceAvailable?.Invoke(this, surface); - } - - return handle; - } - - protected override void DestroyNativeControlCore(IPlatformHandle control) - { - if (OperatingSystem.IsWindows()) - { - SetCursorAutoHide(false); - } - - var surface = _surface; - _surface = null; - - if (OperatingSystem.IsWindows()) - { - DestroyWin32(); - } - else if (OperatingSystem.IsLinux()) - { - DestroyX11(); - } - else if (OperatingSystem.IsMacOS()) - { - DestroyMacOS(); - } - - if (surface is not null) - { - SurfaceDestroyed?.Invoke(this, surface); - } - } - - private PlatformHandle CreateWin32(IPlatformHandle control) - { - _win32ClassName = $"SharpEmuGameSurface-{Guid.NewGuid():N}"; - _windowProcedure = WindowProcedureImpl; - var classInfo = new WndClassEx - { - Size = (uint)Marshal.SizeOf(), - Style = CsOwnDc, - WindowProcedure = Marshal.GetFunctionPointerForDelegate(_windowProcedure), - Instance = GetModuleHandleW(null), - ClassName = _win32ClassName, - }; - - if (RegisterClassExW(ref classInfo) == 0) - { - throw new InvalidOperationException($"Could not register the embedded game window class (Win32 error {Marshal.GetLastWin32Error()})."); - } - - _windowHandle = CreateWindowExW( - 0, - _win32ClassName, - "SharpEmu Game Surface", - WsChild | (_presentationVisible ? WsVisible : 0) | WsClipSiblings | WsClipChildren, - 0, - 0, - 1, - 1, - control.Handle, - 0, - classInfo.Instance, - 0); - if (_windowHandle == 0) - { - var error = Marshal.GetLastWin32Error(); - _ = UnregisterClassW(_win32ClassName, classInfo.Instance); - throw new InvalidOperationException($"Could not create the embedded game window (Win32 error {error})."); - } - - _surface = new VulkanHostSurface( - VulkanHostSurfaceKind.Win32, - _windowHandle, - classInfo.Instance); - return new PlatformHandle(_windowHandle, "HWND"); - } - - private PlatformHandle CreateX11(IPlatformHandle control) - { - _x11Display = XOpenDisplay(0); - if (_x11Display == 0) - { - throw new InvalidOperationException("Could not connect to the X11 server for the embedded game surface."); - } - - _windowHandle = XCreateSimpleWindow( - _x11Display, - control.Handle, - 0, - 0, - 1, - 1, - 0, - 0, - 0); - if (_windowHandle == 0) - { - XCloseDisplay(_x11Display); - _x11Display = 0; - throw new InvalidOperationException("Could not create the X11 embedded game surface."); - } - - if (_presentationVisible) - { - _ = XMapWindow(_x11Display, _windowHandle); - } - _ = XFlush(_x11Display); - _surface = new VulkanHostSurface(VulkanHostSurfaceKind.Xlib, _windowHandle, _x11Display); - return new PlatformHandle(_windowHandle, "X11"); - } - - private PlatformHandle CreateMacOS() - { - _metalLayer = CreateObjectiveCObject("CAMetalLayer"); - _windowHandle = CreateObjectiveCObject("NSView"); - SendBool(_windowHandle, "setWantsLayer:", true); - SendPointer(_windowHandle, "setLayer:", _metalLayer); - SendBool(_windowHandle, "setHidden:", !_presentationVisible); - - _surface = new VulkanHostSurface(VulkanHostSurfaceKind.Metal, _windowHandle, metalLayerHandle: _metalLayer); - return new PlatformHandle(_windowHandle, "NSView"); - } - - private void UpdateSurfaceSize() - { - if (_surface is null) - { - return; - } - - var renderScale = (VisualRoot as TopLevel)?.RenderScaling ?? 1.0; - var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale)); - var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale)); - var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height; - if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1") - { - Console.Error.WriteLine( - $"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " + - $"scale={renderScale} computed={width}x{height} changed={sizeChanged} " + - $"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}"); - } - _surface.UpdatePixelSize(width, height); - - if (!sizeChanged) - { - return; - } - - if (OperatingSystem.IsWindows() && _windowHandle != 0) - { - _ = SetWindowPos( - _windowHandle, - 0, - 0, - 0, - width, - height, - SwpNoMove | SwpNoZOrder | SwpNoActivate); - } - else if (OperatingSystem.IsLinux() && _x11Display != 0 && _windowHandle != 0) - { - _ = XResizeWindow(_x11Display, _windowHandle, (uint)width, (uint)height); - _ = XFlush(_x11Display); - } - else if (OperatingSystem.IsMacOS() && _metalLayer != 0) - { - SendDouble(_metalLayer, "setContentsScale:", renderScale); - } - } - - private void DestroyWin32() - { - if (_windowHandle != 0) - { - _ = DestroyWindow(_windowHandle); - _windowHandle = 0; - } - - if (!string.IsNullOrWhiteSpace(_win32ClassName)) - { - _ = UnregisterClassW(_win32ClassName, GetModuleHandleW(null)); - _win32ClassName = null; - } - - _windowProcedure = null; - } - - private void DestroyX11() - { - if (_x11Display != 0 && _windowHandle != 0) - { - _ = XDestroyWindow(_x11Display, _windowHandle); - } - if (_x11Display != 0) - { - _ = XCloseDisplay(_x11Display); - } - - _windowHandle = 0; - _x11Display = 0; - } - - private void DestroyMacOS() - { - if (_windowHandle != 0) - { - SendVoid(_windowHandle, "release"); - } - if (_metalLayer != 0) - { - SendVoid(_metalLayer, "release"); - } - - _windowHandle = 0; - _metalLayer = 0; - } - - private nint WindowProcedureImpl(nint window, uint message, nint wParam, nint lParam) - { - if (message == WmMouseMove) - { - _lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp(); - ShowCursorNow(); - } - else if (message == WmSetCursor && _cursorHidden) - { - // Win32 re-resolves the cursor on every mouse message; returning - // TRUE here keeps the parent chain from restoring the arrow. - _ = SetCursor(0); - return 1; - } - - return DefWindowProcW(window, message, wParam, lParam); - } - - private static nint CreateObjectiveCObject(string className) - { - var classHandle = objc_getClass(className); - if (classHandle == 0) - { - throw new InvalidOperationException($"Objective-C class '{className}' is unavailable."); - } - - var instance = objc_msgSend_id(classHandle, sel_registerName("alloc")); - instance = objc_msgSend_id(instance, sel_registerName("init")); - if (instance == 0) - { - throw new InvalidOperationException($"Could not create Objective-C '{className}'."); - } - - return instance; - } - - private static void SendVoid(nint receiver, string selector) => - objc_msgSend_void(receiver, sel_registerName(selector)); - - private static void SendBool(nint receiver, string selector, bool value) => - objc_msgSend_bool(receiver, sel_registerName(selector), value ? (byte)1 : (byte)0); - - private static void SendPointer(nint receiver, string selector, nint value) => - objc_msgSend_pointer(receiver, sel_registerName(selector), value); - - private static void SendDouble(nint receiver, string selector, double value) => - objc_msgSend_double(receiver, sel_registerName(selector), value); - - private delegate nint WindowProcedure(nint window, uint message, nint wParam, nint lParam); - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct WndClassEx - { - public uint Size; - public uint Style; - public nint WindowProcedure; - public int ClassExtra; - public int WindowExtra; - public nint Instance; - public nint Icon; - public nint Cursor; - public nint Background; - public string? MenuName; - public string? ClassName; - public nint IconSmall; - } - - [DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)] - private static extern nint GetModuleHandleW(string? moduleName); - - [DllImport("user32.dll", EntryPoint = "RegisterClassExW", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern ushort RegisterClassExW(ref WndClassEx classInfo); - - [DllImport("user32.dll", EntryPoint = "UnregisterClassW", SetLastError = true, CharSet = CharSet.Unicode)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool UnregisterClassW(string className, nint instance); - - [DllImport("user32.dll", EntryPoint = "CreateWindowExW", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern nint CreateWindowExW( - uint extendedStyle, - string className, - string windowName, - uint style, - int x, - int y, - int width, - int height, - nint parent, - nint menu, - nint instance, - nint parameter); - - [DllImport("user32.dll", EntryPoint = "DestroyWindow", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool DestroyWindow(nint window); - - [DllImport("user32.dll", EntryPoint = "SetWindowPos", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetWindowPos( - nint window, - nint insertAfter, - int x, - int y, - int width, - int height, - uint flags); - - [DllImport("user32.dll", EntryPoint = "DefWindowProcW", CharSet = CharSet.Unicode)] - private static extern nint DefWindowProcW(nint window, uint message, nint wParam, nint lParam); - - [DllImport("user32.dll", EntryPoint = "SetCursor")] - private static extern nint SetCursor(nint cursor); - - [DllImport("user32.dll", EntryPoint = "LoadCursorW", CharSet = CharSet.Unicode)] - private static extern nint LoadCursorW(nint instance, nint cursorName); - - [DllImport("user32.dll", EntryPoint = "GetCursorPos")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GetCursorPos(out NativePoint point); - - [DllImport("user32.dll", EntryPoint = "WindowFromPoint")] - private static extern nint WindowFromPoint(NativePoint point); - - [StructLayout(LayoutKind.Sequential)] - private struct NativePoint - { - public int X; - public int Y; - } - - [DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")] - private static extern nint XOpenDisplay(nint displayName); - - [DllImport("libX11.so.6", EntryPoint = "XCreateSimpleWindow")] - private static extern nint XCreateSimpleWindow( - nint display, - nint parent, - int x, - int y, - uint width, - uint height, - uint borderWidth, - ulong border, - ulong background); - - [DllImport("libX11.so.6", EntryPoint = "XMapWindow")] - private static extern int XMapWindow(nint display, nint window); - - [DllImport("libX11.so.6", EntryPoint = "XUnmapWindow")] - private static extern int XUnmapWindow(nint display, nint window); - - [DllImport("libX11.so.6", EntryPoint = "XResizeWindow")] - private static extern int XResizeWindow(nint display, nint window, uint width, uint height); - - [DllImport("libX11.so.6", EntryPoint = "XDestroyWindow")] - private static extern int XDestroyWindow(nint display, nint window); - - [DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")] - private static extern int XCloseDisplay(nint display); - - [DllImport("libX11.so.6", EntryPoint = "XFlush")] - private static extern int XFlush(nint display); - - [DllImport("/usr/lib/libobjc.A.dylib")] - private static extern nint objc_getClass(string name); - - [DllImport("/usr/lib/libobjc.A.dylib")] - private static extern nint sel_registerName(string name); - - [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] - private static extern nint objc_msgSend_id(nint receiver, nint selector); - - [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] - private static extern void objc_msgSend_void(nint receiver, nint selector); - - [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] - private static extern void objc_msgSend_bool(nint receiver, nint selector, byte value); - - [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] - private static extern void objc_msgSend_pointer(nint receiver, nint selector, nint value); - - [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] - private static extern void objc_msgSend_double(nint receiver, nint selector, double value); -} diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs index a6510036..73e98be2 100644 --- a/src/SharpEmu.GUI/GuiSettings.cs +++ b/src/SharpEmu.GUI/GuiSettings.cs @@ -50,6 +50,20 @@ public sealed class GuiSettings public bool CheckForUpdatesOnStartup { get; set; } = true; + public string WindowMode { get; set; } = "Windowed"; + + public string Resolution { get; set; } = "1920x1080"; + + public int DisplayIndex { get; set; } + + public int RefreshRate { get; set; } + + public string ScalingMode { get; set; } = "Fit"; + + public bool VSync { get; set; } = true; + + public string HdrMode { get; set; } = "Auto"; + /// Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch. public List EnvironmentToggles { get; set; } = new(); @@ -103,6 +117,12 @@ public sealed class GuiSettings { settings.RenderResolutionScale = 1.0; } + settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive"); + settings.Resolution = NormalizeResolution(settings.Resolution); + settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer"); + settings.HdrMode = NormalizeChoice(settings.HdrMode, "Auto", "On", "Off"); + settings.DisplayIndex = Math.Max(0, settings.DisplayIndex); + settings.RefreshRate = Math.Clamp(settings.RefreshRate, 0, 1000); return settings; } @@ -118,6 +138,20 @@ public sealed class GuiSettings return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList(); } + private static string NormalizeChoice(string? value, string fallback, params string[] choices) => + choices.Prepend(fallback).FirstOrDefault( + choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback; + + private static string NormalizeResolution(string? value) + { + if (!HostDisplayOptions.TryParseResolution(value, out var width, out var height)) + { + return "1920x1080"; + } + + return $"{width}x{height}"; + } + public void Save() { try diff --git a/src/SharpEmu.GUI/HostDisplayOptions.cs b/src/SharpEmu.GUI/HostDisplayOptions.cs new file mode 100644 index 00000000..ee3d1143 --- /dev/null +++ b/src/SharpEmu.GUI/HostDisplayOptions.cs @@ -0,0 +1,138 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.VideoOut; + +namespace SharpEmu.GUI; + +internal sealed record HostDisplayOption(HostDisplayInfo Display) +{ + public int Index => Display.Index; + + public IReadOnlyList Modes => Display.Modes; + + public override string ToString() => $"{Index + 1}: {Display.Name}"; +} + +internal sealed record HostRefreshRateOption(int Value, string Label) +{ + public override string ToString() => Label; +} + +internal static class HostDisplayOptions +{ + public static IReadOnlyList BuildDisplays( + IReadOnlyList detected, + int selectedIndex) + { + selectedIndex = Math.Max(0, selectedIndex); + var options = detected + .Select(display => new HostDisplayOption(display)) + .ToList(); + if (options.Count == 0) + { + options.Add(new HostDisplayOption(new HostDisplayInfo( + 0, + "Display 1", + CreateFallbackModes()))); + } + + if (options.All(display => display.Index != selectedIndex)) + { + options.Add(new HostDisplayOption(new HostDisplayInfo( + selectedIndex, + $"Display {selectedIndex + 1}", + options[0].Modes))); + } + + return options.OrderBy(display => display.Index).ToArray(); + } + + public static HostDisplayOption SelectDisplay( + IReadOnlyList displays, + int selectedIndex) => + displays.FirstOrDefault(display => display.Index == selectedIndex) ?? displays[0]; + + public static IReadOnlyList BuildResolutions( + HostDisplayOption display, + string? selectedResolution) + { + var resolutions = display.Modes + .Where(mode => mode.Width > 0 && mode.Height > 0) + .Select(mode => $"{mode.Width}x{mode.Height}") + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (TryParseResolution(selectedResolution, out var selectedWidth, out var selectedHeight)) + { + var selected = $"{selectedWidth}x{selectedHeight}"; + if (!resolutions.Contains(selected, StringComparer.OrdinalIgnoreCase)) + { + resolutions.Add(selected); + } + } + + if (resolutions.Count == 0) + { + resolutions.Add("1920x1080"); + } + + return resolutions + .OrderByDescending(resolution => ResolutionArea(resolution)) + .ThenByDescending(resolution => resolution, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static IReadOnlyList BuildRefreshRates( + HostDisplayOption display, + string? resolution, + int selectedRefreshRate, + string automaticLabel) + { + TryParseResolution(resolution, out var width, out var height); + var rates = display.Modes + .Where(mode => mode.Width == width && mode.Height == height && mode.RefreshRate > 0) + .Select(mode => mode.RefreshRate) + .Distinct() + .OrderByDescending(rate => rate) + .ToList(); + if (selectedRefreshRate > 0 && !rates.Contains(selectedRefreshRate)) + { + rates.Add(selectedRefreshRate); + rates.Sort((left, right) => right.CompareTo(left)); + } + + return new[] { new HostRefreshRateOption(0, automaticLabel) } + .Concat(rates.Select(rate => new HostRefreshRateOption(rate, $"{rate} Hz"))) + .ToArray(); + } + + public static bool TryParseResolution(string? value, out int width, out int height) + { + width = 0; + height = 0; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var separator = value.IndexOf('x', StringComparison.OrdinalIgnoreCase); + return separator > 0 && + int.TryParse(value.AsSpan(0, separator), out width) && + int.TryParse(value.AsSpan(separator + 1), out height) && + width > 0 && + height > 0; + } + + private static long ResolutionArea(string resolution) => + TryParseResolution(resolution, out var width, out var height) + ? (long)width * height + : 0; + + private static IReadOnlyList CreateFallbackModes() => + [ + new HostDisplayMode(3840, 2160, 60), + new HostDisplayMode(2560, 1440, 60), + new HostDisplayMode(1920, 1080, 60), + new HostDisplayMode(1280, 720, 60), + ]; +} diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json index 9d4e7ab4..ce7d8a46 100644 --- a/src/SharpEmu.GUI/Languages/en.json +++ b/src/SharpEmu.GUI/Languages/en.json @@ -41,6 +41,24 @@ "Options.Section.Emulation": "EMULATION", "Options.Section.Logging": "LOGGING", "Options.Section.Launcher": "LAUNCHER", + "Options.Section.Display": "DISPLAY", + "Options.Graphics": "Graphics", + + "Options.WindowMode.Label": "Window mode", + "Options.WindowMode.Desc": "Regular window, desktop borderless, or exclusive fullscreen.", + "Options.Resolution.Label": "Resolution", + "Options.Resolution.Desc": "Initial window size or exclusive fullscreen resolution.", + "Options.Display.Label": "Display", + "Options.Display.Desc": "Monitor used for centering and fullscreen.", + "Options.RefreshRate.Label": "Refresh rate", + "Options.RefreshRate.Desc": "Exclusive fullscreen refresh rate. Automatic selects the closest mode.", + "Options.RefreshRate.Automatic": "Automatic", + "Options.Scaling.Label": "Scaling", + "Options.Scaling.Desc": "Scale the native guest image without changing its internal resolution.", + "Options.VSync.Label": "VSync", + "Options.VSync.Desc": "Use FIFO presentation for tear-free output.", + "Options.Hdr.Label": "HDR output", + "Options.Hdr.Desc": "Use HDR when the selected display and graphics backend support it. Auto falls back to SDR.", "Options.CpuEngine.Label": "CPU engine", "Options.CpuEngine.Desc": "Execution engine used to run game code.", @@ -87,6 +105,8 @@ "PerGame.Title": "Per-game settings — {0} ({1})", "PerGame.InheritNote": "Unchecked rows inherit the global defaults.", + "PerGame.Tab.General": "General", + "PerGame.Tab.Graphics": "Graphics", "PerGame.EnvToggles.Label": "Environment toggles", "PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.", diff --git a/src/SharpEmu.GUI/Languages/tr.json b/src/SharpEmu.GUI/Languages/tr.json index 89fa5ff7..5132220c 100644 --- a/src/SharpEmu.GUI/Languages/tr.json +++ b/src/SharpEmu.GUI/Languages/tr.json @@ -29,6 +29,24 @@ "Options.Section.Emulation": "EMÜLASYON", "Options.Section.Logging": "GÜNLÜKLEME", "Options.Section.Launcher": "BAŞLATICI", + "Options.Section.Display": "GÖRÜNTÜ", + "Options.Graphics": "Grafik", + + "Options.WindowMode.Label": "Pencere modu", + "Options.WindowMode.Desc": "Normal pencere, kenarlıksız masaüstü veya özel tam ekran.", + "Options.Resolution.Label": "Çözünürlük", + "Options.Resolution.Desc": "Başlangıç pencere boyutu veya özel tam ekran çözünürlüğü.", + "Options.Display.Label": "Ekran", + "Options.Display.Desc": "Ortalama ve tam ekran için kullanılan monitör.", + "Options.RefreshRate.Label": "Yenileme hızı", + "Options.RefreshRate.Desc": "Özel tam ekran yenileme hızı. Otomatik, en yakın modu seçer.", + "Options.RefreshRate.Automatic": "Otomatik", + "Options.Scaling.Label": "Ölçekleme", + "Options.Scaling.Desc": "Dahili çözünürlüğü değiştirmeden oyun görüntüsünü ölçekle.", + "Options.VSync.Label": "VSync", + "Options.VSync.Desc": "Yırtılmasız görüntü için FIFO sunumunu kullan.", + "Options.Hdr.Label": "HDR çıkışı", + "Options.Hdr.Desc": "Seçili ekran ve grafik backend'i destekliyorsa HDR kullan. Otomatik mod SDR'ye geri döner.", "Options.CpuEngine.Label": "CPU motoru", "Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.", @@ -159,6 +177,8 @@ "Common.Cancel": "İptal", "PerGame.Title": "Oyuna özel ayarlar — {0} ({1})", "PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.", + "PerGame.Tab.General": "Genel", + "PerGame.Tab.Graphics": "Grafik", "PerGame.EnvToggles.Label": "Ortam anahtarları", "PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.", "Options.About": "Hakkında", diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml index 9e59d23f..31169e47 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml +++ b/src/SharpEmu.GUI/MainWindow.axaml @@ -60,12 +60,6 @@ SPDX-License-Identifier: GPL-2.0-or-later - - - - - - - - - - - - - - - - - - - - - - - -