From cee712105850ac3385cd0091a923438167433f9f Mon Sep 17 00:00:00 2001 From: TSR Berry <20988865+TSRBerry@users.noreply.github.com> Date: Sat, 8 Apr 2023 01:22:00 +0200 Subject: Move solution and projects to src --- .../HeadlessDynamicTextInputHandler.cs | 51 ++ src/Ryujinx.Headless.SDL2/HeadlessHostUiTheme.cs | 17 + src/Ryujinx.Headless.SDL2/HideCursor.cs | 9 + src/Ryujinx.Headless.SDL2/OpenGL/OpenGLWindow.cs | 169 +++++ src/Ryujinx.Headless.SDL2/Options.cs | 209 ++++++ src/Ryujinx.Headless.SDL2/Program.cs | 704 +++++++++++++++++++++ .../Ryujinx.Headless.SDL2.csproj | 66 ++ src/Ryujinx.Headless.SDL2/Ryujinx.bmp | Bin 0 -> 9354 bytes src/Ryujinx.Headless.SDL2/SDL2Mouse.cs | 90 +++ src/Ryujinx.Headless.SDL2/SDL2MouseDriver.cs | 164 +++++ .../StatusUpdatedEventArgs.cs | 24 + src/Ryujinx.Headless.SDL2/Vulkan/VulkanWindow.cs | 104 +++ src/Ryujinx.Headless.SDL2/WindowBase.cs | 499 +++++++++++++++ 13 files changed, 2106 insertions(+) create mode 100644 src/Ryujinx.Headless.SDL2/HeadlessDynamicTextInputHandler.cs create mode 100644 src/Ryujinx.Headless.SDL2/HeadlessHostUiTheme.cs create mode 100644 src/Ryujinx.Headless.SDL2/HideCursor.cs create mode 100644 src/Ryujinx.Headless.SDL2/OpenGL/OpenGLWindow.cs create mode 100644 src/Ryujinx.Headless.SDL2/Options.cs create mode 100644 src/Ryujinx.Headless.SDL2/Program.cs create mode 100644 src/Ryujinx.Headless.SDL2/Ryujinx.Headless.SDL2.csproj create mode 100644 src/Ryujinx.Headless.SDL2/Ryujinx.bmp create mode 100644 src/Ryujinx.Headless.SDL2/SDL2Mouse.cs create mode 100644 src/Ryujinx.Headless.SDL2/SDL2MouseDriver.cs create mode 100644 src/Ryujinx.Headless.SDL2/StatusUpdatedEventArgs.cs create mode 100644 src/Ryujinx.Headless.SDL2/Vulkan/VulkanWindow.cs create mode 100644 src/Ryujinx.Headless.SDL2/WindowBase.cs (limited to 'src/Ryujinx.Headless.SDL2') diff --git a/src/Ryujinx.Headless.SDL2/HeadlessDynamicTextInputHandler.cs b/src/Ryujinx.Headless.SDL2/HeadlessDynamicTextInputHandler.cs new file mode 100644 index 00000000..7e624152 --- /dev/null +++ b/src/Ryujinx.Headless.SDL2/HeadlessDynamicTextInputHandler.cs @@ -0,0 +1,51 @@ +using Ryujinx.HLE.Ui; +using System.Threading; +using System.Threading.Tasks; + +namespace Ryujinx.Headless.SDL2 +{ + /// + /// Headless text processing class, right now there is no way to forward the input to it. + /// + internal class HeadlessDynamicTextInputHandler : IDynamicTextInputHandler + { + private bool _canProcessInput; + + public event DynamicTextChangedHandler TextChangedEvent; + public event KeyPressedHandler KeyPressedEvent { add { } remove { } } + public event KeyReleasedHandler KeyReleasedEvent { add { } remove { } } + + public bool TextProcessingEnabled + { + get + { + return Volatile.Read(ref _canProcessInput); + } + + set + { + Volatile.Write(ref _canProcessInput, value); + + // Launch a task to update the text. + Task.Run(() => + { + Thread.Sleep(100); + TextChangedEvent?.Invoke("Ryujinx", 7, 7, false); + }); + } + } + + public HeadlessDynamicTextInputHandler() + { + // Start with input processing turned off so the text box won't accumulate text + // if the user is playing on the keyboard. + _canProcessInput = false; + } + + public void SetText(string text, int cursorBegin) { } + + public void SetText(string text, int cursorBegin, int cursorEnd) { } + + public void Dispose() { } + } +} \ No newline at end of file diff --git a/src/Ryujinx.Headless.SDL2/HeadlessHostUiTheme.cs b/src/Ryujinx.Headless.SDL2/HeadlessHostUiTheme.cs new file mode 100644 index 00000000..4ef00b3c --- /dev/null +++ b/src/Ryujinx.Headless.SDL2/HeadlessHostUiTheme.cs @@ -0,0 +1,17 @@ +using Ryujinx.HLE.Ui; + +namespace Ryujinx.Headless.SDL2 +{ + internal class HeadlessHostUiTheme : IHostUiTheme + { + public string FontFamily => "sans-serif"; + + public ThemeColor DefaultBackgroundColor => new ThemeColor(1, 0, 0, 0); + public ThemeColor DefaultForegroundColor => new ThemeColor(1, 1, 1, 1); + public ThemeColor DefaultBorderColor => new ThemeColor(1, 1, 1, 1); + public ThemeColor SelectionBackgroundColor => new ThemeColor(1, 1, 1, 1); + public ThemeColor SelectionForegroundColor => new ThemeColor(1, 0, 0, 0); + + public HeadlessHostUiTheme() { } + } +} \ No newline at end of file diff --git a/src/Ryujinx.Headless.SDL2/HideCursor.cs b/src/Ryujinx.Headless.SDL2/HideCursor.cs new file mode 100644 index 00000000..2dc0bd6a --- /dev/null +++ b/src/Ryujinx.Headless.SDL2/HideCursor.cs @@ -0,0 +1,9 @@ +namespace Ryujinx.Headless.SDL2 +{ + public enum HideCursor + { + Never, + OnIdle, + Always + } +} \ No newline at end of file diff --git a/src/Ryujinx.Headless.SDL2/OpenGL/OpenGLWindow.cs b/src/Ryujinx.Headless.SDL2/OpenGL/OpenGLWindow.cs new file mode 100644 index 00000000..69b0f42f --- /dev/null +++ b/src/Ryujinx.Headless.SDL2/OpenGL/OpenGLWindow.cs @@ -0,0 +1,169 @@ +using OpenTK; +using OpenTK.Graphics.OpenGL; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.OpenGL; +using Ryujinx.Input.HLE; +using System; +using static SDL2.SDL; + +namespace Ryujinx.Headless.SDL2.OpenGL +{ + class OpenGLWindow : WindowBase + { + private static void SetupOpenGLAttributes(bool sharedContext, GraphicsDebugLevel debugLevel) + { + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_PROFILE_MASK, SDL_GLprofile.SDL_GL_CONTEXT_PROFILE_COMPATIBILITY); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_CONTEXT_FLAGS, debugLevel != GraphicsDebugLevel.None ? (int)SDL_GLcontext.SDL_GL_CONTEXT_DEBUG_FLAG : 0); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_SHARE_WITH_CURRENT_CONTEXT, sharedContext ? 1 : 0); + + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_ACCELERATED_VISUAL, 1); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_RED_SIZE, 8); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_GREEN_SIZE, 8); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_BLUE_SIZE, 8); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_ALPHA_SIZE, 8); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_DEPTH_SIZE, 16); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_STENCIL_SIZE, 0); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_STEREO, 0); + } + + private class OpenToolkitBindingsContext : IBindingsContext + { + public IntPtr GetProcAddress(string procName) + { + return SDL_GL_GetProcAddress(procName); + } + } + + private class SDL2OpenGLContext : IOpenGLContext + { + private IntPtr _context; + private IntPtr _window; + private bool _shouldDisposeWindow; + + public SDL2OpenGLContext(IntPtr context, IntPtr window, bool shouldDisposeWindow = true) + { + _context = context; + _window = window; + _shouldDisposeWindow = shouldDisposeWindow; + } + + public static SDL2OpenGLContext CreateBackgroundContext(SDL2OpenGLContext sharedContext) + { + sharedContext.MakeCurrent(); + + // Ensure we share our contexts. + SetupOpenGLAttributes(true, GraphicsDebugLevel.None); + IntPtr windowHandle = SDL_CreateWindow("Ryujinx background context window", 0, 0, 1, 1, SDL_WindowFlags.SDL_WINDOW_OPENGL | SDL_WindowFlags.SDL_WINDOW_HIDDEN); + IntPtr context = SDL_GL_CreateContext(windowHandle); + + GL.LoadBindings(new OpenToolkitBindingsContext()); + + SDL_GL_SetAttribute(SDL_GLattr.SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0); + + SDL_GL_MakeCurrent(windowHandle, IntPtr.Zero); + + return new SDL2OpenGLContext(context, windowHandle); + } + + public void MakeCurrent() + { + if (SDL_GL_GetCurrentContext() == _context || SDL_GL_GetCurrentWindow() == _window) + { + return; + } + + int res = SDL_GL_MakeCurrent(_window, _context); + + if (res != 0) + { + string errorMessage = $"SDL_GL_CreateContext failed with error \"{SDL_GetError()}\""; + + Logger.Error?.Print(LogClass.Application, errorMessage); + + throw new Exception(errorMessage); + } + } + + public void Dispose() + { + SDL_GL_DeleteContext(_context); + + if (_shouldDisposeWindow) + { + SDL_DestroyWindow(_window); + } + } + } + + private GraphicsDebugLevel _glLogLevel; + private SDL2OpenGLContext _openGLContext; + + public OpenGLWindow( + InputManager inputManager, + GraphicsDebugLevel glLogLevel, + AspectRatio aspectRatio, + bool enableMouse, + HideCursor hideCursor) + : base(inputManager, glLogLevel, aspectRatio, enableMouse, hideCursor) + { + _glLogLevel = glLogLevel; + } + + public override SDL_WindowFlags GetWindowFlags() => SDL_WindowFlags.SDL_WINDOW_OPENGL; + + protected override void InitializeWindowRenderer() + { + // Ensure to not share this context with other contexts before this point. + SetupOpenGLAttributes(false, _glLogLevel); + IntPtr context = SDL_GL_CreateContext(WindowHandle); + SDL_GL_SetSwapInterval(1); + + if (context == IntPtr.Zero) + { + string errorMessage = $"SDL_GL_CreateContext failed with error \"{SDL_GetError()}\""; + + Logger.Error?.Print(LogClass.Application, errorMessage); + + throw new Exception(errorMessage); + } + + // NOTE: The window handle needs to be disposed by the thread that created it and is handled separately. + _openGLContext = new SDL2OpenGLContext(context, WindowHandle, false); + + // First take exclusivity on the OpenGL context. + ((OpenGLRenderer)Renderer).InitializeBackgroundContext(SDL2OpenGLContext.CreateBackgroundContext(_openGLContext)); + + _openGLContext.MakeCurrent(); + + GL.ClearColor(0, 0, 0, 1.0f); + GL.Clear(ClearBufferMask.ColorBufferBit); + SwapBuffers(); + + Renderer?.Window.SetSize(DefaultWidth, DefaultHeight); + MouseDriver.SetClientSize(DefaultWidth, DefaultHeight); + } + + protected override void InitializeRenderer() { } + + protected override void FinalizeWindowRenderer() + { + // Try to bind the OpenGL context before calling the gpu disposal. + _openGLContext.MakeCurrent(); + + Device.DisposeGpu(); + + // Unbind context and destroy everything + SDL_GL_MakeCurrent(WindowHandle, IntPtr.Zero); + _openGLContext.Dispose(); + } + + protected override void SwapBuffers() + { + SDL_GL_SwapWindow(WindowHandle); + } + } +} \ No newline at end of file diff --git a/src/Ryujinx.Headless.SDL2/Options.cs b/src/Ryujinx.Headless.SDL2/Options.cs new file mode 100644 index 00000000..982d0990 --- /dev/null +++ b/src/Ryujinx.Headless.SDL2/Options.cs @@ -0,0 +1,209 @@ +using CommandLine; +using Ryujinx.Common.Configuration; +using Ryujinx.HLE.HOS.SystemState; + +namespace Ryujinx.Headless.SDL2 +{ + public class Options + { + // General + + [Option("root-data-dir", Required = false, HelpText = "Set the custom folder path for Ryujinx data.")] + public string BaseDataDir { get; set; } + + [Option("profile", Required = false, HelpText = "Set the user profile to launch the game with.")] + public string UserProfile { get; set; } + + // Input + + [Option("input-profile-1", Required = false, HelpText = "Set the input profile in use for Player 1.")] + public string InputProfile1Name { get; set; } + + [Option("input-profile-2", Required = false, HelpText = "Set the input profile in use for Player 2.")] + public string InputProfile2Name { get; set; } + + [Option("input-profile-3", Required = false, HelpText = "Set the input profile in use for Player 3.")] + public string InputProfile3Name { get; set; } + + [Option("input-profile-4", Required = false, HelpText = "Set the input profile in use for Player 4.")] + public string InputProfile4Name { get; set; } + + [Option("input-profile-5", Required = false, HelpText = "Set the input profile in use for Player 5.")] + public string InputProfile5Name { get; set; } + + [Option("input-profile-6", Required = false, HelpText = "Set the input profile in use for Player 6.")] + public string InputProfile6Name { get; set; } + + [Option("input-profile-7", Required = false, HelpText = "Set the input profile in use for Player 7.")] + public string InputProfile7Name { get; set; } + + [Option("input-profile-8", Required = false, HelpText = "Set the input profile in use for Player 8.")] + public string InputProfile8Name { get; set; } + + [Option("input-profile-handheld", Required = false, HelpText = "Set the input profile in use for the Handheld Player.")] + public string InputProfileHandheldName { get; set; } + + [Option("input-id-1", Required = false, HelpText = "Set the input id in use for Player 1.")] + public string InputId1 { get; set; } + + [Option("input-id-2", Required = false, HelpText = "Set the input id in use for Player 2.")] + public string InputId2 { get; set; } + + [Option("input-id-3", Required = false, HelpText = "Set the input id in use for Player 3.")] + public string InputId3 { get; set; } + + [Option("input-id-4", Required = false, HelpText = "Set the input id in use for Player 4.")] + public string InputId4 { get; set; } + + [Option("input-id-5", Required = false, HelpText = "Set the input id in use for Player 5.")] + public string InputId5 { get; set; } + + [Option("input-id-6", Required = false, HelpText = "Set the input id in use for Player 6.")] + public string InputId6 { get; set; } + + [Option("input-id-7", Required = false, HelpText = "Set the input id in use for Player 7.")] + public string InputId7 { get; set; } + + [Option("input-id-8", Required = false, HelpText = "Set the input id in use for Player 8.")] + public string InputId8 { get; set; } + + [Option("input-id-handheld", Required = false, HelpText = "Set the input id in use for the Handheld Player.")] + public string InputIdHandheld { get; set; } + + [Option("enable-keyboard", Required = false, Default = false, HelpText = "Enable or disable keyboard support (Independent from controllers binding).")] + public bool EnableKeyboard { get; set; } + + [Option("enable-mouse", Required = false, Default = false, HelpText = "Enable or disable mouse support.")] + public bool EnableMouse { get; set; } + + [Option("hide-cursor", Required = false, Default = HideCursor.OnIdle, HelpText = "Change when the cursor gets hidden.")] + public HideCursor HideCursor { get; set; } + + [Option("list-input-profiles", Required = false, HelpText = "List inputs profiles.")] + public bool ListInputProfiles { get; set; } + + [Option("list-inputs-ids", Required = false, HelpText = "List inputs ids.")] + public bool ListInputIds { get; set; } + + // System + + [Option("disable-ptc", Required = false, HelpText = "Disables profiled persistent translation cache.")] + public bool DisablePtc { get; set; } + + [Option("enable-internet-connection", Required = false, Default = false, HelpText = "Enables guest Internet connection.")] + public bool EnableInternetAccess { get; set; } + + [Option("disable-fs-integrity-checks", Required = false, HelpText = "Disables integrity checks on Game content files.")] + public bool DisableFsIntegrityChecks { get; set; } + + [Option("fs-global-access-log-mode", Required = false, Default = 0, HelpText = "Enables FS access log output to the console.")] + public int FsGlobalAccessLogMode { get; set; } + + [Option("disable-vsync", Required = false, HelpText = "Disables Vertical Sync.")] + public bool DisableVsync { get; set; } + + [Option("disable-shader-cache", Required = false, HelpText = "Disables Shader cache.")] + public bool DisableShaderCache { get; set; } + + [Option("enable-texture-recompression", Required = false, Default = false, HelpText = "Enables Texture recompression.")] + public bool EnableTextureRecompression { get; set; } + + [Option("disable-docked-mode", Required = false, HelpText = "Disables Docked Mode.")] + public bool DisableDockedMode { get; set; } + + [Option("system-language", Required = false, Default = SystemLanguage.AmericanEnglish, HelpText = "Change System Language.")] + public SystemLanguage SystemLanguage { get; set; } + + [Option("system-region", Required = false, Default = RegionCode.USA, HelpText = "Change System Region.")] + public RegionCode SystemRegion { get; set; } + + [Option("system-timezone", Required = false, Default = "UTC", HelpText = "Change System TimeZone.")] + public string SystemTimeZone { get; set; } + + [Option("system-time-offset", Required = false, Default = 0, HelpText = "Change System Time Offset in seconds.")] + public long SystemTimeOffset { get; set; } + + [Option("memory-manager-mode", Required = false, Default = MemoryManagerMode.HostMappedUnsafe, HelpText = "The selected memory manager mode.")] + public MemoryManagerMode MemoryManagerMode { get; set; } + + [Option("audio-volume", Required = false, Default = 1.0f, HelpText ="The audio level (0 to 1).")] + public float AudioVolume { get; set; } + + [Option("use-hypervisor", Required = false, Default = true, HelpText = "Uses Hypervisor over JIT if available.")] + public bool UseHypervisor { get; set; } + + [Option("lan-interface-id", Required = false, Default = "0", HelpText = "GUID for the network interface used by LAN.")] + public string MultiplayerLanInterfaceId { get; set; } + + // Logging + + [Option("disable-file-logging", Required = false, Default = false, HelpText = "Disables logging to a file on disk.")] + public bool DisableFileLog { get; set; } + + [Option("enable-debug-logs", Required = false, Default = false, HelpText = "Enables printing debug log messages.")] + public bool LoggingEnableDebug { get; set; } + + [Option("disable-stub-logs", Required = false, HelpText = "Disables printing stub log messages.")] + public bool LoggingDisableStub { get; set; } + + [Option("disable-info-logs", Required = false, HelpText = "Disables printing info log messages.")] + public bool LoggingDisableInfo { get; set; } + + [Option("disable-warning-logs", Required = false, HelpText = "Disables printing warning log messages.")] + public bool LoggingDisableWarning { get; set; } + + [Option("disable-error-logs", Required = false, HelpText = "Disables printing error log messages.")] + public bool LoggingEnableError { get; set; } + + [Option("enable-trace-logs", Required = false, Default = false, HelpText = "Enables printing trace log messages.")] + public bool LoggingEnableTrace { get; set; } + + [Option("disable-guest-logs", Required = false, HelpText = "Disables printing guest log messages.")] + public bool LoggingDisableGuest { get; set; } + + [Option("enable-fs-access-logs", Required = false, Default = false, HelpText = "Enables printing FS access log messages.")] + public bool LoggingEnableFsAccessLog { get; set; } + + [Option("graphics-debug-level", Required = false, Default = GraphicsDebugLevel.None, HelpText = "Change Graphics API debug log level.")] + public GraphicsDebugLevel LoggingGraphicsDebugLevel { get; set; } + + // Graphics + + [Option("resolution-scale", Required = false, Default = 1, HelpText = "Resolution Scale. A floating point scale applied to applicable render targets.")] + public float ResScale { get; set; } + + [Option("max-anisotropy", Required = false, Default = -1, HelpText = "Max Anisotropy. Values range from 0 - 16. Set to -1 to let the game decide.")] + public float MaxAnisotropy { get; set; } + + [Option("aspect-ratio", Required = false, Default = AspectRatio.Fixed16x9, HelpText = "Aspect Ratio applied to the renderer window.")] + public AspectRatio AspectRatio { get; set; } + + [Option("backend-threading", Required = false, Default = BackendThreading.Auto, HelpText = "Whether or not backend threading is enabled. The \"Auto\" setting will determine whether threading should be enabled at runtime.")] + public BackendThreading BackendThreading { get; set; } + + [Option("disable-macro-hle", Required= false, HelpText = "Disables high-level emulation of Macro code. Leaving this enabled improves performance but may cause graphical glitches in some games.")] + public bool DisableMacroHLE { get; set; } + + [Option("graphics-shaders-dump-path", Required = false, HelpText = "Dumps shaders in this local directory. (Developer only)")] + public string GraphicsShadersDumpPath { get; set; } + + [Option("graphics-backend", Required = false, Default = GraphicsBackend.OpenGl, HelpText = "Change Graphics Backend to use.")] + public GraphicsBackend GraphicsBackend { get; set; } + + [Option("preferred-gpu-vendor", Required = false, Default = "", HelpText = "When using the Vulkan backend, prefer using the GPU from the specified vendor.")] + public string PreferredGpuVendor { get; set; } + + // Hacks + + [Option("expand-ram", Required = false, Default = false, HelpText = "Expands the RAM amount on the emulated system from 4GiB to 6GiB.")] + public bool ExpandRam { get; set; } + + [Option("ignore-missing-services", Required = false, Default = false, HelpText = "Enable ignoring missing services.")] + public bool IgnoreMissingServices { get; set; } + + // Values + + [Value(0, MetaName = "input", HelpText = "Input to load.", Required = true)] + public string InputPath { get; set; } + } +} \ No newline at end of file diff --git a/src/Ryujinx.Headless.SDL2/Program.cs b/src/Ryujinx.Headless.SDL2/Program.cs new file mode 100644 index 00000000..b0bdb97f --- /dev/null +++ b/src/Ryujinx.Headless.SDL2/Program.cs @@ -0,0 +1,704 @@ +using ARMeilleure.Translation; +using CommandLine; +using LibHac.Tools.FsSystem; +using Ryujinx.Audio.Backends.SDL2; +using Ryujinx.Common; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Configuration.Hid; +using Ryujinx.Common.Configuration.Hid.Controller; +using Ryujinx.Common.Configuration.Hid.Controller.Motion; +using Ryujinx.Common.Configuration.Hid.Keyboard; +using Ryujinx.Common.Logging; +using Ryujinx.Common.SystemInterop; +using Ryujinx.Common.Utilities; +using Ryujinx.Cpu; +using Ryujinx.Graphics.GAL; +using Ryujinx.Graphics.GAL.Multithreading; +using Ryujinx.Graphics.Gpu; +using Ryujinx.Graphics.Gpu.Shader; +using Ryujinx.Graphics.OpenGL; +using Ryujinx.Graphics.Vulkan; +using Ryujinx.Headless.SDL2.OpenGL; +using Ryujinx.Headless.SDL2.Vulkan; +using Ryujinx.HLE; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.HOS; +using Ryujinx.HLE.HOS.Services.Account.Acc; +using Ryujinx.Input; +using Ryujinx.Input.HLE; +using Ryujinx.Input.SDL2; +using Silk.NET.Vulkan; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading; +using ConfigGamepadInputId = Ryujinx.Common.Configuration.Hid.Controller.GamepadInputId; +using ConfigStickInputId = Ryujinx.Common.Configuration.Hid.Controller.StickInputId; +using Key = Ryujinx.Common.Configuration.Hid.Key; + +namespace Ryujinx.Headless.SDL2 +{ + class Program + { + public static string Version { get; private set; } + + private static VirtualFileSystem _virtualFileSystem; + private static ContentManager _contentManager; + private static AccountManager _accountManager; + private static LibHacHorizonManager _libHacHorizonManager; + private static UserChannelPersistence _userChannelPersistence; + private static InputManager _inputManager; + private static Switch _emulationContext; + private static WindowBase _window; + private static WindowsMultimediaTimerResolution _windowsMultimediaTimerResolution; + private static List _inputConfiguration; + private static bool _enableKeyboard; + private static bool _enableMouse; + + private static readonly InputConfigJsonSerializerContext SerializerContext = new(JsonHelper.GetDefaultSerializerOptions()); + + static void Main(string[] args) + { + Version = ReleaseInformation.GetVersion(); + + Console.Title = $"Ryujinx Console {Version} (Headless SDL2)"; + + if (OperatingSystem.IsMacOS() || OperatingSystem.IsLinux()) + { + AutoResetEvent invoked = new AutoResetEvent(false); + + // MacOS must perform SDL polls from the main thread. + Ryujinx.SDL2.Common.SDL2Driver.MainThreadDispatcher = (Action action) => + { + invoked.Reset(); + + WindowBase.QueueMainThreadAction(() => + { + action(); + + invoked.Set(); + }); + + invoked.WaitOne(); + }; + } + + Parser.Default.ParseArguments(args) + .WithParsed(Load) + .WithNotParsed(errors => errors.Output()); + } + + private static InputConfig HandlePlayerConfiguration(string inputProfileName, string inputId, PlayerIndex index) + { + if (inputId == null) + { + if (index == PlayerIndex.Player1) + { + Logger.Info?.Print(LogClass.Application, $"{index} not configured, defaulting to default keyboard."); + + // Default to keyboard + inputId = "0"; + } + else + { + Logger.Info?.Print(LogClass.Application, $"{index} not configured"); + + return null; + } + } + + IGamepad gamepad; + + bool isKeyboard = true; + + gamepad = _inputManager.KeyboardDriver.GetGamepad(inputId); + + if (gamepad == null) + { + gamepad = _inputManager.GamepadDriver.GetGamepad(inputId); + isKeyboard = false; + + if (gamepad == null) + { + Logger.Error?.Print(LogClass.Application, $"{index} gamepad not found (\"{inputId}\")"); + + return null; + } + } + + string gamepadName = gamepad.Name; + + gamepad.Dispose(); + + InputConfig config; + + if (inputProfileName == null || inputProfileName.Equals("default")) + { + if (isKeyboard) + { + config = new StandardKeyboardInputConfig + { + Version = InputConfig.CurrentVersion, + Backend = InputBackendType.WindowKeyboard, + Id = null, + ControllerType = ControllerType.JoyconPair, + LeftJoycon = new LeftJoyconCommonConfig + { + DpadUp = Key.Up, + DpadDown = Key.Down, + DpadLeft = Key.Left, + DpadRight = Key.Right, + ButtonMinus = Key.Minus, + ButtonL = Key.E, + ButtonZl = Key.Q, + ButtonSl = Key.Unbound, + ButtonSr = Key.Unbound + }, + + LeftJoyconStick = new JoyconConfigKeyboardStick + { + StickUp = Key.W, + StickDown = Key.S, + StickLeft = Key.A, + StickRight = Key.D, + StickButton = Key.F, + }, + + RightJoycon = new RightJoyconCommonConfig + { + ButtonA = Key.Z, + ButtonB = Key.X, + ButtonX = Key.C, + ButtonY = Key.V, + ButtonPlus = Key.Plus, + ButtonR = Key.U, + ButtonZr = Key.O, + ButtonSl = Key.Unbound, + ButtonSr = Key.Unbound + }, + + RightJoyconStick = new JoyconConfigKeyboardStick + { + StickUp = Key.I, + StickDown = Key.K, + StickLeft = Key.J, + StickRight = Key.L, + StickButton = Key.H, + } + }; + } + else + { + bool isNintendoStyle = gamepadName.Contains("Nintendo"); + + config = new StandardControllerInputConfig + { + Version = InputConfig.CurrentVersion, + Backend = InputBackendType.GamepadSDL2, + Id = null, + ControllerType = ControllerType.JoyconPair, + DeadzoneLeft = 0.1f, + DeadzoneRight = 0.1f, + RangeLeft = 1.0f, + RangeRight = 1.0f, + TriggerThreshold = 0.5f, + LeftJoycon = new LeftJoyconCommonConfig + { + DpadUp = ConfigGamepadInputId.DpadUp, + DpadDown = ConfigGamepadInputId.DpadDown, + DpadLeft = ConfigGamepadInputId.DpadLeft, + DpadRight = ConfigGamepadInputId.DpadRight, + ButtonMinus = ConfigGamepadInputId.Minus, + ButtonL = ConfigGamepadInputId.LeftShoulder, + ButtonZl = ConfigGamepadInputId.LeftTrigger, + ButtonSl = ConfigGamepadInputId.Unbound, + ButtonSr = ConfigGamepadInputId.Unbound, + }, + + LeftJoyconStick = new JoyconConfigControllerStick + { + Joystick = ConfigStickInputId.Left, + StickButton = ConfigGamepadInputId.LeftStick, + InvertStickX = false, + InvertStickY = false, + Rotate90CW = false, + }, + + RightJoycon = new RightJoyconCommonConfig + { + ButtonA = isNintendoStyle ? ConfigGamepadInputId.A : ConfigGamepadInputId.B, + ButtonB = isNintendoStyle ? ConfigGamepadInputId.B : ConfigGamepadInputId.A, + ButtonX = isNintendoStyle ? ConfigGamepadInputId.X : ConfigGamepadInputId.Y, + ButtonY = isNintendoStyle ? ConfigGamepadInputId.Y : ConfigGamepadInputId.X, + ButtonPlus = ConfigGamepadInputId.Plus, + ButtonR = ConfigGamepadInputId.RightShoulder, + ButtonZr = ConfigGamepadInputId.RightTrigger, + ButtonSl = ConfigGamepadInputId.Unbound, + ButtonSr = ConfigGamepadInputId.Unbound, + }, + + RightJoyconStick = new JoyconConfigControllerStick + { + Joystick = ConfigStickInputId.Right, + StickButton = ConfigGamepadInputId.RightStick, + InvertStickX = false, + InvertStickY = false, + Rotate90CW = false, + }, + + Motion = new StandardMotionConfigController + { + MotionBackend = MotionInputBackendType.GamepadDriver, + EnableMotion = true, + Sensitivity = 100, + GyroDeadzone = 1, + }, + Rumble = new RumbleConfigController + { + StrongRumble = 1f, + WeakRumble = 1f, + EnableRumble = false + } + }; + } + } + else + { + string profileBasePath; + + if (isKeyboard) + { + profileBasePath = Path.Combine(AppDataManager.ProfilesDirPath, "keyboard"); + } + else + { + profileBasePath = Path.Combine(AppDataManager.ProfilesDirPath, "controller"); + } + + string path = Path.Combine(profileBasePath, inputProfileName + ".json"); + + if (!File.Exists(path)) + { + Logger.Error?.Print(LogClass.Application, $"Input profile \"{inputProfileName}\" not found for \"{inputId}\""); + + return null; + } + + try + { + config = JsonHelper.DeserializeFromFile(path, SerializerContext.InputConfig); + } + catch (JsonException) + { + Logger.Error?.Print(LogClass.Application, $"Input profile \"{inputProfileName}\" parsing failed for \"{inputId}\""); + + return null; + } + } + + config.Id = inputId; + config.PlayerIndex = index; + + string inputTypeName = isKeyboard ? "Keyboard" : "Gamepad"; + + Logger.Info?.Print(LogClass.Application, $"{config.PlayerIndex} configured with {inputTypeName} \"{config.Id}\""); + + // If both stick ranges are 0 (usually indicative of an outdated profile load) then both sticks will be set to 1.0. + if (config is StandardControllerInputConfig controllerConfig) + { + if (controllerConfig.RangeLeft <= 0.0f && controllerConfig.RangeRight <= 0.0f) + { + controllerConfig.RangeLeft = 1.0f; + controllerConfig.RangeRight = 1.0f; + + Logger.Info?.Print(LogClass.Application, $"{config.PlayerIndex} stick range reset. Save the profile now to update your configuration"); + } + } + + return config; + } + + static void Load(Options option) + { + AppDataManager.Initialize(option.BaseDataDir); + + _virtualFileSystem = VirtualFileSystem.CreateInstance(); + _libHacHorizonManager = new LibHacHorizonManager(); + + _libHacHorizonManager.InitializeFsServer(_virtualFileSystem); + _libHacHorizonManager.InitializeArpServer(); + _libHacHorizonManager.InitializeBcatServer(); + _libHacHorizonManager.InitializeSystemClients(); + + _contentManager = new ContentManager(_virtualFileSystem); + _accountManager = new AccountManager(_libHacHorizonManager.RyujinxClient, option.UserProfile); + _userChannelPersistence = new UserChannelPersistence(); + + _inputManager = new InputManager(new SDL2KeyboardDriver(), new SDL2GamepadDriver()); + + GraphicsConfig.EnableShaderCache = true; + + IGamepad gamepad; + + if (option.ListInputIds) + { + Logger.Info?.Print(LogClass.Application, "Input Ids:"); + + foreach (string id in _inputManager.KeyboardDriver.GamepadsIds) + { + gamepad = _inputManager.KeyboardDriver.GetGamepad(id); + + Logger.Info?.Print(LogClass.Application, $"- {id} (\"{gamepad.Name}\")"); + + gamepad.Dispose(); + } + + foreach (string id in _inputManager.GamepadDriver.GamepadsIds) + { + gamepad = _inputManager.GamepadDriver.GetGamepad(id); + + Logger.Info?.Print(LogClass.Application, $"- {id} (\"{gamepad.Name}\")"); + + gamepad.Dispose(); + } + + return; + } + + if (option.InputPath == null) + { + Logger.Error?.Print(LogClass.Application, "Please provide a file to load"); + + return; + } + + _inputConfiguration = new List(); + _enableKeyboard = option.EnableKeyboard; + _enableMouse = option.EnableMouse; + + void LoadPlayerConfiguration(string inputProfileName, string inputId, PlayerIndex index) + { + InputConfig inputConfig = HandlePlayerConfiguration(inputProfileName, inputId, index); + + if (inputConfig != null) + { + _inputConfiguration.Add(inputConfig); + } + } + + LoadPlayerConfiguration(option.InputProfile1Name, option.InputId1, PlayerIndex.Player1); + LoadPlayerConfiguration(option.InputProfile2Name, option.InputId2, PlayerIndex.Player2); + LoadPlayerConfiguration(option.InputProfile3Name, option.InputId3, PlayerIndex.Player3); + LoadPlayerConfiguration(option.InputProfile4Name, option.InputId4, PlayerIndex.Player4); + LoadPlayerConfiguration(option.InputProfile5Name, option.InputId5, PlayerIndex.Player5); + LoadPlayerConfiguration(option.InputProfile6Name, option.InputId6, PlayerIndex.Player6); + LoadPlayerConfiguration(option.InputProfile7Name, option.InputId7, PlayerIndex.Player7); + LoadPlayerConfiguration(option.InputProfile8Name, option.InputId8, PlayerIndex.Player8); + LoadPlayerConfiguration(option.InputProfileHandheldName, option.InputIdHandheld, PlayerIndex.Handheld); + + if (_inputConfiguration.Count == 0) + { + return; + } + + // Setup logging level + Logger.SetEnable(LogLevel.Debug, option.LoggingEnableDebug); + Logger.SetEnable(LogLevel.Stub, !option.LoggingDisableStub); + Logger.SetEnable(LogLevel.Info, !option.LoggingDisableInfo); + Logger.SetEnable(LogLevel.Warning, !option.LoggingDisableWarning); + Logger.SetEnable(LogLevel.Error, option.LoggingEnableError); + Logger.SetEnable(LogLevel.Trace, option.LoggingEnableTrace); + Logger.SetEnable(LogLevel.Guest, !option.LoggingDisableGuest); + Logger.SetEnable(LogLevel.AccessLog, option.LoggingEnableFsAccessLog); + + if (!option.DisableFileLog) + { + Logger.AddTarget(new AsyncLogTargetWrapper( + new FileLogTarget(ReleaseInformation.GetBaseApplicationDirectory(), "file"), + 1000, + AsyncLogTargetOverflowAction.Block + )); + } + + // Setup graphics configuration + GraphicsConfig.EnableShaderCache = !option.DisableShaderCache; + GraphicsConfig.EnableTextureRecompression = option.EnableTextureRecompression; + GraphicsConfig.ResScale = option.ResScale; + GraphicsConfig.MaxAnisotropy = option.MaxAnisotropy; + GraphicsConfig.ShadersDumpPath = option.GraphicsShadersDumpPath; + GraphicsConfig.EnableMacroHLE = !option.DisableMacroHLE; + + while (true) + { + LoadApplication(option); + + if (_userChannelPersistence.PreviousIndex == -1 || !_userChannelPersistence.ShouldRestart) + { + break; + } + + _userChannelPersistence.ShouldRestart = false; + } + + _inputManager.Dispose(); + } + + private static void SetupProgressHandler() + { + if (_emulationContext.Processes.ActiveApplication.DiskCacheLoadState != null) + { + _emulationContext.Processes.ActiveApplication.DiskCacheLoadState.StateChanged -= ProgressHandler; + _emulationContext.Processes.ActiveApplication.DiskCacheLoadState.StateChanged += ProgressHandler; + } + + _emulationContext.Gpu.ShaderCacheStateChanged -= ProgressHandler; + _emulationContext.Gpu.ShaderCacheStateChanged += ProgressHandler; + } + + private static void ProgressHandler(T state, int current, int total) where T : Enum + { + string label; + + switch (state) + { + case LoadState ptcState: + label = $"PTC : {current}/{total}"; + break; + case ShaderCacheState shaderCacheState: + label = $"Shaders : {current}/{total}"; + break; + default: + throw new ArgumentException($"Unknown Progress Handler type {typeof(T)}"); + } + + Logger.Info?.Print(LogClass.Application, label); + } + + private static WindowBase CreateWindow(Options options) + { + return options.GraphicsBackend == GraphicsBackend.Vulkan + ? new VulkanWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableMouse, options.HideCursor) + : new OpenGLWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, options.EnableMouse, options.HideCursor); + } + + private static IRenderer CreateRenderer(Options options, WindowBase window) + { + if (options.GraphicsBackend == GraphicsBackend.Vulkan && window is VulkanWindow vulkanWindow) + { + string preferredGpuId = string.Empty; + + if (!string.IsNullOrEmpty(options.PreferredGpuVendor)) + { + string preferredGpuVendor = options.PreferredGpuVendor.ToLowerInvariant(); + var devices = VulkanRenderer.GetPhysicalDevices(); + + foreach (var device in devices) + { + if (device.Vendor.ToLowerInvariant() == preferredGpuVendor) + { + preferredGpuId = device.Id; + break; + } + } + } + + return new VulkanRenderer( + (instance, vk) => new SurfaceKHR((ulong)(vulkanWindow.CreateWindowSurface(instance.Handle))), + vulkanWindow.GetRequiredInstanceExtensions, + preferredGpuId); + } + else + { + return new OpenGLRenderer(); + } + } + + private static Switch InitializeEmulationContext(WindowBase window, IRenderer renderer, Options options) + { + BackendThreading threadingMode = options.BackendThreading; + + bool threadedGAL = threadingMode == BackendThreading.On || (threadingMode == BackendThreading.Auto && renderer.PreferThreading); + + if (threadedGAL) + { + renderer = new ThreadedRenderer(renderer); + } + + HLEConfiguration configuration = new HLEConfiguration(_virtualFileSystem, + _libHacHorizonManager, + _contentManager, + _accountManager, + _userChannelPersistence, + renderer, + new SDL2HardwareDeviceDriver(), + options.ExpandRam ? MemoryConfiguration.MemoryConfiguration6GiB : MemoryConfiguration.MemoryConfiguration4GiB, + window, + options.SystemLanguage, + options.SystemRegion, + !options.DisableVsync, + !options.DisableDockedMode, + !options.DisablePtc, + options.EnableInternetAccess, + !options.DisableFsIntegrityChecks ? IntegrityCheckLevel.ErrorOnInvalid : IntegrityCheckLevel.None, + options.FsGlobalAccessLogMode, + options.SystemTimeOffset, + options.SystemTimeZone, + options.MemoryManagerMode, + options.IgnoreMissingServices, + options.AspectRatio, + options.AudioVolume, + options.UseHypervisor, + options.MultiplayerLanInterfaceId); + + return new Switch(configuration); + } + + private static void ExecutionEntrypoint() + { + if (OperatingSystem.IsWindows()) + { + _windowsMultimediaTimerResolution = new WindowsMultimediaTimerResolution(1); + } + + DisplaySleep.Prevent(); + + _window.Initialize(_emulationContext, _inputConfiguration, _enableKeyboard, _enableMouse); + + _window.Execute(); + + _emulationContext.Dispose(); + _window.Dispose(); + + if (OperatingSystem.IsWindows()) + { + _windowsMultimediaTimerResolution?.Dispose(); + _windowsMultimediaTimerResolution = null; + } + } + + private static bool LoadApplication(Options options) + { + string path = options.InputPath; + + Logger.RestartTime(); + + WindowBase window = CreateWindow(options); + IRenderer renderer = CreateRenderer(options, window); + + _window = window; + + _emulationContext = InitializeEmulationContext(window, renderer, options); + + SystemVersion firmwareVersion = _contentManager.GetCurrentFirmwareVersion(); + + Logger.Notice.Print(LogClass.Application, $"Using Firmware Version: {firmwareVersion?.VersionString}"); + + if (Directory.Exists(path)) + { + string[] romFsFiles = Directory.GetFiles(path, "*.istorage"); + + if (romFsFiles.Length == 0) + { + romFsFiles = Directory.GetFiles(path, "*.romfs"); + } + + if (romFsFiles.Length > 0) + { + Logger.Info?.Print(LogClass.Application, "Loading as cart with RomFS."); + + if (!_emulationContext.LoadCart(path, romFsFiles[0])) + { + _emulationContext.Dispose(); + + return false; + } + } + else + { + Logger.Info?.Print(LogClass.Application, "Loading as cart WITHOUT RomFS."); + + if (!_emulationContext.LoadCart(path)) + { + _emulationContext.Dispose(); + + return false; + } + } + } + else if (File.Exists(path)) + { + switch (Path.GetExtension(path).ToLowerInvariant()) + { + case ".xci": + Logger.Info?.Print(LogClass.Application, "Loading as XCI."); + + if (!_emulationContext.LoadXci(path)) + { + _emulationContext.Dispose(); + + return false; + } + break; + case ".nca": + Logger.Info?.Print(LogClass.Application, "Loading as NCA."); + + if (!_emulationContext.LoadNca(path)) + { + _emulationContext.Dispose(); + + return false; + } + break; + case ".nsp": + case ".pfs0": + Logger.Info?.Print(LogClass.Application, "Loading as NSP."); + + if (!_emulationContext.LoadNsp(path)) + { + _emulationContext.Dispose(); + + return false; + } + break; + default: + Logger.Info?.Print(LogClass.Application, "Loading as Homebrew."); + try + { + if (!_emulationContext.LoadProgram(path)) + { + _emulationContext.Dispose(); + + return false; + } + } + catch (ArgumentOutOfRangeException) + { + Logger.Error?.Print(LogClass.Application, "The specified file is not supported by Ryujinx."); + + _emulationContext.Dispose(); + + return false; + } + break; + } + } + else + { + Logger.Warning?.Print(LogClass.Application, $"Couldn't load '{options.InputPath}'. Please specify a valid XCI/NCA/NSP/PFS0/NRO file."); + + _emulationContext.Dispose(); + + return false; + } + + SetupProgressHandler(); + + Translator.IsReadyForTranslation.Reset(); + + ExecutionEntrypoint(); + + return true; + } + } +} diff --git a/src/Ryujinx.Headless.SDL2/Ryujinx.Headless.SDL2.csproj b/src/Ryujinx.Headless.SDL2/Ryujinx.Headless.SDL2.csproj new file mode 100644 index 00000000..fc912d32 --- /dev/null +++ b/src/Ryujinx.Headless.SDL2/Ryujinx.Headless.SDL2.csproj @@ -0,0 +1,66 @@ + + + + net7.0 + win10-x64;osx-x64;linux-x64 + Exe + true + 1.0.0-dirty + $(DefineConstants);$(ExtraDefineConstants) + true + + + + + + + + + + + + + + + + + + + + + + + + + + Always + THIRDPARTY.md + + + Always + LICENSE.txt + + + + + + Always + + + + + + + + + + false + ..\Ryujinx\Ryujinx.ico + + + + true + true + partial + + diff --git a/src/Ryujinx.Headless.SDL2/Ryujinx.bmp b/src/Ryujinx.Headless.SDL2/Ryujinx.bmp new file mode 100644 index 00000000..1daa7ce9 Binary files /dev/null and b/src/Ryujinx.Headless.SDL2/Ryujinx.bmp differ diff --git a/src/Ryujinx.Headless.SDL2/SDL2Mouse.cs b/src/Ryujinx.Headless.SDL2/SDL2Mouse.cs new file mode 100644 index 00000000..4ce8eafd --- /dev/null +++ b/src/Ryujinx.Headless.SDL2/SDL2Mouse.cs @@ -0,0 +1,90 @@ +using Ryujinx.Common.Configuration.Hid; +using Ryujinx.Input; +using System; +using System.Drawing; +using System.Numerics; + +namespace Ryujinx.Headless.SDL2 +{ + class SDL2Mouse : IMouse + { + private SDL2MouseDriver _driver; + + public GamepadFeaturesFlag Features => throw new NotImplementedException(); + + public string Id => "0"; + + public string Name => "SDL2Mouse"; + + public bool IsConnected => true; + + public bool[] Buttons => _driver.PressedButtons; + + Size IMouse.ClientSize => _driver.GetClientSize(); + + public SDL2Mouse(SDL2MouseDriver driver) + { + _driver = driver; + } + + public Vector2 GetPosition() + { + return _driver.CurrentPosition; + } + + public Vector2 GetScroll() + { + return _driver.Scroll; + } + + public GamepadStateSnapshot GetMappedStateSnapshot() + { + throw new NotImplementedException(); + } + + public Vector3 GetMotionData(MotionInputId inputId) + { + throw new NotImplementedException(); + } + + public GamepadStateSnapshot GetStateSnapshot() + { + throw new NotImplementedException(); + } + + public (float, float) GetStick(StickInputId inputId) + { + throw new NotImplementedException(); + } + + public bool IsButtonPressed(MouseButton button) + { + return _driver.IsButtonPressed(button); + } + + public bool IsPressed(GamepadButtonInputId inputId) + { + throw new NotImplementedException(); + } + + public void Rumble(float lowFrequency, float highFrequency, uint durationMs) + { + throw new NotImplementedException(); + } + + public void SetConfiguration(InputConfig configuration) + { + throw new NotImplementedException(); + } + + public void SetTriggerThreshold(float triggerThreshold) + { + throw new NotImplementedException(); + } + + public void Dispose() + { + _driver = null; + } + } +} \ No newline at end of file diff --git a/src/Ryujinx.Headless.SDL2/SDL2MouseDriver.cs b/src/Ryujinx.Headless.SDL2/SDL2MouseDriver.cs new file mode 100644 index 00000000..8c3412ff --- /dev/null +++ b/src/Ryujinx.Headless.SDL2/SDL2MouseDriver.cs @@ -0,0 +1,164 @@ +using Ryujinx.Input; +using System; +using System.Diagnostics; +using System.Drawing; +using System.Numerics; +using System.Runtime.CompilerServices; +using static SDL2.SDL; + +namespace Ryujinx.Headless.SDL2 +{ + class SDL2MouseDriver : IGamepadDriver + { + private const int CursorHideIdleTime = 5; // seconds + + private bool _isDisposed; + private HideCursor _hideCursor; + private bool _isHidden; + private long _lastCursorMoveTime; + + public bool[] PressedButtons { get; } + + public Vector2 CurrentPosition { get; private set; } + public Vector2 Scroll { get; private set; } + public Size _clientSize; + + public SDL2MouseDriver(HideCursor hideCursor) + { + PressedButtons = new bool[(int)MouseButton.Count]; + _hideCursor = hideCursor; + + if (_hideCursor == HideCursor.Always) + { + SDL_ShowCursor(SDL_DISABLE); + _isHidden = true; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static MouseButton DriverButtonToMouseButton(uint rawButton) + { + Debug.Assert(rawButton > 0 && rawButton <= (int)MouseButton.Count); + + return (MouseButton)(rawButton - 1); + } + + public void UpdatePosition() + { + SDL_GetMouseState(out int posX, out int posY); + Vector2 position = new(posX, posY); + + if (CurrentPosition != position) + { + CurrentPosition = position; + _lastCursorMoveTime = Stopwatch.GetTimestamp(); + } + + CheckIdle(); + } + + private void CheckIdle() + { + if (_hideCursor != HideCursor.OnIdle) + { + return; + } + + long cursorMoveDelta = Stopwatch.GetTimestamp() - _lastCursorMoveTime; + + if (cursorMoveDelta >= CursorHideIdleTime * Stopwatch.Frequency) + { + if (!_isHidden) + { + SDL_ShowCursor(SDL_DISABLE); + _isHidden = true; + } + } + else + { + if (_isHidden) + { + SDL_ShowCursor(SDL_ENABLE); + _isHidden = false; + } + } + } + + public void Update(SDL_Event evnt) + { + switch (evnt.type) + { + case SDL_EventType.SDL_MOUSEBUTTONDOWN: + case SDL_EventType.SDL_MOUSEBUTTONUP: + uint rawButton = evnt.button.button; + + if (rawButton > 0 && rawButton <= (int)MouseButton.Count) + { + PressedButtons[(int)DriverButtonToMouseButton(rawButton)] = evnt.type == SDL_EventType.SDL_MOUSEBUTTONDOWN; + + CurrentPosition = new Vector2(evnt.button.x, evnt.button.y); + } + + break; + + // NOTE: On Linux using Wayland mouse motion events won't be received at all. + case SDL_EventType.SDL_MOUSEMOTION: + CurrentPosition = new Vector2(evnt.motion.x, evnt.motion.y); + _lastCursorMoveTime = Stopwatch.GetTimestamp(); + + break; + + case SDL_EventType.SDL_MOUSEWHEEL: + Scroll = new Vector2(evnt.wheel.x, evnt.wheel.y); + + break; + } + } + + public void SetClientSize(int width, int height) + { + _clientSize = new Size(width, height); + } + + public bool IsButtonPressed(MouseButton button) + { + return PressedButtons[(int)button]; + } + + public Size GetClientSize() + { + return _clientSize; + } + + public string DriverName => "SDL2"; + + public event Action OnGamepadConnected + { + add { } + remove { } + } + + public event Action OnGamepadDisconnected + { + add { } + remove { } + } + + public ReadOnlySpan GamepadsIds => new[] { "0" }; + + public IGamepad GetGamepad(string id) + { + return new SDL2Mouse(this); + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + } + } +} \ No newline at end of file diff --git a/src/Ryujinx.Headless.SDL2/StatusUpdatedEventArgs.cs b/src/Ryujinx.Headless.SDL2/StatusUpdatedEventArgs.cs new file mode 100644 index 00000000..62e161df --- /dev/null +++ b/src/Ryujinx.Headless.SDL2/StatusUpdatedEventArgs.cs @@ -0,0 +1,24 @@ +using System; + +namespace Ryujinx.Headless.SDL2 +{ + class StatusUpdatedEventArgs : EventArgs + { + public bool VSyncEnabled; + public string DockedMode; + public string AspectRatio; + public string GameStatus; + public string FifoStatus; + public string GpuName; + + public StatusUpdatedEventArgs(bool vSyncEnabled, string dockedMode, string aspectRatio, string gameStatus, string fifoStatus, string gpuName) + { + VSyncEnabled = vSyncEnabled; + DockedMode = dockedMode; + AspectRatio = aspectRatio; + GameStatus = gameStatus; + FifoStatus = fifoStatus; + GpuName = gpuName; + } + } +} diff --git a/src/Ryujinx.Headless.SDL2/Vulkan/VulkanWindow.cs b/src/Ryujinx.Headless.SDL2/Vulkan/VulkanWindow.cs new file mode 100644 index 00000000..172b7685 --- /dev/null +++ b/src/Ryujinx.Headless.SDL2/Vulkan/VulkanWindow.cs @@ -0,0 +1,104 @@ +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Logging; +using Ryujinx.Input.HLE; +using Ryujinx.SDL2.Common; +using System; +using System.Runtime.InteropServices; +using static SDL2.SDL; + +namespace Ryujinx.Headless.SDL2.Vulkan +{ + class VulkanWindow : WindowBase + { + private GraphicsDebugLevel _glLogLevel; + + public VulkanWindow( + InputManager inputManager, + GraphicsDebugLevel glLogLevel, + AspectRatio aspectRatio, + bool enableMouse, + HideCursor hideCursor) + : base(inputManager, glLogLevel, aspectRatio, enableMouse, hideCursor) + { + _glLogLevel = glLogLevel; + } + + public override SDL_WindowFlags GetWindowFlags() => SDL_WindowFlags.SDL_WINDOW_VULKAN; + + protected override void InitializeWindowRenderer() { } + + protected override void InitializeRenderer() + { + Renderer?.Window.SetSize(DefaultWidth, DefaultHeight); + MouseDriver.SetClientSize(DefaultWidth, DefaultHeight); + } + + private void BasicInvoke(Action action) + { + action(); + } + + public unsafe IntPtr CreateWindowSurface(IntPtr instance) + { + ulong surfaceHandle = 0; + + Action createSurface = () => + { + if (SDL_Vulkan_CreateSurface(WindowHandle, instance, out surfaceHandle) == SDL_bool.SDL_FALSE) + { + string errorMessage = $"SDL_Vulkan_CreateSurface failed with error \"{SDL_GetError()}\""; + + Logger.Error?.Print(LogClass.Application, errorMessage); + + throw new Exception(errorMessage); + } + }; + + if (SDL2Driver.MainThreadDispatcher != null) + { + SDL2Driver.MainThreadDispatcher(createSurface); + } + else + { + createSurface(); + } + + return (IntPtr)surfaceHandle; + } + + public unsafe string[] GetRequiredInstanceExtensions() + { + if (SDL_Vulkan_GetInstanceExtensions(WindowHandle, out uint extensionsCount, IntPtr.Zero) == SDL_bool.SDL_TRUE) + { + IntPtr[] rawExtensions = new IntPtr[(int)extensionsCount]; + string[] extensions = new string[(int)extensionsCount]; + + fixed (IntPtr* rawExtensionsPtr = rawExtensions) + { + if (SDL_Vulkan_GetInstanceExtensions(WindowHandle, out extensionsCount, (IntPtr)rawExtensionsPtr) == SDL_bool.SDL_TRUE) + { + for (int i = 0; i < extensions.Length; i++) + { + extensions[i] = Marshal.PtrToStringUTF8(rawExtensions[i]); + } + + return extensions; + } + } + } + + string errorMessage = $"SDL_Vulkan_GetInstanceExtensions failed with error \"{SDL_GetError()}\""; + + Logger.Error?.Print(LogClass.Application, errorMessage); + + throw new Exception(errorMessage); + } + + protected override void FinalizeWindowRenderer() + { + Device.DisposeGpu(); + } + + protected override void SwapBuffers() { } + } +} \ No newline at end of file diff --git a/src/Ryujinx.Headless.SDL2/WindowBase.cs b/src/Ryujinx.Headless.SDL2/WindowBase.cs new file mode 100644 index 00000000..e3371042 --- /dev/null +++ b/src/Ryujinx.Headless.SDL2/WindowBase.cs @@ -0,0 +1,499 @@ +using ARMeilleure.Translation; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Configuration.Hid; +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using Ryujinx.Graphics.GAL.Multithreading; +using Ryujinx.HLE.HOS.Applets; +using Ryujinx.HLE.HOS.Services.Am.AppletOE.ApplicationProxyService.ApplicationProxy.Types; +using Ryujinx.HLE.Ui; +using Ryujinx.Input; +using Ryujinx.Input.HLE; +using Ryujinx.SDL2.Common; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using static SDL2.SDL; +using Switch = Ryujinx.HLE.Switch; + +namespace Ryujinx.Headless.SDL2 +{ + abstract partial class WindowBase : IHostUiHandler, IDisposable + { + protected const int DefaultWidth = 1280; + protected const int DefaultHeight = 720; + private const SDL_WindowFlags DefaultFlags = SDL_WindowFlags.SDL_WINDOW_ALLOW_HIGHDPI | SDL_WindowFlags.SDL_WINDOW_RESIZABLE | SDL_WindowFlags.SDL_WINDOW_INPUT_FOCUS | SDL_WindowFlags.SDL_WINDOW_SHOWN; + private const int TargetFps = 60; + + private static ConcurrentQueue MainThreadActions = new ConcurrentQueue(); + + [LibraryImport("SDL2")] + // TODO: Remove this as soon as SDL2-CS was updated to expose this method publicly + private static partial IntPtr SDL_LoadBMP_RW(IntPtr src, int freesrc); + + public static void QueueMainThreadAction(Action action) + { + MainThreadActions.Enqueue(action); + } + + public NpadManager NpadManager { get; } + public TouchScreenManager TouchScreenManager { get; } + public Switch Device { get; private set; } + public IRenderer Renderer { get; private set; } + + public event EventHandler StatusUpdatedEvent; + + protected IntPtr WindowHandle { get; set; } + + public IHostUiTheme HostUiTheme { get; } + public int Width { get; private set; } + public int Height { get; private set; } + + protected SDL2MouseDriver MouseDriver; + private InputManager _inputManager; + private IKeyboard _keyboardInterface; + private GraphicsDebugLevel _glLogLevel; + private readonly Stopwatch _chrono; + private readonly long _ticksPerFrame; + private readonly CancellationTokenSource _gpuCancellationTokenSource; + private readonly ManualResetEvent _exitEvent; + + private long _ticks; + private bool _isActive; + private bool _isStopped; + private uint _windowId; + + private string _gpuVendorName; + + private AspectRatio _aspectRatio; + private bool _enableMouse; + + public WindowBase( + InputManager inputManager, + GraphicsDebugLevel glLogLevel, + AspectRatio aspectRatio, + bool enableMouse, + HideCursor hideCursor) + { + MouseDriver = new SDL2MouseDriver(hideCursor); + _inputManager = inputManager; + _inputManager.SetMouseDriver(MouseDriver); + NpadManager = _inputManager.CreateNpadManager(); + TouchScreenManager = _inputManager.CreateTouchScreenManager(); + _keyboardInterface = (IKeyboard)_inputManager.KeyboardDriver.GetGamepad("0"); + _glLogLevel = glLogLevel; + _chrono = new Stopwatch(); + _ticksPerFrame = Stopwatch.Frequency / TargetFps; + _gpuCancellationTokenSource = new CancellationTokenSource(); + _exitEvent = new ManualResetEvent(false); + _aspectRatio = aspectRatio; + _enableMouse = enableMouse; + HostUiTheme = new HeadlessHostUiTheme(); + + SDL2Driver.Instance.Initialize(); + } + + public void Initialize(Switch device, List inputConfigs, bool enableKeyboard, bool enableMouse) + { + Device = device; + + IRenderer renderer = Device.Gpu.Renderer; + + if (renderer is ThreadedRenderer tr) + { + renderer = tr.BaseRenderer; + } + + Renderer = renderer; + + NpadManager.Initialize(device, inputConfigs, enableKeyboard, enableMouse); + TouchScreenManager.Initialize(device); + } + + private void SetWindowIcon() + { + Stream iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("Ryujinx.Headless.SDL2.Ryujinx.bmp"); + byte[] iconBytes = new byte[iconStream!.Length]; + + if (iconStream.Read(iconBytes, 0, iconBytes.Length) != iconBytes.Length) + { + Logger.Error?.Print(LogClass.Application, "Failed to read icon to byte array."); + iconStream.Close(); + + return; + } + + iconStream.Close(); + + unsafe + { + fixed (byte* iconPtr = iconBytes) + { + IntPtr rwOpsStruct = SDL_RWFromConstMem((IntPtr)iconPtr, iconBytes.Length); + IntPtr iconHandle = SDL_LoadBMP_RW(rwOpsStruct, 1); + + SDL_SetWindowIcon(WindowHandle, iconHandle); + SDL_FreeSurface(iconHandle); + } + } + } + + private void InitializeWindow() + { + var activeProcess = Device.Processes.ActiveApplication; + var nacp = activeProcess.ApplicationControlProperties; + int desiredLanguage = (int)Device.System.State.DesiredTitleLanguage; + + string titleNameSection = string.IsNullOrWhiteSpace(nacp.Title[desiredLanguage].NameString.ToString()) ? string.Empty : $" - {nacp.Title[desiredLanguage].NameString.ToString()}"; + string titleVersionSection = string.IsNullOrWhiteSpace(nacp.DisplayVersionString.ToString()) ? string.Empty : $" v{nacp.DisplayVersionString.ToString()}"; + string titleIdSection = string.IsNullOrWhiteSpace(activeProcess.ProgramIdText) ? string.Empty : $" ({activeProcess.ProgramIdText.ToUpper()})"; + string titleArchSection = activeProcess.Is64Bit ? " (64-bit)" : " (32-bit)"; + + WindowHandle = SDL_CreateWindow($"Ryujinx {Program.Version}{titleNameSection}{titleVersionSection}{titleIdSection}{titleArchSection}", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, DefaultWidth, DefaultHeight, DefaultFlags | GetWindowFlags()); + + if (WindowHandle == IntPtr.Zero) + { + string errorMessage = $"SDL_CreateWindow failed with error \"{SDL_GetError()}\""; + + Logger.Error?.Print(LogClass.Application, errorMessage); + + throw new Exception(errorMessage); + } + + SetWindowIcon(); + + _windowId = SDL_GetWindowID(WindowHandle); + SDL2Driver.Instance.RegisterWindow(_windowId, HandleWindowEvent); + + Width = DefaultWidth; + Height = DefaultHeight; + } + + private void HandleWindowEvent(SDL_Event evnt) + { + if (evnt.type == SDL_EventType.SDL_WINDOWEVENT) + { + switch (evnt.window.windowEvent) + { + case SDL_WindowEventID.SDL_WINDOWEVENT_SIZE_CHANGED: + Width = evnt.window.data1; + Height = evnt.window.data2; + Renderer?.Window.SetSize(Width, Height); + MouseDriver.SetClientSize(Width, Height); + break; + + case SDL_WindowEventID.SDL_WINDOWEVENT_CLOSE: + Exit(); + break; + + default: + break; + } + } + else + { + MouseDriver.Update(evnt); + } + } + + protected abstract void InitializeWindowRenderer(); + + protected abstract void InitializeRenderer(); + + protected abstract void FinalizeWindowRenderer(); + + protected abstract void SwapBuffers(); + + public abstract SDL_WindowFlags GetWindowFlags(); + + private string GetGpuVendorName() + { + return Renderer.GetHardwareInfo().GpuVendor; + } + + public void Render() + { + InitializeWindowRenderer(); + + Device.Gpu.Renderer.Initialize(_glLogLevel); + + InitializeRenderer(); + + _gpuVendorName = GetGpuVendorName(); + + Device.Gpu.Renderer.RunLoop(() => + { + Device.Gpu.SetGpuThread(); + Device.Gpu.InitializeShaderCache(_gpuCancellationTokenSource.Token); + Translator.IsReadyForTranslation.Set(); + + while (_isActive) + { + if (_isStopped) + { + return; + } + + _ticks += _chrono.ElapsedTicks; + + _chrono.Restart(); + + if (Device.WaitFifo()) + { + Device.Statistics.RecordFifoStart(); + Device.ProcessFrame(); + Device.Statistics.RecordFifoEnd(); + } + + while (Device.ConsumeFrameAvailable()) + { + Device.PresentFrame(SwapBuffers); + } + + if (_ticks >= _ticksPerFrame) + { + string dockedMode = Device.System.State.DockedMode ? "Docked" : "Handheld"; + float scale = Graphics.Gpu.GraphicsConfig.ResScale; + if (scale != 1) + { + dockedMode += $" ({scale}x)"; + } + + StatusUpdatedEvent?.Invoke(this, new StatusUpdatedEventArgs( + Device.EnableDeviceVsync, + dockedMode, + Device.Configuration.AspectRatio.ToText(), + $"Game: {Device.Statistics.GetGameFrameRate():00.00} FPS ({Device.Statistics.GetGameFrameTime():00.00} ms)", + $"FIFO: {Device.Statistics.GetFifoPercent():0.00} %", + $"GPU: {_gpuVendorName}")); + + _ticks = Math.Min(_ticks - _ticksPerFrame, _ticksPerFrame); + } + } + }); + + FinalizeWindowRenderer(); + } + + public void Exit() + { + TouchScreenManager?.Dispose(); + NpadManager?.Dispose(); + + if (_isStopped) + { + return; + } + + _gpuCancellationTokenSource.Cancel(); + + _isStopped = true; + _isActive = false; + + _exitEvent.WaitOne(); + _exitEvent.Dispose(); + } + + public void ProcessMainThreadQueue() + { + while (MainThreadActions.TryDequeue(out Action action)) + { + action(); + } + } + + public void MainLoop() + { + while (_isActive) + { + UpdateFrame(); + + SDL_PumpEvents(); + + ProcessMainThreadQueue(); + + // Polling becomes expensive if it's not slept + Thread.Sleep(1); + } + + _exitEvent.Set(); + } + + private void NVStutterWorkaround() + { + while (_isActive) + { + // When NVIDIA Threaded Optimization is on, the driver will snapshot all threads in the system whenever the application creates any new ones. + // The ThreadPool has something called a "GateThread" which terminates itself after some inactivity. + // However, it immediately starts up again, since the rules regarding when to terminate and when to start differ. + // This creates a new thread every second or so. + // The main problem with this is that the thread snapshot can take 70ms, is on the OpenGL thread and will delay rendering any graphics. + // This is a little over budget on a frame time of 16ms, so creates a large stutter. + // The solution is to keep the ThreadPool active so that it never has a reason to terminate the GateThread. + + // TODO: This should be removed when the issue with the GateThread is resolved. + + ThreadPool.QueueUserWorkItem((state) => { }); + Thread.Sleep(300); + } + } + + private bool UpdateFrame() + { + if (!_isActive) + { + return true; + } + + if (_isStopped) + { + return false; + } + + NpadManager.Update(); + + // Touchscreen + bool hasTouch = false; + + // Get screen touch position + if (!_enableMouse) + { + hasTouch = TouchScreenManager.Update(true, (_inputManager.MouseDriver as SDL2MouseDriver).IsButtonPressed(MouseButton.Button1), _aspectRatio.ToFloat()); + } + + if (!hasTouch) + { + TouchScreenManager.Update(false); + } + + Device.Hid.DebugPad.Update(); + + // TODO: Replace this with MouseDriver.CheckIdle() when mouse motion events are received on every supported platform. + MouseDriver.UpdatePosition(); + + return true; + } + + public void Execute() + { + _chrono.Restart(); + _isActive = true; + + InitializeWindow(); + + Thread renderLoopThread = new Thread(Render) + { + Name = "GUI.RenderLoop" + }; + renderLoopThread.Start(); + + Thread nvStutterWorkaround = null; + if (Renderer is Graphics.OpenGL.OpenGLRenderer) + { + nvStutterWorkaround = new Thread(NVStutterWorkaround) + { + Name = "GUI.NVStutterWorkaround" + }; + nvStutterWorkaround.Start(); + } + + MainLoop(); + + renderLoopThread.Join(); + nvStutterWorkaround?.Join(); + + Exit(); + } + + public bool DisplayInputDialog(SoftwareKeyboardUiArgs args, out string userText) + { + // SDL2 doesn't support input dialogs + userText = "Ryujinx"; + + return true; + } + + public bool DisplayMessageDialog(string title, string message) + { + SDL_ShowSimpleMessageBox(SDL_MessageBoxFlags.SDL_MESSAGEBOX_INFORMATION, title, message, WindowHandle); + + return true; + } + + public bool DisplayMessageDialog(ControllerAppletUiArgs args) + { + string playerCount = args.PlayerCountMin == args.PlayerCountMax ? $"exactly {args.PlayerCountMin}" : $"{args.PlayerCountMin}-{args.PlayerCountMax}"; + + string message = $"Application requests {playerCount} player(s) with:\n\n" + + $"TYPES: {args.SupportedStyles}\n\n" + + $"PLAYERS: {string.Join(", ", args.SupportedPlayers)}\n\n" + + (args.IsDocked ? "Docked mode set. Handheld is also invalid.\n\n" : "") + + "Please reconfigure Input now and then press OK."; + + return DisplayMessageDialog("Controller Applet", message); + } + + public IDynamicTextInputHandler CreateDynamicTextInputHandler() + { + return new HeadlessDynamicTextInputHandler(); + } + + public void ExecuteProgram(Switch device, ProgramSpecifyKind kind, ulong value) + { + device.Configuration.UserChannelPersistence.ExecuteProgram(kind, value); + + Exit(); + } + + public bool DisplayErrorAppletDialog(string title, string message, string[] buttonsText) + { + SDL_MessageBoxData data = new SDL_MessageBoxData + { + title = title, + message = message, + buttons = new SDL_MessageBoxButtonData[buttonsText.Length], + numbuttons = buttonsText.Length, + window = WindowHandle + }; + + for (int i = 0; i < buttonsText.Length; i++) + { + data.buttons[i] = new SDL_MessageBoxButtonData + { + buttonid = i, + text = buttonsText[i] + }; + } + + SDL_ShowMessageBox(ref data, out int _); + + return true; + } + + public void Dispose() + { + Dispose(true); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _isActive = false; + TouchScreenManager?.Dispose(); + NpadManager.Dispose(); + + SDL2Driver.Instance.UnregisterWindow(_windowId); + + SDL_DestroyWindow(WindowHandle); + + SDL2Driver.Instance.Dispose(); + } + } + } +} \ No newline at end of file -- cgit v1.2.3