Merge branch 'input-cycle' into 'master'

Input cycling and overlay system

See merge request [ryubing/ryujinx!72](https://git.ryujinx.app/ryubing/ryujinx/-/merge_requests/72)
This commit is contained in:
SomeoneIsWorking 2025-06-23 18:03:31 -05:00
commit cbc50e72a9
35 changed files with 2151 additions and 151 deletions

View file

@ -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": ""
}
}
]
}

View file

@ -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
{
/// <summary>
/// Provides default input configurations for keyboard and controller devices
/// </summary>
public static class DefaultInputConfigurationProvider
{
/// <summary>
/// Creates a default keyboard input configuration
/// </summary>
/// <param name="id">Device ID</param>
/// <param name="name">Device name</param>
/// <param name="playerIndex">Player index</param>
/// <param name="controllerType">Controller type (defaults to ProController)</param>
/// <returns>Default keyboard input configuration</returns>
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<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,
},
};
}
/// <summary>
/// Creates a default controller input configuration
/// </summary>
/// <param name="id">Device ID</param>
/// <param name="name">Device name</param>
/// <param name="playerIndex">Player index</param>
/// <param name="isNintendoStyle">Whether to use Nintendo-style button mapping</param>
/// <returns>Default controller input configuration</returns>
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<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
{
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,
}
};
}
/// <summary>
/// Gets the short name of a gamepad by removing SDL prefix and truncating if too long
/// </summary>
/// <param name="name">Full gamepad name</param>
/// <param name="maxLength">Maximum length before truncation (default: 50)</param>
/// <returns>Short gamepad name</returns>
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;
}
/// <summary>
/// Determines if a controller uses Nintendo-style button mapping
/// </summary>
/// <param name="name">Controller name</param>
/// <returns>True if Nintendo-style mapping should be used</returns>
public static bool IsNintendoStyleController(string name)
{
return name.Contains("Nintendo");
}
}
}

View file

@ -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; }
}
}

View file

@ -41,5 +41,10 @@ namespace Ryujinx.Graphics.GAL.Multithreading
public void SetScalingFilterLevel(float level) { }
public void SetColorSpacePassthrough(bool colorSpacePassthroughEnabled) { }
/// <summary>
/// Gets the underlying implementation window for direct access
/// </summary>
public IWindow BaseWindow => _impl.Window;
}
}

View file

@ -40,6 +40,7 @@ namespace Ryujinx.Graphics.Gpu
/// GPU synchronization manager.
/// </summary>
public SynchronizationManager Synchronization { get; }
public IOverlayManager OverlayManager { get; }
/// <summary>
/// Presentation window.
@ -121,14 +122,18 @@ namespace Ryujinx.Graphics.Gpu
/// Creates a new instance of the GPU emulation context.
/// </summary>
/// <param name="renderer">Host renderer</param>
public GpuContext(IRenderer renderer, DirtyHacks hacks)
/// <param name="hacks">Enabled dirty hacks</param>
/// <param name="overlayManager">Overlay manager for rendering overlays</param>
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();
}
}
}

View file

@ -0,0 +1,54 @@
using SkiaSharp;
using System;
namespace Ryujinx.Graphics.Gpu
{
/// <summary>
/// Interface for overlay functionality
/// </summary>
public interface IOverlay : IDisposable
{
/// <summary>
/// Name of the overlay
/// </summary>
string Name { get; set; }
/// <summary>
/// Whether the overlay is visible
/// </summary>
bool IsVisible { get; set; }
/// <summary>
/// Opacity of the overlay (0.0 to 1.0)
/// </summary>
float Opacity { get; set; }
/// <summary>
/// X position of the overlay
/// </summary>
float X { get; set; }
/// <summary>
/// Y position of the overlay
/// </summary>
float Y { get; set; }
/// <summary>
/// Z-index for overlay ordering
/// </summary>
int ZIndex { get; set; }
/// <summary>
/// Update overlay (for animations)
/// </summary>
/// <param name="deltaTime">Time elapsed since last update</param>
/// <param name="screenSize">Current screen size</param>
void Update(float deltaTime, SKSize screenSize = default);
/// <summary>
/// Render this overlay
/// </summary>
/// <param name="canvas">The canvas to render to</param>
void Render(SKCanvas canvas);
}
}

View file

@ -0,0 +1,30 @@
using SkiaSharp;
using System;
namespace Ryujinx.Graphics.Gpu
{
/// <summary>
/// Interface for overlay management functionality
/// </summary>
public interface IOverlayManager : IDisposable
{
/// <summary>
/// Add an overlay to the manager
/// </summary>
/// <param name="overlay">The overlay to add</param>
void AddOverlay(IOverlay overlay);
/// <summary>
/// Update all overlays (for animations)
/// </summary>
/// <param name="deltaTime">Time elapsed since last update</param>
/// <param name="screenSize">Current screen size</param>
void Update(float deltaTime, SKSize screenSize = default);
/// <summary>
/// Render all visible overlays
/// </summary>
/// <param name="canvas">The canvas to render to</param>
void Render(SKCanvas canvas);
}
}

View file

@ -14,4 +14,8 @@
<ProjectReference Include="..\Ryujinx.Graphics.Shader\Ryujinx.Graphics.Shader.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SkiaSharp" />
</ItemGroup>
</Project>

View file

@ -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;
/// <summary>
/// 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
}
}
/// <summary>
/// Add overlay to the overlay manager
/// </summary>
public void AddOverlay(IOverlay overlay)
{
_context.OverlayManager.AddOverlay(overlay);
}
/// <summary>
/// Add overlays to the texture using SkiaSharp
/// </summary>
/// <param name="texture">The texture to modify</param>
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<byte>.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}");
}
}
/// <summary>
/// Indicate that a frame on the queue is ready to be acquired.
/// </summary>

View file

@ -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
/// <remarks>This cannot be changed after <see cref="Switch"/> instantiation.</remarks>
internal IHostUIHandler HostUIHandler { get; private set; }
/// <summary>
/// The overlay manager to use for all overlay operations.
/// </summary>
/// <remarks>This cannot be changed after <see cref="Switch"/> instantiation.</remarks>
internal IOverlayManager OverlayManager { get; private set; }
/// <summary>
/// Control the memory configuration used by the emulation context.
/// </summary>
@ -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;
}
}

View file

@ -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);

View file

@ -15,5 +15,14 @@ namespace Ryujinx.Ava.Common
CustomVSyncIntervalIncrement,
CustomVSyncIntervalDecrement,
TurboMode,
CycleInputDevicePlayer1,
CycleInputDevicePlayer2,
CycleInputDevicePlayer3,
CycleInputDevicePlayer4,
CycleInputDevicePlayer5,
CycleInputDevicePlayer6,
CycleInputDevicePlayer7,
CycleInputDevicePlayer8,
CycleInputDeviceHandheld,
}
}

View file

@ -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<string>
{
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<string>

View file

@ -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()
)
);
}

View file

@ -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<StatusUpdatedEventArgs> 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<InputConfig> 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<InputConfig> 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;
}

View file

@ -15,7 +15,7 @@ namespace Ryujinx.Ava.Systems.Configuration
/// <summary>
/// The current version of the file format
/// </summary>
public const int CurrentVersion = 70;
public const int CurrentVersion = 72;
/// <summary>
/// Version of the configuration file format
@ -217,6 +217,16 @@ namespace Ryujinx.Ava.Systems.Configuration
/// </summary>
public HideCursorMode HideCursor { get; set; }
/// <summary>
/// Duration to show controller overlay when game starts (seconds, 0 = disabled)
/// </summary>
public int ControllerOverlayGameStartDuration { get; set; }
/// <summary>
/// Duration to show controller overlay when input is cycled (seconds, 0 = disabled)
/// </summary>
public int ControllerOverlayInputCycleDuration { get; set; }
/// <summary>
/// Enables or disables Vertical Sync
/// </summary>

View file

@ -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
};
})
);
}
}

View file

@ -846,6 +846,16 @@ namespace Ryujinx.Ava.Systems.Configuration
/// </summary>
public ReactiveObject<HideCursorMode> HideCursor { get; private set; }
/// <summary>
/// Duration to show controller overlay when game starts (seconds, 0 = disabled)
/// </summary>
public ReactiveObject<int> ControllerOverlayGameStartDuration { get; private set; }
/// <summary>
/// Duration to show controller overlay when input is cycled (seconds, 0 = disabled)
/// </summary>
public ReactiveObject<int> ControllerOverlayInputCycleDuration { get; private set; }
private ConfigurationState()
{
UI = new UISection();
@ -863,6 +873,8 @@ namespace Ryujinx.Ava.Systems.Configuration
RememberWindowState = new ReactiveObject<bool>();
ShowOldUI = new ReactiveObject<bool>();
EnableHardwareAcceleration = new ReactiveObject<bool>();
ControllerOverlayGameStartDuration = new ReactiveObject<int>();
ControllerOverlayInputCycleDuration = new ReactiveObject<int>();
}
public HleConfiguration CreateHleConfiguration() =>

View file

@ -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 =

View file

@ -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();
}
}
}

View file

@ -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
};
}
}

View file

@ -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
{
/// <summary>
/// Controller overlay that shows controller bindings matching the original AXAML design
/// </summary>
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);
}
/// <summary>
/// Show controller bindings with localized strings
/// </summary>
public void ShowControllerBindings(List<OriginalInputConfig> inputConfigs, int durationSeconds)
{
// Update title text
var titleElement = FindElement<TextElement>("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<OriginalPlayerIndex, List<OriginalInputConfig>>();
foreach (var config in inputConfigs.Where(c => c.PlayerIndex <= OriginalPlayerIndex.Handheld))
{
if (!playerBindings.ContainsKey(config.PlayerIndex))
{
playerBindings[config.PlayerIndex] = new List<OriginalInputConfig>();
}
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<RectangleElement>("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;
}
/// <summary>
/// Clear all player bindings
/// </summary>
private void ClearPlayerBindings()
{
var elementsToRemove = new List<OverlayElement>();
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();
}
}
/// <summary>
/// Update overlay
/// </summary>
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);
}
}
/// <summary>
/// Position overlay to top-right matching original AXAML positioning
/// </summary>
public void SetPositionToTopRight(float screenWidth, float screenHeight)
{
X = screenWidth - OverlayWidth - 20; // 20px margin from right
Y = 50; // 50px margin from top
}
}
}

View file

@ -0,0 +1,146 @@
using Ryujinx.Common.Logging;
using SkiaSharp;
using System;
namespace Ryujinx.UI.Overlay
{
/// <summary>
/// Image overlay element
/// </summary>
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);
}
/// <summary>
/// Set image from byte array
/// </summary>
public void SetImageData(byte[] imageData)
{
_imageData = imageData;
_imagePath = null;
LoadBitmap();
}
/// <summary>
/// Set image from file path
/// </summary>
public void SetImagePath(string imagePath)
{
_imagePath = imagePath;
_imageData = null;
LoadBitmap();
}
/// <summary>
/// Set image from existing SKBitmap
/// </summary>
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);
}
}
}

View file

@ -0,0 +1,116 @@
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.Linq;
using Ryujinx.Graphics.Gpu;
namespace Ryujinx.UI.Overlay
{
/// <summary>
/// Base overlay class containing multiple elements
/// </summary>
public abstract class Overlay : IOverlay
{
private readonly List<OverlayElement> _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;
}
/// <summary>
/// Add an element to this overlay
/// </summary>
public void AddElement(OverlayElement element)
{
_elements.Add(element);
}
/// <summary>
/// Remove an element from this overlay
/// </summary>
public void RemoveElement(OverlayElement element)
{
_elements.Remove(element);
}
/// <summary>
/// Get all elements
/// </summary>
public IReadOnlyList<OverlayElement> GetElements()
{
return _elements.AsReadOnly();
}
/// <summary>
/// Find element by name
/// </summary>
public T FindElement<T>(string name) where T : OverlayElement
{
return _elements.OfType<T>().FirstOrDefault(e => e.Name == name);
}
/// <summary>
/// Clear all elements
/// </summary>
public void Clear()
{
foreach (var element in _elements)
{
element.Dispose();
}
_elements.Clear();
}
/// <summary>
/// Update overlay
/// </summary>
public abstract void Update(float deltaTime, SKSize screenSize = default);
/// <summary>
/// Render this overlay
/// </summary>
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();
}
}
}

View file

@ -0,0 +1,66 @@
using SkiaSharp;
using System;
namespace Ryujinx.UI.Overlay
{
/// <summary>
/// Base class for all overlay elements
/// </summary>
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;
/// <summary>
/// Render this element to the canvas
/// </summary>
/// <param name="canvas">The canvas to draw on</param>
/// <param name="globalOpacity">Global opacity multiplier</param>
public abstract void Render(SKCanvas canvas, float globalOpacity = 1.0f);
/// <summary>
/// Check if a point is within this element's bounds
/// </summary>
public virtual bool Contains(float x, float y)
{
return x >= X && x <= X + Width && y >= Y && y <= Y + Height;
}
/// <summary>
/// Get the bounds of this element
/// </summary>
public SKRect GetBounds()
{
return new SKRect(X, Y, X + Width, Y + Height);
}
/// <summary>
/// Apply opacity to a color
/// </summary>
protected SKColor ApplyOpacity(SKColor color, float opacity)
{
return color.WithAlpha((byte)(color.Alpha * opacity));
}
/// <summary>
/// Dispose of resources
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Dispose of resources
/// </summary>
protected virtual void Dispose(bool disposing)
{
}
}
}

View file

@ -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
{
/// <summary>
/// Manages multiple overlays and handles rendering
/// </summary>
public class OverlayManager : IOverlayManager
{
private readonly List<IOverlay> _overlays = new();
private readonly object _lock = new();
/// <summary>
/// Add an overlay to the manager
/// </summary>
public void AddOverlay(IOverlay overlay)
{
lock (_lock)
{
_overlays.Add(overlay);
SortOverlays();
}
}
/// <summary>
/// Remove an overlay from the manager
/// </summary>
public void RemoveOverlay(Overlay overlay)
{
lock (_lock)
{
_overlays.Remove(overlay);
}
}
/// <summary>
/// Remove overlay by name
/// </summary>
public void RemoveOverlay(string name)
{
lock (_lock)
{
var overlay = _overlays.FirstOrDefault(o => o.Name == name);
if (overlay != null)
{
_overlays.Remove(overlay);
overlay.Dispose();
}
}
}
/// <summary>
/// Find overlay by name
/// </summary>
public IOverlay FindOverlay(string name)
{
lock (_lock)
{
return _overlays.FirstOrDefault(o => o.Name == name);
}
}
/// <summary>
/// Get all overlays
/// </summary>
public IReadOnlyList<IOverlay> GetOverlays()
{
lock (_lock)
{
return _overlays.AsReadOnly();
}
}
/// <summary>
/// Clear all overlays
/// </summary>
public void Clear()
{
lock (_lock)
{
foreach (var overlay in _overlays)
{
overlay.Dispose();
}
_overlays.Clear();
}
}
/// <summary>
/// Update all overlays (for animations)
/// </summary>
public void Update(float deltaTime, SKSize screenSize = default)
{
lock (_lock)
{
foreach (var overlay in _overlays.Where(o => o.IsVisible))
{
overlay.Update(deltaTime, screenSize);
}
}
}
/// <summary>
/// Render all visible overlays
/// </summary>
public void Render(SKCanvas canvas)
{
lock (_lock)
{
foreach (var overlay in _overlays.Where(o => o.IsVisible && o.Opacity > 0.0f))
{
overlay.Render(canvas);
}
}
}
/// <summary>
/// Sort overlays by Z-index
/// </summary>
private void SortOverlays()
{
_overlays.Sort((a, b) => a.ZIndex.CompareTo(b.ZIndex));
}
/// <summary>
/// Show overlay
/// </summary>
public void ShowOverlay(string name)
{
var overlay = FindOverlay(name);
if (overlay != null)
{
overlay.IsVisible = true;
overlay.Opacity = 1.0f;
}
}
/// <summary>
/// Hide overlay
/// </summary>
public void HideOverlay(string name)
{
var overlay = FindOverlay(name);
if (overlay != null)
{
overlay.IsVisible = false;
overlay.Opacity = 0.0f;
}
}
public void Dispose()
{
Clear();
}
}
}

View file

@ -0,0 +1,78 @@
using SkiaSharp;
namespace Ryujinx.UI.Overlay
{
/// <summary>
/// Rectangle overlay element
/// </summary>
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);
}
}
}
}
}

View file

@ -0,0 +1,121 @@
using SkiaSharp;
namespace Ryujinx.UI.Overlay
{
/// <summary>
/// Text overlay element
/// </summary>
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);
}
/// <summary>
/// Update width and height based on current text and font settings
/// </summary>
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
}
}
}

View file

@ -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<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,
},
};
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<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,
},
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;
}

View file

@ -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;

View file

@ -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];

View file

@ -120,6 +120,60 @@
<TextBlock Text="{ext:Locale SettingsTabHotkeysOnlyWhilePressed}" Margin="10,0" />
<CheckBox IsChecked="{Binding KeyboardHotkey.TurboModeWhileHeld}" />
</StackPanel>
<StackPanel>
<TextBlock Text="{ext:Locale ControllerSettingsPlayer1, Converter={x:Static helpers:PlayerHotkeyLabelConverter.Instance}}" Classes="settingHeader" />
<ToggleButton Name="CycleInputDevicePlayer1">
<TextBlock Text="{Binding KeyboardHotkey.CycleInputDevicePlayer1, Converter={x:Static helpers:KeyValueConverter.Instance}}" />
</ToggleButton>
</StackPanel>
<StackPanel>
<TextBlock Text="{ext:Locale ControllerSettingsPlayer2, Converter={x:Static helpers:PlayerHotkeyLabelConverter.Instance}}" Classes="settingHeader" />
<ToggleButton Name="CycleInputDevicePlayer2">
<TextBlock Text="{Binding KeyboardHotkey.CycleInputDevicePlayer2, Converter={x:Static helpers:KeyValueConverter.Instance}}" />
</ToggleButton>
</StackPanel>
<StackPanel>
<TextBlock Text="{ext:Locale ControllerSettingsPlayer3, Converter={x:Static helpers:PlayerHotkeyLabelConverter.Instance}}" Classes="settingHeader" />
<ToggleButton Name="CycleInputDevicePlayer3">
<TextBlock Text="{Binding KeyboardHotkey.CycleInputDevicePlayer3, Converter={x:Static helpers:KeyValueConverter.Instance}}" />
</ToggleButton>
</StackPanel>
<StackPanel>
<TextBlock Text="{ext:Locale ControllerSettingsPlayer4, Converter={x:Static helpers:PlayerHotkeyLabelConverter.Instance}}" Classes="settingHeader" />
<ToggleButton Name="CycleInputDevicePlayer4">
<TextBlock Text="{Binding KeyboardHotkey.CycleInputDevicePlayer4, Converter={x:Static helpers:KeyValueConverter.Instance}}" />
</ToggleButton>
</StackPanel>
<StackPanel>
<TextBlock Text="{ext:Locale ControllerSettingsPlayer5, Converter={x:Static helpers:PlayerHotkeyLabelConverter.Instance}}" Classes="settingHeader" />
<ToggleButton Name="CycleInputDevicePlayer5">
<TextBlock Text="{Binding KeyboardHotkey.CycleInputDevicePlayer5, Converter={x:Static helpers:KeyValueConverter.Instance}}" />
</ToggleButton>
</StackPanel>
<StackPanel>
<TextBlock Text="{ext:Locale ControllerSettingsPlayer6, Converter={x:Static helpers:PlayerHotkeyLabelConverter.Instance}}" Classes="settingHeader" />
<ToggleButton Name="CycleInputDevicePlayer6">
<TextBlock Text="{Binding KeyboardHotkey.CycleInputDevicePlayer6, Converter={x:Static helpers:KeyValueConverter.Instance}}" />
</ToggleButton>
</StackPanel>
<StackPanel>
<TextBlock Text="{ext:Locale ControllerSettingsPlayer7, Converter={x:Static helpers:PlayerHotkeyLabelConverter.Instance}}" Classes="settingHeader" />
<ToggleButton Name="CycleInputDevicePlayer7">
<TextBlock Text="{Binding KeyboardHotkey.CycleInputDevicePlayer7, Converter={x:Static helpers:KeyValueConverter.Instance}}" />
</ToggleButton>
</StackPanel>
<StackPanel>
<TextBlock Text="{ext:Locale ControllerSettingsPlayer8, Converter={x:Static helpers:PlayerHotkeyLabelConverter.Instance}}" Classes="settingHeader" />
<ToggleButton Name="CycleInputDevicePlayer8">
<TextBlock Text="{Binding KeyboardHotkey.CycleInputDevicePlayer8, Converter={x:Static helpers:KeyValueConverter.Instance}}" />
</ToggleButton>
</StackPanel>
<StackPanel>
<TextBlock Text="{ext:Locale ControllerSettingsHandheld, Converter={x:Static helpers:PlayerHotkeyLabelConverter.Instance}}" Classes="settingHeader" />
<ToggleButton Name="CycleInputDeviceHandheld">
<TextBlock Text="{Binding KeyboardHotkey.CycleInputDeviceHandheld, Converter={x:Static helpers:KeyValueConverter.Instance}}" />
</ToggleButton>
</StackPanel>
</StackPanel>
</Border>
</ScrollViewer>

View file

@ -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<Key>();
break;
case "CycleInputDevicePlayer1":
ViewModel.KeyboardHotkey.CycleInputDevicePlayer1 = buttonValue.AsHidType<Key>();
break;
case "CycleInputDevicePlayer2":
ViewModel.KeyboardHotkey.CycleInputDevicePlayer2 = buttonValue.AsHidType<Key>();
break;
case "CycleInputDevicePlayer3":
ViewModel.KeyboardHotkey.CycleInputDevicePlayer3 = buttonValue.AsHidType<Key>();
break;
case "CycleInputDevicePlayer4":
ViewModel.KeyboardHotkey.CycleInputDevicePlayer4 = buttonValue.AsHidType<Key>();
break;
case "CycleInputDevicePlayer5":
ViewModel.KeyboardHotkey.CycleInputDevicePlayer5 = buttonValue.AsHidType<Key>();
break;
case "CycleInputDevicePlayer6":
ViewModel.KeyboardHotkey.CycleInputDevicePlayer6 = buttonValue.AsHidType<Key>();
break;
case "CycleInputDevicePlayer7":
ViewModel.KeyboardHotkey.CycleInputDevicePlayer7 = buttonValue.AsHidType<Key>();
break;
case "CycleInputDevicePlayer8":
ViewModel.KeyboardHotkey.CycleInputDevicePlayer8 = buttonValue.AsHidType<Key>();
break;
case "CycleInputDeviceHandheld":
ViewModel.KeyboardHotkey.CycleInputDeviceHandheld = buttonValue.AsHidType<Key>();
break;
}
});
}

View file

@ -157,6 +157,32 @@
</ComboBox>
<TextBlock Classes="globalConfigMarker" IsVisible="{Binding IsGameTitleNotNull}" />
</StackPanel>
<Separator Height="1" Margin="0,15,0,15" />
<TextBlock Classes="h1" Text="{ext:Locale SettingsTabUIControllerOverlay}" />
<StackPanel Margin="10,0,0,0" Orientation="Vertical" Spacing="10">
<StackPanel Orientation="Horizontal">
<TextBlock VerticalAlignment="Center"
Text="{ext:Locale SettingsTabUIControllerOverlayGameStartDuration}"
Width="200" />
<NumericUpDown Value="{Binding ControllerOverlayGameStartDuration}"
Minimum="0"
Maximum="30"
Increment="1"
FormatString="0"
ToolTip.Tip="{ext:Locale SettingsTabUIControllerOverlayGameStartDurationTooltip}" />
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock VerticalAlignment="Center"
Text="{ext:Locale SettingsTabUIControllerOverlayInputCycleDuration}"
Width="200" />
<NumericUpDown Value="{Binding ControllerOverlayInputCycleDuration}"
Minimum="0"
Maximum="30"
Increment="1"
FormatString="0"
ToolTip.Tip="{ext:Locale SettingsTabUIControllerOverlayInputCycleDurationTooltip}" />
</StackPanel>
</StackPanel>
</StackPanel>
</StackPanel>
<Border Grid.Column="1"

View file

@ -10,6 +10,7 @@
xmlns:controls="clr-namespace:Ryujinx.Ava.UI.Controls"
xmlns:main="clr-namespace:Ryujinx.Ava.UI.Views.Main"
xmlns:viewsMisc="clr-namespace:Ryujinx.Ava.UI.Views.Misc"
xmlns:overlayControls="clr-namespace:Ryujinx.Ava.UI.Controls"
Cursor="{Binding Cursor}"
Title="{Binding Title}"
WindowState="{Binding WindowState}"
@ -178,5 +179,7 @@
Name="StatusBarView"
Grid.Row="2" />
</Grid>
</Grid>
</window:StyleableAppWindow>