From 31cbd09a75a9d5f4814c3907a060e0961eb2bb15 Mon Sep 17 00:00:00 2001
From: Mary <me@thog.eu>
Date: Tue, 6 Jul 2021 22:08:44 +0200
Subject: [PATCH] frontend: Add a SDL2 headless window (#2310)

---
 .github/workflows/build.yml                   |  13 +-
 Ryujinx.HLE/HLEConfiguration.cs               |   2 +-
 Ryujinx.Headless.SDL2/OpenGL/OpenGLWindow.cs  | 167 ++++++
 Ryujinx.Headless.SDL2/Options.cs              | 168 ++++++
 Ryujinx.Headless.SDL2/Program.cs              | 561 ++++++++++++++++++
 .../Ryujinx.Headless.SDL2.csproj              |  54 ++
 Ryujinx.Headless.SDL2/SDL2Mouse.cs            |  90 +++
 Ryujinx.Headless.SDL2/SDL2MouseDriver.cs      | 104 ++++
 .../StatusUpdatedEventArgs.cs                 |  24 +
 Ryujinx.Headless.SDL2/WindowBase.cs           | 393 ++++++++++++
 Ryujinx.SDL2.Common/SDL2Driver.cs             |  24 +-
 Ryujinx.sln                                   |  10 +-
 appveyor.yml                                  |  11 +
 13 files changed, 1615 insertions(+), 6 deletions(-)
 create mode 100644 Ryujinx.Headless.SDL2/OpenGL/OpenGLWindow.cs
 create mode 100644 Ryujinx.Headless.SDL2/Options.cs
 create mode 100644 Ryujinx.Headless.SDL2/Program.cs
 create mode 100644 Ryujinx.Headless.SDL2/Ryujinx.Headless.SDL2.csproj
 create mode 100644 Ryujinx.Headless.SDL2/SDL2Mouse.cs
 create mode 100644 Ryujinx.Headless.SDL2/SDL2MouseDriver.cs
 create mode 100644 Ryujinx.Headless.SDL2/StatusUpdatedEventArgs.cs
 create mode 100644 Ryujinx.Headless.SDL2/WindowBase.cs

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 672835d86a..fcb8c2aff2 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -62,12 +62,21 @@ jobs:
         run: dotnet build -c "${{ matrix.configuration }}" /p:Version="1.0.0" /p:SourceRevisionId="${{ steps.git_short_hash.outputs.result }}" /p:ExtraDefineConstants=DISABLE_UPDATER
       - name: Test
         run: dotnet test -c "${{ matrix.configuration }}"
-      - name: Publish
+      - name: Publish Ryujinx
         run: dotnet publish -c "${{ matrix.configuration }}" -r "${{ matrix.DOTNET_RUNTIME_IDENTIFIER }}" -o ./publish /p:Version="1.0.0" /p:DebugType=embedded /p:SourceRevisionId="${{ steps.git_short_hash.outputs.result }}" /p:ExtraDefineConstants=DISABLE_UPDATER Ryujinx
         if: github.event_name == 'pull_request'
-      - name: Upload artifacts
+      - name: Publish Ryujinx.Headless.SDL2
+        run: dotnet publish -c "${{ matrix.configuration }}" -r "${{ matrix.DOTNET_RUNTIME_IDENTIFIER }}" -o ./publish_sdl2_headless /p:Version="1.0.0" /p:DebugType=embedded /p:SourceRevisionId="${{ steps.git_short_hash.outputs.result }}" /p:ExtraDefineConstants=DISABLE_UPDATER Ryujinx.Headless.SDL2
+        if: github.event_name == 'pull_request'
+      - name: Upload Ryujinx artifact
         uses: actions/upload-artifact@v2
         with:
           name: ryujinx-${{ matrix.configuration }}-1.0.0+${{ steps.git_short_hash.outputs.result }}-${{ matrix.RELEASE_ZIP_OS_NAME }}
           path: publish
         if: github.event_name == 'pull_request'
+      - name: Upload Ryujinx.Headless.SDL2 artifact
+        uses: actions/upload-artifact@v2
+        with:
+          name: ryujinx-headless-sdl2-${{ matrix.configuration }}-1.0.0+${{ steps.git_short_hash.outputs.result }}-${{ matrix.RELEASE_ZIP_OS_NAME }}
+          path: publish_sdl2_headless
+        if: github.event_name == 'pull_request'
diff --git a/Ryujinx.HLE/HLEConfiguration.cs b/Ryujinx.HLE/HLEConfiguration.cs
index 72205827db..ba35b92c17 100644
--- a/Ryujinx.HLE/HLEConfiguration.cs
+++ b/Ryujinx.HLE/HLEConfiguration.cs
@@ -133,7 +133,7 @@ namespace Ryujinx.HLE
         /// <summary>
         /// Aspect Ratio applied to the renderer window by the SurfaceFlinger service.
         /// </summary>
-        public AspectRatio AspectRatio { internal get; set; }
+        public AspectRatio AspectRatio { get; set; }
 
         /// <summary>
         /// An action called when HLE force a refresh of output after docked mode changed.
diff --git a/Ryujinx.Headless.SDL2/OpenGL/OpenGLWindow.cs b/Ryujinx.Headless.SDL2/OpenGL/OpenGLWindow.cs
new file mode 100644
index 0000000000..df0dd1e718
--- /dev/null
+++ b/Ryujinx.Headless.SDL2/OpenGL/OpenGLWindow.cs
@@ -0,0 +1,167 @@
+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) : base(inputManager, glLogLevel, aspectRatio, enableMouse)
+        {
+            _glLogLevel = glLogLevel;
+        }
+
+        protected override string GetGpuVendorName()
+        {
+            return ((Renderer)Renderer).GpuVendor;
+        }
+
+        public override SDL_WindowFlags GetWindowFlags() => SDL_WindowFlags.SDL_WINDOW_OPENGL;
+
+        protected override void InitializeRenderer()
+        {
+            // 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.
+            ((Renderer)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 FinalizeRenderer()
+        {
+            // 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);
+        }
+    }
+}
diff --git a/Ryujinx.Headless.SDL2/Options.cs b/Ryujinx.Headless.SDL2/Options.cs
new file mode 100644
index 0000000000..a396ff403f
--- /dev/null
+++ b/Ryujinx.Headless.SDL2/Options.cs
@@ -0,0 +1,168 @@
+using CommandLine;
+using Ryujinx.Common.Configuration;
+using Ryujinx.HLE.HOS.SystemState;
+
+namespace Ryujinx.Headless.SDL2
+{
+    public class Options
+    {
+        // 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 5.")]
+        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("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("enable-ptc", Required = false, Default = true, HelpText = "Enables profiled translation cache persistency.")]
+        public bool? EnablePtc { get; set; }
+
+        [Option("enable-fs-integrity-checks", Required = false, Default = true, HelpText = "Enables integrity checks on Game content files.")]
+        public bool? EnableFsIntegrityChecks { 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("enable-vsync", Required = false, Default = true, HelpText = "Enables Vertical Sync.")]
+        public bool? EnableVsync { get; set; }
+
+        [Option("enable-shader-cache", Required = false, Default = true, HelpText = "Enables Shader cache.")]
+        public bool? EnableShaderCache { get; set; }
+
+        [Option("enable-docked-mode", Required = false, Default = true, HelpText = "Enables Docked Mode.")]
+        public bool? EnableDockedMode { get; set; }
+
+        [Option("system-language", Required = false, Default = SystemLanguage.AmericanEnglish, HelpText = "Change System Language.")]
+        public SystemLanguage SystemLanguage { get; set; }
+
+        [Option("system-language", 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; }
+
+        // Logging
+
+        [Option("enable-file-logging", Required = false, Default = false, HelpText = "Enables logging to a file on disk.")]
+        public bool? EnableFileLog { get; set; }
+
+        [Option("enable-debug-logs", Required = false, Default = false, HelpText = "Enables printing debug log messages.")]
+        public bool? LoggingEnableDebug { get; set; }
+
+        [Option("enable-stub-logs", Required = false, Default = true, HelpText = "Enables printing stub log messages.")]
+        public bool? LoggingEnableStub { get; set; }
+
+        [Option("enable-info-logs", Required = false, Default = true, HelpText = "Enables printing info log messages.")]
+        public bool? LoggingEnableInfo { get; set; }
+
+        [Option("enable-warning-logs", Required = false, Default = true, HelpText = "Enables printing warning log messages.")]
+        public bool? LoggingEnableWarning { get; set; }
+
+        [Option("enable-warning-logs", Required = false, Default = true, HelpText = "Enables printing error log messages.")]
+        public bool? LoggingEnableError { get; set; }
+
+        [Option("enable-guest-logs", Required = false, Default = true, HelpText = "Enables printing guest log messages.")]
+        public bool? LoggingEnableGuest { 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("graphics-shaders-dump-path", Required = false, HelpText = "Dumps shaders in this local directory. (Developer only)")]
+        public string GraphicsShadersDumpPath { get; set; }
+
+        // Hacks
+
+        [Option("expand-ram", Required = false, Default = false, HelpText = "Expands the RAM amount on the emulated system from 4GB to 6GB.")]
+        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; }
+    }
+}
diff --git a/Ryujinx.Headless.SDL2/Program.cs b/Ryujinx.Headless.SDL2/Program.cs
new file mode 100644
index 0000000000..2884f38a3e
--- /dev/null
+++ b/Ryujinx.Headless.SDL2/Program.cs
@@ -0,0 +1,561 @@
+using ARMeilleure.Translation;
+using ARMeilleure.Translation.PTC;
+using CommandLine;
+using Ryujinx.Audio.Backends.SDL2;
+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.System;
+using Ryujinx.Common.Utilities;
+using Ryujinx.Graphics.Gpu;
+using Ryujinx.Graphics.Gpu.Shader;
+using Ryujinx.Graphics.OpenGL;
+using Ryujinx.Headless.SDL2.OpenGL;
+using Ryujinx.HLE;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.HLE.FileSystem.Content;
+using Ryujinx.HLE.HOS;
+using Ryujinx.HLE.HOS.Services.Account.Acc;
+using Ryujinx.Input;
+using Ryujinx.Input.HLE;
+using Ryujinx.Input.SDL2;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Runtime.InteropServices;
+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 UserChannelPersistence _userChannelPersistence;
+        private static InputManager _inputManager;
+        private static Switch _emulationContext;
+        private static WindowBase _window;
+        private static WindowsMultimediaTimerResolution _windowsMultimediaTimerResolution;
+        private static List<InputConfig> _inputConfiguration;
+        private static bool _enableKeyboard;
+        private static bool _enableMouse;
+
+        static void Main(string[] args)
+        {
+            Version = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion;
+
+            Console.Title = $"Ryujinx Console {Version} (Headless SDL2)";
+
+            AppDataManager.Initialize(null);
+
+            _virtualFileSystem = VirtualFileSystem.CreateInstance();
+            _contentManager = new ContentManager(_virtualFileSystem);
+            _accountManager = new AccountManager(_virtualFileSystem);
+            _userChannelPersistence = new UserChannelPersistence();
+
+            _inputManager = new InputManager(new SDL2KeyboardDriver(), new SDL2GamepadDriver());
+
+            GraphicsConfig.EnableShaderCache = true;
+
+            Parser.Default.ParseArguments<Options>(args)
+            .WithParsed(options => Load(options))
+            .WithNotParsed(errors => errors.Output());
+
+            _inputManager.Dispose();
+        }
+
+        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<Key>
+                        {
+                            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<Key>
+                        {
+                            StickUp      = Key.W,
+                            StickDown    = Key.S,
+                            StickLeft    = Key.A,
+                            StickRight   = Key.D,
+                            StickButton  = Key.F,
+                        },
+
+                        RightJoycon      = new RightJoyconCommonConfig<Key>
+                        {
+                            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<Key>
+                        {
+                            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,
+                        TriggerThreshold = 0.5f,
+                        LeftJoycon = new LeftJoyconCommonConfig<ConfigGamepadInputId>
+                        {
+                            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<ConfigGamepadInputId, ConfigStickInputId>
+                        {
+                            Joystick     = ConfigStickInputId.Left,
+                            StickButton  = ConfigGamepadInputId.LeftStick,
+                            InvertStickX = false,
+                            InvertStickY = false,
+                        },
+
+                        RightJoycon = new RightJoyconCommonConfig<ConfigGamepadInputId>
+                        {
+                            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<ConfigGamepadInputId, ConfigStickInputId>
+                        {
+                            Joystick     = ConfigStickInputId.Right,
+                            StickButton  = ConfigGamepadInputId.RightStick,
+                            InvertStickX = false,
+                            InvertStickY = false,
+                        },
+
+                        Motion = new StandardMotionConfigController
+                        {
+                            MotionBackend = MotionInputBackendType.GamepadDriver,
+                            EnableMotion = true,
+                            Sensitivity  = 100,
+                            GyroDeadzone = 1,
+                        }
+                    };
+                }
+            }
+            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
+                {
+                    using (Stream stream = File.OpenRead(path))
+                    {
+                        config = JsonHelper.Deserialize<InputConfig>(stream);
+                    }
+                }
+                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}\"");
+
+            return config;
+        }
+
+        static void Load(Options option)
+        {
+            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<InputConfig>();
+            _enableKeyboard = (bool)option.EnableKeyboard;
+            _enableMouse = (bool)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, (bool)option.LoggingEnableDebug);
+            Logger.SetEnable(LogLevel.Stub, (bool)option.LoggingEnableStub);
+            Logger.SetEnable(LogLevel.Info, (bool)option.LoggingEnableInfo);
+            Logger.SetEnable(LogLevel.Warning, (bool)option.LoggingEnableWarning);
+            Logger.SetEnable(LogLevel.Error, (bool)option.LoggingEnableError);
+            Logger.SetEnable(LogLevel.Guest, (bool)option.LoggingEnableGuest);
+            Logger.SetEnable(LogLevel.AccessLog, (bool)option.LoggingEnableFsAccessLog);
+
+            if ((bool)option.EnableFileLog)
+            {
+                Logger.AddTarget(new AsyncLogTargetWrapper(
+                    new FileLogTarget(AppDomain.CurrentDomain.BaseDirectory, "file"),
+                    1000,
+                    AsyncLogTargetOverflowAction.Block
+                ));
+            }
+
+            // Setup graphics configuration
+            GraphicsConfig.EnableShaderCache = (bool)option.EnableShaderCache;
+            GraphicsConfig.ResScale = option.ResScale;
+            GraphicsConfig.MaxAnisotropy = option.MaxAnisotropy;
+            GraphicsConfig.ShadersDumpPath = option.GraphicsShadersDumpPath;
+
+            while (true)
+            {
+                LoadApplication(option);
+
+                if (_userChannelPersistence.PreviousIndex == -1 || !_userChannelPersistence.ShouldRestart)
+                {
+                    break;
+                }
+
+                _userChannelPersistence.ShouldRestart = false;
+            }
+        }
+
+        private static void SetupProgressHandler()
+        {
+            Ptc.PtcStateChanged -= ProgressHandler;
+            Ptc.PtcStateChanged += ProgressHandler;
+
+            _emulationContext.Gpu.ShaderCacheStateChanged -= ProgressHandler;
+            _emulationContext.Gpu.ShaderCacheStateChanged += ProgressHandler;
+        }
+
+        private static void ProgressHandler<T>(T state, int current, int total) where T : Enum
+        {
+            string label;
+
+            switch (state)
+            {
+                case PtcLoadingState 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 Switch InitializeEmulationContext(WindowBase window, Options options)
+        {
+            HLEConfiguration configuration = new HLEConfiguration(_virtualFileSystem,
+                                                                  _contentManager,
+                                                                  _accountManager,
+                                                                  _userChannelPersistence,
+                                                                  new Renderer(),
+                                                                  new SDL2HardwareDeviceDriver(),
+                                                                  (bool)options.ExpandRam ? MemoryConfiguration.MemoryConfiguration6GB : MemoryConfiguration.MemoryConfiguration4GB,
+                                                                  window,
+                                                                  options.SystemLanguage,
+                                                                  options.SystemRegion,
+                                                                  (bool)options.EnableVsync,
+                                                                  (bool)options.EnableDockedMode,
+                                                                  (bool)options.EnablePtc,
+                                                                  (bool)options.EnableFsIntegrityChecks ? LibHac.FsSystem.IntegrityCheckLevel.ErrorOnInvalid : LibHac.FsSystem.IntegrityCheckLevel.None,
+                                                                  options.FsGlobalAccessLogMode,
+                                                                  options.SystemTimeOffset,
+                                                                  options.SystemTimeZone,
+                                                                  options.MemoryManagerMode,
+                                                                  (bool)options.IgnoreMissingServices,
+                                                                  options.AspectRatio);
+
+            return new Switch(configuration);
+        }
+
+        private static void ExecutionEntrypoint()
+        {
+            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+            {
+                _windowsMultimediaTimerResolution = new WindowsMultimediaTimerResolution(1);
+            }
+
+            DisplaySleep.Prevent();
+
+            _window.Initialize(_emulationContext, _inputConfiguration, _enableKeyboard, _enableMouse);
+
+            _window.Execute();
+
+            Ptc.Close();
+            PtcProfiler.Stop();
+
+            _emulationContext.Dispose();
+            _window.Dispose();
+
+            _windowsMultimediaTimerResolution?.Dispose();
+            _windowsMultimediaTimerResolution = null;
+        }
+
+        private static bool LoadApplication(Options options)
+        {
+            string path = options.InputPath;
+
+            Logger.RestartTime();
+
+            _window = new OpenGLWindow(_inputManager, options.LoggingGraphicsDebugLevel, options.AspectRatio, (bool)options.EnableMouse);
+            _emulationContext = InitializeEmulationContext(_window, options);
+
+            SetupProgressHandler();
+
+            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.");
+                    _emulationContext.LoadCart(path, romFsFiles[0]);
+                }
+                else
+                {
+                    Logger.Info?.Print(LogClass.Application, "Loading as cart WITHOUT RomFS.");
+                    _emulationContext.LoadCart(path);
+                }
+            }
+            else if (File.Exists(path))
+            {
+                switch (Path.GetExtension(path).ToLowerInvariant())
+                {
+                    case ".xci":
+                        Logger.Info?.Print(LogClass.Application, "Loading as XCI.");
+                        _emulationContext.LoadXci(path);
+                        break;
+                    case ".nca":
+                        Logger.Info?.Print(LogClass.Application, "Loading as NCA.");
+                        _emulationContext.LoadNca(path);
+                        break;
+                    case ".nsp":
+                    case ".pfs0":
+                        Logger.Info?.Print(LogClass.Application, "Loading as NSP.");
+                        _emulationContext.LoadNsp(path);
+                        break;
+                    default:
+                        Logger.Info?.Print(LogClass.Application, "Loading as Homebrew.");
+                        try
+                        {
+                            _emulationContext.LoadProgram(path);
+                        }
+                        catch (ArgumentOutOfRangeException)
+                        {
+                            Logger.Error?.Print(LogClass.Application, "The specified file is not supported by Ryujinx.");
+
+                            return false;
+                        }
+                        break;
+                }
+            }
+            else
+            {
+                Logger.Warning?.Print(LogClass.Application, "Please specify a valid XCI/NCA/NSP/PFS0/NRO file.");
+
+                _emulationContext.Dispose();
+
+                return false;
+            }
+
+            Translator.IsReadyForTranslation.Reset();
+
+            Thread windowThread = new Thread(() =>
+            {
+                ExecutionEntrypoint();
+            })
+            {
+                Name = "GUI.WindowThread"
+            };
+
+            windowThread.Start();
+            windowThread.Join();
+
+            return true;
+        }
+    }
+}
diff --git a/Ryujinx.Headless.SDL2/Ryujinx.Headless.SDL2.csproj b/Ryujinx.Headless.SDL2/Ryujinx.Headless.SDL2.csproj
new file mode 100644
index 0000000000..a40e23cb57
--- /dev/null
+++ b/Ryujinx.Headless.SDL2/Ryujinx.Headless.SDL2.csproj
@@ -0,0 +1,54 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net5.0</TargetFramework>
+    <RuntimeIdentifiers>win-x64;osx-x64;linux-x64</RuntimeIdentifiers>
+    <OutputType>Exe</OutputType>
+    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+    <Version>1.0.0-dirty</Version>
+    <TieredCompilation>false</TieredCompilation>
+    <TieredCompilationQuickJit>false</TieredCompilationQuickJit>
+    <DefineConstants Condition=" '$(ExtraDefineConstants)' != '' ">$(DefineConstants);$(ExtraDefineConstants)</DefineConstants>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Ryujinx.Graphics.Nvdec.Dependencies" Version="4.4.0-build7" Condition="'$(RuntimeIdentifier)' != 'linux-x64' AND '$(RuntimeIdentifier)' != 'osx-x64'" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\Ryujinx.Input\Ryujinx.Input.csproj" />
+    <ProjectReference Include="..\Ryujinx.Input.SDL2\Ryujinx.Input.SDL2.csproj" />
+    <ProjectReference Include="..\Ryujinx.Audio.Backends.SDL2\Ryujinx.Audio.Backends.SDL2.csproj" />
+    <ProjectReference Include="..\Ryujinx.Common\Ryujinx.Common.csproj" />
+    <ProjectReference Include="..\Ryujinx.HLE\Ryujinx.HLE.csproj" />
+    <ProjectReference Include="..\ARMeilleure\ARMeilleure.csproj" />
+    <ProjectReference Include="..\Ryujinx.Graphics.OpenGL\Ryujinx.Graphics.OpenGL.csproj" />
+    <ProjectReference Include="..\Ryujinx.Graphics.Gpu\Ryujinx.Graphics.Gpu.csproj" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <PackageReference Include="CommandLineParser" Version="2.8.0" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <None Update="..\Ryujinx\THIRDPARTY.md">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+      <TargetPath>THIRDPARTY.md</TargetPath>
+    </None>
+    <ContentWithTargetPath Include="..\Ryujinx.Audio\LICENSE.txt">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+      <TargetPath>LICENSE-Ryujinx.Audio.txt</TargetPath>
+    </ContentWithTargetPath>
+  </ItemGroup>
+
+  <!-- Due to .net core 3.1 embedded resource loading -->
+  <PropertyGroup>
+    <EmbeddedResourceUseDependentUponConvention>false</EmbeddedResourceUseDependentUponConvention>
+    <ApplicationIcon>..\Ryujinx\Ryujinx.ico</ApplicationIcon>
+  </PropertyGroup>
+
+  <PropertyGroup Condition="'$(RuntimeIdentifier)' != ''">
+    <PublishSingleFile>true</PublishSingleFile>
+    <PublishTrimmed>true</PublishTrimmed>
+  </PropertyGroup>
+</Project>
diff --git a/Ryujinx.Headless.SDL2/SDL2Mouse.cs b/Ryujinx.Headless.SDL2/SDL2Mouse.cs
new file mode 100644
index 0000000000..4ce8eafd9b
--- /dev/null
+++ b/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/Ryujinx.Headless.SDL2/SDL2MouseDriver.cs b/Ryujinx.Headless.SDL2/SDL2MouseDriver.cs
new file mode 100644
index 0000000000..24f8e86401
--- /dev/null
+++ b/Ryujinx.Headless.SDL2/SDL2MouseDriver.cs
@@ -0,0 +1,104 @@
+using Ryujinx.Common.Logging;
+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 bool _isDisposed;
+
+        public bool[] PressedButtons { get; }
+
+        public Vector2 CurrentPosition { get; private set; }
+        public Vector2 Scroll { get; private set; }
+        public Size _clientSize;
+
+        public SDL2MouseDriver()
+        {
+            PressedButtons = new bool[(int)MouseButton.Count];
+        }
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        private static MouseButton DriverButtonToMouseButton(uint rawButton)
+        {
+            Debug.Assert(rawButton > 0 && rawButton <= (int)MouseButton.Count);
+
+            return (MouseButton)(rawButton - 1);
+        }
+
+        public void Update(SDL_Event evnt)
+        {
+            if (evnt.type == SDL_EventType.SDL_MOUSEBUTTONDOWN || evnt.type == 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);
+                }
+            }
+            else if (evnt.type == SDL_EventType.SDL_MOUSEMOTION)
+            {
+                CurrentPosition = new Vector2(evnt.motion.x, evnt.motion.y);
+            }
+            else if (evnt.type == SDL_EventType.SDL_MOUSEWHEEL)
+            {
+                Scroll = new Vector2(evnt.wheel.x, evnt.wheel.y);
+            }
+        }
+
+        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<string> OnGamepadConnected
+        {
+            add { }
+            remove { }
+        }
+
+        public event Action<string> OnGamepadDisconnected
+        {
+            add { }
+            remove { }
+        }
+
+        public ReadOnlySpan<string> GamepadsIds => new[] { "0" };
+
+        public IGamepad GetGamepad(string id)
+        {
+            return new SDL2Mouse(this);
+        }
+
+        public void Dispose()
+        {
+            if (_isDisposed)
+            {
+                return;
+            }
+
+            _isDisposed = true;
+        }
+    }
+}
diff --git a/Ryujinx.Headless.SDL2/StatusUpdatedEventArgs.cs b/Ryujinx.Headless.SDL2/StatusUpdatedEventArgs.cs
new file mode 100644
index 0000000000..62e161dfd1
--- /dev/null
+++ b/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/Ryujinx.Headless.SDL2/WindowBase.cs b/Ryujinx.Headless.SDL2/WindowBase.cs
new file mode 100644
index 0000000000..7f574e979d
--- /dev/null
+++ b/Ryujinx.Headless.SDL2/WindowBase.cs
@@ -0,0 +1,393 @@
+using ARMeilleure.Translation;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Configuration.Hid;
+using Ryujinx.Common.Logging;
+using Ryujinx.Graphics.GAL;
+using Ryujinx.HLE;
+using Ryujinx.HLE.HOS.Applets;
+using Ryujinx.HLE.HOS.Services.Am.AppletOE.ApplicationProxyService.ApplicationProxy.Types;
+using Ryujinx.HLE.HOS.Services.Hid;
+using Ryujinx.Input;
+using Ryujinx.Input.HLE;
+using Ryujinx.SDL2.Common;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using static SDL2.SDL;
+using Switch = Ryujinx.HLE.Switch;
+
+namespace Ryujinx.Headless.SDL2
+{
+    abstract 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;
+
+        public NpadManager NpadManager { get; }
+        public TouchScreenManager TouchScreenManager { get; }
+        public Switch Device { get; private set; }
+        public IRenderer Renderer { get; private set; }
+
+        public event EventHandler<StatusUpdatedEventArgs> StatusUpdatedEvent;
+
+        protected IntPtr WindowHandle { get; set; }
+        protected SDL2MouseDriver MouseDriver;
+        private InputManager _inputManager;
+        private IKeyboard _keyboardInterface;
+        private GraphicsDebugLevel _glLogLevel;
+        private readonly Stopwatch _chrono;
+        private readonly long _ticksPerFrame;
+        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)
+        {
+            MouseDriver = new SDL2MouseDriver();
+            _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;
+            _exitEvent = new ManualResetEvent(false);
+            _aspectRatio = aspectRatio;
+            _enableMouse = enableMouse;
+
+            SDL2Driver.Instance.Initialize();
+        }
+
+        public void Initialize(Switch device, List<InputConfig> inputConfigs, bool enableKeyboard, bool enableMouse)
+        {
+            Device = device;
+            Renderer = Device.Gpu.Renderer;
+
+            NpadManager.Initialize(device, inputConfigs, enableKeyboard, enableMouse);
+            TouchScreenManager.Initialize(device);
+        }
+
+        private void InitializeWindow()
+        {
+            string titleNameSection = string.IsNullOrWhiteSpace(Device.Application.TitleName) ? string.Empty
+                : $" - {Device.Application.TitleName}";
+
+            string titleVersionSection = string.IsNullOrWhiteSpace(Device.Application.DisplayVersion) ? string.Empty
+                : $" v{Device.Application.DisplayVersion}";
+
+            string titleIdSection = string.IsNullOrWhiteSpace(Device.Application.TitleIdText) ? string.Empty
+                : $" ({Device.Application.TitleIdText.ToUpper()})";
+
+            string titleArchSection = Device.Application.TitleIs64Bit ? " (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);
+            }
+
+            _windowId = SDL_GetWindowID(WindowHandle);
+            SDL2Driver.Instance.RegisterWindow(_windowId, HandleWindowEvent);
+        }
+
+        private void HandleWindowEvent(SDL_Event evnt)
+        {
+            if (evnt.type == SDL_EventType.SDL_WINDOWEVENT)
+            {
+                switch (evnt.window.windowEvent)
+                {
+                    case SDL_WindowEventID.SDL_WINDOWEVENT_SIZE_CHANGED:
+                        Renderer?.Window.SetSize(evnt.window.data1, evnt.window.data2);
+                        MouseDriver.SetClientSize(evnt.window.data1, evnt.window.data2);
+                        break;
+                    case SDL_WindowEventID.SDL_WINDOWEVENT_CLOSE:
+                        Exit();
+                        break;
+                    default:
+                        break;
+                }
+            }
+            else
+            {
+                MouseDriver.Update(evnt);
+            }
+        }
+
+        protected abstract void InitializeRenderer();
+
+        protected abstract void FinalizeRenderer();
+
+        protected abstract void SwapBuffers();
+
+        protected abstract string GetGpuVendorName();
+
+        public abstract SDL_WindowFlags GetWindowFlags();
+
+        public void Render()
+        {
+            InitializeRenderer();
+
+            Device.Gpu.Renderer.Initialize(_glLogLevel);
+
+            _gpuVendorName = GetGpuVendorName();
+
+            Device.Gpu.InitializeShaderCache();
+            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",
+                        $"FIFO: {Device.Statistics.GetFifoPercent():0.00} %",
+                        $"GPU: {_gpuVendorName}"));
+
+                    _ticks = Math.Min(_ticks - _ticksPerFrame, _ticksPerFrame);
+                }
+            }
+
+            FinalizeRenderer();
+        }
+
+        public void Exit()
+        {
+            TouchScreenManager?.Dispose();
+            NpadManager?.Dispose();
+
+            if (_isStopped)
+            {
+                return;
+            }
+
+            _isStopped = true;
+            _isActive = false;
+
+            _exitEvent.WaitOne();
+            _exitEvent.Dispose();
+        }
+
+        public void MainLoop()
+        {
+            while (_isActive)
+            {
+                UpdateFrame();
+
+                SDL_PumpEvents();
+
+                // 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();
+
+            return true;
+        }
+
+        public void Execute()
+        {
+            _chrono.Restart();
+            _isActive = true;
+
+            InitializeWindow();
+
+            Thread renderLoopThread = new Thread(Render)
+            {
+                Name = "GUI.RenderLoop"
+            };
+            renderLoopThread.Start();
+
+            Thread 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 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();
+            }
+        }
+    }
+}
diff --git a/Ryujinx.SDL2.Common/SDL2Driver.cs b/Ryujinx.SDL2.Common/SDL2Driver.cs
index edd634ee76..cc8e7614e5 100644
--- a/Ryujinx.SDL2.Common/SDL2Driver.cs
+++ b/Ryujinx.SDL2.Common/SDL2Driver.cs
@@ -1,5 +1,7 @@
 using Ryujinx.Common.Logging;
 using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
 using System.IO;
 using System.Threading;
 using static SDL2.SDL;
@@ -25,7 +27,7 @@ namespace Ryujinx.SDL2.Common
             }
         }
 
-        private const uint SdlInitFlags = SDL_INIT_EVENTS | SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK | SDL_INIT_AUDIO;
+        private const uint SdlInitFlags = SDL_INIT_EVENTS | SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK | SDL_INIT_AUDIO | SDL_INIT_VIDEO;
 
         private bool _isRunning;
         private uint _refereceCount;
@@ -34,6 +36,8 @@ namespace Ryujinx.SDL2.Common
         public event Action<int, int> OnJoyStickConnected;
         public event Action<int> OnJoystickDisconnected;
 
+        private ConcurrentDictionary<uint, Action<SDL_Event>> _registeredWindowHandlers;
+
         private object _lock = new object();
 
         private SDL2Driver() {}
@@ -85,12 +89,23 @@ namespace Ryujinx.SDL2.Common
                     SDL_GameControllerAddMappingsFromFile(gamepadDbPath);
                 }
 
+                _registeredWindowHandlers = new ConcurrentDictionary<uint, Action<SDL_Event>>();
                 _worker = new Thread(EventWorker);
                 _isRunning = true;
                 _worker.Start();
             }
         }
 
+        public bool RegisterWindow(uint windowId, Action<SDL_Event> windowEventHandler)
+        {
+            return _registeredWindowHandlers.TryAdd(windowId, windowEventHandler);
+        }
+
+        public void UnregisterWindow(uint windowId)
+        {
+            _registeredWindowHandlers.Remove(windowId, out _);
+        }
+
         private void HandleSDLEvent(ref SDL_Event evnt)
         {
             if (evnt.type == SDL_EventType.SDL_JOYDEVICEADDED)
@@ -115,6 +130,13 @@ namespace Ryujinx.SDL2.Common
 
                 OnJoystickDisconnected?.Invoke(evnt.cbutton.which);
             }
+            else if (evnt.type == SDL_EventType.SDL_WINDOWEVENT || evnt.type == SDL_EventType.SDL_MOUSEBUTTONDOWN || evnt.type == SDL_EventType.SDL_MOUSEBUTTONUP)
+            {
+                if (_registeredWindowHandlers.TryGetValue(evnt.window.windowID, out Action<SDL_Event> handler))
+                {
+                    handler(evnt);
+                }
+            }
         }
 
         private void EventWorker()
diff --git a/Ryujinx.sln b/Ryujinx.sln
index f4eec57347..9504bbc2db 100644
--- a/Ryujinx.sln
+++ b/Ryujinx.sln
@@ -63,9 +63,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Input", "Ryujinx.In
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Input.SDL2", "Ryujinx.Input.SDL2\Ryujinx.Input.SDL2.csproj", "{DFAB6F2D-B9BF-4AFF-B22B-7684A328EBA3}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ryujinx.SDL2.Common", "Ryujinx.SDL2.Common\Ryujinx.SDL2.Common.csproj", "{2D5D3A1D-5730-4648-B0AB-06C53CB910C0}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.SDL2.Common", "Ryujinx.SDL2.Common\Ryujinx.SDL2.Common.csproj", "{2D5D3A1D-5730-4648-B0AB-06C53CB910C0}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ryujinx.Audio.Backends.SDL2", "Ryujinx.Audio.Backends.SDL2\Ryujinx.Audio.Backends.SDL2.csproj", "{D99A395A-8569-4DB0-B336-900647890052}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Audio.Backends.SDL2", "Ryujinx.Audio.Backends.SDL2\Ryujinx.Audio.Backends.SDL2.csproj", "{D99A395A-8569-4DB0-B336-900647890052}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ryujinx.Headless.SDL2", "Ryujinx.Headless.SDL2\Ryujinx.Headless.SDL2.csproj", "{390DC343-5CB4-4C79-A5DD-E3ED235E4C49}"
 EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -189,6 +191,10 @@ Global
 		{D99A395A-8569-4DB0-B336-900647890052}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{D99A395A-8569-4DB0-B336-900647890052}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{D99A395A-8569-4DB0-B336-900647890052}.Release|Any CPU.Build.0 = Release|Any CPU
+		{390DC343-5CB4-4C79-A5DD-E3ED235E4C49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{390DC343-5CB4-4C79-A5DD-E3ED235E4C49}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{390DC343-5CB4-4C79-A5DD-E3ED235E4C49}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{390DC343-5CB4-4C79-A5DD-E3ED235E4C49}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
diff --git a/appveyor.yml b/appveyor.yml
index 431a5c91f6..c4024cebc1 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -26,7 +26,18 @@ build_script:
 
     7z a ryujinx$env:config_name$env:APPVEYOR_BUILD_VERSION-osx_x64.zip $env:APPVEYOR_BUILD_FOLDER\Ryujinx\bin\$env:config\$env:appveyor_dotnet_runtime\osx-x64\publish\
 
+    7z a ryujinx-headless-sdl2$env:config_name$env:APPVEYOR_BUILD_VERSION-win_x64.zip $env:APPVEYOR_BUILD_FOLDER\Ryujinx.Headless.SDL2\bin\$env:config\$env:appveyor_dotnet_runtime\win-x64\publish\
+
+    7z a ryujinx-headless-sdl2$env:config_name$env:APPVEYOR_BUILD_VERSION-linux_x64.tar $env:APPVEYOR_BUILD_FOLDER\Ryujinx.Headless.SDL2\bin\$env:config\$env:appveyor_dotnet_runtime\linux-x64\publish\
+
+    7z a ryujinx-headless-sdl2$env:config_name$env:APPVEYOR_BUILD_VERSION-linux_x64.tar.gz ryujinx-headless-sdl2$env:config_name$env:APPVEYOR_BUILD_VERSION-linux_x64.tar
+
+    7z a ryujinx-headless-sdl2$env:config_name$env:APPVEYOR_BUILD_VERSION-osx_x64.zip $env:APPVEYOR_BUILD_FOLDER\Ryujinx.Headless.SDL2\bin\$env:config\$env:appveyor_dotnet_runtime\osx-x64\publish\
+
 artifacts:
 - path: ryujinx%config_name%%APPVEYOR_BUILD_VERSION%-win_x64.zip
 - path: ryujinx%config_name%%APPVEYOR_BUILD_VERSION%-linux_x64.tar.gz
 - path: ryujinx%config_name%%APPVEYOR_BUILD_VERSION%-osx_x64.zip
+- path: ryujinx-headless-sdl2%config_name%%APPVEYOR_BUILD_VERSION%-win_x64.zip
+- path: ryujinx-headless-sdl2%config_name%%APPVEYOR_BUILD_VERSION%-linux_x64.tar.gz
+- path: ryujinx-headless-sdl2%config_name%%APPVEYOR_BUILD_VERSION%-osx_x64.zip
\ No newline at end of file