frontend: Add a SDL2 headless window (#2310)
This commit is contained in:
parent
d125fce3e8
commit
31cbd09a75
13 changed files with 1615 additions and 6 deletions
13
.github/workflows/build.yml
vendored
13
.github/workflows/build.yml
vendored
|
@ -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
|
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
|
- name: Test
|
||||||
run: dotnet test -c "${{ matrix.configuration }}"
|
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
|
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'
|
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
|
uses: actions/upload-artifact@v2
|
||||||
with:
|
with:
|
||||||
name: ryujinx-${{ matrix.configuration }}-1.0.0+${{ steps.git_short_hash.outputs.result }}-${{ matrix.RELEASE_ZIP_OS_NAME }}
|
name: ryujinx-${{ matrix.configuration }}-1.0.0+${{ steps.git_short_hash.outputs.result }}-${{ matrix.RELEASE_ZIP_OS_NAME }}
|
||||||
path: publish
|
path: publish
|
||||||
if: github.event_name == 'pull_request'
|
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'
|
||||||
|
|
|
@ -133,7 +133,7 @@ namespace Ryujinx.HLE
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Aspect Ratio applied to the renderer window by the SurfaceFlinger service.
|
/// Aspect Ratio applied to the renderer window by the SurfaceFlinger service.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public AspectRatio AspectRatio { internal get; set; }
|
public AspectRatio AspectRatio { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An action called when HLE force a refresh of output after docked mode changed.
|
/// An action called when HLE force a refresh of output after docked mode changed.
|
||||||
|
|
167
Ryujinx.Headless.SDL2/OpenGL/OpenGLWindow.cs
Normal file
167
Ryujinx.Headless.SDL2/OpenGL/OpenGLWindow.cs
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
168
Ryujinx.Headless.SDL2/Options.cs
Normal file
168
Ryujinx.Headless.SDL2/Options.cs
Normal file
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
561
Ryujinx.Headless.SDL2/Program.cs
Normal file
561
Ryujinx.Headless.SDL2/Program.cs
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
54
Ryujinx.Headless.SDL2/Ryujinx.Headless.SDL2.csproj
Normal file
54
Ryujinx.Headless.SDL2/Ryujinx.Headless.SDL2.csproj
Normal file
|
@ -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>
|
90
Ryujinx.Headless.SDL2/SDL2Mouse.cs
Normal file
90
Ryujinx.Headless.SDL2/SDL2Mouse.cs
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
104
Ryujinx.Headless.SDL2/SDL2MouseDriver.cs
Normal file
104
Ryujinx.Headless.SDL2/SDL2MouseDriver.cs
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
Ryujinx.Headless.SDL2/StatusUpdatedEventArgs.cs
Normal file
24
Ryujinx.Headless.SDL2/StatusUpdatedEventArgs.cs
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
393
Ryujinx.Headless.SDL2/WindowBase.cs
Normal file
393
Ryujinx.Headless.SDL2/WindowBase.cs
Normal file
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
using Ryujinx.Common.Logging;
|
using Ryujinx.Common.Logging;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using static SDL2.SDL;
|
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 bool _isRunning;
|
||||||
private uint _refereceCount;
|
private uint _refereceCount;
|
||||||
|
@ -34,6 +36,8 @@ namespace Ryujinx.SDL2.Common
|
||||||
public event Action<int, int> OnJoyStickConnected;
|
public event Action<int, int> OnJoyStickConnected;
|
||||||
public event Action<int> OnJoystickDisconnected;
|
public event Action<int> OnJoystickDisconnected;
|
||||||
|
|
||||||
|
private ConcurrentDictionary<uint, Action<SDL_Event>> _registeredWindowHandlers;
|
||||||
|
|
||||||
private object _lock = new object();
|
private object _lock = new object();
|
||||||
|
|
||||||
private SDL2Driver() {}
|
private SDL2Driver() {}
|
||||||
|
@ -85,12 +89,23 @@ namespace Ryujinx.SDL2.Common
|
||||||
SDL_GameControllerAddMappingsFromFile(gamepadDbPath);
|
SDL_GameControllerAddMappingsFromFile(gamepadDbPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_registeredWindowHandlers = new ConcurrentDictionary<uint, Action<SDL_Event>>();
|
||||||
_worker = new Thread(EventWorker);
|
_worker = new Thread(EventWorker);
|
||||||
_isRunning = true;
|
_isRunning = true;
|
||||||
_worker.Start();
|
_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)
|
private void HandleSDLEvent(ref SDL_Event evnt)
|
||||||
{
|
{
|
||||||
if (evnt.type == SDL_EventType.SDL_JOYDEVICEADDED)
|
if (evnt.type == SDL_EventType.SDL_JOYDEVICEADDED)
|
||||||
|
@ -115,6 +130,13 @@ namespace Ryujinx.SDL2.Common
|
||||||
|
|
||||||
OnJoystickDisconnected?.Invoke(evnt.cbutton.which);
|
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()
|
private void EventWorker()
|
||||||
|
|
10
Ryujinx.sln
10
Ryujinx.sln
|
@ -63,9 +63,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Input", "Ryujinx.In
|
||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Input.SDL2", "Ryujinx.Input.SDL2\Ryujinx.Input.SDL2.csproj", "{DFAB6F2D-B9BF-4AFF-B22B-7684A328EBA3}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Input.SDL2", "Ryujinx.Input.SDL2\Ryujinx.Input.SDL2.csproj", "{DFAB6F2D-B9BF-4AFF-B22B-7684A328EBA3}"
|
||||||
EndProject
|
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
|
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
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{D99A395A-8569-4DB0-B336-900647890052}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|
11
appveyor.yml
11
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$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:
|
artifacts:
|
||||||
- path: ryujinx%config_name%%APPVEYOR_BUILD_VERSION%-win_x64.zip
|
- 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%-linux_x64.tar.gz
|
||||||
- path: ryujinx%config_name%%APPVEYOR_BUILD_VERSION%-osx_x64.zip
|
- 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
|
Loading…
Reference in a new issue