diff --git a/Directory.Packages.props b/Directory.Packages.props
index f1d7cac614..6009313e79 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,6 +3,7 @@
true
+
diff --git a/Ryujinx.sln b/Ryujinx.sln
index 9e197e85ff..a144b674bd 100644
--- a/Ryujinx.sln
+++ b/Ryujinx.sln
@@ -75,6 +75,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Horizon", "src\Ryuj
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Horizon.Kernel.Generators", "src\Ryujinx.Horizon.Kernel.Generators\Ryujinx.Horizon.Kernel.Generators.csproj", "{7F55A45D-4E1D-4A36-ADD3-87F29A285AA2}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ryujinx.Input.SDL3", "src\Ryujinx.Input.SDL3\Ryujinx.Input.SDL3.csproj", "{3BF24278-547D-42C2-9D43-182B978F54DD}"
+EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.HLE.Generators", "src\Ryujinx.HLE.Generators\Ryujinx.HLE.Generators.csproj", "{B575BCDE-2FD8-4A5D-8756-31CDD7FE81F0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ryujinx.Graphics.Metal", "src\Ryujinx.Graphics.Metal\Ryujinx.Graphics.Metal.csproj", "{C08931FA-1191-417A-864F-3882D93E683B}"
@@ -259,6 +261,10 @@ Global
{81EA598C-DBA1-40B0-8DA4-4796B78F2037}.Debug|Any CPU.Build.0 = Debug|Any CPU
{81EA598C-DBA1-40B0-8DA4-4796B78F2037}.Release|Any CPU.ActiveCfg = Release|Any CPU
{81EA598C-DBA1-40B0-8DA4-4796B78F2037}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3BF24278-547D-42C2-9D43-182B978F54DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3BF24278-547D-42C2-9D43-182B978F54DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3BF24278-547D-42C2-9D43-182B978F54DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3BF24278-547D-42C2-9D43-182B978F54DD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/Ryujinx.Common/Configuration/Hid/Controller/Motion/JsonMotionConfigControllerConverter.cs b/src/Ryujinx.Common/Configuration/Hid/Controller/Motion/JsonMotionConfigControllerConverter.cs
index 40f067aea3..4f218c0d25 100644
--- a/src/Ryujinx.Common/Configuration/Hid/Controller/Motion/JsonMotionConfigControllerConverter.cs
+++ b/src/Ryujinx.Common/Configuration/Hid/Controller/Motion/JsonMotionConfigControllerConverter.cs
@@ -56,6 +56,7 @@ public override MotionConfigController Read(ref Utf8JsonReader reader, Type type
return motionBackendType switch
{
MotionInputBackendType.GamepadDriver => JsonSerializer.Deserialize(ref reader, _serializerContext.StandardMotionConfigController),
+ MotionInputBackendType.Handheld => JsonSerializer.Deserialize(ref reader, _serializerContext.StandardMotionConfigController),
MotionInputBackendType.CemuHook => JsonSerializer.Deserialize(ref reader, _serializerContext.CemuHookMotionConfigController),
_ => throw new InvalidOperationException($"Unknown backend type {motionBackendType}"),
};
@@ -66,6 +67,7 @@ public override void Write(Utf8JsonWriter writer, MotionConfigController value,
switch (value.MotionBackend)
{
case MotionInputBackendType.GamepadDriver:
+ case MotionInputBackendType.Handheld:
JsonSerializer.Serialize(writer, value as StandardMotionConfigController, _serializerContext.StandardMotionConfigController);
break;
case MotionInputBackendType.CemuHook:
diff --git a/src/Ryujinx.Common/Configuration/Hid/Controller/Motion/MotionInputBackendType.cs b/src/Ryujinx.Common/Configuration/Hid/Controller/Motion/MotionInputBackendType.cs
index fd8391289c..fd09b25d83 100644
--- a/src/Ryujinx.Common/Configuration/Hid/Controller/Motion/MotionInputBackendType.cs
+++ b/src/Ryujinx.Common/Configuration/Hid/Controller/Motion/MotionInputBackendType.cs
@@ -9,5 +9,6 @@ public enum MotionInputBackendType : byte
Invalid,
GamepadDriver,
CemuHook,
+ Handheld,
}
}
diff --git a/src/Ryujinx.Input.SDL3/Ryujinx.Input.SDL3.csproj b/src/Ryujinx.Input.SDL3/Ryujinx.Input.SDL3.csproj
new file mode 100644
index 0000000000..dc61e8b135
--- /dev/null
+++ b/src/Ryujinx.Input.SDL3/Ryujinx.Input.SDL3.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net9.0
+ true
+
+
+
+
+
+
+
+
diff --git a/src/Ryujinx.Input.SDL3/SDL3MotionDriver.cs b/src/Ryujinx.Input.SDL3/SDL3MotionDriver.cs
new file mode 100644
index 0000000000..f86ffbc4b3
--- /dev/null
+++ b/src/Ryujinx.Input.SDL3/SDL3MotionDriver.cs
@@ -0,0 +1,85 @@
+using SDL3;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using static SDL3.SDL3;
+
+namespace Ryujinx.Input.SDL3
+{
+ public unsafe class SDL3MotionDriver : IHandheld, IDisposable
+ {
+ private readonly Dictionary sensors;
+ private bool _disposed;
+
+ public SDL3MotionDriver()
+ {
+ int result = SDL_Init(SDL_InitFlags.Sensor);
+ if (result < 0)
+ {
+ throw new InvalidOperationException($"SDL sensor initialization failed: {SDL_GetError()}");
+ }
+ sensors = SDL_GetSensors().ToArray().ToDictionary(SDL_GetSensorTypeForID, SDL_OpenSensor);
+ }
+
+ ~SDL3MotionDriver()
+ {
+ Dispose(false);
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (disposing && sensors != null)
+ {
+ foreach (var sensor in sensors.Values)
+ {
+ if (sensor != IntPtr.Zero)
+ {
+ SDL_CloseSensor(sensor);
+ }
+ }
+ }
+
+ _disposed = true;
+ }
+
+ public Vector3 GetMotionData(MotionInputId inputType)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ return inputType switch
+ {
+ MotionInputId.Gyroscope => GetSensorVector(SDL_SensorType.Gyro) * 180 / MathF.PI,
+ MotionInputId.Accelerometer => GetSensorVector(SDL_SensorType.Accel) / SDL_STANDARD_GRAVITY,
+ _ => Vector3.Zero
+ };
+ }
+
+ private Vector3 GetSensorVector(SDL_SensorType sensorType)
+ {
+ if (!sensors.TryGetValue(sensorType, out SDL_Sensor sensor))
+ {
+ return Vector3.Zero;
+ }
+
+ var data = stackalloc float[3];
+ if (SDL_GetSensorData(sensor, data, 3) < 0)
+ {
+ return Vector3.Zero;
+ }
+
+ return new Vector3(data[0], data[1], data[2]);
+ }
+ }
+}
diff --git a/src/Ryujinx.Input/HLE/InputManager.cs b/src/Ryujinx.Input/HLE/InputManager.cs
index 2825542a01..2ccc78a2c2 100644
--- a/src/Ryujinx.Input/HLE/InputManager.cs
+++ b/src/Ryujinx.Input/HLE/InputManager.cs
@@ -2,12 +2,13 @@
namespace Ryujinx.Input.HLE
{
- public class InputManager(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver)
+ public class InputManager(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, IHandheld handheld)
: IDisposable
{
public IGamepadDriver KeyboardDriver { get; } = keyboardDriver;
public IGamepadDriver GamepadDriver { get; } = gamepadDriver;
public IGamepadDriver MouseDriver { get; private set; }
+ public IHandheld Handheld { get; } = handheld;
public void SetMouseDriver(IGamepadDriver mouseDriver)
{
@@ -18,7 +19,7 @@ public void SetMouseDriver(IGamepadDriver mouseDriver)
public NpadManager CreateNpadManager()
{
- return new NpadManager(KeyboardDriver, GamepadDriver, MouseDriver);
+ return new NpadManager(KeyboardDriver, GamepadDriver, MouseDriver, Handheld);
}
public TouchScreenManager CreateTouchScreenManager()
@@ -38,6 +39,7 @@ protected virtual void Dispose(bool disposing)
KeyboardDriver?.Dispose();
GamepadDriver?.Dispose();
MouseDriver?.Dispose();
+ Handheld?.Dispose();
}
}
diff --git a/src/Ryujinx.Input/HLE/NpadController.cs b/src/Ryujinx.Input/HLE/NpadController.cs
index b77ae5662d..60e0318e7e 100644
--- a/src/Ryujinx.Input/HLE/NpadController.cs
+++ b/src/Ryujinx.Input/HLE/NpadController.cs
@@ -218,12 +218,14 @@ public HLEKeyboardMappingEntry(Key targetKey, byte target)
public string Id { get; private set; }
private readonly CemuHookClient _cemuHookClient;
+ private readonly IHandheld _handheld;
- public NpadController(CemuHookClient cemuHookClient)
+ public NpadController(CemuHookClient cemuHookClient, IHandheld handheld)
{
State = default;
Id = null;
_cemuHookClient = cemuHookClient;
+ _handheld = handheld;
}
public bool UpdateDriverConfiguration(IGamepadDriver gamepadDriver, InputConfig config)
@@ -287,6 +289,18 @@ public void Update()
if (_config is StandardControllerInputConfig controllerConfig && controllerConfig.Motion.EnableMotion)
{
+ if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.Handheld)
+ {
+ Vector3 accelerometer = _handheld.GetMotionData(MotionInputId.Accelerometer);
+ Vector3 gyroscope = _handheld.GetMotionData(MotionInputId.Gyroscope);
+
+ accelerometer = new Vector3(accelerometer.X, -accelerometer.Z, accelerometer.Y);
+ gyroscope = new Vector3(gyroscope.X, -gyroscope.Z, gyroscope.Y);
+
+ _leftMotionInput.Update(accelerometer, gyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
+ _rightMotionInput = _leftMotionInput;
+ }
+
if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.GamepadDriver)
{
if (gamepad.Features.HasFlag(GamepadFeaturesFlag.Motion))
diff --git a/src/Ryujinx.Input/HLE/NpadManager.cs b/src/Ryujinx.Input/HLE/NpadManager.cs
index 21219d91b0..3bec2eb045 100644
--- a/src/Ryujinx.Input/HLE/NpadManager.cs
+++ b/src/Ryujinx.Input/HLE/NpadManager.cs
@@ -31,6 +31,7 @@ public class NpadManager : IDisposable
private readonly IGamepadDriver _keyboardDriver;
private readonly IGamepadDriver _gamepadDriver;
private readonly IGamepadDriver _mouseDriver;
+ private readonly IHandheld _handheld;
private bool _isDisposed;
private List _inputConfig;
@@ -38,7 +39,7 @@ public class NpadManager : IDisposable
private bool _enableMouse;
private Switch _device;
- public NpadManager(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, IGamepadDriver mouseDriver)
+ public NpadManager(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, IGamepadDriver mouseDriver, IHandheld handheld)
{
_controllers = new NpadController[MaxControllers];
_cemuHookClient = new CemuHookClient(this);
@@ -47,6 +48,7 @@ public NpadManager(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver,
_gamepadDriver = gamepadDriver;
_mouseDriver = mouseDriver;
_inputConfig = [];
+ _handheld = handheld;
_gamepadDriver.OnGamepadConnected += HandleOnGamepadConnected;
_gamepadDriver.OnGamepadDisconnected += HandleOnGamepadDisconnected;
@@ -139,7 +141,7 @@ public void ReloadConfiguration(List inputConfig, bool enableKeyboa
}
else
{
- controller = new(_cemuHookClient);
+ controller = new(_cemuHookClient, _handheld);
}
bool isValid = DriverConfigurationUpdate(ref controller, inputConfigEntry);
diff --git a/src/Ryujinx.Input/IHandheld.cs b/src/Ryujinx.Input/IHandheld.cs
new file mode 100644
index 0000000000..b2290147ba
--- /dev/null
+++ b/src/Ryujinx.Input/IHandheld.cs
@@ -0,0 +1,10 @@
+using System;
+using System.Numerics;
+
+namespace Ryujinx.Input
+{
+ public interface IHandheld : IDisposable
+ {
+ Vector3 GetMotionData(MotionInputId gyroscope);
+ }
+}
diff --git a/src/Ryujinx/Assets/locales.json b/src/Ryujinx/Assets/locales.json
index 3c753e1177..1fbbeb30e5 100644
--- a/src/Ryujinx/Assets/locales.json
+++ b/src/Ryujinx/Assets/locales.json
@@ -8197,6 +8197,31 @@
"zh_TW": "使用與 CemuHook 相容的體感"
}
},
+ {
+ "ID": "ControllerSettingsMotionUseHandheldCompatibleMotion",
+ "Translations": {
+ "ar_SA": "استخدام الحركة المتوافقة مع Hendheld",
+ "de_DE": "Hendheld kompatible Bewegungssteuerung",
+ "el_GR": "Κίνηση συμβατή με Hendheld",
+ "en_US": "Use Hendheld compatible motion",
+ "es_ES": "Usar movimiento compatible con Hendheld",
+ "fr_FR": "Utiliser un capteur de mouvements Hendheld",
+ "he_IL": "השתמש בתנועת Hendheld תואמת ",
+ "it_IT": "Usa sensore compatibile con Hendheld",
+ "ja_JP": "Hendheld 互換モーションを使用",
+ "ko_KR": "Hendheld 호환 모션 사용",
+ "no_NO": "Bruk Hendheld kompatibel bevegelse",
+ "pl_PL": "Użyj ruchu zgodnego z Hendheld",
+ "pt_BR": "Usar sensor compatível com Hendheld",
+ "ru_RU": "Включить совместимость с Hendheld",
+ "sv_SE": "Använd Hendheld-kompatibel rörelse",
+ "th_TH": "ใช้การเคลื่อนไหวที่เข้ากันได้กับ Hendheld",
+ "tr_TR": "Hendheld uyumlu hareket kullan",
+ "uk_UA": "Використовувати рух, сумісний з Hendheld",
+ "zh_CN": "使用 Hendheld 兼容的体感协议",
+ "zh_TW": "使用與 Hendheld 相容的體感"
+ }
+ },
{
"ID": "ControllerSettingsMotionControllerSlot",
"Translations": {
diff --git a/src/Ryujinx/Headless/HeadlessRyujinx.cs b/src/Ryujinx/Headless/HeadlessRyujinx.cs
index 003b8a31e3..9881531c40 100644
--- a/src/Ryujinx/Headless/HeadlessRyujinx.cs
+++ b/src/Ryujinx/Headless/HeadlessRyujinx.cs
@@ -23,6 +23,7 @@
using Ryujinx.Input;
using Ryujinx.Input.HLE;
using Ryujinx.Input.SDL2;
+using Ryujinx.Input.SDL3;
using Ryujinx.SDL2.Common;
using System;
using System.Collections.Generic;
@@ -182,7 +183,7 @@ static void Load(string[] originalArgs, Options option)
_accountManager = new AccountManager(_libHacHorizonManager.RyujinxClient, option.UserProfile);
_userChannelPersistence = new UserChannelPersistence();
- _inputManager = new InputManager(new SDL2KeyboardDriver(), new SDL2GamepadDriver());
+ _inputManager = new InputManager(new SDL2KeyboardDriver(), new SDL2GamepadDriver(), new SDL3MotionDriver());
GraphicsConfig.EnableShaderCache = !option.DisableShaderCache;
diff --git a/src/Ryujinx/Ryujinx.csproj b/src/Ryujinx/Ryujinx.csproj
index 174aadeb05..a5b08cfbd0 100644
--- a/src/Ryujinx/Ryujinx.csproj
+++ b/src/Ryujinx/Ryujinx.csproj
@@ -76,6 +76,7 @@
+
diff --git a/src/Ryujinx/UI/Models/Input/GamepadInputConfig.cs b/src/Ryujinx/UI/Models/Input/GamepadInputConfig.cs
index 300d7977cc..021240c6f8 100644
--- a/src/Ryujinx/UI/Models/Input/GamepadInputConfig.cs
+++ b/src/Ryujinx/UI/Models/Input/GamepadInputConfig.cs
@@ -10,6 +10,7 @@ namespace Ryujinx.Ava.UI.Models.Input
public partial class GamepadInputConfig : BaseModel
{
public bool EnableCemuHookMotion { get; set; }
+ public bool EnableHandheldMotion { get; set; }
public string DsuServerHost { get; set; }
public int DsuServerPort { get; set; }
public int Slot { get; set; }
@@ -162,7 +163,7 @@ public GamepadInputConfig(InputConfig config)
EnableMotion = controllerInput.Motion.EnableMotion;
GyroDeadzone = controllerInput.Motion.GyroDeadzone;
Sensitivity = controllerInput.Motion.Sensitivity;
-
+ EnableHandheldMotion = controllerInput.Motion.MotionBackend == MotionInputBackendType.Handheld;
if (controllerInput.Motion is CemuHookMotionConfigController cemuHook)
{
EnableCemuHookMotion = true;
@@ -285,7 +286,7 @@ public InputConfig GetConfig()
config.Motion = new StandardMotionConfigController
{
EnableMotion = EnableMotion,
- MotionBackend = MotionInputBackendType.GamepadDriver,
+ MotionBackend = EnableHandheldMotion ? MotionInputBackendType.Handheld : MotionInputBackendType.GamepadDriver,
GyroDeadzone = GyroDeadzone,
Sensitivity = Sensitivity,
};
diff --git a/src/Ryujinx/UI/ViewModels/Input/MotionInputViewModel.cs b/src/Ryujinx/UI/ViewModels/Input/MotionInputViewModel.cs
index ba8686831a..f3628d3b3d 100644
--- a/src/Ryujinx/UI/ViewModels/Input/MotionInputViewModel.cs
+++ b/src/Ryujinx/UI/ViewModels/Input/MotionInputViewModel.cs
@@ -18,6 +18,34 @@ public partial class MotionInputViewModel : BaseModel
[ObservableProperty] private double _gyroDeadzone;
- [ObservableProperty] private bool _enableCemuHookMotion;
+ private bool _enableCemuHookMotion;
+ public bool EnableCemuHookMotion
+ {
+ get => _enableCemuHookMotion;
+ set
+ {
+ if (value)
+ {
+ EnableHandheldMotion = false;
+ }
+ _enableCemuHookMotion = value;
+ OnPropertyChanged();
+ }
+ }
+
+ private bool _enableHandheldMotion;
+ public bool EnableHandheldMotion
+ {
+ get => _enableHandheldMotion;
+ set
+ {
+ if (value)
+ {
+ EnableCemuHookMotion = false;
+ }
+ _enableHandheldMotion = value;
+ OnPropertyChanged();
+ }
+ }
}
}
diff --git a/src/Ryujinx/UI/Views/Input/MotionInputView.axaml b/src/Ryujinx/UI/Views/Input/MotionInputView.axaml
index 9096a06d1a..55c2850c19 100644
--- a/src/Ryujinx/UI/Views/Input/MotionInputView.axaml
+++ b/src/Ryujinx/UI/Views/Input/MotionInputView.axaml
@@ -61,6 +61,17 @@
Margin="5, 0"
Text="{Binding GyroDeadzone, StringFormat=\{0:0.00\}}" />
+
+
+
+
diff --git a/src/Ryujinx/UI/Views/Input/MotionInputView.axaml.cs b/src/Ryujinx/UI/Views/Input/MotionInputView.axaml.cs
index 36068821f8..95a9af316c 100644
--- a/src/Ryujinx/UI/Views/Input/MotionInputView.axaml.cs
+++ b/src/Ryujinx/UI/Views/Input/MotionInputView.axaml.cs
@@ -30,6 +30,7 @@ public MotionInputView(ControllerInputViewModel viewModel)
Sensitivity = config.Sensitivity,
GyroDeadzone = config.GyroDeadzone,
EnableCemuHookMotion = config.EnableCemuHookMotion,
+ EnableHandheldMotion = config.EnableHandheldMotion,
};
InitializeComponent();
@@ -58,6 +59,7 @@ public static async Task Show(ControllerInputViewModel viewModel)
config.DsuServerHost = content._viewModel.DsuServerHost;
config.DsuServerPort = content._viewModel.DsuServerPort;
config.EnableCemuHookMotion = content._viewModel.EnableCemuHookMotion;
+ config.EnableHandheldMotion = content._viewModel.EnableHandheldMotion;
config.MirrorInput = content._viewModel.MirrorInput;
};
diff --git a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs
index 96b3d08aaa..2e249c8766 100644
--- a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs
+++ b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs
@@ -31,6 +31,7 @@
using Ryujinx.HLE.HOS.Services.Account.Acc;
using Ryujinx.Input.HLE;
using Ryujinx.Input.SDL2;
+using Ryujinx.Input.SDL3;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -109,7 +110,7 @@ public MainWindow()
if (Program.PreviewerDetached)
{
- InputManager = new InputManager(new AvaloniaKeyboardDriver(this), new SDL2GamepadDriver());
+ InputManager = new InputManager(new AvaloniaKeyboardDriver(this), new SDL2GamepadDriver(), new SDL3MotionDriver());
_ = this.GetObservable(IsActiveProperty).Subscribe(it => ViewModel.IsActive = it);
this.ScalingChanged += OnScalingChanged;