diff --git a/assets/locales.json b/assets/locales.json
index d931017aa..c9cd9f69f 100644
--- a/assets/locales.json
+++ b/assets/locales.json
@@ -24372,6 +24372,156 @@
"zh_TW": "只在按下時"
}
},
+ {
+ "ID": "SettingsTabHotkeysCycleInputDevicePlayerX",
+ "Translations": {
+ "ar_SA": "",
+ "de_DE": "",
+ "el_GR": "",
+ "en_US": "Cycle Input Device {0}:",
+ "es_ES": "",
+ "fr_FR": "",
+ "he_IL": "",
+ "it_IT": "",
+ "ja_JP": "",
+ "ko_KR": "",
+ "no_NO": "",
+ "pl_PL": "",
+ "pt_BR": "",
+ "ru_RU": "",
+ "sv_SE": "",
+ "th_TH": "",
+ "tr_TR": "",
+ "uk_UA": "",
+ "zh_CN": "",
+ "zh_TW": ""
+ }
+ },
+ {
+ "ID": "ControllerOverlayTitle",
+ "Translations": {
+ "ar_SA": "",
+ "de_DE": "",
+ "el_GR": "",
+ "en_US": "Controller Bindings",
+ "es_ES": "",
+ "fr_FR": "",
+ "he_IL": "",
+ "it_IT": "",
+ "ja_JP": "",
+ "ko_KR": "",
+ "no_NO": "",
+ "pl_PL": "",
+ "pt_BR": "",
+ "ru_RU": "",
+ "sv_SE": "",
+ "th_TH": "",
+ "tr_TR": "",
+ "uk_UA": "",
+ "zh_CN": "",
+ "zh_TW": ""
+ }
+ },
+ {
+ "ID": "ControllerOverlayNoController",
+ "Translations": {
+ "ar_SA": "",
+ "de_DE": "",
+ "el_GR": "",
+ "en_US": "No controller assigned",
+ "es_ES": "",
+ "fr_FR": "",
+ "he_IL": "",
+ "it_IT": "",
+ "ja_JP": "",
+ "ko_KR": "",
+ "no_NO": "",
+ "pl_PL": "",
+ "pt_BR": "",
+ "ru_RU": "",
+ "sv_SE": "",
+ "th_TH": "",
+ "tr_TR": "",
+ "uk_UA": "",
+ "zh_CN": "",
+ "zh_TW": ""
+ }
+ },
+ {
+ "ID": "ControllerOverlayKeyboard",
+ "Translations": {
+ "ar_SA": "",
+ "de_DE": "",
+ "el_GR": "",
+ "en_US": "Keyboard",
+ "es_ES": "",
+ "fr_FR": "",
+ "he_IL": "",
+ "it_IT": "",
+ "ja_JP": "",
+ "ko_KR": "",
+ "no_NO": "",
+ "pl_PL": "",
+ "pt_BR": "",
+ "ru_RU": "",
+ "sv_SE": "",
+ "th_TH": "",
+ "tr_TR": "",
+ "uk_UA": "",
+ "zh_CN": "",
+ "zh_TW": ""
+ }
+ },
+ {
+ "ID": "ControllerOverlayController",
+ "Translations": {
+ "ar_SA": "",
+ "de_DE": "",
+ "el_GR": "",
+ "en_US": "Controller",
+ "es_ES": "",
+ "fr_FR": "",
+ "he_IL": "",
+ "it_IT": "",
+ "ja_JP": "",
+ "ko_KR": "",
+ "no_NO": "",
+ "pl_PL": "",
+ "pt_BR": "",
+ "ru_RU": "",
+ "sv_SE": "",
+ "th_TH": "",
+ "tr_TR": "",
+ "uk_UA": "",
+ "zh_CN": "",
+ "zh_TW": ""
+ }
+ },
+ {
+ "ID": "ControllerOverlayUnknown",
+ "Translations": {
+ "ar_SA": "",
+ "de_DE": "",
+ "el_GR": "",
+ "en_US": "Unknown",
+ "es_ES": "",
+ "fr_FR": "",
+ "he_IL": "",
+ "it_IT": "",
+ "ja_JP": "",
+ "ko_KR": "",
+ "no_NO": "",
+ "pl_PL": "",
+ "pt_BR": "",
+ "ru_RU": "",
+ "sv_SE": "",
+ "th_TH": "",
+ "tr_TR": "",
+ "uk_UA": "",
+ "zh_CN": "",
+ "zh_TW": ""
+ }
+ },
{
"ID": "CompatibilityListLastUpdated",
"Translations": {
@@ -25021,6 +25171,131 @@
"zh_CN": "动态 Rich Presence",
"zh_TW": "動態 Rich Presence"
}
+ },
+ {
+ "ID": "SettingsTabUIControllerOverlay",
+ "Translations": {
+ "ar_SA": "",
+ "de_DE": "",
+ "el_GR": "",
+ "en_US": "Controller Overlay",
+ "es_ES": "",
+ "fr_FR": "",
+ "he_IL": "",
+ "it_IT": "",
+ "ja_JP": "",
+ "ko_KR": "",
+ "no_NO": "",
+ "pl_PL": "",
+ "pt_BR": "",
+ "ru_RU": "",
+ "sv_SE": "",
+ "th_TH": "",
+ "tr_TR": "",
+ "uk_UA": "",
+ "zh_CN": "",
+ "zh_TW": ""
+ }
+ },
+ {
+ "ID": "SettingsTabUIControllerOverlayGameStartDuration",
+ "Translations": {
+ "ar_SA": "",
+ "de_DE": "",
+ "el_GR": "",
+ "en_US": "Show on game start (seconds):",
+ "es_ES": "",
+ "fr_FR": "",
+ "he_IL": "",
+ "it_IT": "",
+ "ja_JP": "",
+ "ko_KR": "",
+ "no_NO": "",
+ "pl_PL": "",
+ "pt_BR": "",
+ "ru_RU": "",
+ "sv_SE": "",
+ "th_TH": "",
+ "tr_TR": "",
+ "uk_UA": "",
+ "zh_CN": "",
+ "zh_TW": ""
+ }
+ },
+ {
+ "ID": "SettingsTabUIControllerOverlayGameStartDurationTooltip",
+ "Translations": {
+ "ar_SA": "",
+ "de_DE": "",
+ "el_GR": "",
+ "en_US": "Duration to show controller overlay when game starts (0 = disabled)",
+ "es_ES": "",
+ "fr_FR": "",
+ "he_IL": "",
+ "it_IT": "",
+ "ja_JP": "",
+ "ko_KR": "",
+ "no_NO": "",
+ "pl_PL": "",
+ "pt_BR": "",
+ "ru_RU": "",
+ "sv_SE": "",
+ "th_TH": "",
+ "tr_TR": "",
+ "uk_UA": "",
+ "zh_CN": "",
+ "zh_TW": ""
+ }
+ },
+ {
+ "ID": "SettingsTabUIControllerOverlayInputCycleDuration",
+ "Translations": {
+ "ar_SA": "",
+ "de_DE": "",
+ "el_GR": "",
+ "en_US": "Show on input cycle (seconds):",
+ "es_ES": "",
+ "fr_FR": "",
+ "he_IL": "",
+ "it_IT": "",
+ "ja_JP": "",
+ "ko_KR": "",
+ "no_NO": "",
+ "pl_PL": "",
+ "pt_BR": "",
+ "ru_RU": "",
+ "sv_SE": "",
+ "th_TH": "",
+ "tr_TR": "",
+ "uk_UA": "",
+ "zh_CN": "",
+ "zh_TW": ""
+ }
+ },
+ {
+ "ID": "SettingsTabUIControllerOverlayInputCycleDurationTooltip",
+ "Translations": {
+ "ar_SA": "",
+ "de_DE": "",
+ "el_GR": "",
+ "en_US": "Duration to show controller overlay when cycling inputs (0 = disabled)",
+ "es_ES": "",
+ "fr_FR": "",
+ "he_IL": "",
+ "it_IT": "",
+ "ja_JP": "",
+ "ko_KR": "",
+ "no_NO": "",
+ "pl_PL": "",
+ "pt_BR": "",
+ "ru_RU": "",
+ "sv_SE": "",
+ "th_TH": "",
+ "tr_TR": "",
+ "uk_UA": "",
+ "zh_CN": "",
+ "zh_TW": ""
+ }
}
]
}
diff --git a/src/Ryujinx.Common/Configuration/Hid/DefaultInputConfigurationProvider.cs b/src/Ryujinx.Common/Configuration/Hid/DefaultInputConfigurationProvider.cs
new file mode 100644
index 000000000..be145029f
--- /dev/null
+++ b/src/Ryujinx.Common/Configuration/Hid/DefaultInputConfigurationProvider.cs
@@ -0,0 +1,200 @@
+using System;
+using Ryujinx.Common.Configuration.Hid.Controller;
+using Ryujinx.Common.Configuration.Hid.Controller.Motion;
+using Ryujinx.Common.Configuration.Hid.Keyboard;
+using ConfigGamepadInputId = Ryujinx.Common.Configuration.Hid.Controller.GamepadInputId;
+using ConfigStickInputId = Ryujinx.Common.Configuration.Hid.Controller.StickInputId;
+
+namespace Ryujinx.Common.Configuration.Hid
+{
+ ///
+ /// Provides default input configurations for keyboard and controller devices
+ ///
+ public static class DefaultInputConfigurationProvider
+ {
+ ///
+ /// Creates a default keyboard input configuration
+ ///
+ /// Device ID
+ /// Device name
+ /// Player index
+ /// Controller type (defaults to ProController)
+ /// Default keyboard input configuration
+ public static StandardKeyboardInputConfig CreateDefaultKeyboardConfig(string id, string name, PlayerIndex playerIndex, ControllerType controllerType = ControllerType.ProController)
+ {
+ return new StandardKeyboardInputConfig
+ {
+ Version = InputConfig.CurrentVersion,
+ Backend = InputBackendType.WindowKeyboard,
+ Id = id,
+ Name = name,
+ ControllerType = ControllerType.ProController,
+ PlayerIndex = playerIndex,
+ LeftJoycon = new LeftJoyconCommonConfig
+ {
+ DpadUp = Key.Up,
+ DpadDown = Key.Down,
+ DpadLeft = Key.Left,
+ DpadRight = Key.Right,
+ ButtonMinus = Key.Minus,
+ ButtonL = Key.E,
+ ButtonZl = Key.Q,
+ ButtonSl = Key.Unbound,
+ ButtonSr = Key.Unbound,
+ },
+ LeftJoyconStick = new JoyconConfigKeyboardStick
+ {
+ StickUp = Key.W,
+ StickDown = Key.S,
+ StickLeft = Key.A,
+ StickRight = Key.D,
+ StickButton = Key.F,
+ },
+ RightJoycon = new RightJoyconCommonConfig
+ {
+ ButtonA = Key.Z,
+ ButtonB = Key.X,
+ ButtonX = Key.C,
+ ButtonY = Key.V,
+ ButtonPlus = Key.Plus,
+ ButtonR = Key.U,
+ ButtonZr = Key.O,
+ ButtonSl = Key.Unbound,
+ ButtonSr = Key.Unbound,
+ },
+ RightJoyconStick = new JoyconConfigKeyboardStick
+ {
+ StickUp = Key.I,
+ StickDown = Key.K,
+ StickLeft = Key.J,
+ StickRight = Key.L,
+ StickButton = Key.H,
+ },
+ };
+ }
+
+ ///
+ /// Creates a default controller input configuration
+ ///
+ /// Device ID
+ /// Device name
+ /// Player index
+ /// Whether to use Nintendo-style button mapping
+ /// Default controller input configuration
+ public static StandardControllerInputConfig CreateDefaultControllerConfig(string id, string name, PlayerIndex playerIndex, bool isNintendoStyle = false)
+ {
+ // Split the ID for controller configs
+ string cleanId = id.Split(" ")[0];
+
+ return new StandardControllerInputConfig
+ {
+ Version = InputConfig.CurrentVersion,
+ Backend = InputBackendType.GamepadSDL2,
+ Id = cleanId,
+ Name = name,
+ ControllerType = ControllerType.ProController,
+ PlayerIndex = playerIndex,
+ DeadzoneLeft = 0.1f,
+ DeadzoneRight = 0.1f,
+ RangeLeft = 1.0f,
+ RangeRight = 1.0f,
+ TriggerThreshold = 0.5f,
+ LeftJoycon = new LeftJoyconCommonConfig
+ {
+ DpadUp = ConfigGamepadInputId.DpadUp,
+ DpadDown = ConfigGamepadInputId.DpadDown,
+ DpadLeft = ConfigGamepadInputId.DpadLeft,
+ DpadRight = ConfigGamepadInputId.DpadRight,
+ ButtonMinus = ConfigGamepadInputId.Minus,
+ ButtonL = ConfigGamepadInputId.LeftShoulder,
+ ButtonZl = ConfigGamepadInputId.LeftTrigger,
+ ButtonSl = ConfigGamepadInputId.Unbound,
+ ButtonSr = ConfigGamepadInputId.Unbound,
+ },
+ LeftJoyconStick = new JoyconConfigControllerStick
+ {
+ Joystick = ConfigStickInputId.Left,
+ StickButton = ConfigGamepadInputId.LeftStick,
+ InvertStickX = false,
+ InvertStickY = false,
+ },
+ RightJoycon = new RightJoyconCommonConfig
+ {
+ ButtonA = isNintendoStyle ? ConfigGamepadInputId.A : ConfigGamepadInputId.B,
+ ButtonB = isNintendoStyle ? ConfigGamepadInputId.B : ConfigGamepadInputId.A,
+ ButtonX = isNintendoStyle ? ConfigGamepadInputId.X : ConfigGamepadInputId.Y,
+ ButtonY = isNintendoStyle ? ConfigGamepadInputId.Y : ConfigGamepadInputId.X,
+ ButtonPlus = ConfigGamepadInputId.Plus,
+ ButtonR = ConfigGamepadInputId.RightShoulder,
+ ButtonZr = ConfigGamepadInputId.RightTrigger,
+ ButtonSl = ConfigGamepadInputId.Unbound,
+ ButtonSr = ConfigGamepadInputId.Unbound,
+ },
+ RightJoyconStick = new JoyconConfigControllerStick
+ {
+ Joystick = ConfigStickInputId.Right,
+ StickButton = ConfigGamepadInputId.RightStick,
+ InvertStickX = false,
+ InvertStickY = false,
+ },
+ Motion = new StandardMotionConfigController
+ {
+ EnableMotion = true,
+ MotionBackend = MotionInputBackendType.GamepadDriver,
+ GyroDeadzone = 1,
+ Sensitivity = 100,
+ },
+ Rumble = new RumbleConfigController
+ {
+ EnableRumble = false,
+ WeakRumble = 1f,
+ StrongRumble = 1f,
+ },
+ Led = new LedConfigController
+ {
+ EnableLed = false,
+ TurnOffLed = false,
+ UseRainbow = false,
+ LedColor = 0xFFFFFFFF,
+ }
+ };
+ }
+
+ ///
+ /// Gets the short name of a gamepad by removing SDL prefix and truncating if too long
+ ///
+ /// Full gamepad name
+ /// Maximum length before truncation (default: 50)
+ /// Short gamepad name
+ public static string GetShortGamepadName(string name, int maxLength = 50)
+ {
+ const string SdlGamepadNamePrefix = "SDL2 Gamepad ";
+ const string Ellipsis = "...";
+
+ // First remove SDL prefix if present
+ string shortName = name;
+ if (name.StartsWith(SdlGamepadNamePrefix))
+ {
+ shortName = name[SdlGamepadNamePrefix.Length..];
+ }
+
+ // Then truncate if too long
+ if (shortName.Length > maxLength)
+ {
+ return $"{shortName.AsSpan(0, maxLength - Ellipsis.Length)}{Ellipsis}";
+ }
+
+ return shortName;
+ }
+
+ ///
+ /// Determines if a controller uses Nintendo-style button mapping
+ ///
+ /// Controller name
+ /// True if Nintendo-style mapping should be used
+ public static bool IsNintendoStyleController(string name)
+ {
+ return name.Contains("Nintendo");
+ }
+ }
+}
diff --git a/src/Ryujinx.Common/Configuration/Hid/KeyboardHotkeys.cs b/src/Ryujinx.Common/Configuration/Hid/KeyboardHotkeys.cs
index efdb422e7..cc6ec55d5 100644
--- a/src/Ryujinx.Common/Configuration/Hid/KeyboardHotkeys.cs
+++ b/src/Ryujinx.Common/Configuration/Hid/KeyboardHotkeys.cs
@@ -15,5 +15,14 @@ namespace Ryujinx.Common.Configuration.Hid
public Key CustomVSyncIntervalDecrement { get; set; }
public Key TurboMode { get; set; }
public bool TurboModeWhileHeld { get; set; }
+ public Key CycleInputDevicePlayer1 { get; set; }
+ public Key CycleInputDevicePlayer2 { get; set; }
+ public Key CycleInputDevicePlayer3 { get; set; }
+ public Key CycleInputDevicePlayer4 { get; set; }
+ public Key CycleInputDevicePlayer5 { get; set; }
+ public Key CycleInputDevicePlayer6 { get; set; }
+ public Key CycleInputDevicePlayer7 { get; set; }
+ public Key CycleInputDevicePlayer8 { get; set; }
+ public Key CycleInputDeviceHandheld { get; set; }
}
}
diff --git a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs
index 7a4836982..340a47321 100644
--- a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs
+++ b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs
@@ -41,5 +41,10 @@ namespace Ryujinx.Graphics.GAL.Multithreading
public void SetScalingFilterLevel(float level) { }
public void SetColorSpacePassthrough(bool colorSpacePassthroughEnabled) { }
+
+ ///
+ /// Gets the underlying implementation window for direct access
+ ///
+ public IWindow BaseWindow => _impl.Window;
}
}
diff --git a/src/Ryujinx.Graphics.Gpu/GpuContext.cs b/src/Ryujinx.Graphics.Gpu/GpuContext.cs
index d0b8277da..4e71e8582 100644
--- a/src/Ryujinx.Graphics.Gpu/GpuContext.cs
+++ b/src/Ryujinx.Graphics.Gpu/GpuContext.cs
@@ -40,6 +40,7 @@ namespace Ryujinx.Graphics.Gpu
/// GPU synchronization manager.
///
public SynchronizationManager Synchronization { get; }
+ public IOverlayManager OverlayManager { get; }
///
/// Presentation window.
@@ -121,14 +122,18 @@ namespace Ryujinx.Graphics.Gpu
/// Creates a new instance of the GPU emulation context.
///
/// Host renderer
- public GpuContext(IRenderer renderer, DirtyHacks hacks)
+ /// Enabled dirty hacks
+ /// Overlay manager for rendering overlays
+ public GpuContext(IRenderer renderer, DirtyHacks hacks, IOverlayManager overlayManager)
{
Renderer = renderer;
GPFifo = new GPFifoDevice(this);
Synchronization = new SynchronizationManager();
-
+
+ OverlayManager = overlayManager;
+
Window = new Window(this);
HostInitalized = new ManualResetEvent(false);
@@ -462,6 +467,8 @@ namespace Ryujinx.Graphics.Gpu
RunDeferredActions();
Renderer.Dispose();
+
+ OverlayManager.Dispose();
}
}
}
diff --git a/src/Ryujinx.Graphics.Gpu/IOverlay.cs b/src/Ryujinx.Graphics.Gpu/IOverlay.cs
new file mode 100644
index 000000000..700b6330a
--- /dev/null
+++ b/src/Ryujinx.Graphics.Gpu/IOverlay.cs
@@ -0,0 +1,54 @@
+using SkiaSharp;
+using System;
+
+namespace Ryujinx.Graphics.Gpu
+{
+ ///
+ /// Interface for overlay functionality
+ ///
+ public interface IOverlay : IDisposable
+ {
+ ///
+ /// Name of the overlay
+ ///
+ string Name { get; set; }
+
+ ///
+ /// Whether the overlay is visible
+ ///
+ bool IsVisible { get; set; }
+
+ ///
+ /// Opacity of the overlay (0.0 to 1.0)
+ ///
+ float Opacity { get; set; }
+
+ ///
+ /// X position of the overlay
+ ///
+ float X { get; set; }
+
+ ///
+ /// Y position of the overlay
+ ///
+ float Y { get; set; }
+
+ ///
+ /// Z-index for overlay ordering
+ ///
+ int ZIndex { get; set; }
+
+ ///
+ /// Update overlay (for animations)
+ ///
+ /// Time elapsed since last update
+ /// Current screen size
+ void Update(float deltaTime, SKSize screenSize = default);
+
+ ///
+ /// Render this overlay
+ ///
+ /// The canvas to render to
+ void Render(SKCanvas canvas);
+ }
+}
diff --git a/src/Ryujinx.Graphics.Gpu/IOverlayManager.cs b/src/Ryujinx.Graphics.Gpu/IOverlayManager.cs
new file mode 100644
index 000000000..d3aeba260
--- /dev/null
+++ b/src/Ryujinx.Graphics.Gpu/IOverlayManager.cs
@@ -0,0 +1,30 @@
+using SkiaSharp;
+using System;
+
+namespace Ryujinx.Graphics.Gpu
+{
+ ///
+ /// Interface for overlay management functionality
+ ///
+ public interface IOverlayManager : IDisposable
+ {
+ ///
+ /// Add an overlay to the manager
+ ///
+ /// The overlay to add
+ void AddOverlay(IOverlay overlay);
+
+ ///
+ /// Update all overlays (for animations)
+ ///
+ /// Time elapsed since last update
+ /// Current screen size
+ void Update(float deltaTime, SKSize screenSize = default);
+
+ ///
+ /// Render all visible overlays
+ ///
+ /// The canvas to render to
+ void Render(SKCanvas canvas);
+ }
+}
diff --git a/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj b/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj
index 4cd9c1d5c..9228fceba 100644
--- a/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj
+++ b/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj
@@ -14,4 +14,8 @@
+
+
+
+
diff --git a/src/Ryujinx.Graphics.Gpu/Window.cs b/src/Ryujinx.Graphics.Gpu/Window.cs
index 5c3463f2a..6bcedd2b7 100644
--- a/src/Ryujinx.Graphics.Gpu/Window.cs
+++ b/src/Ryujinx.Graphics.Gpu/Window.cs
@@ -1,10 +1,14 @@
+using Ryujinx.Common.Logging;
+using Ryujinx.Common.Memory;
using Ryujinx.Graphics.GAL;
using Ryujinx.Graphics.Gpu.Image;
using Ryujinx.Graphics.Gpu.Memory;
using Ryujinx.Graphics.Texture;
using Ryujinx.Memory.Range;
+using SkiaSharp;
using System;
using System.Collections.Concurrent;
+using System.Collections.Generic;
using System.Threading;
namespace Ryujinx.Graphics.Gpu
@@ -15,6 +19,7 @@ namespace Ryujinx.Graphics.Gpu
public class Window
{
private readonly GpuContext _context;
+ private DateTime? _lastUpdateTime = null;
///
/// Texture presented on the window.
@@ -207,6 +212,9 @@ namespace Ryujinx.Graphics.Gpu
texture.SynchronizeMemory();
+ // Add overlays by modifying texture data directly
+ AddOverlaysToTexture(texture);
+
ImageCrop crop = new(
(int)(pt.Crop.Left * texture.ScaleFactor),
(int)MathF.Ceiling(pt.Crop.Right * texture.ScaleFactor),
@@ -244,6 +252,98 @@ namespace Ryujinx.Graphics.Gpu
}
}
+ ///
+ /// Add overlay to the overlay manager
+ ///
+ public void AddOverlay(IOverlay overlay)
+ {
+ _context.OverlayManager.AddOverlay(overlay);
+ }
+
+ ///
+ /// Add overlays to the texture using SkiaSharp
+ ///
+ /// The texture to modify
+ private void AddOverlaysToTexture(Image.Texture texture)
+ {
+ try
+ {
+ DateTime currentTime = DateTime.UtcNow;
+ if (_lastUpdateTime != null)
+ {
+ // Calculate delta time for lifespan updates
+ float deltaTime = (float)(currentTime - _lastUpdateTime.Value).TotalSeconds;
+ _context.OverlayManager.Update(deltaTime, new SKSize(texture.Info.Width, texture.Info.Height));
+ }
+
+ // Update overlay animations
+ _lastUpdateTime = currentTime;
+
+ // Get texture data from host texture
+ using var pinnedData = texture.HostTexture.GetData();
+ var data = pinnedData.Get().ToArray();
+ if (data == null || data.Length == 0)
+ return;
+
+ int width = texture.Info.Width;
+ int height = texture.Info.Height;
+ int bytesPerPixel = texture.Info.FormatInfo.BytesPerPixel;
+
+ // Determine the SKColorType based on bytes per pixel
+ SKColorType colorType = bytesPerPixel switch
+ {
+ 4 => SKColorType.Rgba8888,
+ 3 => SKColorType.Rgb888x,
+ 2 => SKColorType.Rgb565,
+ _ => SKColorType.Rgba8888
+ };
+
+ // Create SKBitmap from texture data
+ var imageInfo = new SKImageInfo(width, height, colorType, SKAlphaType.Premul);
+ using var bitmap = new SKBitmap(imageInfo);
+
+ // Copy texture data to bitmap
+ unsafe
+ {
+ fixed (byte* dataPtr = data)
+ {
+ bitmap.SetPixels((IntPtr)dataPtr);
+ }
+ }
+
+ // Create canvas for drawing overlays
+ using var canvas = new SKCanvas(bitmap);
+
+ // On Linux with OpenGL, we need to flip the Y-axis because OpenGL uses bottom-left origin
+ // while SkiaSharp uses top-left origin
+ if (OperatingSystem.IsLinux())
+ {
+ canvas.Scale(1, -1);
+ canvas.Translate(0, -height);
+ }
+
+ // Render all overlays
+ _context.OverlayManager.Render(canvas);
+
+ // Copy modified bitmap data back to texture data array
+ var pixels = bitmap.Bytes;
+ if (pixels.Length <= data.Length)
+ {
+ Array.Copy(pixels, data, pixels.Length);
+ }
+
+ // Upload modified data back to texture
+ var memoryOwner = MemoryOwner.Rent(data.Length);
+ data.CopyTo(memoryOwner.Span);
+ texture.HostTexture.SetData(memoryOwner); // SetData will dispose the MemoryOwner
+ }
+ catch (Exception ex)
+ {
+ // Silently fail if overlay rendering doesn't work
+ Logger.Error?.Print(LogClass.Gpu, $"Overlay rendering failed: {ex.Message}");
+ }
+ }
+
///
/// Indicate that a frame on the queue is ready to be acquired.
///
diff --git a/src/Ryujinx.HLE/HleConfiguration.cs b/src/Ryujinx.HLE/HleConfiguration.cs
index 10c2a1f30..85d0e3502 100644
--- a/src/Ryujinx.HLE/HleConfiguration.cs
+++ b/src/Ryujinx.HLE/HleConfiguration.cs
@@ -3,6 +3,7 @@ using Ryujinx.Audio.Integration;
using Ryujinx.Common.Configuration;
using Ryujinx.Common.Configuration.Multiplayer;
using Ryujinx.Graphics.GAL;
+using Ryujinx.Graphics.Gpu;
using Ryujinx.HLE.FileSystem;
using Ryujinx.HLE.HOS;
using Ryujinx.HLE.HOS.Services.Account.Acc;
@@ -65,6 +66,12 @@ namespace Ryujinx.HLE
/// This cannot be changed after instantiation.
internal IHostUIHandler HostUIHandler { get; private set; }
+ ///
+ /// The overlay manager to use for all overlay operations.
+ ///
+ /// This cannot be changed after instantiation.
+ internal IOverlayManager OverlayManager { get; private set; }
+
///
/// Control the memory configuration used by the emulation context.
///
@@ -262,7 +269,8 @@ namespace Ryujinx.HLE
UserChannelPersistence userChannelPersistence,
IRenderer gpuRenderer,
IHardwareDeviceDriver audioDeviceDriver,
- IHostUIHandler hostUIHandler
+ IHostUIHandler hostUIHandler,
+ IOverlayManager overlayManager
)
{
VirtualFileSystem = virtualFileSystem;
@@ -273,6 +281,7 @@ namespace Ryujinx.HLE
GpuRenderer = gpuRenderer;
AudioDeviceDriver = audioDeviceDriver;
HostUIHandler = hostUIHandler;
+ OverlayManager = overlayManager;
return this;
}
}
diff --git a/src/Ryujinx.HLE/Switch.cs b/src/Ryujinx.HLE/Switch.cs
index bdcbe82c7..071d4125d 100644
--- a/src/Ryujinx.HLE/Switch.cs
+++ b/src/Ryujinx.HLE/Switch.cs
@@ -71,7 +71,7 @@ namespace Ryujinx.HLE
DirtyHacks = new DirtyHacks(Configuration.Hacks);
AudioDeviceDriver = new CompatLayerHardwareDeviceDriver(Configuration.AudioDeviceDriver);
Memory = new MemoryBlock(Configuration.MemoryConfiguration.ToDramSize(), memoryAllocationFlags);
- Gpu = new GpuContext(Configuration.GpuRenderer, DirtyHacks);
+ Gpu = new GpuContext(Configuration.GpuRenderer, DirtyHacks, Configuration.OverlayManager);
System = new HOS.Horizon(this);
Statistics = new PerformanceStatistics(this);
Hid = new Hid(this, System.HidStorage);
diff --git a/src/Ryujinx/Common/KeyboardHotkeyState.cs b/src/Ryujinx/Common/KeyboardHotkeyState.cs
index b6fb02f04..2df1d5970 100644
--- a/src/Ryujinx/Common/KeyboardHotkeyState.cs
+++ b/src/Ryujinx/Common/KeyboardHotkeyState.cs
@@ -15,5 +15,14 @@ namespace Ryujinx.Ava.Common
CustomVSyncIntervalIncrement,
CustomVSyncIntervalDecrement,
TurboMode,
+ CycleInputDevicePlayer1,
+ CycleInputDevicePlayer2,
+ CycleInputDevicePlayer3,
+ CycleInputDevicePlayer4,
+ CycleInputDevicePlayer5,
+ CycleInputDevicePlayer6,
+ CycleInputDevicePlayer7,
+ CycleInputDevicePlayer8,
+ CycleInputDeviceHandheld,
}
}
diff --git a/src/Ryujinx/Common/Markup/MarkupExtensions.cs b/src/Ryujinx/Common/Markup/MarkupExtensions.cs
index b2ed01517..07d7b3613 100644
--- a/src/Ryujinx/Common/Markup/MarkupExtensions.cs
+++ b/src/Ryujinx/Common/Markup/MarkupExtensions.cs
@@ -1,3 +1,4 @@
+using Avalonia.Data.Converters;
using Avalonia.Markup.Xaml.MarkupExtensions;
using Projektanker.Icons.Avalonia;
using Ryujinx.Ava.Common.Locale;
@@ -18,11 +19,19 @@ namespace Ryujinx.Ava.Common.Markup
internal class LocaleExtension(LocaleKeys key) : BasicMarkupExtension
{
+ public IValueConverter Converter { get; set; }
+
public override string Name => "Translation";
protected override string Value => LocaleManager.Instance[key];
protected override void ConfigureBindingExtension(CompiledBindingExtension bindingExtension)
- => bindingExtension.Source = LocaleManager.Instance;
+ {
+ bindingExtension.Source = LocaleManager.Instance;
+ if (Converter != null)
+ {
+ bindingExtension.Converter = Converter;
+ }
+ }
}
internal class WindowTitleExtension(LocaleKeys key, bool includeVersion) : BasicMarkupExtension
diff --git a/src/Ryujinx/Headless/HeadlessRyujinx.Init.cs b/src/Ryujinx/Headless/HeadlessRyujinx.Init.cs
index f15d24e8a..ce1597fb4 100644
--- a/src/Ryujinx/Headless/HeadlessRyujinx.Init.cs
+++ b/src/Ryujinx/Headless/HeadlessRyujinx.Init.cs
@@ -17,6 +17,7 @@ using Ryujinx.Graphics.OpenGL;
using Ryujinx.Graphics.Vulkan;
using Ryujinx.HLE;
using Ryujinx.Input;
+using Ryujinx.UI.Overlay;
using Silk.NET.Vulkan;
using System;
using System.IO;
@@ -348,7 +349,8 @@ namespace Ryujinx.Headless
_userChannelPersistence,
renderer.TryMakeThreaded(options.BackendThreading),
new SDL2HardwareDeviceDriver(),
- window
+ window,
+ new OverlayManager()
)
);
}
diff --git a/src/Ryujinx/Systems/AppHost.cs b/src/Ryujinx/Systems/AppHost.cs
index 1c5f64309..3c9c6f6b3 100644
--- a/src/Ryujinx/Systems/AppHost.cs
+++ b/src/Ryujinx/Systems/AppHost.cs
@@ -33,6 +33,7 @@ using Ryujinx.Common.Utilities;
using Ryujinx.Graphics.GAL;
using Ryujinx.Graphics.GAL.Multithreading;
using Ryujinx.Graphics.Gpu;
+using Ryujinx.UI.Overlay;
using Ryujinx.Graphics.OpenGL;
using Ryujinx.Graphics.Vulkan;
using Ryujinx.HLE.FileSystem;
@@ -40,6 +41,11 @@ using Ryujinx.HLE.HOS;
using Ryujinx.HLE.HOS.Services.Account.Acc;
using Ryujinx.Input;
using Ryujinx.Input.HLE;
+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 System.Linq;
using SkiaSharp;
using SPB.Graphics.Vulkan;
using System;
@@ -124,6 +130,7 @@ namespace Ryujinx.Ava.Systems
private readonly bool _isFirmwareTitle;
private readonly Lock _lockObject = new();
+ private ControllerOverlay _controllerOverlay;
public event EventHandler AppExit;
public event EventHandler StatusUpdatedEvent;
@@ -924,9 +931,13 @@ namespace Ryujinx.Ava.Systems
_userChannelPersistence,
renderer.TryMakeThreaded(ConfigurationState.Instance.Graphics.BackendThreading),
InitializeAudio(),
- _viewModel.UiHandler
+ _viewModel.UiHandler,
+ new OverlayManager()
)
);
+
+ _controllerOverlay = new ControllerOverlay();
+ Device.Gpu.Window.AddOverlay(_controllerOverlay);
}
private static IHardwareDeviceDriver InitializeAudio()
@@ -1333,6 +1344,33 @@ namespace Ryujinx.Ava.Systems
_viewModel.Volume = Device.GetVolume();
break;
+ case KeyboardHotkeyState.CycleInputDevicePlayer1:
+ CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player1);
+ break;
+ case KeyboardHotkeyState.CycleInputDevicePlayer2:
+ CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player2);
+ break;
+ case KeyboardHotkeyState.CycleInputDevicePlayer3:
+ CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player3);
+ break;
+ case KeyboardHotkeyState.CycleInputDevicePlayer4:
+ CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player4);
+ break;
+ case KeyboardHotkeyState.CycleInputDevicePlayer5:
+ CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player5);
+ break;
+ case KeyboardHotkeyState.CycleInputDevicePlayer6:
+ CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player6);
+ break;
+ case KeyboardHotkeyState.CycleInputDevicePlayer7:
+ CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player7);
+ break;
+ case KeyboardHotkeyState.CycleInputDevicePlayer8:
+ CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Player8);
+ break;
+ case KeyboardHotkeyState.CycleInputDeviceHandheld:
+ CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex.Handheld);
+ break;
case KeyboardHotkeyState.None:
(_keyboardInterface as AvaloniaKeyboard).Clear();
break;
@@ -1366,6 +1404,118 @@ namespace Ryujinx.Ava.Systems
return true;
}
+ private void CycleInputDevice(HLE.HOS.Services.Hid.PlayerIndex playerIndex)
+ {
+ // Get current input configuration
+ List currentConfig = new(ConfigurationState.Instance.Hid.InputConfig.Value);
+
+ // Find current configuration for this player
+ InputConfig playerConfig = currentConfig.FirstOrDefault(x => x.PlayerIndex == (PlayerIndex)playerIndex);
+
+ // Get available devices from InputManager
+ List<(DeviceType Type, string Id, string Name)> availableDevices = [];
+
+ // Add disabled option
+ availableDevices.Add((DeviceType.None, "disabled", "Disabled"));
+
+ // Add keyboard devices
+ foreach (string id in _inputManager.KeyboardDriver.GamepadsIds)
+ {
+ using var gamepad = _inputManager.KeyboardDriver.GetGamepad(id);
+ if (gamepad != null)
+ {
+ availableDevices.Add((DeviceType.Keyboard, id, gamepad.Name));
+ }
+ }
+
+ // Add controller devices
+ int controllerNumber = 0;
+ foreach (string id in _inputManager.GamepadDriver.GamepadsIds)
+ {
+ using var gamepad = _inputManager.GamepadDriver.GetGamepad(id);
+ if (gamepad != null)
+ {
+ string name = $"{DefaultInputConfigurationProvider.GetShortGamepadName(gamepad.Name)} ({controllerNumber++})";
+ availableDevices.Add((DeviceType.Controller, id, name));
+ }
+ }
+
+ // Find current device index
+ int currentIndex = 0;
+ if (playerConfig != null)
+ {
+ DeviceType currentType = DeviceType.None;
+ if (playerConfig is StandardKeyboardInputConfig)
+ currentType = DeviceType.Keyboard;
+ else if (playerConfig is StandardControllerInputConfig)
+ currentType = DeviceType.Controller;
+
+ currentIndex = availableDevices.FindIndex(x => x.Type == currentType && x.Id == playerConfig.Id);
+ if (currentIndex == -1) currentIndex = 0;
+ }
+
+ // Cycle to next device
+ int nextIndex = (currentIndex + 1) % availableDevices.Count;
+ var nextDevice = availableDevices[nextIndex];
+
+ // Remove existing configuration for this player
+ currentConfig.RemoveAll(x => x.PlayerIndex == (PlayerIndex)playerIndex);
+
+ // Add new configuration if not disabled
+ if (nextDevice.Type != DeviceType.None)
+ {
+ InputConfig newConfig = CreateDefaultInputConfig(nextDevice, (PlayerIndex)playerIndex);
+ if (newConfig != null)
+ {
+ currentConfig.Add(newConfig);
+ }
+ }
+
+ // Apply the new configuration
+ ConfigurationState.Instance.Hid.InputConfig.Value = currentConfig;
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+
+ // Reload the input system
+ NpadManager.ReloadConfiguration(currentConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
+
+ // Show controller overlay
+ ShowControllerOverlay(currentConfig, ConfigurationState.Instance.ControllerOverlayInputCycleDuration.Value);
+ }
+
+ private InputConfig CreateDefaultInputConfig((DeviceType Type, string Id, string Name) device, PlayerIndex playerIndex)
+ {
+ if (device.Type == DeviceType.Keyboard)
+ {
+ return DefaultInputConfigurationProvider.CreateDefaultKeyboardConfig(device.Id, device.Name, playerIndex);
+ }
+ else if (device.Type == DeviceType.Controller)
+ {
+ bool isNintendoStyle = DefaultInputConfigurationProvider.IsNintendoStyleController(device.Name);
+ return DefaultInputConfigurationProvider.CreateDefaultControllerConfig(device.Id, device.Name, playerIndex, isNintendoStyle);
+ }
+
+ return null;
+ }
+
+ public void ShowControllerOverlay(List inputConfigs, int duration)
+ {
+ try
+ {
+ if (_controllerOverlay != null)
+ {
+ _controllerOverlay.ShowControllerBindings(inputConfigs, duration);
+ }
+ else
+ {
+ Logger.Warning?.Print(LogClass.Application, "AppHost: Cannot show overlay - ControllerOverlay is null");
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Error?.Print(LogClass.Application, $"Failed to show controller overlay: {ex.Message}");
+ }
+ }
+
private KeyboardHotkeyState GetHotkeyState()
{
KeyboardHotkeyState state = KeyboardHotkeyState.None;
@@ -1418,6 +1568,42 @@ namespace Ryujinx.Ava.Systems
{
state = KeyboardHotkeyState.TurboMode;
}
+ else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer1))
+ {
+ state = KeyboardHotkeyState.CycleInputDevicePlayer1;
+ }
+ else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer2))
+ {
+ state = KeyboardHotkeyState.CycleInputDevicePlayer2;
+ }
+ else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer3))
+ {
+ state = KeyboardHotkeyState.CycleInputDevicePlayer3;
+ }
+ else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer4))
+ {
+ state = KeyboardHotkeyState.CycleInputDevicePlayer4;
+ }
+ else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer5))
+ {
+ state = KeyboardHotkeyState.CycleInputDevicePlayer5;
+ }
+ else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer6))
+ {
+ state = KeyboardHotkeyState.CycleInputDevicePlayer6;
+ }
+ else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer7))
+ {
+ state = KeyboardHotkeyState.CycleInputDevicePlayer7;
+ }
+ else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDevicePlayer8))
+ {
+ state = KeyboardHotkeyState.CycleInputDevicePlayer8;
+ }
+ else if (_keyboardInterface.IsPressed((Key)ConfigurationState.Instance.Hid.Hotkeys.Value.CycleInputDeviceHandheld))
+ {
+ state = KeyboardHotkeyState.CycleInputDeviceHandheld;
+ }
return state;
}
diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs b/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs
index c21383349..eaa92c92a 100644
--- a/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs
+++ b/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs
@@ -15,7 +15,7 @@ namespace Ryujinx.Ava.Systems.Configuration
///
/// The current version of the file format
///
- public const int CurrentVersion = 70;
+ public const int CurrentVersion = 72;
///
/// Version of the configuration file format
@@ -217,6 +217,16 @@ namespace Ryujinx.Ava.Systems.Configuration
///
public HideCursorMode HideCursor { get; set; }
+ ///
+ /// Duration to show controller overlay when game starts (seconds, 0 = disabled)
+ ///
+ public int ControllerOverlayGameStartDuration { get; set; }
+
+ ///
+ /// Duration to show controller overlay when input is cycled (seconds, 0 = disabled)
+ ///
+ public int ControllerOverlayInputCycleDuration { get; set; }
+
///
/// Enables or disables Vertical Sync
///
diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs
index afabdb4e3..3cf2e04d7 100644
--- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs
+++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs
@@ -53,6 +53,8 @@ namespace Ryujinx.Ava.Systems.Configuration
ShowOldUI.Value = shouldLoadFromFile ? cff.ShowTitleBar : ShowOldUI.Value; // Get from global config only
EnableHardwareAcceleration.Value = shouldLoadFromFile ? cff.EnableHardwareAcceleration : EnableHardwareAcceleration.Value; // Get from global config only
HideCursor.Value = cff.HideCursor;
+ ControllerOverlayGameStartDuration.Value = cff.ControllerOverlayGameStartDuration;
+ ControllerOverlayInputCycleDuration.Value = cff.ControllerOverlayInputCycleDuration;
Logger.EnableFileLog.Value = cff.EnableFileLog;
Logger.EnableDebug.Value = cff.LoggingEnableDebug;
@@ -479,7 +481,40 @@ namespace Ryujinx.Ava.Systems.Configuration
};
}
),
- (69, static cff => cff.SkipUserProfiles = false)
+ (69, static cff => cff.SkipUserProfiles = false),
+ (71, static cff =>
+ {
+ cff.ControllerOverlayGameStartDuration = 3;
+ cff.ControllerOverlayInputCycleDuration = 2;
+ }),
+ (72, static cff =>
+ {
+ cff.Hotkeys = new KeyboardHotkeys
+ {
+ ToggleVSyncMode = cff.Hotkeys.ToggleVSyncMode,
+ Screenshot = cff.Hotkeys.Screenshot,
+ ShowUI = cff.Hotkeys.ShowUI,
+ Pause = cff.Hotkeys.Pause,
+ ToggleMute = cff.Hotkeys.ToggleMute,
+ ResScaleUp = cff.Hotkeys.ResScaleUp,
+ ResScaleDown = cff.Hotkeys.ResScaleDown,
+ VolumeUp = cff.Hotkeys.VolumeUp,
+ VolumeDown = cff.Hotkeys.VolumeDown,
+ CustomVSyncIntervalIncrement = cff.Hotkeys.CustomVSyncIntervalIncrement,
+ CustomVSyncIntervalDecrement = cff.Hotkeys.CustomVSyncIntervalDecrement,
+ TurboMode = cff.Hotkeys.TurboMode,
+ TurboModeWhileHeld = cff.Hotkeys.TurboModeWhileHeld,
+ CycleInputDevicePlayer1 = Key.Unbound,
+ CycleInputDevicePlayer2 = Key.Unbound,
+ CycleInputDevicePlayer3 = Key.Unbound,
+ CycleInputDevicePlayer4 = Key.Unbound,
+ CycleInputDevicePlayer5 = Key.Unbound,
+ CycleInputDevicePlayer6 = Key.Unbound,
+ CycleInputDevicePlayer7 = Key.Unbound,
+ CycleInputDevicePlayer8 = Key.Unbound,
+ CycleInputDeviceHandheld = Key.Unbound
+ };
+ })
);
}
}
diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs
index 29a390b26..2f9a0787c 100644
--- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs
+++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs
@@ -846,6 +846,16 @@ namespace Ryujinx.Ava.Systems.Configuration
///
public ReactiveObject HideCursor { get; private set; }
+ ///
+ /// Duration to show controller overlay when game starts (seconds, 0 = disabled)
+ ///
+ public ReactiveObject ControllerOverlayGameStartDuration { get; private set; }
+
+ ///
+ /// Duration to show controller overlay when input is cycled (seconds, 0 = disabled)
+ ///
+ public ReactiveObject ControllerOverlayInputCycleDuration { get; private set; }
+
private ConfigurationState()
{
UI = new UISection();
@@ -863,6 +873,8 @@ namespace Ryujinx.Ava.Systems.Configuration
RememberWindowState = new ReactiveObject();
ShowOldUI = new ReactiveObject();
EnableHardwareAcceleration = new ReactiveObject();
+ ControllerOverlayGameStartDuration = new ReactiveObject();
+ ControllerOverlayInputCycleDuration = new ReactiveObject();
}
public HleConfiguration CreateHleConfiguration() =>
diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.cs
index 4a565d5d3..0d4211e1b 100644
--- a/src/Ryujinx/Systems/Configuration/ConfigurationState.cs
+++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.cs
@@ -65,6 +65,8 @@ namespace Ryujinx.Ava.Systems.Configuration
ShowTitleBar = ShowOldUI,
EnableHardwareAcceleration = EnableHardwareAcceleration,
HideCursor = HideCursor,
+ ControllerOverlayGameStartDuration = ControllerOverlayGameStartDuration,
+ ControllerOverlayInputCycleDuration = ControllerOverlayInputCycleDuration,
VSyncMode = Graphics.VSyncMode,
EnableCustomVSyncInterval = Graphics.EnableCustomVSyncInterval,
CustomVSyncInterval = Graphics.CustomVSyncInterval,
@@ -190,6 +192,8 @@ namespace Ryujinx.Ava.Systems.Configuration
ShowOldUI.Value = !OperatingSystem.IsWindows();
EnableHardwareAcceleration.Value = true;
HideCursor.Value = HideCursorMode.OnIdle;
+ ControllerOverlayGameStartDuration.Value = 3;
+ ControllerOverlayInputCycleDuration.Value = 2;
Graphics.VSyncMode.Value = VSyncMode.Switch;
Graphics.CustomVSyncInterval.Value = 120;
Graphics.EnableCustomVSyncInterval.Value = false;
@@ -269,7 +273,16 @@ namespace Ryujinx.Ava.Systems.Configuration
CustomVSyncIntervalIncrement = Key.Unbound,
CustomVSyncIntervalDecrement = Key.Unbound,
TurboMode = Key.Unbound,
- TurboModeWhileHeld = false
+ TurboModeWhileHeld = false,
+ CycleInputDevicePlayer1 = Key.Unbound,
+ CycleInputDevicePlayer2 = Key.Unbound,
+ CycleInputDevicePlayer3 = Key.Unbound,
+ CycleInputDevicePlayer4 = Key.Unbound,
+ CycleInputDevicePlayer5 = Key.Unbound,
+ CycleInputDevicePlayer6 = Key.Unbound,
+ CycleInputDevicePlayer7 = Key.Unbound,
+ CycleInputDevicePlayer8 = Key.Unbound,
+ CycleInputDeviceHandheld = Key.Unbound
};
Hid.RainbowSpeed.Value = 1f;
Hid.InputConfig.Value =
diff --git a/src/Ryujinx/UI/Helpers/Converters/PlayerHotkeyLabelConverter.cs b/src/Ryujinx/UI/Helpers/Converters/PlayerHotkeyLabelConverter.cs
new file mode 100644
index 000000000..9ac7392a6
--- /dev/null
+++ b/src/Ryujinx/UI/Helpers/Converters/PlayerHotkeyLabelConverter.cs
@@ -0,0 +1,28 @@
+using Avalonia.Data.Converters;
+using Ryujinx.Ava.Common.Locale;
+using System;
+using System.Globalization;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ internal class PlayerHotkeyLabelConverter : IValueConverter
+ {
+ public static readonly PlayerHotkeyLabelConverter Instance = new();
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is string playerName && !string.IsNullOrEmpty(playerName))
+ {
+ string baseText = LocaleManager.Instance[LocaleKeys.SettingsTabHotkeysCycleInputDevicePlayerX];
+ return string.Format(baseText, playerName);
+ }
+
+ return string.Empty;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+ }
+}
diff --git a/src/Ryujinx/UI/Models/Input/HotkeyConfig.cs b/src/Ryujinx/UI/Models/Input/HotkeyConfig.cs
index 9e557d7b1..b6de26aa0 100644
--- a/src/Ryujinx/UI/Models/Input/HotkeyConfig.cs
+++ b/src/Ryujinx/UI/Models/Input/HotkeyConfig.cs
@@ -32,6 +32,24 @@ namespace Ryujinx.Ava.UI.Models.Input
[ObservableProperty] private bool _turboModeWhileHeld;
+ [ObservableProperty] private Key _cycleInputDevicePlayer1;
+
+ [ObservableProperty] private Key _cycleInputDevicePlayer2;
+
+ [ObservableProperty] private Key _cycleInputDevicePlayer3;
+
+ [ObservableProperty] private Key _cycleInputDevicePlayer4;
+
+ [ObservableProperty] private Key _cycleInputDevicePlayer5;
+
+ [ObservableProperty] private Key _cycleInputDevicePlayer6;
+
+ [ObservableProperty] private Key _cycleInputDevicePlayer7;
+
+ [ObservableProperty] private Key _cycleInputDevicePlayer8;
+
+ [ObservableProperty] private Key _cycleInputDeviceHandheld;
+
public HotkeyConfig(KeyboardHotkeys config)
{
if (config == null)
@@ -50,6 +68,15 @@ namespace Ryujinx.Ava.UI.Models.Input
CustomVSyncIntervalDecrement = config.CustomVSyncIntervalDecrement;
TurboMode = config.TurboMode;
TurboModeWhileHeld = config.TurboModeWhileHeld;
+ CycleInputDevicePlayer1 = config.CycleInputDevicePlayer1;
+ CycleInputDevicePlayer2 = config.CycleInputDevicePlayer2;
+ CycleInputDevicePlayer3 = config.CycleInputDevicePlayer3;
+ CycleInputDevicePlayer4 = config.CycleInputDevicePlayer4;
+ CycleInputDevicePlayer5 = config.CycleInputDevicePlayer5;
+ CycleInputDevicePlayer6 = config.CycleInputDevicePlayer6;
+ CycleInputDevicePlayer7 = config.CycleInputDevicePlayer7;
+ CycleInputDevicePlayer8 = config.CycleInputDevicePlayer8;
+ CycleInputDeviceHandheld = config.CycleInputDeviceHandheld;
}
public KeyboardHotkeys GetConfig() =>
@@ -67,7 +94,16 @@ namespace Ryujinx.Ava.UI.Models.Input
CustomVSyncIntervalIncrement = CustomVSyncIntervalIncrement,
CustomVSyncIntervalDecrement = CustomVSyncIntervalDecrement,
TurboMode = TurboMode,
- TurboModeWhileHeld = TurboModeWhileHeld
+ TurboModeWhileHeld = TurboModeWhileHeld,
+ CycleInputDevicePlayer1 = CycleInputDevicePlayer1,
+ CycleInputDevicePlayer2 = CycleInputDevicePlayer2,
+ CycleInputDevicePlayer3 = CycleInputDevicePlayer3,
+ CycleInputDevicePlayer4 = CycleInputDevicePlayer4,
+ CycleInputDevicePlayer5 = CycleInputDevicePlayer5,
+ CycleInputDevicePlayer6 = CycleInputDevicePlayer6,
+ CycleInputDevicePlayer7 = CycleInputDevicePlayer7,
+ CycleInputDevicePlayer8 = CycleInputDevicePlayer8,
+ CycleInputDeviceHandheld = CycleInputDeviceHandheld
};
}
}
diff --git a/src/Ryujinx/UI/Overlay/ControllerOverlay.cs b/src/Ryujinx/UI/Overlay/ControllerOverlay.cs
new file mode 100644
index 000000000..e8ea8a547
--- /dev/null
+++ b/src/Ryujinx/UI/Overlay/ControllerOverlay.cs
@@ -0,0 +1,258 @@
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OriginalInputConfig = Ryujinx.Common.Configuration.Hid.InputConfig;
+using OriginalPlayerIndex = Ryujinx.Common.Configuration.Hid.PlayerIndex;
+using OriginalInputBackendType = Ryujinx.Common.Configuration.Hid.InputBackendType;
+using Ryujinx.Ava.Common.Locale;
+
+namespace Ryujinx.UI.Overlay
+{
+ ///
+ /// Controller overlay that shows controller bindings matching the original AXAML design
+ ///
+ public class ControllerOverlay : Overlay
+ {
+ private const float OverlayWidth = 400;
+ private const float Padding = 24;
+ private const float Spacing = 16;
+ private const float PlayerSpacing = 12;
+ private const float PlayerRowHeight = 32;
+
+ private const float TitleTextSize = 25;
+ private const float PlayerTextSize = 22;
+
+ private float _lifespan = 0f;
+
+ public ControllerOverlay() : base("ControllerOverlay")
+ {
+ CreateBaseElements();
+ }
+
+ private void CreateBaseElements()
+ {
+ // Main background container
+ var background = new RectangleElement(0, 0, OverlayWidth, 200, // Dynamic height will be set later
+ new SKColor(0, 0, 0, 224)) // #E0000000
+ {
+ Name = "Background",
+ CornerRadius = 12,
+ BorderColor = new SKColor(255, 255, 255, 64), // #40FFFFFF
+ BorderWidth = 1
+ };
+ AddElement(background);
+
+ // Title text (will be updated with localized text)
+ var titleText = new TextElement(Padding + 30, Padding, LocaleManager.Instance[LocaleKeys.ControllerOverlayTitle], TitleTextSize, SKColors.White)
+ {
+ Name = "TitleText",
+ FontStyle = SKFontStyle.Bold
+ };
+ AddElement(titleText);
+ }
+
+ ///
+ /// Show controller bindings with localized strings
+ ///
+ public void ShowControllerBindings(List inputConfigs, int durationSeconds)
+ {
+ // Update title text
+ var titleElement = FindElement("TitleText");
+ if (titleElement != null)
+ {
+ titleElement.Text = LocaleManager.Instance[LocaleKeys.ControllerOverlayTitle];
+ }
+
+ // Reset lifespan and opacity
+ _lifespan = durationSeconds;
+
+ // Clear existing player bindings
+ ClearPlayerBindings();
+
+ // Group controllers by player index (support all players + handheld)
+ var playerBindings = new Dictionary>();
+
+ foreach (var config in inputConfigs.Where(c => c.PlayerIndex <= OriginalPlayerIndex.Handheld))
+ {
+ if (!playerBindings.ContainsKey(config.PlayerIndex))
+ {
+ playerBindings[config.PlayerIndex] = new List();
+ }
+ playerBindings[config.PlayerIndex].Add(config);
+ }
+
+ float currentY = Padding + 40; // After title
+
+ // Add player bindings to UI (support 8 players + handheld)
+ var playerIndices = new[]
+ {
+ OriginalPlayerIndex.Player1, OriginalPlayerIndex.Player2, OriginalPlayerIndex.Player3, OriginalPlayerIndex.Player4,
+ OriginalPlayerIndex.Player5, OriginalPlayerIndex.Player6, OriginalPlayerIndex.Player7, OriginalPlayerIndex.Player8,
+ OriginalPlayerIndex.Handheld
+ };
+
+ for (int i = 0; i < playerIndices.Length; i++)
+ {
+ var playerIndex = playerIndices[i];
+ float rowY = currentY + (i * (PlayerRowHeight + PlayerSpacing));
+
+ // Player number with colored background (circular badge)
+ var playerColor = GetPlayerColor(i);
+ var playerBadge = new RectangleElement(Padding, rowY, 24, 20, playerColor)
+ {
+ Name = $"PlayerBadge_{i}",
+ CornerRadius = 12
+ };
+ AddElement(playerBadge);
+
+ // Player number text
+ string playerLabel = playerIndex == OriginalPlayerIndex.Handheld ? "H" : $"P{(int)playerIndex + 1}";
+ var playerLabelElement = new TextElement(Padding + 12, rowY + 2, playerLabel, PlayerTextSize, SKColors.White)
+ {
+ Name = $"PlayerLabel_{i}",
+ FontStyle = SKFontStyle.Bold,
+ TextAlign = SKTextAlign.Center
+ };
+ AddElement(playerLabelElement);
+
+ // Controller info
+ if (playerBindings.ContainsKey(playerIndex))
+ {
+ var controllers = playerBindings[playerIndex];
+ var controllerNames = controllers.Select(c => GetControllerDisplayName(c)).ToList();
+
+ var controllerTextElement = new TextElement(Padding + 56, rowY + 2, string.Join(", ", controllerNames), PlayerTextSize, new SKColor(144, 238, 144)) // LightGreen
+ {
+ Name = $"ControllerText_{i}",
+ FontStyle = SKFontStyle.Bold
+ };
+ AddElement(controllerTextElement);
+ }
+ else
+ {
+ var noControllerTextElement = new TextElement(Padding + 56, rowY + 2, LocaleManager.Instance[LocaleKeys.ControllerOverlayNoController], PlayerTextSize, new SKColor(128, 128, 128)) // Gray
+ {
+ Name = $"NoControllerText_{i}",
+ FontStyle = SKFontStyle.Italic
+ };
+ AddElement(noControllerTextElement);
+ }
+ }
+
+ // Calculate total height and update background
+ float totalHeight = Padding + 40 + (playerIndices.Length * (PlayerRowHeight + PlayerSpacing)) + Padding + 20;
+ var background = FindElement("Background");
+ if (background != null)
+ {
+ background.Height = totalHeight;
+ }
+
+ // Show the overlay (position will be set by Window class with actual dimensions)
+ IsVisible = true;
+ }
+
+ private static SKColor GetPlayerColor(int playerIndex)
+ {
+ return playerIndex switch
+ {
+ 0 => new SKColor(255, 92, 92), // Red for Player 1
+ 1 => new SKColor(54, 162, 235), // Blue for Player 2
+ 2 => new SKColor(255, 206, 84), // Yellow for Player 3
+ 3 => new SKColor(75, 192, 192), // Green for Player 4
+ 4 => new SKColor(153, 102, 255), // Purple for Player 5
+ 5 => new SKColor(255, 159, 64), // Orange for Player 6
+ 6 => new SKColor(199, 199, 199), // Light Gray for Player 7
+ 7 => new SKColor(83, 102, 255), // Indigo for Player 8
+ 8 => new SKColor(255, 99, 132), // Pink for Handheld
+ _ => new SKColor(128, 128, 128) // Gray fallback
+ };
+ }
+
+ private string GetControllerDisplayName(OriginalInputConfig config)
+ {
+ if (string.IsNullOrEmpty(config.Name))
+ {
+ return config.Backend switch
+ {
+ OriginalInputBackendType.WindowKeyboard => LocaleManager.Instance[LocaleKeys.ControllerOverlayKeyboard],
+ OriginalInputBackendType.GamepadSDL2 => LocaleManager.Instance[LocaleKeys.ControllerOverlayController],
+ _ => LocaleManager.Instance[LocaleKeys.ControllerOverlayUnknown]
+ };
+ }
+
+ // Truncate long controller names
+ string name = config.Name;
+ if (name.Length > 25)
+ {
+ name = name.Substring(0, 22) + "...";
+ }
+
+ return name;
+ }
+
+ ///
+ /// Clear all player bindings
+ ///
+ private void ClearPlayerBindings()
+ {
+ var elementsToRemove = new List();
+
+ foreach (var element in GetElements())
+ {
+ if (element.Name.StartsWith("PlayerBadge_") ||
+ element.Name.StartsWith("PlayerLabel_") ||
+ element.Name.StartsWith("ControllerText_") ||
+ element.Name.StartsWith("NoControllerText_"))
+ {
+ elementsToRemove.Add(element);
+ }
+ }
+
+ foreach (var element in elementsToRemove)
+ {
+ RemoveElement(element);
+ element.Dispose();
+ }
+ }
+
+ ///
+ /// Update overlay
+ ///
+ public override void Update(float deltaTime, SKSize screenSize)
+ {
+ _lifespan -= deltaTime;
+
+ if (_lifespan <= 0)
+ {
+ IsVisible = false;
+ return;
+ }
+
+ if (_lifespan <= 0.5f)
+ {
+ // Fade out during the last 0.5 seconds
+ Opacity = _lifespan / 0.5f;
+ }
+ else
+ {
+ Opacity = 1;
+ }
+
+ // Update position if screen size is provided
+ if (screenSize.Width > 0 && screenSize.Height > 0)
+ {
+ SetPositionToTopRight(screenSize.Width, screenSize.Height);
+ }
+ }
+
+ ///
+ /// Position overlay to top-right matching original AXAML positioning
+ ///
+ public void SetPositionToTopRight(float screenWidth, float screenHeight)
+ {
+ X = screenWidth - OverlayWidth - 20; // 20px margin from right
+ Y = 50; // 50px margin from top
+ }
+ }
+}
diff --git a/src/Ryujinx/UI/Overlay/ImageElement.cs b/src/Ryujinx/UI/Overlay/ImageElement.cs
new file mode 100644
index 000000000..7745d5333
--- /dev/null
+++ b/src/Ryujinx/UI/Overlay/ImageElement.cs
@@ -0,0 +1,146 @@
+using Ryujinx.Common.Logging;
+using SkiaSharp;
+using System;
+
+namespace Ryujinx.UI.Overlay
+{
+ ///
+ /// Image overlay element
+ ///
+ public class ImageElement : OverlayElement
+ {
+ private SKBitmap _bitmap;
+ private byte[] _imageData;
+ private string _imagePath;
+
+ public SKFilterQuality FilterQuality { get; set; } = SKFilterQuality.Medium;
+ public bool MaintainAspectRatio { get; set; } = true;
+
+ public ImageElement()
+ {
+ }
+
+ public ImageElement(float x, float y, float width, float height, byte[] imageData)
+ {
+ X = x;
+ Y = y;
+ Width = width;
+ Height = height;
+ SetImageData(imageData);
+ }
+
+ public ImageElement(float x, float y, float width, float height, string imagePath)
+ {
+ X = x;
+ Y = y;
+ Width = width;
+ Height = height;
+ SetImagePath(imagePath);
+ }
+
+ ///
+ /// Set image from byte array
+ ///
+ public void SetImageData(byte[] imageData)
+ {
+ _imageData = imageData;
+ _imagePath = null;
+ LoadBitmap();
+ }
+
+ ///
+ /// Set image from file path
+ ///
+ public void SetImagePath(string imagePath)
+ {
+ _imagePath = imagePath;
+ _imageData = null;
+ LoadBitmap();
+ }
+
+ ///
+ /// Set image from existing SKBitmap
+ ///
+ public void SetBitmap(SKBitmap bitmap)
+ {
+ _bitmap?.Dispose();
+ _bitmap = bitmap;
+ _imageData = null;
+ _imagePath = null;
+ }
+
+ private void LoadBitmap()
+ {
+ try
+ {
+ _bitmap?.Dispose();
+ _bitmap = null;
+
+ if (_imageData != null)
+ {
+ _bitmap = SKBitmap.Decode(_imageData);
+ }
+ else if (!string.IsNullOrEmpty(_imagePath))
+ {
+ _bitmap = SKBitmap.Decode(_imagePath);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Error?.Print(LogClass.Gpu, $"Failed to load image: {ex.Message}");
+ _bitmap = null;
+ }
+ }
+
+ public override void Render(SKCanvas canvas, float globalOpacity = 1.0f)
+ {
+ if (!IsVisible || _bitmap == null || Width <= 0 || Height <= 0)
+ return;
+
+ float effectiveOpacity = Opacity * globalOpacity;
+
+ using var paint = new SKPaint
+ {
+ FilterQuality = FilterQuality,
+ Color = SKColors.White.WithAlpha((byte)(255 * effectiveOpacity))
+ };
+
+ var sourceRect = new SKRect(0, 0, _bitmap.Width, _bitmap.Height);
+ var destRect = new SKRect(X, Y, X + Width, Y + Height);
+
+ if (MaintainAspectRatio)
+ {
+ // Calculate aspect ratio preserving destination rectangle
+ float sourceAspect = (float)_bitmap.Width / _bitmap.Height;
+ float destAspect = Width / Height;
+
+ if (sourceAspect > destAspect)
+ {
+ // Source is wider, fit to width
+ float newHeight = Width / sourceAspect;
+ float yOffset = (Height - newHeight) / 2;
+ destRect = new SKRect(X, Y + yOffset, X + Width, Y + yOffset + newHeight);
+ }
+ else
+ {
+ // Source is taller, fit to height
+ float newWidth = Height * sourceAspect;
+ float xOffset = (Width - newWidth) / 2;
+ destRect = new SKRect(X + xOffset, Y, X + xOffset + newWidth, Y + Height);
+ }
+ }
+
+ canvas.DrawBitmap(_bitmap, sourceRect, destRect, paint);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _bitmap?.Dispose();
+ _bitmap = null;
+ }
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/src/Ryujinx/UI/Overlay/Overlay.cs b/src/Ryujinx/UI/Overlay/Overlay.cs
new file mode 100644
index 000000000..34616f1bc
--- /dev/null
+++ b/src/Ryujinx/UI/Overlay/Overlay.cs
@@ -0,0 +1,116 @@
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Ryujinx.Graphics.Gpu;
+
+namespace Ryujinx.UI.Overlay
+{
+ ///
+ /// Base overlay class containing multiple elements
+ ///
+ public abstract class Overlay : IOverlay
+ {
+ private readonly List _elements = new();
+
+ public string Name { get; set; } = string.Empty;
+ public bool IsVisible { get; set; } = true;
+ public float Opacity { get; set; } = 1.0f;
+ public float X { get; set; }
+ public float Y { get; set; }
+ public int ZIndex { get; set; } = 0;
+
+ public Overlay()
+ {
+ }
+
+ public Overlay(string name)
+ {
+ Name = name;
+ }
+
+ ///
+ /// Add an element to this overlay
+ ///
+ public void AddElement(OverlayElement element)
+ {
+ _elements.Add(element);
+ }
+
+ ///
+ /// Remove an element from this overlay
+ ///
+ public void RemoveElement(OverlayElement element)
+ {
+ _elements.Remove(element);
+ }
+
+ ///
+ /// Get all elements
+ ///
+ public IReadOnlyList GetElements()
+ {
+ return _elements.AsReadOnly();
+ }
+
+ ///
+ /// Find element by name
+ ///
+ public T FindElement(string name) where T : OverlayElement
+ {
+ return _elements.OfType().FirstOrDefault(e => e.Name == name);
+ }
+
+ ///
+ /// Clear all elements
+ ///
+ public void Clear()
+ {
+ foreach (var element in _elements)
+ {
+ element.Dispose();
+ }
+ _elements.Clear();
+ }
+
+ ///
+ /// Update overlay
+ ///
+ public abstract void Update(float deltaTime, SKSize screenSize = default);
+
+ ///
+ /// Render this overlay
+ ///
+ public void Render(SKCanvas canvas)
+ {
+ if (!IsVisible || Opacity <= 0.0f)
+ return;
+
+ // Save canvas state
+ canvas.Save();
+
+ // Apply overlay position offset
+ if (X != 0 || Y != 0)
+ {
+ canvas.Translate(X, Y);
+ }
+
+ // Render all elements
+ foreach (var element in _elements)
+ {
+ if (element.IsVisible)
+ {
+ element.Render(canvas, Opacity);
+ }
+ }
+
+ // Restore canvas state
+ canvas.Restore();
+ }
+
+ public void Dispose()
+ {
+ Clear();
+ }
+ }
+}
diff --git a/src/Ryujinx/UI/Overlay/OverlayElement.cs b/src/Ryujinx/UI/Overlay/OverlayElement.cs
new file mode 100644
index 000000000..2c5bf66fa
--- /dev/null
+++ b/src/Ryujinx/UI/Overlay/OverlayElement.cs
@@ -0,0 +1,66 @@
+using SkiaSharp;
+using System;
+
+namespace Ryujinx.UI.Overlay
+{
+ ///
+ /// Base class for all overlay elements
+ ///
+ public abstract class OverlayElement : IDisposable
+ {
+ public float X { get; set; }
+ public float Y { get; set; }
+ public float Width { get; set; }
+ public float Height { get; set; }
+ public bool IsVisible { get; set; } = true;
+ public float Opacity { get; set; } = 1.0f;
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Render this element to the canvas
+ ///
+ /// The canvas to draw on
+ /// Global opacity multiplier
+ public abstract void Render(SKCanvas canvas, float globalOpacity = 1.0f);
+
+ ///
+ /// Check if a point is within this element's bounds
+ ///
+ public virtual bool Contains(float x, float y)
+ {
+ return x >= X && x <= X + Width && y >= Y && y <= Y + Height;
+ }
+
+ ///
+ /// Get the bounds of this element
+ ///
+ public SKRect GetBounds()
+ {
+ return new SKRect(X, Y, X + Width, Y + Height);
+ }
+
+ ///
+ /// Apply opacity to a color
+ ///
+ protected SKColor ApplyOpacity(SKColor color, float opacity)
+ {
+ return color.WithAlpha((byte)(color.Alpha * opacity));
+ }
+
+ ///
+ /// Dispose of resources
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Dispose of resources
+ ///
+ protected virtual void Dispose(bool disposing)
+ {
+ }
+ }
+}
diff --git a/src/Ryujinx/UI/Overlay/OverlayManager.cs b/src/Ryujinx/UI/Overlay/OverlayManager.cs
new file mode 100644
index 000000000..d54098ef4
--- /dev/null
+++ b/src/Ryujinx/UI/Overlay/OverlayManager.cs
@@ -0,0 +1,161 @@
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Ryujinx.Graphics.Gpu.Image;
+using Ryujinx.Graphics.Gpu;
+
+namespace Ryujinx.UI.Overlay
+{
+ ///
+ /// Manages multiple overlays and handles rendering
+ ///
+ public class OverlayManager : IOverlayManager
+ {
+ private readonly List _overlays = new();
+ private readonly object _lock = new();
+
+ ///
+ /// Add an overlay to the manager
+ ///
+ public void AddOverlay(IOverlay overlay)
+ {
+ lock (_lock)
+ {
+ _overlays.Add(overlay);
+ SortOverlays();
+ }
+ }
+
+ ///
+ /// Remove an overlay from the manager
+ ///
+ public void RemoveOverlay(Overlay overlay)
+ {
+ lock (_lock)
+ {
+ _overlays.Remove(overlay);
+ }
+ }
+
+ ///
+ /// Remove overlay by name
+ ///
+ public void RemoveOverlay(string name)
+ {
+ lock (_lock)
+ {
+ var overlay = _overlays.FirstOrDefault(o => o.Name == name);
+ if (overlay != null)
+ {
+ _overlays.Remove(overlay);
+ overlay.Dispose();
+ }
+ }
+ }
+
+ ///
+ /// Find overlay by name
+ ///
+ public IOverlay FindOverlay(string name)
+ {
+ lock (_lock)
+ {
+ return _overlays.FirstOrDefault(o => o.Name == name);
+ }
+ }
+
+ ///
+ /// Get all overlays
+ ///
+ public IReadOnlyList GetOverlays()
+ {
+ lock (_lock)
+ {
+ return _overlays.AsReadOnly();
+ }
+ }
+
+ ///
+ /// Clear all overlays
+ ///
+ public void Clear()
+ {
+ lock (_lock)
+ {
+ foreach (var overlay in _overlays)
+ {
+ overlay.Dispose();
+ }
+ _overlays.Clear();
+ }
+ }
+
+ ///
+ /// Update all overlays (for animations)
+ ///
+ public void Update(float deltaTime, SKSize screenSize = default)
+ {
+ lock (_lock)
+ {
+ foreach (var overlay in _overlays.Where(o => o.IsVisible))
+ {
+ overlay.Update(deltaTime, screenSize);
+ }
+ }
+ }
+
+ ///
+ /// Render all visible overlays
+ ///
+ public void Render(SKCanvas canvas)
+ {
+ lock (_lock)
+ {
+ foreach (var overlay in _overlays.Where(o => o.IsVisible && o.Opacity > 0.0f))
+ {
+ overlay.Render(canvas);
+ }
+ }
+ }
+
+ ///
+ /// Sort overlays by Z-index
+ ///
+ private void SortOverlays()
+ {
+ _overlays.Sort((a, b) => a.ZIndex.CompareTo(b.ZIndex));
+ }
+
+ ///
+ /// Show overlay
+ ///
+ public void ShowOverlay(string name)
+ {
+ var overlay = FindOverlay(name);
+ if (overlay != null)
+ {
+ overlay.IsVisible = true;
+ overlay.Opacity = 1.0f;
+ }
+ }
+
+ ///
+ /// Hide overlay
+ ///
+ public void HideOverlay(string name)
+ {
+ var overlay = FindOverlay(name);
+ if (overlay != null)
+ {
+ overlay.IsVisible = false;
+ overlay.Opacity = 0.0f;
+ }
+ }
+
+ public void Dispose()
+ {
+ Clear();
+ }
+ }
+}
diff --git a/src/Ryujinx/UI/Overlay/RectangleElement.cs b/src/Ryujinx/UI/Overlay/RectangleElement.cs
new file mode 100644
index 000000000..a0ce52845
--- /dev/null
+++ b/src/Ryujinx/UI/Overlay/RectangleElement.cs
@@ -0,0 +1,78 @@
+using SkiaSharp;
+
+namespace Ryujinx.UI.Overlay
+{
+ ///
+ /// Rectangle overlay element
+ ///
+ public class RectangleElement : OverlayElement
+ {
+ public SKColor BackgroundColor { get; set; } = SKColors.Transparent;
+ public SKColor BorderColor { get; set; } = SKColors.Transparent;
+ public float BorderWidth { get; set; } = 0;
+ public float CornerRadius { get; set; } = 0;
+
+ public RectangleElement()
+ {
+ }
+
+ public RectangleElement(float x, float y, float width, float height, SKColor backgroundColor)
+ {
+ X = x;
+ Y = y;
+ Width = width;
+ Height = height;
+ BackgroundColor = backgroundColor;
+ }
+
+ public override void Render(SKCanvas canvas, float globalOpacity = 1.0f)
+ {
+ if (!IsVisible || Width <= 0 || Height <= 0)
+ return;
+
+ float effectiveOpacity = Opacity * globalOpacity;
+ var bounds = new SKRect(X, Y, X + Width, Y + Height);
+
+ // Draw background
+ if (BackgroundColor.Alpha > 0)
+ {
+ using var backgroundPaint = new SKPaint
+ {
+ Color = ApplyOpacity(BackgroundColor, effectiveOpacity),
+ Style = SKPaintStyle.Fill,
+ IsAntialias = true
+ };
+
+ if (CornerRadius > 0)
+ {
+ canvas.DrawRoundRect(bounds, CornerRadius, CornerRadius, backgroundPaint);
+ }
+ else
+ {
+ canvas.DrawRect(bounds, backgroundPaint);
+ }
+ }
+
+ // Draw border
+ if (BorderWidth > 0 && BorderColor.Alpha > 0)
+ {
+ using var borderPaint = new SKPaint
+ {
+ Color = ApplyOpacity(BorderColor, effectiveOpacity),
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = BorderWidth,
+ IsAntialias = true
+ };
+
+ if (CornerRadius > 0)
+ {
+ canvas.DrawRoundRect(bounds, CornerRadius, CornerRadius, borderPaint);
+ }
+ else
+ {
+ canvas.DrawRect(bounds, borderPaint);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Ryujinx/UI/Overlay/TextElement.cs b/src/Ryujinx/UI/Overlay/TextElement.cs
new file mode 100644
index 000000000..b4c412fda
--- /dev/null
+++ b/src/Ryujinx/UI/Overlay/TextElement.cs
@@ -0,0 +1,121 @@
+using SkiaSharp;
+
+namespace Ryujinx.UI.Overlay
+{
+ ///
+ /// Text overlay element
+ ///
+ public class TextElement : OverlayElement
+ {
+ public string Text { get; set; } = string.Empty;
+ public SKColor TextColor { get; set; } = SKColors.White;
+ public float FontSize { get; set; } = 16;
+ public string FontFamily { get; set; } = "Arial";
+ public SKFontStyle FontStyle { get; set; } = SKFontStyle.Normal;
+ public SKTextAlign TextAlign { get; set; } = SKTextAlign.Left;
+ public bool IsAntialias { get; set; } = true;
+
+ // Shadow properties
+ public bool HasShadow { get; set; } = false;
+ public SKColor ShadowColor { get; set; } = SKColors.Black;
+ public float ShadowOffsetX { get; set; } = 1;
+ public float ShadowOffsetY { get; set; } = 1;
+ public float ShadowBlur { get; set; } = 0;
+
+ public TextElement()
+ {
+ }
+
+ public TextElement(float x, float y, string text, float fontSize = 16, SKColor? color = null)
+ {
+ X = x;
+ Y = y;
+ Text = text;
+ FontSize = fontSize;
+ TextColor = color ?? SKColors.White;
+
+ // Auto-calculate width and height based on text
+ UpdateDimensions();
+ }
+
+ public override void Render(SKCanvas canvas, float globalOpacity = 1.0f)
+ {
+ if (!IsVisible || string.IsNullOrEmpty(Text))
+ return;
+
+ float effectiveOpacity = Opacity * globalOpacity;
+
+ using var typeface = SKTypeface.FromFamilyName(FontFamily, FontStyle);
+ using var paint = new SKPaint
+ {
+ Color = ApplyOpacity(TextColor, effectiveOpacity),
+ TextSize = FontSize,
+ Typeface = typeface,
+ TextAlign = TextAlign,
+ IsAntialias = IsAntialias
+ };
+
+ float textX = X;
+ float textY = Y + FontSize; // Baseline adjustment
+
+ // Adjust X position based on alignment
+ if (TextAlign == SKTextAlign.Center)
+ {
+ textX += Width / 2;
+ }
+ else if (TextAlign == SKTextAlign.Right)
+ {
+ textX += Width;
+ }
+
+ // Draw shadow if enabled
+ if (HasShadow)
+ {
+ using var shadowPaint = new SKPaint
+ {
+ Color = ApplyOpacity(ShadowColor, effectiveOpacity),
+ TextSize = FontSize,
+ Typeface = typeface,
+ TextAlign = TextAlign,
+ IsAntialias = IsAntialias
+ };
+
+ if (ShadowBlur > 0)
+ {
+ shadowPaint.MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, ShadowBlur);
+ }
+
+ canvas.DrawText(Text, textX + ShadowOffsetX, textY + ShadowOffsetY, shadowPaint);
+ }
+
+ // Draw main text
+ canvas.DrawText(Text, textX, textY, paint);
+ }
+
+ ///
+ /// Update width and height based on current text and font settings
+ ///
+ public void UpdateDimensions()
+ {
+ if (string.IsNullOrEmpty(Text))
+ {
+ Width = 0;
+ Height = 0;
+ return;
+ }
+
+ using var typeface = SKTypeface.FromFamilyName(FontFamily, FontStyle);
+ using var paint = new SKPaint
+ {
+ TextSize = FontSize,
+ Typeface = typeface
+ };
+
+ var bounds = new SKRect();
+ paint.MeasureText(Text, ref bounds);
+
+ Width = bounds.Width;
+ Height = FontSize; // Use font size as height for consistency
+ }
+ }
+}
diff --git a/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs b/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs
index 81aae6b74..7a775aa7c 100644
--- a/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs
+++ b/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs
@@ -526,18 +526,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
}
}
- private static string GetShortGamepadName(string str)
- {
- const string Ellipsis = "...";
- const int MaxSize = 50;
- if (str.Length > MaxSize)
- {
- return $"{str.AsSpan(0, MaxSize - Ellipsis.Length)}{Ellipsis}";
- }
-
- return str;
- }
private static string GetShortGamepadId(string str)
{
@@ -551,7 +540,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
{
string GetGamepadName(IGamepad gamepad, int controllerNumber)
{
- return $"{GetShortGamepadName(gamepad.Name)} ({controllerNumber})";
+ return $"{DefaultInputConfigurationProvider.GetShortGamepadName(gamepad.Name)} ({controllerNumber})";
}
string GetUniqueGamepadName(IGamepad gamepad, ref int controllerNumber)
@@ -579,7 +568,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
if (gamepad != null)
{
- Devices.Add((DeviceType.Keyboard, id, $"{GetShortGamepadName(gamepad.Name)}"));
+ Devices.Add((DeviceType.Keyboard, id, $"{DefaultInputConfigurationProvider.GetShortGamepadName(gamepad.Name)}"));
}
}
@@ -652,138 +641,21 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
InputConfig config;
if (activeDevice.Type == DeviceType.Keyboard)
{
- string id = activeDevice.Id;
- string name = activeDevice.Name;
-
- config = new StandardKeyboardInputConfig
- {
- Version = InputConfig.CurrentVersion,
- Backend = InputBackendType.WindowKeyboard,
- Id = id,
- Name = name,
- ControllerType = ControllerType.ProController,
- LeftJoycon = new LeftJoyconCommonConfig
- {
- DpadUp = Key.Up,
- DpadDown = Key.Down,
- DpadLeft = Key.Left,
- DpadRight = Key.Right,
- ButtonMinus = Key.Minus,
- ButtonL = Key.E,
- ButtonZl = Key.Q,
- ButtonSl = Key.Unbound,
- ButtonSr = Key.Unbound,
- },
- LeftJoyconStick =
- new JoyconConfigKeyboardStick
- {
- StickUp = Key.W,
- StickDown = Key.S,
- StickLeft = Key.A,
- StickRight = Key.D,
- StickButton = Key.F,
- },
- RightJoycon = new RightJoyconCommonConfig
- {
- ButtonA = Key.Z,
- ButtonB = Key.X,
- ButtonX = Key.C,
- ButtonY = Key.V,
- ButtonPlus = Key.Plus,
- ButtonR = Key.U,
- ButtonZr = Key.O,
- ButtonSl = Key.Unbound,
- ButtonSr = Key.Unbound,
- },
- RightJoyconStick = new JoyconConfigKeyboardStick
- {
- StickUp = Key.I,
- StickDown = Key.K,
- StickLeft = Key.J,
- StickRight = Key.L,
- StickButton = Key.H,
- },
- };
+ config = DefaultInputConfigurationProvider.CreateDefaultKeyboardConfig(activeDevice.Id, activeDevice.Name, _playerId);
}
else if (activeDevice.Type == DeviceType.Controller)
{
- bool isNintendoStyle = Devices.ToList().FirstOrDefault(x => x.Id == activeDevice.Id).Name.Contains("Nintendo");
-
- string id = activeDevice.Id.Split(" ")[0];
- string name = activeDevice.Name;
-
- config = new StandardControllerInputConfig
- {
- Version = InputConfig.CurrentVersion,
- Backend = InputBackendType.GamepadSDL2,
- Id = id,
- Name = name,
- ControllerType = ControllerType.ProController,
- DeadzoneLeft = 0.1f,
- DeadzoneRight = 0.1f,
- RangeLeft = 1.0f,
- RangeRight = 1.0f,
- TriggerThreshold = 0.5f,
- LeftJoycon = new LeftJoyconCommonConfig
- {
- DpadUp = ConfigGamepadInputId.DpadUp,
- DpadDown = ConfigGamepadInputId.DpadDown,
- DpadLeft = ConfigGamepadInputId.DpadLeft,
- DpadRight = ConfigGamepadInputId.DpadRight,
- ButtonMinus = ConfigGamepadInputId.Minus,
- ButtonL = ConfigGamepadInputId.LeftShoulder,
- ButtonZl = ConfigGamepadInputId.LeftTrigger,
- ButtonSl = ConfigGamepadInputId.Unbound,
- ButtonSr = ConfigGamepadInputId.Unbound,
- },
- LeftJoyconStick = new JoyconConfigControllerStick
- {
- Joystick = ConfigStickInputId.Left,
- StickButton = ConfigGamepadInputId.LeftStick,
- InvertStickX = false,
- InvertStickY = false,
- },
- RightJoycon = new RightJoyconCommonConfig
- {
- ButtonA = isNintendoStyle ? ConfigGamepadInputId.A : ConfigGamepadInputId.B,
- ButtonB = isNintendoStyle ? ConfigGamepadInputId.B : ConfigGamepadInputId.A,
- ButtonX = isNintendoStyle ? ConfigGamepadInputId.X : ConfigGamepadInputId.Y,
- ButtonY = isNintendoStyle ? ConfigGamepadInputId.Y : ConfigGamepadInputId.X,
- ButtonPlus = ConfigGamepadInputId.Plus,
- ButtonR = ConfigGamepadInputId.RightShoulder,
- ButtonZr = ConfigGamepadInputId.RightTrigger,
- ButtonSl = ConfigGamepadInputId.Unbound,
- ButtonSr = ConfigGamepadInputId.Unbound,
- },
- RightJoyconStick = new JoyconConfigControllerStick
- {
- Joystick = ConfigStickInputId.Right,
- StickButton = ConfigGamepadInputId.RightStick,
- InvertStickX = false,
- InvertStickY = false,
- },
- Motion = new StandardMotionConfigController
- {
- MotionBackend = MotionInputBackendType.GamepadDriver,
- EnableMotion = true,
- Sensitivity = 100,
- GyroDeadzone = 1,
- },
- Rumble = new RumbleConfigController
- {
- StrongRumble = 1f,
- WeakRumble = 1f,
- EnableRumble = false,
- },
- };
+ bool isNintendoStyle = DefaultInputConfigurationProvider.IsNintendoStyleController(activeDevice.Name);
+ config = DefaultInputConfigurationProvider.CreateDefaultControllerConfig(activeDevice.Id, activeDevice.Name, _playerId, isNintendoStyle);
}
else
{
- config = new InputConfig();
+ config = new InputConfig
+ {
+ PlayerIndex = _playerId
+ };
}
- config.PlayerIndex = _playerId;
-
return config;
}
diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs
index 4ca21e788..4c73f0aa4 100644
--- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs
+++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs
@@ -32,6 +32,7 @@ using Ryujinx.Ava.UI.Windows;
using Ryujinx.Ava.Utilities;
using Ryujinx.Common;
using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Configuration.Hid;
using Ryujinx.Common.Helper;
using Ryujinx.Common.Logging;
using Ryujinx.Common.UI;
@@ -1599,9 +1600,9 @@ namespace Ryujinx.Ava.UI.ViewModels
// Code where conditions will be executed after loading user configuration
if (ConfigurationState.Instance.Graphics.BackendThreading.Value.ToString() != backendThreadingInit)
{
- Rebooter.RebootAppWithGame(application.Path,
+ Rebooter.RebootAppWithGame(application.Path,
[
- "--bt",
+ "--bt",
ConfigurationState.Instance.Graphics.BackendThreading.Value.ToString()
]);
@@ -1690,11 +1691,34 @@ namespace Ryujinx.Ava.UI.ViewModels
SetMainContent(RendererHostControl);
RendererHostControl.Focus();
+
+ // Show controller overlay
+ ShowControllerOverlay();
});
public static void UpdateGameMetadata(string titleId, TimeSpan playTime)
=> ApplicationLibrary.LoadAndSaveMetaData(titleId, appMetadata => appMetadata.UpdatePostGame(playTime));
+ private void ShowControllerOverlay()
+ {
+ try
+ {
+ var inputConfigs = ConfigurationState.Instance.System.UseInputGlobalConfig.Value && Program.UseExtraConfig
+ ? ConfigurationState.InstanceExtra.Hid.InputConfig.Value
+ : ConfigurationState.Instance.Hid.InputConfig.Value;
+
+ // Always show overlay - if no configs, it will show test data
+ int duration = ConfigurationState.Instance.ControllerOverlayGameStartDuration.Value;
+ // Show overlay through the GPU context window directly
+ AppHost.ShowControllerOverlay(inputConfigs, duration);
+ }
+ catch (Exception ex)
+ {
+ // Log the error but don't let it crash the game launch
+ Logger.Error?.Print(LogClass.UI, $"Failed to show controller overlay: {ex.Message}");
+ }
+ }
+
public void RefreshFirmwareStatus()
{
SystemVersion version = null;
diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs
index 654eb0c43..b24b74dcb 100644
--- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs
+++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs
@@ -146,6 +146,8 @@ namespace Ryujinx.Ava.UI.ViewModels
public bool EnableMouse { get; set; }
public bool DisableInputWhenOutOfFocus { get; set; }
public int FocusLostActionType { get; set; }
+ public int ControllerOverlayGameStartDuration { get; set; }
+ public int ControllerOverlayInputCycleDuration { get; set; }
public bool UseGlobalInputConfig
{
@@ -587,6 +589,8 @@ namespace Ryujinx.Ava.UI.ViewModels
HideCursor = (int)config.HideCursor.Value;
UpdateCheckerType = (int)config.UpdateCheckerType.Value;
FocusLostActionType = (int)config.FocusLostActionType.Value;
+ ControllerOverlayGameStartDuration = config.ControllerOverlayGameStartDuration.Value;
+ ControllerOverlayInputCycleDuration = config.ControllerOverlayInputCycleDuration.Value;
GameDirectories.Clear();
GameDirectories.AddRange(config.UI.GameDirs.Value);
@@ -698,6 +702,8 @@ namespace Ryujinx.Ava.UI.ViewModels
config.HideCursor.Value = (HideCursorMode)HideCursor;
config.UpdateCheckerType.Value = (UpdaterType)UpdateCheckerType;
config.FocusLostActionType.Value = (FocusLostType)FocusLostActionType;
+ config.ControllerOverlayGameStartDuration.Value = ControllerOverlayGameStartDuration;
+ config.ControllerOverlayInputCycleDuration.Value = ControllerOverlayInputCycleDuration;
config.UI.GameDirs.Value = [.. GameDirectories];
config.UI.AutoloadDirs.Value = [.. AutoloadDirectories];
diff --git a/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml
index 917177fb5..e120a4ef1 100644
--- a/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml
+++ b/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml
@@ -120,6 +120,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml.cs b/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml.cs
index b9a5462b2..e547f2e70 100644
--- a/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml.cs
+++ b/src/Ryujinx/UI/Views/Settings/SettingsHotkeysView.axaml.cs
@@ -82,7 +82,16 @@ namespace Ryujinx.Ava.UI.Views.Settings
{ "VolumeDown", () => viewModel.KeyboardHotkey.VolumeDown = Key.Unbound },
{ "CustomVSyncIntervalIncrement", () => viewModel.KeyboardHotkey.CustomVSyncIntervalIncrement = Key.Unbound },
{ "CustomVSyncIntervalDecrement", () => viewModel.KeyboardHotkey.CustomVSyncIntervalDecrement = Key.Unbound },
- { "TurboMode", () => viewModel.KeyboardHotkey.TurboMode = Key.Unbound }
+ { "TurboMode", () => viewModel.KeyboardHotkey.TurboMode = Key.Unbound },
+ { "CycleInputDevicePlayer1", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer1 = Key.Unbound },
+ { "CycleInputDevicePlayer2", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer2 = Key.Unbound },
+ { "CycleInputDevicePlayer3", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer3 = Key.Unbound },
+ { "CycleInputDevicePlayer4", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer4 = Key.Unbound },
+ { "CycleInputDevicePlayer5", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer5 = Key.Unbound },
+ { "CycleInputDevicePlayer6", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer6 = Key.Unbound },
+ { "CycleInputDevicePlayer7", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer7 = Key.Unbound },
+ { "CycleInputDevicePlayer8", () => viewModel.KeyboardHotkey.CycleInputDevicePlayer8 = Key.Unbound },
+ { "CycleInputDeviceHandheld", () => viewModel.KeyboardHotkey.CycleInputDeviceHandheld = Key.Unbound }
};
if (buttonActions.TryGetValue(_currentAssigner.ToggledButton.Name, out Action action))
@@ -162,6 +171,33 @@ namespace Ryujinx.Ava.UI.Views.Settings
case "TurboMode":
ViewModel.KeyboardHotkey.TurboMode = buttonValue.AsHidType();
break;
+ case "CycleInputDevicePlayer1":
+ ViewModel.KeyboardHotkey.CycleInputDevicePlayer1 = buttonValue.AsHidType();
+ break;
+ case "CycleInputDevicePlayer2":
+ ViewModel.KeyboardHotkey.CycleInputDevicePlayer2 = buttonValue.AsHidType();
+ break;
+ case "CycleInputDevicePlayer3":
+ ViewModel.KeyboardHotkey.CycleInputDevicePlayer3 = buttonValue.AsHidType();
+ break;
+ case "CycleInputDevicePlayer4":
+ ViewModel.KeyboardHotkey.CycleInputDevicePlayer4 = buttonValue.AsHidType();
+ break;
+ case "CycleInputDevicePlayer5":
+ ViewModel.KeyboardHotkey.CycleInputDevicePlayer5 = buttonValue.AsHidType();
+ break;
+ case "CycleInputDevicePlayer6":
+ ViewModel.KeyboardHotkey.CycleInputDevicePlayer6 = buttonValue.AsHidType();
+ break;
+ case "CycleInputDevicePlayer7":
+ ViewModel.KeyboardHotkey.CycleInputDevicePlayer7 = buttonValue.AsHidType();
+ break;
+ case "CycleInputDevicePlayer8":
+ ViewModel.KeyboardHotkey.CycleInputDevicePlayer8 = buttonValue.AsHidType();
+ break;
+ case "CycleInputDeviceHandheld":
+ ViewModel.KeyboardHotkey.CycleInputDeviceHandheld = buttonValue.AsHidType();
+ break;
}
});
}
diff --git a/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml
index ad05efd06..0b9aa1514 100644
--- a/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml
+++ b/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml
@@ -157,6 +157,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+