diff --git a/assets/Locales/Root.json b/assets/Locales/Root.json index 9ca437a83..75ffe3bbe 100644 --- a/assets/Locales/Root.json +++ b/assets/Locales/Root.json @@ -6200,6 +6200,206 @@ "zh_TW": "" } }, + { + "ID": "ControllerSettingsDynamicInputSwap", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Dynamic Input Swap", + "es_ES": "Cambio Dinámico de Entrada", + "fr_FR": "Bascule Dynamique des Périphériques", + "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": "ControllerSettingsDynamicInputSwapTooltip", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Allows real-time switching between connected input devices. When enabled, you can assign specific devices to this player slot to prevent input interference between players.", + "es_ES": "Permite cambiar en tiempo real entre dispositivos de entrada conectados. Cuando está activado, puedes asignar dispositivos específicos a este espacio de jugador para evitar interferencias de entrada entre jugadores.", + "fr_FR": "Permet de basculer en temps réel entre les périphériques connectés. Lorsqu'il est activé, vous pouvez assigner des périphériques spécifiques à cet emplacement de joueur pour éviter les interférences entre les joueurs.", + "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": "DialogDynamicInputSwapDeviceAssignmentsHint", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "You can assign devices to specific players by clicking the gear icon next to each input device.", + "es_ES": "Puedes asignar dispositivos a jugadores específicos haciendo clic en el icono de engranaje junto a cada dispositivo de entrada.", + "fr_FR": "Vous pouvez assigner des périphériques à des joueurs spécifiques en cliquant sur l'icône d'engrenage à côté de chaque périphérique d'entrée.", + "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": "DialogDontShowAgain", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Don't show this message again", + "es_ES": "No mostrar este mensaje de nuevo", + "fr_FR": "Ne plus afficher ce message", + "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": "ControllerSettingsAssignedInputDevices", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Assigned Devices", + "es_ES": "Dispositivos asignados", + "fr_FR": "Périphériques assignés", + "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": "ControllerSettingsAssignedInputDevicesTooltip", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Open the Assigned Devices settings to configure Dynamic Input Swap and per-device profile bindings for this player.", + "es_ES": "Abre la configuración de dispositivos asignados para configurar el Cambio Dinámico de Entrada y las vinculaciones de perfil por dispositivo para este jugador.", + "fr_FR": "Ouvre les paramètres des périphériques assignés pour configurer la Bascule Dynamique des Périphériques et les liaisons de profil par périphérique pour ce joueur.", + "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": "ControllerSettingsAllowDuplicateDeviceAssignment", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Allow the same device for multiple players", + "es_ES": "Permitir el mismo dispositivo para varios jugadores", + "fr_FR": "Autoriser le même périphérique pour plusieurs joueurs", + "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": "ControllerSettingsBindProfileToolTip", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Link this profile as the default for the currently connected input device. The linked profile will load automatically when this device is connected or swapped via Dynamic Input Swap.", + "es_ES": "Vincular este perfil como predeterminado para el dispositivo de entrada actualmente conectado. El perfil vinculado se cargará automáticamente cuando se conecte este dispositivo o se intercambie mediante Intercambio Dinámico de Entrada.", + "fr_FR": "Lier ce profil comme profil par défaut pour le périphérique d'entrée actuellement connecté. Le profil lié sera chargé automatiquement lorsque ce périphérique sera connecté ou échangé via l'Échange Dynamique d'Entrée.", + "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": "ControllerSettingsDeviceDisabled", "Translations": { diff --git a/src/Ryujinx.Common/Configuration/Hid/AssignedInputDevice.cs b/src/Ryujinx.Common/Configuration/Hid/AssignedInputDevice.cs new file mode 100644 index 000000000..ee7add6a4 --- /dev/null +++ b/src/Ryujinx.Common/Configuration/Hid/AssignedInputDevice.cs @@ -0,0 +1,11 @@ +namespace Ryujinx.Common.Configuration.Hid +{ + public class AssignedInputDevice + { + public AssignedInputDeviceType Type { get; set; } + + public string Id { get; set; } + + public string ProfileName { get; set; } + } +} diff --git a/src/Ryujinx.Common/Configuration/Hid/AssignedInputDeviceType.cs b/src/Ryujinx.Common/Configuration/Hid/AssignedInputDeviceType.cs new file mode 100644 index 000000000..aff9971ef --- /dev/null +++ b/src/Ryujinx.Common/Configuration/Hid/AssignedInputDeviceType.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Ryujinx.Common.Configuration.Hid +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum AssignedInputDeviceType + { + Keyboard, + Controller, + } +} diff --git a/src/Ryujinx.Common/Configuration/Hid/InputConfig.cs b/src/Ryujinx.Common/Configuration/Hid/InputConfig.cs index ccf9ead16..874961c70 100644 --- a/src/Ryujinx.Common/Configuration/Hid/InputConfig.cs +++ b/src/Ryujinx.Common/Configuration/Hid/InputConfig.cs @@ -36,6 +36,12 @@ namespace Ryujinx.Common.Configuration.Hid /// public PlayerIndex PlayerIndex { get; set; } + /// + /// Allow a keyboard configuration to temporarily promote to a connected gamepad, + /// while preserving the existing keyboard fallback path when that gamepad disappears. + /// + public bool EnableDynamicGamepadSwap { get; set; } + public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) diff --git a/src/Ryujinx.Common/Configuration/Hid/PlayerInputAssignment.cs b/src/Ryujinx.Common/Configuration/Hid/PlayerInputAssignment.cs new file mode 100644 index 000000000..aacd8400c --- /dev/null +++ b/src/Ryujinx.Common/Configuration/Hid/PlayerInputAssignment.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Ryujinx.Common.Configuration.Hid +{ + public class PlayerInputAssignment + { + public PlayerIndex PlayerIndex { get; set; } + + public bool EnableDynamicInputSwap { get; set; } + + public List Devices { get; set; } = []; + } +} diff --git a/src/Ryujinx.Common/Configuration/Hid/PlayerInputAssignmentHelper.cs b/src/Ryujinx.Common/Configuration/Hid/PlayerInputAssignmentHelper.cs new file mode 100644 index 000000000..5bfa26a45 --- /dev/null +++ b/src/Ryujinx.Common/Configuration/Hid/PlayerInputAssignmentHelper.cs @@ -0,0 +1,166 @@ +using Ryujinx.Common.Configuration.Hid.Keyboard; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Ryujinx.Common.Configuration.Hid +{ + public static class PlayerInputAssignmentHelper + { + public static AssignedInputDevice CreatePrimaryDevice(InputConfig inputConfig) + { + if (inputConfig == null || string.IsNullOrWhiteSpace(inputConfig.Id)) + { + return null; + } + + return new AssignedInputDevice + { + Type = inputConfig is StandardKeyboardInputConfig + ? AssignedInputDeviceType.Keyboard + : AssignedInputDeviceType.Controller, + Id = inputConfig.Id, + }; + } + + public static PlayerInputAssignment Normalize(PlayerInputAssignment assignment, AssignedInputDevice preferredDevice = null) + { + if (assignment == null) + { + return null; + } + + PlayerInputAssignment normalized = new() + { + PlayerIndex = assignment.PlayerIndex, + EnableDynamicInputSwap = assignment.EnableDynamicInputSwap, + }; + + List distinctDevices = Deduplicate(assignment.Devices); + + if (assignment.EnableDynamicInputSwap) + { + normalized.Devices.AddRange(distinctDevices.Select(Clone)); + return normalized; + } + + AssignedInputDevice primaryDevice = SelectPrimaryDevice(distinctDevices, preferredDevice) ?? Clone(preferredDevice); + + if (primaryDevice != null) + { + normalized.Devices.Add(Clone(primaryDevice)); + } + + return normalized; + } + + public static bool AreEquivalent( + PlayerInputAssignment left, + PlayerInputAssignment right, + AssignedInputDevice leftPreferredDevice = null, + AssignedInputDevice rightPreferredDevice = null) + { + if (left == null || right == null) + { + return left == right; + } + + PlayerInputAssignment normalizedLeft = Normalize(left, leftPreferredDevice); + PlayerInputAssignment normalizedRight = Normalize(right, rightPreferredDevice); + + if (normalizedLeft.EnableDynamicInputSwap != normalizedRight.EnableDynamicInputSwap) + { + return false; + } + + List<(AssignedInputDeviceType Type, string Id, string ProfileName)> leftDevices = normalizedLeft.Devices + .Select(device => (Type: device.Type, Id: device.Id, ProfileName: device.ProfileName ?? string.Empty)) + .OrderBy(device => device.Type) + .ThenBy(device => device.Id, StringComparer.Ordinal) + .ThenBy(device => device.ProfileName, StringComparer.Ordinal) + .ToList(); + + List<(AssignedInputDeviceType Type, string Id, string ProfileName)> rightDevices = normalizedRight.Devices + .Select(device => (Type: device.Type, Id: device.Id, ProfileName: device.ProfileName ?? string.Empty)) + .OrderBy(device => device.Type) + .ThenBy(device => device.Id, StringComparer.Ordinal) + .ThenBy(device => device.ProfileName, StringComparer.Ordinal) + .ToList(); + + return leftDevices.SequenceEqual(rightDevices); + } + + private static List Deduplicate(IEnumerable devices) + { + List result = []; + + if (devices == null) + { + return result; + } + + foreach (AssignedInputDevice device in devices) + { + if (device == null || string.IsNullOrWhiteSpace(device.Id)) + { + continue; + } + + int existingIndex = result.FindIndex(existing => + existing.Type == device.Type && + string.Equals(existing.Id, device.Id, StringComparison.Ordinal)); + + if (existingIndex == -1) + { + result.Add(Clone(device)); + continue; + } + + if (!string.IsNullOrWhiteSpace(device.ProfileName) || + string.IsNullOrWhiteSpace(result[existingIndex].ProfileName)) + { + result[existingIndex].ProfileName = device.ProfileName; + } + } + + return result; + } + + private static AssignedInputDevice SelectPrimaryDevice(List devices, AssignedInputDevice preferredDevice) + { + if (devices == null || devices.Count == 0) + { + return null; + } + + if (preferredDevice != null) + { + AssignedInputDevice matchedDevice = devices.FirstOrDefault(device => + device.Type == preferredDevice.Type && + string.Equals(device.Id, preferredDevice.Id, StringComparison.Ordinal)); + + if (matchedDevice != null) + { + return matchedDevice; + } + } + + return devices[0]; + } + + private static AssignedInputDevice Clone(AssignedInputDevice device) + { + if (device == null) + { + return null; + } + + return new AssignedInputDevice + { + Type = device.Type, + Id = device.Id, + ProfileName = device.ProfileName, + }; + } + } +} diff --git a/src/Ryujinx.Input.SDL3/SDL3Gamepad.cs b/src/Ryujinx.Input.SDL3/SDL3Gamepad.cs index 47ef0b59f..36b29ee19 100644 --- a/src/Ryujinx.Input.SDL3/SDL3Gamepad.cs +++ b/src/Ryujinx.Input.SDL3/SDL3Gamepad.cs @@ -275,7 +275,7 @@ namespace Ryujinx.Input.SDL3 { _configuration = (StandardControllerInputConfig)configuration; - if ((Features & GamepadFeaturesFlag.Led) != 0 && _configuration.Led.EnableLed) + if ((Features & GamepadFeaturesFlag.Led) != 0 && _configuration.Led?.EnableLed == true) { if (_configuration.Led.TurnOffLed) (this as IGamepad).ClearLed(); diff --git a/src/Ryujinx.Input/HLE/NpadController.cs b/src/Ryujinx.Input/HLE/NpadController.cs index d33d802da..dcdd8493f 100644 --- a/src/Ryujinx.Input/HLE/NpadController.cs +++ b/src/Ryujinx.Input/HLE/NpadController.cs @@ -1,14 +1,20 @@ using Ryujinx.Common; +using Ryujinx.Common.Configuration; using Ryujinx.Common.Configuration.Hid; using Ryujinx.Common.Configuration.Hid.Controller; using Ryujinx.Common.Configuration.Hid.Controller.Motion; using Ryujinx.Common.Configuration.Hid.Keyboard; using Ryujinx.Common.Logging; +using Ryujinx.Common.Utilities; using Ryujinx.HLE.HOS.Services.Hid; using System; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Numerics; using System.Runtime.CompilerServices; +using System.Text.Json; using CemuHookClient = Ryujinx.Input.Motion.CemuHook.Client; using ConfigControllerType = Ryujinx.Common.Configuration.Hid.ControllerType; @@ -16,6 +22,9 @@ namespace Ryujinx.Input.HLE { public class NpadController : IDisposable { + private const string KeyboardString = "keyboard"; + private const string ControllerString = "controller"; + private class HLEButtonMappingEntry { public readonly GamepadButtonInputId DriverInputId; @@ -211,14 +220,40 @@ namespace Ryujinx.Input.HLE private MotionInput _rightMotionInput; private IGamepad _gamepad; + private IGamepad _keyboardGamepad; + private IGamepad _controllerGamepad; + private readonly List _assignedControllerGamepads = []; + private readonly List _assignedControllerConfigs = []; private InputConfig _config; + private InputConfig _activeConfig; + private StandardKeyboardInputConfig _keyboardConfig; + private StandardControllerInputConfig _controllerConfig; + private GamepadStateSnapshot _previousKeyboardState; + private readonly List _previousControllerStates = []; + private DynamicInputSource _activeInputSource; + private PlayerInputAssignment _playerInputAssignment; + private bool _singleUsesKeyboardDriver; + private IGamepadDriver _keyboardDriver; + private IGamepadDriver _controllerDriver; + private int _activeControllerIndex = -1; public IGamepadDriver GamepadDriver { get; private set; } public GamepadStateSnapshot State { get; private set; } + public InputConfig ActiveConfig => _activeConfig; public string Id { get; private set; } + public bool IsAvailable => _gamepad != null || _keyboardGamepad != null || _assignedControllerGamepads.Count > 0; + private readonly CemuHookClient _cemuHookClient; + private static readonly InputConfigJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); + + private enum DynamicInputSource + { + None, + Keyboard, + Controller, + } public NpadController(CemuHookClient cemuHookClient) { @@ -227,31 +262,114 @@ namespace Ryujinx.Input.HLE _cemuHookClient = cemuHookClient; } - public bool UpdateDriverConfiguration(IGamepadDriver gamepadDriver, InputConfig config) + public bool MatchesDriverConfiguration(InputConfig config, PlayerInputAssignment playerInputAssignment) { - GamepadDriver = gamepadDriver; + if (_config?.EnableDynamicGamepadSwap != config.EnableDynamicGamepadSwap) + { + return false; + } - _gamepad?.Dispose(); + if (playerInputAssignment?.EnableDynamicInputSwap == true) + { + if (_playerInputAssignment == null || _playerInputAssignment.EnableDynamicInputSwap != playerInputAssignment.EnableDynamicInputSwap) + { + return false; + } - Id = config.Id; - _gamepad = config is StandardKeyboardInputConfig && GamepadDriver is IKeyboardModeDriver keyboardModeDriver - ? keyboardModeDriver.GetKeyboard(Id, KeyboardInputMode.Physical) - : GamepadDriver.GetGamepad(Id); + return PlayerInputAssignmentHelper.AreEquivalent(_playerInputAssignment, playerInputAssignment); + } + + return _singleUsesKeyboardDriver == (config is StandardKeyboardInputConfig) && + Id == config.Id; + } + + public bool UpdateDriverConfiguration(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, InputConfig config, PlayerInputAssignment playerInputAssignment) + { + _keyboardDriver = keyboardDriver; + _controllerDriver = gamepadDriver; + _playerInputAssignment = playerInputAssignment; + + DisposeOpenedGamepads(); + + _gamepad = null; + _keyboardGamepad = null; + _controllerGamepad = null; + _assignedControllerGamepads.Clear(); + _assignedControllerConfigs.Clear(); + _previousKeyboardState = default; + _previousControllerStates.Clear(); + _activeInputSource = DynamicInputSource.None; + _activeControllerIndex = -1; + + if (playerInputAssignment?.EnableDynamicInputSwap == true) + { + ConfigureDynamicGamepads(keyboardDriver, gamepadDriver, config); + } + else + { + _singleUsesKeyboardDriver = config is StandardKeyboardInputConfig; + GamepadDriver = _singleUsesKeyboardDriver ? keyboardDriver : gamepadDriver; + Id = config.Id; + _gamepad = OpenSingleGamepad(GamepadDriver, config.Id, _singleUsesKeyboardDriver); + } UpdateUserConfiguration(config); - return _gamepad != null; + return IsAvailable; } public void UpdateUserConfiguration(InputConfig config) { + InputConfig oldConfig = _config; + + if (_playerInputAssignment?.EnableDynamicInputSwap == true) + { + StandardControllerInputConfig oldControllerConfig = _controllerConfig; + + _config = config; + UpdateDynamicConfigurations(config); + + if (_controllerConfig?.Motion == null) + { + _leftMotionInput = null; + _rightMotionInput = null; + } + else if (NeedsMotionInputUpdate(oldControllerConfig, _controllerConfig)) + { + UpdateMotionInput(_controllerConfig.Motion); + } + + if (_keyboardConfig != null) + { + _keyboardGamepad?.SetConfiguration(_keyboardConfig); + } + + for (int i = 0; i < _assignedControllerGamepads.Count; i++) + { + StandardControllerInputConfig assignedControllerConfig = i < _assignedControllerConfigs.Count + ? _assignedControllerConfigs[i] + : _controllerConfig; + + if (assignedControllerConfig != null) + { + _assignedControllerGamepads[i].SetConfiguration(assignedControllerConfig); + } + } + + UpdateActiveGamepad(); + return; + } + + _config = config; + if (config is StandardControllerInputConfig controllerConfig) { - bool needsMotionInputUpdate = _config is not StandardControllerInputConfig oldControllerConfig || - ((oldControllerConfig.Motion.EnableMotion != controllerConfig.Motion.EnableMotion) && - (oldControllerConfig.Motion.MotionBackend != controllerConfig.Motion.MotionBackend)); - - if (needsMotionInputUpdate) + if (controllerConfig.Motion == null) + { + _leftMotionInput = null; + _rightMotionInput = null; + } + else if (NeedsMotionInputUpdate(oldConfig as StandardControllerInputConfig, controllerConfig)) { UpdateMotionInput(controllerConfig.Motion); } @@ -260,15 +378,23 @@ namespace Ryujinx.Input.HLE { // Non-controller doesn't have motions. _leftMotionInput = null; + _rightMotionInput = null; } - _config = config; + _activeConfig = config; _gamepad?.SetConfiguration(config); } private void UpdateMotionInput(MotionConfigController motionConfig) { + if (motionConfig == null) + { + _leftMotionInput = null; + _rightMotionInput = null; + return; + } + if (motionConfig.MotionBackend != MotionInputBackendType.CemuHook) { _leftMotionInput = new MotionInput(); @@ -281,72 +407,50 @@ namespace Ryujinx.Input.HLE } } + private bool NeedsMotionInputUpdate(StandardControllerInputConfig oldConfig, StandardControllerInputConfig newConfig) + { + if (newConfig?.Motion == null) + { + return false; + } + + bool motionWasDisabled = oldConfig?.Motion == null; + bool leftMotionMissing = _leftMotionInput == null; + bool isJoyconPairNeedingRightMotion = newConfig.ControllerType == ConfigControllerType.JoyconPair && _rightMotionInput == null; + bool motionEnabledChanged = oldConfig.Motion.EnableMotion != newConfig.Motion.EnableMotion; + bool motionBackendChanged = oldConfig.Motion.MotionBackend != newConfig.Motion.MotionBackend; + + return motionWasDisabled || + leftMotionMissing || + isJoyconPairNeedingRightMotion || + motionEnabledChanged || + motionBackendChanged; + } + public void Update() { + if (_playerInputAssignment?.EnableDynamicInputSwap == true) + { + UpdateDynamic(); + return; + } + // _gamepad may be altered by other threads IGamepad gamepad = _gamepad; if (gamepad != null && GamepadDriver != null) { State = gamepad.GetMappedStateSnapshot(); + _activeConfig = _config; - if (_config is StandardControllerInputConfig controllerConfig && controllerConfig.Motion.EnableMotion) + if (_activeConfig is StandardControllerInputConfig controllerConfig && controllerConfig.Motion?.EnableMotion == true) { - if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.GamepadDriver) - { - if ((gamepad.Features & GamepadFeaturesFlag.Motion) != 0) - { - Vector3 accelerometer = gamepad.GetMotionData(MotionInputId.Accelerometer); - Vector3 gyroscope = gamepad.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); - - if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair) - { - if (gamepad.Id == "JoyConPair") - { - Vector3 rightAccelerometer = gamepad.GetMotionData(MotionInputId.SecondAccelerometer); - Vector3 rightGyroscope = gamepad.GetMotionData(MotionInputId.SecondGyroscope); - - rightAccelerometer = new Vector3(rightAccelerometer.X, -rightAccelerometer.Z, rightAccelerometer.Y); - rightGyroscope = new Vector3(rightGyroscope.X, -rightGyroscope.Z, rightGyroscope.Y); - - _rightMotionInput.Update(rightAccelerometer, rightGyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone); - } - else - { - _rightMotionInput = _leftMotionInput; - } - } - } - } - else if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.CemuHook && controllerConfig.Motion is CemuHookMotionConfigController cemuControllerConfig) - { - int clientId = (int)controllerConfig.PlayerIndex; - - // First of all ensure we are registered - _cemuHookClient.RegisterClient(clientId, cemuControllerConfig.DsuServerHost, cemuControllerConfig.DsuServerPort); - - // Then request and retrieve the data - _cemuHookClient.RequestData(clientId, cemuControllerConfig.Slot); - _cemuHookClient.TryGetData(clientId, cemuControllerConfig.Slot, out _leftMotionInput); - - if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair) - { - if (!cemuControllerConfig.MirrorInput) - { - _cemuHookClient.RequestData(clientId, cemuControllerConfig.AltSlot); - _cemuHookClient.TryGetData(clientId, cemuControllerConfig.AltSlot, out _rightMotionInput); - } - else - { - _rightMotionInput = _leftMotionInput; - } - } - } + UpdateControllerMotion(gamepad, controllerConfig); + } + else + { + _leftMotionInput = null; + _rightMotionInput = null; } } else @@ -371,7 +475,7 @@ namespace Ryujinx.Input.HLE } } - if (_gamepad is IKeyboard) + if (_activeConfig is StandardKeyboardInputConfig) { (float leftAxisX, float leftAxisY) = State.GetStick(StickInputId.Left); (float rightAxisX, float rightAxisY) = State.GetStick(StickInputId.Right); @@ -388,7 +492,7 @@ namespace Ryujinx.Input.HLE Dy = ClampAxis(rightAxisY), }; } - else if (_config is StandardControllerInputConfig controllerConfig) + else if (_activeConfig is StandardControllerInputConfig controllerConfig) { (float leftAxisX, float leftAxisY) = State.GetStick(StickInputId.Left); (float rightAxisX, float rightAxisY) = State.GetStick(StickInputId.Right); @@ -509,9 +613,12 @@ namespace Ryujinx.Input.HLE return value; } - public static KeyboardInput GetHLEKeyboardInput(IGamepadDriver KeyboardDriver) + public static KeyboardInput GetHLEKeyboardInput(IGamepadDriver keyboardDriver) { - IKeyboard keyboard = KeyboardDriver.GetGamepad("0") as IKeyboard; + if (keyboardDriver.GetGamepad("0") is not IKeyboard keyboard) + { + return default; + } KeyboardStateSnapshot keyboardState = keyboard.GetKeyboardStateSnapshot(); @@ -543,7 +650,7 @@ namespace Ryujinx.Input.HLE { if (disposing) { - _gamepad?.Dispose(); + DisposeOpenedGamepads(); } } @@ -557,38 +664,565 @@ namespace Ryujinx.Input.HLE { if (queue.TryDequeue(out (VibrationValue, VibrationValue) dualVibrationValue)) { - if (_config is not StandardControllerInputConfig controllerConfig || - !controllerConfig.Rumble.EnableRumble) + if (_controllerConfig is StandardControllerInputConfig dynamicControllerConfig && + _playerInputAssignment?.EnableDynamicInputSwap == true && + dynamicControllerConfig.Rumble?.EnableRumble == true) { - return; + ApplyRumble(_controllerGamepad ?? _assignedControllerGamepads.FirstOrDefault(), dynamicControllerConfig, dualVibrationValue); + } + else if (_config is StandardControllerInputConfig controllerConfig && controllerConfig.Rumble?.EnableRumble == true) + { + ApplyRumble(_gamepad, controllerConfig, dualVibrationValue); + } + } + } + public bool HasAssignedControllerId(string id) + { + if (string.IsNullOrEmpty(id)) + { + return false; + } + + if (_playerInputAssignment?.EnableDynamicInputSwap == true) + { + return _assignedControllerGamepads.Any(gamepad => gamepad?.Id == id); + } + + return Id == id; + } + + private void ApplyRumble(IGamepad gamepad, StandardControllerInputConfig controllerConfig, (VibrationValue, VibrationValue) dualVibrationValue) + { + if (gamepad == null) + { + return; + } + + VibrationValue leftVibrationValue = dualVibrationValue.Item1; + VibrationValue rightVibrationValue = dualVibrationValue.Item2; + + leftVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble; + leftVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble; + rightVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble; + rightVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble; + + if (!controllerConfig.Rumble.UseHDRumble || gamepad.HDRumble(leftVibrationValue, rightVibrationValue) == false) + { + float low = Math.Min(1f, (float)(rightVibrationValue.AmplitudeLow * 0.85 + rightVibrationValue.AmplitudeHigh * 0.15)); + float high = Math.Min(1f, (float)(leftVibrationValue.AmplitudeLow * 0.15 + leftVibrationValue.AmplitudeHigh * 0.85)); + gamepad.Rumble(low, high, 0xFFFFFFFF); + } + + Logger.Debug?.Print(LogClass.Hid, $"Effect for {controllerConfig.PlayerIndex} " + + // Value=value/multiplier * multiplier (result) + $"L.low.amp={leftVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeLow}), " + + $"L.high.amp={leftVibrationValue.AmplitudeHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeHigh}), " + + $"L.low.freq={leftVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyLow}), " + + $"L.high.freq={leftVibrationValue.FrequencyHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyHigh}), " + + $"R.low.amp={rightVibrationValue.AmplitudeLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeLow}), " + + $"R.high.amp={rightVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeHigh}), " + + $"R.low.freq={rightVibrationValue.FrequencyLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyLow}), " + + $"R.high.freq={rightVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyHigh})"); + } + + private void ConfigureDynamicGamepads(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, InputConfig config) + { + AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard); + + if (!string.IsNullOrEmpty(assignedKeyboard?.Id)) + { + _keyboardGamepad = OpenSingleGamepad(keyboardDriver, assignedKeyboard.Id, true); + } + + foreach (AssignedInputDevice assignedController in ResolveDynamicControllerAssignments(gamepadDriver, config)) + { + IGamepad controllerGamepad = OpenSingleGamepad(gamepadDriver, assignedController.Id, false); + + if (controllerGamepad != null) + { + _assignedControllerGamepads.Add(controllerGamepad); + _assignedControllerConfigs.Add(null); + _previousControllerStates.Add(default); + } + } + + _controllerGamepad = _assignedControllerGamepads.FirstOrDefault(); + GamepadDriver = null; + Id = _assignedControllerGamepads.FirstOrDefault()?.Id ?? config.Id; + } + + private IEnumerable ResolveDynamicControllerAssignments(IGamepadDriver gamepadDriver, InputConfig config) + { + if (gamepadDriver == null) + { + yield break; + } + + List assignedControllers = _playerInputAssignment?.Devices + .Where(device => device.Type == AssignedInputDeviceType.Controller) + .ToList() ?? []; + + if (_playerInputAssignment?.EnableDynamicInputSwap == true) + { + foreach (AssignedInputDevice assignedController in assignedControllers) + { + foreach (string gamepadId in gamepadDriver.GamepadsIds) + { + if (gamepadId == assignedController.Id) + { + yield return assignedController; + break; + } + } } - VibrationValue leftVibrationValue = dualVibrationValue.Item1; - VibrationValue rightVibrationValue = dualVibrationValue.Item2; + yield break; + } - leftVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble; - leftVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble; - rightVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble; - rightVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble; - - if (!controllerConfig.Rumble.UseHDRumble || _gamepad?.HDRumble(leftVibrationValue, rightVibrationValue) == false) + if (config is StandardControllerInputConfig) + { + foreach (string gamepadId in gamepadDriver.GamepadsIds) { - float low = Math.Min(1f, (float)((rightVibrationValue.AmplitudeLow * 0.85 + rightVibrationValue.AmplitudeHigh * 0.15))); - float high = Math.Min(1f, (float)((leftVibrationValue.AmplitudeLow * 0.15 + leftVibrationValue.AmplitudeHigh * 0.85))); - _gamepad?.Rumble(low, high, 0xFFFFFFFF); + if (gamepadId == config.Id) + { + yield return new AssignedInputDevice + { + Type = AssignedInputDeviceType.Controller, + Id = gamepadId, + }; + yield break; + } + } + } + + if (!gamepadDriver.GamepadsIds.IsEmpty) + { + yield return new AssignedInputDevice + { + Type = AssignedInputDeviceType.Controller, + Id = gamepadDriver.GamepadsIds[0], + }; + } + } + + private static IGamepad OpenSingleGamepad(IGamepadDriver driver, string id, bool keyboard) + { + if (driver == null || string.IsNullOrEmpty(id)) + { + return null; + } + + if (keyboard && driver is IKeyboardModeDriver keyboardModeDriver) + { + return keyboardModeDriver.GetKeyboard(id, KeyboardInputMode.Physical); + } + + return driver.GetGamepad(id); + } + + private void UpdateDynamicConfigurations(InputConfig config) + { + if (config is StandardKeyboardInputConfig keyboardConfig) + { + AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard); + + _keyboardConfig = ResolveKeyboardConfiguration(assignedKeyboard, keyboardConfig, _keyboardGamepad); + + _assignedControllerConfigs.Clear(); + + foreach (IGamepad controllerGamepad in _assignedControllerGamepads) + { + AssignedInputDevice assignedController = _playerInputAssignment?.Devices.FirstOrDefault(device => + device.Type == AssignedInputDeviceType.Controller && + device.Id == controllerGamepad.Id); + + _assignedControllerConfigs.Add(ResolveControllerConfiguration(assignedController, keyboardConfig, controllerGamepad)); } - Logger.Debug?.Print(LogClass.Hid, $"Effect for {controllerConfig.PlayerIndex} " + - // Value=value/multiplier * multiplier (result) - $"L.low.amp={leftVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeLow}), " + - $"L.high.amp={leftVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({leftVibrationValue.AmplitudeHigh}), " + - $"L.low.freq={leftVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyLow}), " + - $"L.high.freq={leftVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({leftVibrationValue.FrequencyHigh}), " + - $"R.low.amp={rightVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({rightVibrationValue.AmplitudeLow}), " + - $"R.high.amp={rightVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeHigh}), " + - $"R.low.freq={rightVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({rightVibrationValue.FrequencyLow}), " + - $"R.high.freq={rightVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyHigh})"); + _controllerConfig = _assignedControllerConfigs.FirstOrDefault(); + } + else if (config is StandardControllerInputConfig controllerConfig) + { + _assignedControllerConfigs.Clear(); + + foreach (IGamepad controllerGamepad in _assignedControllerGamepads) + { + AssignedInputDevice assignedController = _playerInputAssignment?.Devices.FirstOrDefault(device => + device.Type == AssignedInputDeviceType.Controller && + device.Id == controllerGamepad.Id); + + _assignedControllerConfigs.Add(ResolveControllerConfiguration(assignedController, controllerConfig, controllerGamepad)); + } + + _controllerConfig = _assignedControllerConfigs.FirstOrDefault() ?? controllerConfig; + + if (_keyboardGamepad != null) + { + AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard); + + _keyboardConfig = ResolveKeyboardConfiguration(assignedKeyboard, controllerConfig, _keyboardGamepad); + } + else + { + _keyboardConfig = null; + } + } + } + + private StandardKeyboardInputConfig ResolveKeyboardConfiguration(AssignedInputDevice assignedKeyboard, InputConfig baseConfig, IGamepad keyboardGamepad) + { + if (keyboardGamepad == null) + { + return null; + } + + if (TryLoadAssignedProfile(assignedKeyboard, KeyboardString, keyboardGamepad, baseConfig, out StandardKeyboardInputConfig profileConfig)) + { + return profileConfig; + } + + if (baseConfig is StandardKeyboardInputConfig keyboardBaseConfig) + { + StandardKeyboardInputConfig clonedConfig = CloneConfig(keyboardBaseConfig); + + if (clonedConfig != null) + { + clonedConfig.Id = keyboardGamepad.Id; + clonedConfig.Name = keyboardGamepad.Name; + clonedConfig.PlayerIndex = baseConfig.PlayerIndex; + clonedConfig.EnableDynamicGamepadSwap = true; + return clonedConfig; + } + } + + StandardKeyboardInputConfig defaultConfig = InputConfigDefaults.CreateDefaultKeyboardConfiguration( + keyboardGamepad.Id, + keyboardGamepad.Name, + baseConfig.ControllerType, + baseConfig.PlayerIndex); + defaultConfig.EnableDynamicGamepadSwap = true; + return defaultConfig; + } + + private StandardControllerInputConfig ResolveControllerConfiguration(AssignedInputDevice assignedController, InputConfig baseConfig, IGamepad controllerGamepad) + { + if (controllerGamepad == null) + { + return null; + } + + if (TryLoadAssignedProfile(assignedController, ControllerString, controllerGamepad, baseConfig, out StandardControllerInputConfig profileConfig)) + { + return profileConfig; + } + + if (baseConfig is StandardControllerInputConfig controllerBaseConfig) + { + StandardControllerInputConfig clonedConfig = CloneConfig(controllerBaseConfig); + + if (clonedConfig != null) + { + clonedConfig.Id = controllerGamepad.Id; + clonedConfig.Name = controllerGamepad.Name; + clonedConfig.PlayerIndex = baseConfig.PlayerIndex; + clonedConfig.EnableDynamicGamepadSwap = true; + return clonedConfig; + } + } + + StandardControllerInputConfig defaultConfig = InputConfigDefaults.CreateDefaultControllerConfiguration( + controllerGamepad.Id, + controllerGamepad.Name, + baseConfig.ControllerType, + baseConfig.PlayerIndex, + controllerGamepad.Name?.Contains("Nintendo") == true); + defaultConfig.EnableDynamicGamepadSwap = true; + return defaultConfig; + } + + private static T CloneConfig(T config) where T : InputConfig + { + return JsonHelper.Deserialize( + JsonHelper.Serialize(config, _serializerContext.InputConfig), + _serializerContext.InputConfig) as T; + } + + private static bool TryLoadAssignedProfile(AssignedInputDevice assignedDevice, string profileDirectory, IGamepad gamepad, InputConfig baseConfig, out T config) + where T : InputConfig + { + config = null; + + if (string.IsNullOrWhiteSpace(assignedDevice?.ProfileName)) + { + return false; + } + + string path = Path.Combine(AppDataManager.ProfilesDirPath, profileDirectory, assignedDevice.ProfileName + ".json"); + + if (!File.Exists(path)) + { + return false; + } + + try + { + config = JsonHelper.DeserializeFromFile(path, _serializerContext.InputConfig) as T; + } + catch (JsonException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + + if (config == null) + { + return false; + } + + config.Id = gamepad.Id; + config.Name = gamepad.Name; + config.PlayerIndex = baseConfig.PlayerIndex; + config.EnableDynamicGamepadSwap = true; + return true; + } + + private void UpdateDynamic() + { + GamepadStateSnapshot keyboardState = _keyboardGamepad?.GetMappedStateSnapshot() ?? default; + bool keyboardHasInput = _keyboardGamepad != null && HasInput(keyboardState); + bool keyboardNewInput = _keyboardGamepad != null && HasNewInput(keyboardState, _previousKeyboardState); + int controllerWithNewInput = -1; + int controllerWithHeldInput = -1; + + // Note: dynamic swap is "last input wins", so we scan every assigned controller + // and promote whichever one most recently produced a meaningful state change. + for (int i = 0; i < _assignedControllerGamepads.Count; i++) + { + IGamepad controllerGamepad = _assignedControllerGamepads[i]; + GamepadStateSnapshot controllerState = controllerGamepad?.GetMappedStateSnapshot() ?? default; + + if (HasNewInput(controllerState, _previousControllerStates[i])) + { + controllerWithNewInput = i; + } + + if (controllerWithHeldInput == -1 && HasInput(controllerState)) + { + controllerWithHeldInput = i; + } + + _previousControllerStates[i] = controllerState; + } + + if (keyboardNewInput && controllerWithNewInput == -1) + { + _activeInputSource = DynamicInputSource.Keyboard; + } + else if (controllerWithNewInput != -1 && !keyboardNewInput) + { + _activeInputSource = DynamicInputSource.Controller; + _activeControllerIndex = controllerWithNewInput; + } + else if (_activeInputSource == DynamicInputSource.Keyboard && !keyboardHasInput && controllerWithHeldInput != -1) + { + _activeInputSource = DynamicInputSource.Controller; + _activeControllerIndex = controllerWithHeldInput; + } + else if (_activeInputSource == DynamicInputSource.Controller && controllerWithHeldInput == -1 && keyboardHasInput) + { + _activeInputSource = DynamicInputSource.Keyboard; + } + else if (_activeInputSource == DynamicInputSource.None) + { + _activeInputSource = _config switch + { + StandardKeyboardInputConfig when _keyboardGamepad != null => DynamicInputSource.Keyboard, + StandardControllerInputConfig when _assignedControllerGamepads.Count > 0 => DynamicInputSource.Controller, + _ when keyboardHasInput => DynamicInputSource.Keyboard, + _ when controllerWithHeldInput != -1 => DynamicInputSource.Controller, + _ when _keyboardGamepad != null => DynamicInputSource.Keyboard, + _ when _assignedControllerGamepads.Count > 0 => DynamicInputSource.Controller, + _ => DynamicInputSource.None, + }; + + if (_activeInputSource == DynamicInputSource.Controller) + { + _activeControllerIndex = controllerWithHeldInput != -1 ? controllerWithHeldInput : 0; + } + } + + UpdateActiveGamepad(); + + State = _activeInputSource switch + { + DynamicInputSource.Keyboard => keyboardState, + DynamicInputSource.Controller when _activeControllerIndex >= 0 && _activeControllerIndex < _previousControllerStates.Count => _previousControllerStates[_activeControllerIndex], + _ => default, + }; + + if (_activeConfig is StandardControllerInputConfig controllerConfig && _controllerGamepad != null && _activeInputSource == DynamicInputSource.Controller) + { + UpdateControllerMotion(_controllerGamepad, controllerConfig); + } + else + { + _leftMotionInput = null; + _rightMotionInput = null; + } + + _previousKeyboardState = keyboardState; + } + + private void UpdateActiveGamepad() + { + (_gamepad, _activeConfig, GamepadDriver) = _activeInputSource switch + { + DynamicInputSource.Keyboard => (_keyboardGamepad, _keyboardConfig, _keyboardDriver), + DynamicInputSource.Controller => + ( + _activeControllerIndex >= 0 && _activeControllerIndex < _assignedControllerGamepads.Count + ? _assignedControllerGamepads[_activeControllerIndex] + : _assignedControllerGamepads.FirstOrDefault(), + _activeControllerIndex >= 0 && _activeControllerIndex < _assignedControllerConfigs.Count + ? _assignedControllerConfigs[_activeControllerIndex] + : _assignedControllerConfigs.FirstOrDefault(), + _controllerDriver + ), + _ => ((IGamepad?)null, (InputConfig?)null, (IGamepadDriver?)null) + }; + + _controllerGamepad = _gamepad; + } + + private void UpdateControllerMotion(IGamepad gamepad, StandardControllerInputConfig controllerConfig) + { + if (gamepad == null || controllerConfig?.Motion == null || !controllerConfig.Motion.EnableMotion) + { + _leftMotionInput = null; + _rightMotionInput = null; + return; + } + + if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.GamepadDriver) + { + if ((gamepad.Features & GamepadFeaturesFlag.Motion) != 0) + { + _leftMotionInput ??= new MotionInput(); + _rightMotionInput ??= new MotionInput(); + + Vector3 accelerometer = gamepad.GetMotionData(MotionInputId.Accelerometer); + Vector3 gyroscope = gamepad.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); + + if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair) + { + if (gamepad.Id == "JoyConPair") + { + Vector3 rightAccelerometer = gamepad.GetMotionData(MotionInputId.SecondAccelerometer); + Vector3 rightGyroscope = gamepad.GetMotionData(MotionInputId.SecondGyroscope); + + rightAccelerometer = new Vector3(rightAccelerometer.X, -rightAccelerometer.Z, rightAccelerometer.Y); + rightGyroscope = new Vector3(rightGyroscope.X, -rightGyroscope.Z, rightGyroscope.Y); + + _rightMotionInput.Update(rightAccelerometer, rightGyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone); + } + else + { + _rightMotionInput = _leftMotionInput; + } + } + } + else + { + _leftMotionInput = null; + _rightMotionInput = null; + } + } + else if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.CemuHook && controllerConfig.Motion is CemuHookMotionConfigController cemuControllerConfig) + { + int clientId = (int)controllerConfig.PlayerIndex; + + _cemuHookClient.RegisterClient(clientId, cemuControllerConfig.DsuServerHost, cemuControllerConfig.DsuServerPort); + _cemuHookClient.RequestData(clientId, cemuControllerConfig.Slot); + _cemuHookClient.TryGetData(clientId, cemuControllerConfig.Slot, out _leftMotionInput); + + if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair) + { + if (!cemuControllerConfig.MirrorInput) + { + _cemuHookClient.RequestData(clientId, cemuControllerConfig.AltSlot); + _cemuHookClient.TryGetData(clientId, cemuControllerConfig.AltSlot, out _rightMotionInput); + } + else + { + _rightMotionInput = _leftMotionInput; + } + } + } + } + + private static bool HasInput(GamepadStateSnapshot state) + { + for (GamepadButtonInputId inputId = GamepadButtonInputId.A; inputId < GamepadButtonInputId.Count; inputId++) + { + if (state.IsPressed(inputId)) + { + return true; + } + } + + return StickIsActive(state.GetStick(StickInputId.Left)) || StickIsActive(state.GetStick(StickInputId.Right)); + } + + private static bool HasNewInput(GamepadStateSnapshot current, GamepadStateSnapshot previous) + { + for (GamepadButtonInputId inputId = GamepadButtonInputId.A; inputId < GamepadButtonInputId.Count; inputId++) + { + if (current.IsPressed(inputId) && !previous.IsPressed(inputId)) + { + return true; + } + } + + return StickBecameActive(current.GetStick(StickInputId.Left), previous.GetStick(StickInputId.Left)) || + StickBecameActive(current.GetStick(StickInputId.Right), previous.GetStick(StickInputId.Right)); + } + + private static bool StickIsActive((float X, float Y) stick) + { + const float Threshold = 0.2f; + + return MathF.Abs(stick.X) > Threshold || MathF.Abs(stick.Y) > Threshold; + } + + private static bool StickBecameActive((float X, float Y) current, (float X, float Y) previous) + { + bool currentActive = StickIsActive(current); + bool previousActive = StickIsActive(previous); + + return currentActive && (!previousActive || MathF.Abs(current.X - previous.X) > 0.1f || MathF.Abs(current.Y - previous.Y) > 0.1f); + } + + private void DisposeOpenedGamepads() + { + if (!ReferenceEquals(_gamepad, _keyboardGamepad) && !_assignedControllerGamepads.Contains(_gamepad)) + { + _gamepad?.Dispose(); + } + + _keyboardGamepad?.Dispose(); + + foreach (IGamepad controllerGamepad in _assignedControllerGamepads.Distinct()) + { + controllerGamepad?.Dispose(); } } } diff --git a/src/Ryujinx.Input/HLE/NpadManager.cs b/src/Ryujinx.Input/HLE/NpadManager.cs index bf483e2a7..3e9d2faf8 100644 --- a/src/Ryujinx.Input/HLE/NpadManager.cs +++ b/src/Ryujinx.Input/HLE/NpadManager.cs @@ -37,6 +37,7 @@ namespace Ryujinx.Input.HLE private List _inputConfig; private List _requestedInputConfig; + private List _playerInputAssignments; private bool _enableKeyboard; private bool _enableMouse; private Switch _device; @@ -54,6 +55,7 @@ namespace Ryujinx.Input.HLE _mouseDriver = mouseDriver; _inputConfig = []; _requestedInputConfig = []; + _playerInputAssignments = []; _gamepadDriver.OnGamepadConnected += HandleOnGamepadConnected; _gamepadDriver.OnGamepadDisconnected += HandleOnGamepadDisconnected; @@ -78,52 +80,98 @@ namespace Ryujinx.Input.HLE private void HandleOnGamepadDisconnected(string obj) { - // Force input reload + List requestedInputConfig; + List playerInputAssignments; + bool enableKeyboard; + bool enableMouse; + lock (_lock) { // Forcibly disconnect any controllers with this ID. for (int i = 0; i < _controllers.Length; i++) { - if (_controllers[i]?.Id == obj) + if (_controllers[i]?.HasAssignedControllerId(obj) == true) { _controllers[i]?.Dispose(); _controllers[i] = null; } } - ReloadConfiguration(_requestedInputConfig, _enableKeyboard, _enableMouse); + requestedInputConfig = _requestedInputConfig; + playerInputAssignments = _playerInputAssignments; + enableKeyboard = _enableKeyboard; + enableMouse = _enableMouse; } + + // Force input reload. + ReloadConfiguration(requestedInputConfig, playerInputAssignments, enableKeyboard, enableMouse); } - private void HandleOnGamepadConnected(string _) + private void HandleOnGamepadConnected(string id) { + lock (_lock) + { + for (int i = 0; i < _controllers.Length; i++) + { + if (_controllers[i] != null && PlayerHasAssignedControllerId((PlayerIndex)i, id)) + { + _controllers[i]?.Dispose(); + _controllers[i] = null; + } + } + } + // Force input reload - ReloadConfiguration(_requestedInputConfig, _enableKeyboard, _enableMouse); + ReloadConfiguration(_requestedInputConfig, _playerInputAssignments, _enableKeyboard, _enableMouse); + } + + private bool PlayerHasAssignedControllerId(PlayerIndex playerIndex, string id) + { + if (string.IsNullOrEmpty(id)) + { + return false; + } + + InputConfig inputConfig = _requestedInputConfig.FirstOrDefault(config => (int)config.PlayerIndex == (int)playerIndex); + + if (inputConfig == null) + { + return false; + } + + PlayerInputAssignment playerInputAssignment = GetPlayerInputAssignment(inputConfig); + + return playerInputAssignment.EnableDynamicInputSwap && + playerInputAssignment.Devices.Any(device => + device.Type == AssignedInputDeviceType.Controller && + string.Equals(device.Id, id, StringComparison.Ordinal)); } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config) + private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config, PlayerInputAssignment playerInputAssignment) { - IGamepadDriver targetDriver = - config is StandardKeyboardInputConfig - ? _keyboardDriver - : _gamepadDriver; + Debug.Assert(_keyboardDriver != null, "Keyboard driver is not initialized!"); + Debug.Assert(_gamepadDriver != null, "Gamepad driver is not initialized!"); - Debug.Assert(targetDriver != null, "Unknown input configuration!"); - - if (controller.GamepadDriver != targetDriver || controller.Id != config.Id) + if (!controller.MatchesDriverConfiguration(config, playerInputAssignment)) { - return controller.UpdateDriverConfiguration(targetDriver, config); + return controller.UpdateDriverConfiguration(_keyboardDriver, _gamepadDriver, config, playerInputAssignment); } - return controller.GamepadDriver != null; + return controller.IsAvailable; } public void ReloadConfiguration(List inputConfig, bool enableKeyboard, bool enableMouse) + { + ReloadConfiguration(inputConfig, [], enableKeyboard, enableMouse); + } + + public void ReloadConfiguration(List inputConfig, List playerInputAssignments, bool enableKeyboard, bool enableMouse) { lock (_lock) { _requestedInputConfig = inputConfig?.ToList() ?? []; + _playerInputAssignments = playerInputAssignments?.ToList() ?? []; NpadController[] oldControllers = _controllers.ToArray(); @@ -146,14 +194,17 @@ namespace Ryujinx.Input.HLE } InputConfig activeConfig = inputConfigEntry; - bool isValid = DriverConfigurationUpdate(ref controller, activeConfig); + PlayerInputAssignment playerInputAssignment = GetPlayerInputAssignment(inputConfigEntry); + + bool isValid = DriverConfigurationUpdate(ref controller, activeConfig, playerInputAssignment); if (!isValid && + !playerInputAssignment.EnableDynamicInputSwap && inputConfigEntry is StandardControllerInputConfig && TryGetKeyboardFallback(inputConfigEntry, out StandardKeyboardInputConfig fallbackConfig)) { activeConfig = fallbackConfig; - isValid = DriverConfigurationUpdate(ref controller, activeConfig); + isValid = DriverConfigurationUpdate(ref controller, activeConfig, playerInputAssignment); } if (!isValid) @@ -184,6 +235,54 @@ namespace Ryujinx.Input.HLE } } + private PlayerInputAssignment GetPlayerInputAssignment(InputConfig inputConfig) + { + PlayerInputAssignment playerInputAssignment = _playerInputAssignments.FirstOrDefault(assignment => assignment.PlayerIndex == inputConfig.PlayerIndex); + + if (playerInputAssignment != null) + { + PlayerInputAssignment normalizedAssignment = PlayerInputAssignmentHelper.Normalize( + playerInputAssignment, + PlayerInputAssignmentHelper.CreatePrimaryDevice(inputConfig)); + + if (normalizedAssignment.EnableDynamicInputSwap || normalizedAssignment.Devices.Count > 0) + { + return normalizedAssignment; + } + } + + // Note: older configs only know about a single saved device per player, + // so we synthesize a routing entry here until the user saves explicit assignments. + playerInputAssignment = new PlayerInputAssignment + { + PlayerIndex = inputConfig.PlayerIndex, + EnableDynamicInputSwap = inputConfig.EnableDynamicGamepadSwap, + }; + + AssignedInputDevice primaryDevice = PlayerInputAssignmentHelper.CreatePrimaryDevice(inputConfig); + + if (primaryDevice != null) + { + playerInputAssignment.Devices.Add(primaryDevice); + } + + if (playerInputAssignment.EnableDynamicInputSwap && inputConfig is StandardControllerInputConfig) + { + string keyboardId = _keyboardDriver.GamepadsIds.IsEmpty ? null : _keyboardDriver.GamepadsIds[0]; + + if (!string.IsNullOrWhiteSpace(keyboardId)) + { + playerInputAssignment.Devices.Add(new AssignedInputDevice + { + Type = AssignedInputDeviceType.Keyboard, + Id = keyboardId, + }); + } + } + + return playerInputAssignment; + } + private bool TryGetKeyboardFallback(InputConfig inputConfig, out StandardKeyboardInputConfig fallbackConfig) { fallbackConfig = null; @@ -210,6 +309,8 @@ namespace Ryujinx.Input.HLE inputConfig.ControllerType, inputConfig.PlayerIndex); + fallbackConfig.EnableDynamicGamepadSwap = inputConfig.EnableDynamicGamepadSwap; + return true; } @@ -257,11 +358,16 @@ namespace Ryujinx.Input.HLE } public void Initialize(Switch device, List inputConfig, bool enableKeyboard, bool enableMouse) + { + Initialize(device, inputConfig, [], enableKeyboard, enableMouse); + } + + public void Initialize(Switch device, List inputConfig, List playerInputAssignments, bool enableKeyboard, bool enableMouse) { _device = device; _device.Configuration.RefreshInputConfig = RefreshInputConfigForHLE; - ReloadConfiguration(inputConfig, enableKeyboard, enableMouse); + ReloadConfiguration(inputConfig, playerInputAssignments, enableKeyboard, enableMouse); } public void Update(float aspectRatio = 1) @@ -286,7 +392,7 @@ namespace Ryujinx.Input.HLE // Do we allow input updates and is a controller connected? if (_inputUpdateBlockCount == 0 && controller != null) { - DriverConfigurationUpdate(ref controller, inputConfig); + DriverConfigurationUpdate(ref controller, inputConfig, GetPlayerInputAssignment(inputConfig)); controller.UpdateUserConfiguration(inputConfig); controller.Update(); @@ -387,7 +493,9 @@ namespace Ryujinx.Input.HLE { lock (_lock) { - return _inputConfig.FirstOrDefault(x => x.PlayerIndex == (Common.Configuration.Hid.PlayerIndex)index); + NpadController controller = _controllers[index]; + + return controller?.ActiveConfig ?? _inputConfig.FirstOrDefault(x => x.PlayerIndex == (Common.Configuration.Hid.PlayerIndex)index); } } diff --git a/src/Ryujinx/Systems/AppHost.cs b/src/Ryujinx/Systems/AppHost.cs index 23f6d6b47..650ea5ae9 100644 --- a/src/Ryujinx/Systems/AppHost.cs +++ b/src/Ryujinx/Systems/AppHost.cs @@ -468,11 +468,11 @@ namespace Ryujinx.Ava.Systems if (ConfigurationState.Instance.System.UseInputGlobalConfig.Value && Program.UseExtraConfig) { - NpadManager.Initialize(Device, ConfigurationState.InstanceExtra.Hid.InputConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse); + NpadManager.Initialize(Device, ConfigurationState.InstanceExtra.Hid.InputConfig, ConfigurationState.InstanceExtra.Hid.PlayerInputAssignments, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse); } else { - NpadManager.Initialize(Device, ConfigurationState.Instance.Hid.InputConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse); + NpadManager.Initialize(Device, ConfigurationState.Instance.Hid.InputConfig, ConfigurationState.Instance.Hid.PlayerInputAssignments, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse); } TouchScreenManager.Initialize(Device); diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs b/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs index 55b2d2cac..1b70f96f9 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs @@ -426,6 +426,16 @@ namespace Ryujinx.Ava.Systems.Configuration /// public List InputConfig { get; set; } + /// + /// Player-level input routing assignments + /// + public List PlayerInputAssignments { get; set; } + + /// + /// Whether to allow mapping the same input device to multiple players. + /// + public bool AllowDuplicateDeviceAssignment { get; set; } + /// /// The speed of spectrum cycling for the Rainbow LED feature. /// diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs index 0930b11cc..bc3f3bcd5 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs @@ -157,6 +157,8 @@ namespace Ryujinx.Ava.Systems.Configuration Hid.DisableInputWhenOutOfFocus.Value = shouldLoadFromFile ? cff.DisableInputWhenOutOfFocus : Hid.DisableInputWhenOutOfFocus.Value; // Get from global config only Hid.Hotkeys.Value = shouldLoadFromFile ? cff.Hotkeys : Hid.Hotkeys.Value; // Get from global config only Hid.InputConfig.Value = cff.InputConfig ?? [] ; + Hid.PlayerInputAssignments.Value = cff.PlayerInputAssignments ?? []; + Hid.AllowDuplicateDeviceAssignment.Value = cff.AllowDuplicateDeviceAssignment; Hid.RainbowSpeed.Value = cff.RainbowSpeed; Multiplayer.LanInterfaceId.Value = cff.MultiplayerLanInterfaceId; diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs index c66f74ed5..a2f011bbb 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs @@ -191,6 +191,11 @@ namespace Ryujinx.Ava.Systems.Configuration /// public ReactiveObject IsAscendingOrder { get; private set; } + /// + /// Show Dynamic Input Swap first-use warning + /// + public ReactiveObject ShowDynamicInputSwapWarning { get; private set; } + public UISection() { GuiColumns = new Columns(); @@ -210,6 +215,8 @@ namespace Ryujinx.Ava.Systems.Configuration LanguageCode = new ReactiveObject(); ShowConsole = new ReactiveObject(); ShowConsole.Event += static (_, e) => ConsoleHelper.SetConsoleWindowState(e.NewValue); + ShowDynamicInputSwapWarning = new ReactiveObject(); + ShowDynamicInputSwapWarning.Value = true; } } @@ -513,6 +520,19 @@ namespace Ryujinx.Ava.Systems.Configuration /// public ReactiveObject> InputConfig { get; private set; } + /// + /// Player-level input routing assignments. + /// NOTE: This keeps dynamic input swap and multi-device ownership attached to the player, + /// not to the currently edited keyboard/controller profile. + /// + public ReactiveObject> PlayerInputAssignments { get; private set; } + + /// + /// Whether to allow mapping the same input device to multiple players. + /// This is a global setting shared across all players. + /// + public ReactiveObject AllowDuplicateDeviceAssignment { get; private set; } + /// /// The speed of spectrum cycling for the Rainbow LED feature. /// @@ -525,6 +545,8 @@ namespace Ryujinx.Ava.Systems.Configuration DisableInputWhenOutOfFocus = new ReactiveObject(); Hotkeys = new ReactiveObject(); InputConfig = new ReactiveObject>(); + PlayerInputAssignments = new ReactiveObject>(); + AllowDuplicateDeviceAssignment = new ReactiveObject(); RainbowSpeed = new ReactiveObject(); RainbowSpeed.Event += (_, args) => Rainbow.Speed = args.NewValue; } diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.cs index acf8a6793..afff7957f 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.cs @@ -143,6 +143,8 @@ namespace Ryujinx.Ava.Systems.Configuration DisableInputWhenOutOfFocus = Hid.DisableInputWhenOutOfFocus, Hotkeys = Hid.Hotkeys, InputConfig = Hid.InputConfig, + PlayerInputAssignments = Hid.PlayerInputAssignments, + AllowDuplicateDeviceAssignment = Hid.AllowDuplicateDeviceAssignment, RainbowSpeed = Hid.RainbowSpeed, GraphicsBackend = Graphics.GraphicsBackend, PreferredGpu = Graphics.PreferredGpu, @@ -332,6 +334,22 @@ namespace Ryujinx.Ava.Systems.Configuration }, } ]; + Hid.PlayerInputAssignments.Value = + [ + new PlayerInputAssignment + { + PlayerIndex = PlayerIndex.Player1, + EnableDynamicInputSwap = false, + Devices = + [ + new AssignedInputDevice + { + Type = AssignedInputDeviceType.Keyboard, + Id = "0", + }, + ], + }, + ]; Debug.EnableGdbStub.Value = false; Debug.GdbStubPort.Value = 55555; Debug.DebuggerSuspendOnStart.Value = false; diff --git a/src/Ryujinx/UI/Helpers/ContentDialogHelper.cs b/src/Ryujinx/UI/Helpers/ContentDialogHelper.cs index 65de07e6e..942826b83 100644 --- a/src/Ryujinx/UI/Helpers/ContentDialogHelper.cs +++ b/src/Ryujinx/UI/Helpers/ContentDialogHelper.cs @@ -18,6 +18,11 @@ using System.Threading.Tasks; namespace Ryujinx.Ava.UI.Helpers { + public class CheckBoxDialogResult + { + public bool IsChecked { get; set; } + } + public static class ContentDialogHelper { private static bool _isChoiceDialogOpen; @@ -431,6 +436,67 @@ namespace Ryujinx.Ava.UI.Helpers return response == UserResult.Yes; } + internal static async Task CreateCheckBoxDialog(string title, string primaryText, string checkBoxText, bool isCheckedDefault) + { + CheckBoxDialogResult result = new CheckBoxDialogResult { IsChecked = isCheckedDefault }; + + Grid content = new() + { + RowDefinitions = [new(), new(), new()], + ColumnDefinitions = [new(GridLength.Auto), new()], + MinHeight = 80, + }; + + content.Children.Add(new SymbolIcon + { + Symbol = (Symbol)Symbol.Important, + Margin = new Thickness(10), + FontSize = 40, + FlowDirection = FlowDirection.LeftToRight, + VerticalAlignment = VerticalAlignment.Center, + GridColumn = 0, + GridRow = 0, + GridRowSpan = 2 + }); + + content.Children.Add(new TextBlock + { + Text = primaryText, + Margin = new Thickness(5), + TextWrapping = TextWrapping.Wrap, + MaxWidth = 450, + GridColumn = 1, + GridRow = 0 + }); + + CheckBox checkBox = new() + { + Content = checkBoxText, + IsChecked = isCheckedDefault, + Margin = new Thickness(5), + GridColumn = 1, + GridRow = 1 + }; + + checkBox.IsCheckedChanged += (s, e) => + { + result.IsChecked = checkBox.IsChecked == true; + }; + + content.Children.Add(checkBox); + + ContentDialog contentDialog = new() + { + Title = title, + PrimaryButtonText = LocaleManager.Instance[LocaleKeys.InputDialogOk], + Content = content, + }; + + await ShowAsync(contentDialog); + + return result; + } + internal static async Task CreateUpdaterChoiceDialog(string title, string primary, string secondaryText, string changelogUrl) { if (_isChoiceDialogOpen) diff --git a/src/Ryujinx/UI/Helpers/Converters/ProfileNameLinkedConverter.cs b/src/Ryujinx/UI/Helpers/Converters/ProfileNameLinkedConverter.cs new file mode 100644 index 000000000..e0037b1b2 --- /dev/null +++ b/src/Ryujinx/UI/Helpers/Converters/ProfileNameLinkedConverter.cs @@ -0,0 +1,32 @@ +using Avalonia.Data.Converters; +using Ryujinx.Ava.UI.ViewModels.Input; +using System; +using System.Globalization; + +namespace Ryujinx.Ava.UI.Helpers +{ + public class ProfileNameLinkedConverter : IValueConverter + { + public static readonly ProfileNameLinkedConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not string profileName || string.IsNullOrWhiteSpace(profileName)) + { + return false; + } + + if (parameter is InputViewModel viewModel) + { + return viewModel.IsProfileNameLinked(profileName); + } + + return false; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Ryujinx/UI/Models/Input/PlayerInputDeviceAssignmentItem.cs b/src/Ryujinx/UI/Models/Input/PlayerInputDeviceAssignmentItem.cs new file mode 100644 index 000000000..8f94dacec --- /dev/null +++ b/src/Ryujinx/UI/Models/Input/PlayerInputDeviceAssignmentItem.cs @@ -0,0 +1,72 @@ +using Ryujinx.Ava.UI.ViewModels; +using Ryujinx.Common.Configuration.Hid; +using System; + +namespace Ryujinx.Ava.UI.Models.Input +{ + public class PlayerInputDeviceAssignmentItem : BaseModel + { + public DeviceType DeviceType { get; init; } + + public string Id { get; init; } + + public string Name { get; init; } + + public AssignedInputDeviceType AssignedType => + DeviceType == DeviceType.Keyboard ? AssignedInputDeviceType.Keyboard : AssignedInputDeviceType.Controller; + + public bool IsAssigned + { + get; + set + { + field = value; + OnPropertyChanged(); + } + } + + public bool HasBoundProfileName => !string.IsNullOrWhiteSpace(BoundProfileName); + + public string BoundProfileName + { + get; + set + { + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasBoundProfileName)); + } + } + + public bool HasAssignedToPlayers => !string.IsNullOrWhiteSpace(AssignedToPlayers); + + /// + /// Comma-separated list of player names (e.g. "Player 1, Player 3") + /// that have this device assigned. Empty if no other player uses it. + /// + public string AssignedToPlayers + { + get; + set + { + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasAssignedToPlayers)); + } + } + + /// + /// True when this device is assigned to another player and + /// AllowDuplicateDeviceAssignment is disabled, making it unclickable. + /// + public bool IsDisabledByOtherPlayer + { + get; + set + { + field = value; + OnPropertyChanged(); + } + } + } +} diff --git a/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs b/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs index 3b1014b9e..b7c04335c 100644 --- a/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs @@ -54,6 +54,12 @@ namespace Ryujinx.Ava.UI.ViewModels.Input [ObservableProperty] public partial string ProfileName { get; set; } + partial void OnProfileNameChanged(string value) + { + OnPropertyChanged(nameof(IsProfileLinked)); + OnPropertyChanged(nameof(CanDeleteOrSaveProfile)); + } + [ObservableProperty] public partial bool NotificationIsVisible { get; set; } // Automatically call the NotificationView property with OnPropertyChanged() @@ -61,6 +67,11 @@ namespace Ryujinx.Ava.UI.ViewModels.Input public partial string NotificationText { get; set; } // Automatically call the NotificationText property with OnPropertyChanged() private bool _isLoaded; + private bool _enableDynamicGamepadSwap; + private bool _suppressProfileLoad; + private bool _dynamicInputSwapFirstUseWarningShown; + private bool? _allowDuplicateDeviceAssignment; + private List _workingPlayerInputAssignments; private static readonly InputConfigJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); @@ -88,6 +99,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input public ObservableCollection PlayerIndexes { get; set; } public ObservableCollection<(DeviceType Type, string Id, string Name)> Devices { get; set; } + public ObservableCollection PlayerInputDevices { get; set; } internal ObservableCollection Controllers { get; set; } public AvaloniaList ProfilesList { get; set; } @@ -95,12 +107,14 @@ namespace Ryujinx.Ava.UI.ViewModels.Input // XAML Flags public bool ShowSettings => _device > 0; - public bool IsController => _device > 1; - public bool IsKeyboard => !IsController; + public bool IsController => CurrentDeviceType == DeviceType.Controller; + public bool IsKeyboard => CurrentDeviceType == DeviceType.Keyboard; + public bool CanOpenAssignedDevices => ShowSettings && EnableDynamicGamepadSwap; + public bool CanDeleteOrSaveProfile => ShowSettings && !IsDefaultProfileName(ProfileName); public bool IsRight { get; set; } public bool IsLeft { get; set; } - public bool HasLed => (SelectedGamepad.Features & GamepadFeaturesFlag.Led) != 0; - public bool CanClearLed => SelectedGamepad.Name.ContainsIgnoreCase("DualSense"); + public bool HasLed => (SelectedGamepad?.Features & GamepadFeaturesFlag.Led) != 0; + public bool CanClearLed => SelectedGamepad?.Name?.ContainsIgnoreCase("DualSense") == true; public event Action NotifyChangesEvent; @@ -112,7 +126,10 @@ namespace Ryujinx.Ava.UI.ViewModels.Input // When you select a profile, the settings from the profile will be applied. // To save the settings, you still need to click the apply button field = value; - LoadProfile(); + if (!_suppressProfileLoad) + { + LoadProfile(); + } OnPropertyChanged(); } } @@ -130,6 +147,111 @@ namespace Ryujinx.Ava.UI.ViewModels.Input } } + public bool EnableDynamicGamepadSwap + { + get => _enableDynamicGamepadSwap; + set + { + if (_enableDynamicGamepadSwap == value) + { + return; + } + + bool isFirstDynamicInputSwapEnable = value && ShouldInitializePlayerOneDynamicInputSwap(); + bool shouldShowFirstUseWarning = + isFirstDynamicInputSwapEnable && + !_dynamicInputSwapFirstUseWarningShown && + _isChangeTrackingActive && + _isLoaded && + ConfigurationState.Instance.UI.ShowDynamicInputSwapWarning.Value; + + _enableDynamicGamepadSwap = value; + + if (_enableDynamicGamepadSwap) + { + if (isFirstDynamicInputSwapEnable) + { + AssignAllConnectedInputDevices(); + } + else + { + AssignCurrentDeviceIfNoInputDeviceIsAssigned(); + } + } + + RefreshProfileBindingState(); + RefreshModifiedState(); + OnPropertyChanged(); + OnPropertyChanged(nameof(CanOpenAssignedDevices)); + + if (shouldShowFirstUseWarning) + { + _dynamicInputSwapFirstUseWarningShown = true; + ShowDynamicInputSwapFirstUseWarning(); + } + } + } + + private bool ShouldInitializePlayerOneDynamicInputSwap() + { + if (_playerId != PlayerIndex.Player1) + { + return false; + } + + PlayerInputAssignment persistedAssignment = GetPersistedPlayerInputAssignments() + .FirstOrDefault(assignment => assignment.PlayerIndex == _playerId); + + return persistedAssignment == null || !persistedAssignment.EnableDynamicInputSwap; + } + + private async void ShowDynamicInputSwapFirstUseWarning() + { + string message = LocaleManager.Instance[LocaleKeys.DialogDynamicInputSwapDeviceAssignmentsHint]; + + CheckBoxDialogResult result = await ContentDialogHelper.CreateCheckBoxDialog( + LocaleManager.Instance[LocaleKeys.ControllerSettingsAssignedInputDevices], + message, + LocaleManager.Instance[LocaleKeys.DialogDontShowAgain], + false); + + if (result.IsChecked) + { + ConfigurationState.Instance.UI.ShowDynamicInputSwapWarning.Value = false; + } + } + + public bool AllowDuplicateDeviceAssignment + { + get => _allowDuplicateDeviceAssignment ?? GetSavedAllowDuplicateDeviceAssignment(); + set + { + if (AllowDuplicateDeviceAssignment == value) + { + return; + } + + _allowDuplicateDeviceAssignment = value; + + if (!value) + { + KeepCurrentPlayerAssignedDevicesExclusive(); + } + + RefreshPlayerInputDeviceAssignmentState(); + + IsModified = true; + OnPropertyChanged(); + } + } + + private bool GetSavedAllowDuplicateDeviceAssignment() + { + return UseGlobalConfig && Program.UseExtraConfig + ? ConfigurationState.InstanceExtra.Hid.AllowDuplicateDeviceAssignment.Value + : ConfigurationState.Instance.Hid.AllowDuplicateDeviceAssignment.Value; + } + public PlayerIndex PlayerIdChoose { get => _playerIdChoose; @@ -310,7 +432,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input return; } - LoadSelectedDeviceDefaults(); + LoadCurrentDeviceDefaultProfile(); RefreshModifiedState(); FindPairedDeviceInConfigFile(); NotifyChanges(); @@ -397,6 +519,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input PlayerIndexes = []; Controllers = []; Devices = []; + PlayerInputDevices = []; ProfilesList = []; VisualStick = new StickVisualizer(this); @@ -417,17 +540,119 @@ namespace Ryujinx.Ava.UI.ViewModels.Input private InputConfig GetPersistedInputConfig() { - if (UseGlobalConfig && Program.UseExtraConfig) - { - return ConfigurationState.InstanceExtra.Hid.InputConfig.Value.FirstOrDefault(inputConfig => inputConfig.PlayerIndex == _playerId); - } - - return ConfigurationState.Instance.Hid.InputConfig.Value.FirstOrDefault(inputConfig => inputConfig.PlayerIndex == _playerId); + return GetPersistedInputConfig(_playerId); } - private void LoadConfiguration(InputConfig inputConfig = null) + private InputConfig GetPersistedInputConfig(PlayerIndex playerIndex) + { + if (UseGlobalConfig && Program.UseExtraConfig) + { + return ConfigurationState.InstanceExtra.Hid.InputConfig.Value.FirstOrDefault(inputConfig => inputConfig.PlayerIndex == playerIndex); + } + + return ConfigurationState.Instance.Hid.InputConfig.Value.FirstOrDefault(inputConfig => inputConfig.PlayerIndex == playerIndex); + } + + private List GetPersistedInputConfigs() + { + if (UseGlobalConfig && Program.UseExtraConfig) + { + return ConfigurationState.InstanceExtra.Hid.InputConfig.Value ?? []; + } + + return ConfigurationState.Instance.Hid.InputConfig.Value ?? []; + } + + private List GetPersistedPlayerInputAssignments() + { + if (UseGlobalConfig && Program.UseExtraConfig) + { + return ConfigurationState.InstanceExtra.Hid.PlayerInputAssignments.Value ?? []; + } + + return ConfigurationState.Instance.Hid.PlayerInputAssignments.Value ?? []; + } + + private List GetWorkingPlayerInputAssignments() + { + if (_workingPlayerInputAssignments != null) + { + return _workingPlayerInputAssignments; + } + + _workingPlayerInputAssignments = GetPersistedPlayerInputAssignments() + .Where(assignment => assignment != null) + .Select(ClonePlayerInputAssignment) + .ToList(); + + return _workingPlayerInputAssignments; + } + + private static PlayerInputAssignment ClonePlayerInputAssignment(PlayerInputAssignment assignment) + { + if (assignment == null) + { + return null; + } + + return new PlayerInputAssignment + { + PlayerIndex = assignment.PlayerIndex, + EnableDynamicInputSwap = assignment.EnableDynamicInputSwap, + Devices = assignment.Devices? + .Where(device => device != null) + .Select(device => new AssignedInputDevice + { + Type = device.Type, + Id = device.Id, + ProfileName = device.ProfileName, + }) + .ToList() ?? [], + }; + } + + private PlayerInputAssignment GetPersistedPlayerInputAssignment() + { + return GetPersistedPlayerInputAssignment(_playerId); + } + + private PlayerInputAssignment GetPersistedPlayerInputAssignment(PlayerIndex playerIndex) + { + return GetPlayerInputAssignment(playerIndex, GetPersistedPlayerInputAssignments()); + } + + private PlayerInputAssignment GetWorkingPlayerInputAssignment(PlayerIndex playerIndex) + { + return GetPlayerInputAssignment(playerIndex, _workingPlayerInputAssignments ?? GetPersistedPlayerInputAssignments()); + } + + private PlayerInputAssignment GetPlayerInputAssignment(PlayerIndex playerIndex, List assignments) + { + InputConfig persistedConfig = GetPersistedInputConfig(playerIndex); + PlayerInputAssignment persistedAssignment = assignments?.FirstOrDefault(assignment => assignment.PlayerIndex == playerIndex); + + if (persistedAssignment == null) + { + return BuildDefaultPlayerInputAssignment(playerIndex, persistedConfig); + } + + PlayerInputAssignment normalizedAssignment = PlayerInputAssignmentHelper.Normalize( + persistedAssignment, + PlayerInputAssignmentHelper.CreatePrimaryDevice(persistedConfig)); + + return normalizedAssignment; + } + + private void LoadConfiguration(InputConfig inputConfig = null, bool reloadPlayerInputDevices = true) { Config = inputConfig ?? GetDisplayedInputConfig(GetPersistedInputConfig()); + + if (reloadPlayerInputDevices) + { + PlayerInputAssignment persistedAssignment = GetPersistedPlayerInputAssignment(); + EnableDynamicGamepadSwap = persistedAssignment.EnableDynamicInputSwap; + } + ConfigViewModel = null; if (Config is StandardKeyboardInputConfig keyboardInputConfig) @@ -439,6 +664,15 @@ namespace Ryujinx.Ava.UI.ViewModels.Input { ConfigViewModel = new ControllerInputViewModel(this, new GamepadInputConfig(controllerInputConfig), VisualStick); } + + if (reloadPlayerInputDevices) + { + LoadPlayerInputDevices(); + } + else + { + RefreshProfileBindingState(); + } } private InputConfig GetDisplayedInputConfig(InputConfig persistedConfig) @@ -451,7 +685,6 @@ namespace Ryujinx.Ava.UI.ViewModels.Input // If runtime has already fallen back to keyboard, reflect that active config in settings // instead of showing the stale persisted controller config. InputConfig activeConfig = _mainWindow?.ViewModel.AppHost?.NpadManager?.GetPlayerInputConfigByIndex((int)_playerId); - if (activeConfig is StandardKeyboardInputConfig) { return activeConfig; @@ -470,6 +703,531 @@ namespace Ryujinx.Ava.UI.ViewModels.Input return persistedConfig; } + // Note: player-level routing is stored separately from the selected keyboard/controller profile, + // so changing the edited device does not silently clear dynamic input swap or assigned devices. + private PlayerInputAssignment BuildDefaultPlayerInputAssignment(PlayerIndex playerIndex, InputConfig persistedConfig) + { + PlayerInputAssignment assignment = new() + { + PlayerIndex = playerIndex, + EnableDynamicInputSwap = persistedConfig?.EnableDynamicGamepadSwap ?? false, + }; + + if (persistedConfig is StandardKeyboardInputConfig) + { + assignment.Devices.Add(new AssignedInputDevice + { + Type = AssignedInputDeviceType.Keyboard, + Id = persistedConfig.Id, + }); + + if (assignment.EnableDynamicInputSwap) + { + foreach ((DeviceType Type, string Id, string _) in Devices.Where(device => device.Type == DeviceType.Controller)) + { + assignment.Devices.Add(new AssignedInputDevice + { + Type = AssignedInputDeviceType.Controller, + Id = Id, + }); + } + } + } + else if (persistedConfig is StandardControllerInputConfig) + { + assignment.Devices.Add(new AssignedInputDevice + { + Type = AssignedInputDeviceType.Controller, + Id = persistedConfig.Id, + }); + + if (assignment.EnableDynamicInputSwap) + { + (DeviceType Type, string Id, string Name) keyboardDevice = Devices.FirstOrDefault(device => device.Type == DeviceType.Keyboard); + + if (keyboardDevice != default) + { + assignment.Devices.Add(new AssignedInputDevice + { + Type = AssignedInputDeviceType.Keyboard, + Id = keyboardDevice.Id, + }); + } + } + } + + return assignment; + } + + private string GetPlayerDisplayName(PlayerIndex playerIndex) + { + return PlayerIndexes.FirstOrDefault(player => player.Id == playerIndex)?.Name ?? playerIndex.ToString(); + } + + private void LoadPlayerInputDevices(bool preserveEdits = false) + { + PlayerInputAssignment assignment = GetPersistedPlayerInputAssignment(); + Dictionary<(DeviceType Type, string Id), PlayerInputDeviceAssignmentItem> editedItems = preserveEdits + ? PlayerInputDevices.ToDictionary(item => (item.DeviceType, item.Id)) + : null; + + Dictionary<(AssignedInputDeviceType Type, string Id), List> deviceToOtherAssignedPlayers = GetOtherPlayerDeviceAssignments(); + + PlayerInputDevices.Clear(); + + foreach ((DeviceType Type, string Id, string Name) device in Devices.Where(device => device.Type is DeviceType.Keyboard or DeviceType.Controller)) + { + string deviceId = GetConfigDeviceId(device); + AssignedInputDeviceType assignedType = device.Type == DeviceType.Keyboard + ? AssignedInputDeviceType.Keyboard + : AssignedInputDeviceType.Controller; + PlayerInputDeviceAssignmentItem editedItem = null; + editedItems?.TryGetValue((device.Type, deviceId), out editedItem); + + bool isAssigned = editedItem?.IsAssigned ?? assignment.Devices.Any(assignedDevice => + assignedDevice.Type == assignedType && + assignedDevice.Id == deviceId); + + string boundProfile = GetProfileNameOrDefault(editedItem?.BoundProfileName ?? assignment.Devices + .FirstOrDefault(assignedDevice => + assignedDevice.Type == assignedType && + assignedDevice.Id == deviceId)?.ProfileName); + + // Find other players using this device + deviceToOtherAssignedPlayers.TryGetValue((assignedType, deviceId), out List assignedOtherPlayers); + + PlayerInputDevices.Add(new PlayerInputDeviceAssignmentItem + { + DeviceType = device.Type, + Id = deviceId, + Name = device.Name, + BoundProfileName = boundProfile, + IsAssigned = isAssigned, + AssignedToPlayers = FormatAssignedPlayerNames(isAssigned, assignedOtherPlayers), + IsDisabledByOtherPlayer = IsDisabledByOtherPlayer(isAssigned, assignedOtherPlayers), + }); + } + + RefreshPlayerInputDeviceAssignmentState(); + RefreshProfileBindingState(); + OnPropertyChanged(nameof(PlayerInputDevices)); + } + + public void ToggleAssignedPlayerInputDevice(PlayerInputDeviceAssignmentItem item, bool isAssigned) + { + if (item == null) + { + return; + } + + if (item.IsDisabledByOtherPlayer && isAssigned) + { + return; + } + + if (item.IsDisabledByOtherPlayer && !isAssigned) + { + item.IsAssigned = false; + RefreshPlayerInputDeviceAssignmentState(); + return; + } + + item.IsAssigned = isAssigned; + RefreshPlayerInputDeviceAssignmentState(); + RefreshProfileBindingState(); + RefreshModifiedState(); + } + + private Dictionary<(AssignedInputDeviceType Type, string Id), List> GetOtherPlayerDeviceAssignments() + { + IEnumerable otherPlayers = GetPersistedInputConfigs() + .Where(config => config != null && config.PlayerIndex != _playerId) + .Select(config => config.PlayerIndex) + .Concat(((_workingPlayerInputAssignments ?? GetPersistedPlayerInputAssignments()) ?? []) + .Where(assignment => assignment != null && assignment.PlayerIndex != _playerId) + .Select(assignment => assignment.PlayerIndex)) + .Distinct(); + Dictionary<(AssignedInputDeviceType Type, string Id), List> deviceToOtherAssignedPlayers = []; + + foreach (PlayerIndex otherPlayer in otherPlayers) + { + PlayerInputAssignment normalizedOtherAssignment = GetWorkingPlayerInputAssignment(otherPlayer); + + // Only include players who participate in dynamic input swap. + // Players with dynamic swap disabled manage their device through + // the traditional InputConfig and should not appear in the + // Assigned Devices menu for other players. + if (!normalizedOtherAssignment.EnableDynamicInputSwap) + { + continue; + } + + string playerName = GetPlayerDisplayName(otherPlayer); + + foreach (AssignedInputDevice device in normalizedOtherAssignment.Devices) + { + (AssignedInputDeviceType Type, string Id) key = (device.Type, device.Id); + if (!deviceToOtherAssignedPlayers.TryGetValue(key, out List players)) + { + players = []; + deviceToOtherAssignedPlayers[key] = players; + } + + if (!players.Contains(playerName)) + { + players.Add(playerName); + } + } + } + + return deviceToOtherAssignedPlayers; + } + + private void RefreshPlayerInputDeviceAssignmentState() + { + Dictionary<(AssignedInputDeviceType Type, string Id), List> deviceToOtherAssignedPlayers = GetOtherPlayerDeviceAssignments(); + + foreach (PlayerInputDeviceAssignmentItem item in PlayerInputDevices) + { + deviceToOtherAssignedPlayers.TryGetValue((item.AssignedType, item.Id), out List assignedOtherPlayers); + + item.IsDisabledByOtherPlayer = IsDisabledByOtherPlayer(item.IsAssigned, assignedOtherPlayers); + + if (item.IsDisabledByOtherPlayer) + { + item.IsAssigned = false; + } + + item.AssignedToPlayers = FormatAssignedPlayerNames(item.IsAssigned, assignedOtherPlayers); + } + } + + private void KeepCurrentPlayerAssignedDevicesExclusive() + { + List assignments = GetWorkingPlayerInputAssignments(); + + PlayerInputAssignment currentAssignment = GetEditedPlayerInputAssignment(); + int assignmentIndex = assignments.FindIndex(assignment => assignment.PlayerIndex == PlayerId); + + if (assignmentIndex == -1) + { + assignments.Add(currentAssignment); + } + else + { + assignments[assignmentIndex] = currentAssignment; + } + + RemoveDuplicateDeviceAssignmentsForCurrentPlayer(assignments, currentAssignment); + } + + private bool IsDisabledByOtherPlayer(bool isAssigned, List assignedOtherPlayers) + { + return !AllowDuplicateDeviceAssignment && + !isAssigned && + assignedOtherPlayers != null && + assignedOtherPlayers.Count > 0; + } + + private string FormatAssignedPlayerNames(bool isAssigned, List assignedOtherPlayers) + { + if (!isAssigned) + { + return assignedOtherPlayers != null && assignedOtherPlayers.Count > 0 + ? string.Join(", ", assignedOtherPlayers.OrderBy(name => ExtractPlayerNumber(name))) + : null; + } + + string currentPlayerName = GetPlayerDisplayName(_playerId); + + if (!AllowDuplicateDeviceAssignment) + { + return currentPlayerName; + } + + return assignedOtherPlayers != null && assignedOtherPlayers.Count > 0 + ? $"{currentPlayerName}, {string.Join(", ", assignedOtherPlayers.OrderBy(name => ExtractPlayerNumber(name)))}" + : currentPlayerName; + } + + private int ExtractPlayerNumber(string playerName) + { + // Extract the numeric suffix from player names like "Player 1", "Player 2", etc. + // If no number is found, return 0 to sort such names first. + if (string.IsNullOrWhiteSpace(playerName)) + { + return 0; + } + + // Find the last space and try to parse the number after it + int lastSpace = playerName.LastIndexOf(' '); + if (lastSpace >= 0 && lastSpace < playerName.Length - 1) + { + string numberPart = playerName[(lastSpace + 1)..]; + if (int.TryParse(numberPart, out int number)) + { + return number; + } + } + + return 0; + } + + private PlayerInputAssignment GetEditedPlayerInputAssignment() + { + PlayerInputAssignment assignment = new() + { + PlayerIndex = _playerId, + EnableDynamicInputSwap = EnableDynamicGamepadSwap, + }; + + if (EnableDynamicGamepadSwap) + { + foreach (PlayerInputDeviceAssignmentItem item in PlayerInputDevices.Where(item => item.IsAssigned)) + { + assignment.Devices.Add(new AssignedInputDevice + { + Type = item.AssignedType, + Id = item.Id, + ProfileName = GetPersistedProfileName(item.BoundProfileName), + }); + } + } + + // When dynamic swap is off, keep the legacy single selected-device route. + if (!EnableDynamicGamepadSwap && + assignment.Devices.Count == 0 && + TryGetCurrentDevice(out (DeviceType Type, string Id, string Name) currentDevice) && + currentDevice.Type != DeviceType.None) + { + assignment.Devices.Add(new AssignedInputDevice + { + Type = currentDevice.Type == DeviceType.Keyboard ? AssignedInputDeviceType.Keyboard : AssignedInputDeviceType.Controller, + Id = GetConfigDeviceId(currentDevice), + ProfileName = GetPersistedProfileName(FindInputDeviceAssignmentItem(currentDevice)?.BoundProfileName), + }); + } + + return PlayerInputAssignmentHelper.Normalize(assignment, GetCurrentPrimaryAssignedInputDevice()); + } + + private bool PlayerAssignmentsMatch(PlayerInputAssignment currentAssignment, PlayerInputAssignment persistedAssignment) + { + return PlayerInputAssignmentHelper.AreEquivalent( + currentAssignment, + persistedAssignment, + GetCurrentPrimaryAssignedInputDevice(), + GetPersistedPrimaryAssignedInputDevice()); + } + + private void AssignCurrentDeviceIfNoInputDeviceIsAssigned() + { + if (PlayerInputDevices.Any(device => device.IsAssigned)) + { + return; + } + + if (TryGetCurrentDevice(out (DeviceType Type, string Id, string Name) currentDevice) && currentDevice.Type != DeviceType.None) + { + PlayerInputDeviceAssignmentItem currentItem = FindInputDeviceAssignmentItem(currentDevice); + + if (currentItem is { IsDisabledByOtherPlayer: false }) + { + currentItem.IsAssigned = true; + return; + } + } + + PlayerInputDeviceAssignmentItem firstAvailableItem = PlayerInputDevices.FirstOrDefault(device => !device.IsDisabledByOtherPlayer); + + if (firstAvailableItem != null) + { + firstAvailableItem.IsAssigned = true; + } + } + + private void AssignAllConnectedInputDevices() + { + foreach (PlayerInputDeviceAssignmentItem item in PlayerInputDevices.Where(device => !device.IsDisabledByOtherPlayer)) + { + item.IsAssigned = true; + } + + RefreshPlayerInputDeviceAssignmentState(); + } + + private AssignedInputDevice GetCurrentPrimaryAssignedInputDevice() + { + if (TryGetCurrentDevice(out (DeviceType Type, string Id, string Name) currentDevice) && currentDevice.Type != DeviceType.None) + { + return new AssignedInputDevice + { + Type = currentDevice.Type == DeviceType.Keyboard ? AssignedInputDeviceType.Keyboard : AssignedInputDeviceType.Controller, + Id = GetConfigDeviceId(currentDevice), + }; + } + + return PlayerInputAssignmentHelper.CreatePrimaryDevice(GetDisplayedInputConfig(GetPersistedInputConfig())); + } + + private AssignedInputDevice GetPersistedPrimaryAssignedInputDevice() + { + return PlayerInputAssignmentHelper.CreatePrimaryDevice(GetPersistedInputConfig()); + } + + private PlayerInputDeviceAssignmentItem FindInputDeviceAssignmentItem((DeviceType Type, string Id, string Name) device) + { + string deviceId = GetConfigDeviceId(device); + + return PlayerInputDevices.FirstOrDefault(item => + item.DeviceType == device.Type && + item.Id == deviceId); + } + + internal string GetCurrentProfileDefaultName() + { + return LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault]; + } + + private string GetProfileNameOrDefault(string profileName) + { + return string.IsNullOrWhiteSpace(profileName) + ? GetCurrentProfileDefaultName() + : profileName; + } + + private bool IsDefaultProfileName(string profileName) + { + return string.Equals(profileName, GetCurrentProfileDefaultName(), StringComparison.Ordinal); + } + + private string GetPersistedProfileName(string profileName) + { + return string.IsNullOrWhiteSpace(profileName) || IsDefaultProfileName(profileName) + ? null + : profileName; + } + + private string GetBoundProfileNameForCurrentDevice() + { + if (!TryGetCurrentDevice(out (DeviceType Type, string Id, string Name) currentDevice)) + { + return GetCurrentProfileDefaultName(); + } + + return GetProfileNameOrDefault(FindInputDeviceAssignmentItem(currentDevice)?.BoundProfileName); + } + + private void ClearInvalidBindingForCurrentDevice() + { + if (!TryGetCurrentDevice(out (DeviceType Type, string Id, string Name) currentDevice)) + { + return; + } + + PlayerInputDeviceAssignmentItem item = FindInputDeviceAssignmentItem(currentDevice); + if (item != null) + { + item.BoundProfileName = GetCurrentProfileDefaultName(); + } + } + + public bool IsProfileLinked => + !string.IsNullOrWhiteSpace(ProfileName) && + string.Equals(ProfileName, GetBoundProfileNameForCurrentDevice(), StringComparison.Ordinal); + + public bool IsProfileNameLinked(string profileName) + { + return !string.IsNullOrWhiteSpace(profileName) && + string.Equals(profileName, GetBoundProfileNameForCurrentDevice(), StringComparison.Ordinal); + } + + private void ReplaceBoundProfileName(string previousProfileName, string nextProfileName) + { + if (string.IsNullOrWhiteSpace(previousProfileName) || + string.Equals(previousProfileName, nextProfileName, StringComparison.Ordinal)) + { + return; + } + + string replacementProfileName = GetProfileNameOrDefault(nextProfileName); + + foreach (PlayerInputDeviceAssignmentItem item in PlayerInputDevices) + { + if (string.Equals(item.BoundProfileName, previousProfileName, StringComparison.Ordinal)) + { + item.BoundProfileName = replacementProfileName; + } + } + } + + private void SetSelectedProfileSilently(string profileName) + { + bool wasSuppressingProfileLoad = _suppressProfileLoad; + _suppressProfileLoad = true; + + try + { + ProfileName = profileName; + ChosenProfile = profileName; + } + finally + { + _suppressProfileLoad = wasSuppressingProfileLoad; + } + } + + private void RefreshProfileBindingState() + { + OnPropertyChanged(nameof(CanBindSelectedProfile)); + OnPropertyChanged(nameof(IsProfileLinked)); + OnPropertyChanged(nameof(BoundProfileNameForCurrentDevice)); + } + + public string BoundProfileNameForCurrentDevice => GetBoundProfileNameForCurrentDevice(); + + public bool CanBindSelectedProfile => + ShowSettings && + !string.IsNullOrWhiteSpace(ProfileName) && + ProfilesList.Contains(ProfileName); + + public void LinkCurrentProfileToCurrentDevice() + { + if (!CanBindSelectedProfile) + { + return; + } + + if (!TryGetCurrentDevice(out (DeviceType Type, string Id, string Name) currentDevice) || currentDevice.Type == DeviceType.None) + { + return; + } + + PlayerInputDeviceAssignmentItem target = FindInputDeviceAssignmentItem(currentDevice); + + if (target == null) + { + return; + } + + DeviceType selectedType = target.DeviceType; + bool selectedDefaultProfile = IsDefaultProfileName(ProfileName); + + foreach (PlayerInputDeviceAssignmentItem item in PlayerInputDevices.Where(item => item.DeviceType == selectedType)) + { + if ((!selectedDefaultProfile && string.Equals(item.BoundProfileName, ProfileName, StringComparison.Ordinal)) || + item.Id == target.Id) + { + item.BoundProfileName = GetCurrentProfileDefaultName(); + } + } + + target.BoundProfileName = GetProfileNameOrDefault(ProfileName); + + RefreshProfileBindingState(); + RefreshModifiedState(); + } + private void FindPairedDeviceInConfigFile() { // This function allows you to output a message about the device configuration found in the file @@ -549,6 +1307,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input LoadControllers(); } + LoadPlayerInputDevices(_isChangeTrackingActive); FindPairedDeviceInConfigFile(); OnPropertyChanged(nameof(Device)); OnPropertyChanged(nameof(SelectedDeviceItem)); @@ -562,7 +1321,85 @@ namespace Ryujinx.Ava.UI.ViewModels.Input LoadControllers(); } - LoadConfiguration(LoadDefaultConfiguration()); + LoadConfiguration(LoadPreferredConfigurationForCurrentDevice(), false); + SetSelectedProfileSilently(GetBoundProfileNameForCurrentDevice()); + RefreshProfileBindingState(); + } + + private void LoadCurrentDeviceDefaultProfile() + { + if (_device > 0 && _device < Devices.Count && Devices[_device].Type != DeviceType.None) + { + LoadControllers(); + } + + LoadConfiguration(LoadDefaultConfiguration(), false); + SetSelectedProfileSilently(GetCurrentProfileDefaultName()); + RefreshProfileBindingState(); + } + + private string GetProfilePath(string profileName) + { + return Path.Combine(GetProfileBasePath(), profileName + ".json"); + } + + private InputConfig LoadPreferredConfigurationForCurrentDevice() + { + string boundProfileName = GetBoundProfileNameForCurrentDevice(); + + if (!string.IsNullOrWhiteSpace(boundProfileName) && + TryLoadProfileConfiguration(boundProfileName, out InputConfig boundConfig)) + { + return boundConfig; + } + + return LoadDefaultConfiguration(); + } + + private bool TryLoadProfileConfiguration(string profileName, out InputConfig config) + { + config = null; + + if (string.IsNullOrWhiteSpace(profileName) || + string.Equals(profileName, GetCurrentProfileDefaultName(), StringComparison.Ordinal)) + { + config = LoadDefaultConfiguration(); + return true; + } + + string path = GetProfilePath(profileName); + + if (!File.Exists(path)) + { + return false; + } + + try + { + config = JsonHelper.DeserializeFromFile(path, _serializerContext.InputConfig); + } + catch (JsonException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + + if (config == null) + { + return false; + } + + if (TryGetCurrentDevice(out (DeviceType Type, string Id, string Name) currentDevice)) + { + config.Id = GetConfigDeviceId(currentDevice); + config.Name = currentDevice.Name; + config.PlayerIndex = _playerId; + } + + return true; } public void RefreshModifiedState() @@ -572,7 +1409,17 @@ namespace Ryujinx.Ava.UI.ViewModels.Input return; } - IsModified = !ConfigsMatch(GetSelectedDeviceConfig(), GetDisplayedInputConfig(GetPersistedInputConfig())); + IsModified = HasUnsavedChanges(); + } + + private bool HasUnsavedChanges() + { + bool duplicateDeviceAssignmentChanged = _allowDuplicateDeviceAssignment.HasValue && + _allowDuplicateDeviceAssignment.Value != GetSavedAllowDuplicateDeviceAssignment(); + bool configChanged = !ConfigsMatch(GetSelectedDeviceConfig(), GetDisplayedInputConfig(GetPersistedInputConfig())); + bool playerAssignmentsChanged = !PlayerAssignmentsMatch(GetEditedPlayerInputAssignment(), GetPersistedPlayerInputAssignment()); + + return duplicateDeviceAssignmentChanged || configChanged || playerAssignmentsChanged; } private static bool ConfigsMatch(InputConfig currentConfig, InputConfig otherConfig) @@ -626,7 +1473,8 @@ namespace Ryujinx.Ava.UI.ViewModels.Input config.Id = GetConfigDeviceId(device); config.Name = device.Name; config.PlayerIndex = _playerId; - config.ControllerType = Controllers[_controller].Type; + config.ControllerType = GetSelectedControllerType(); + config.EnableDynamicGamepadSwap = EnableDynamicGamepadSwap; return config; } @@ -668,60 +1516,69 @@ namespace Ryujinx.Ava.UI.ViewModels.Input private void HandleOnGamepadDisconnected(string id) { - _isChangeTrackingActive = false; // Disable configuration change tracking - bool selectedControllerDisconnected = TryGetCurrentDevice(out (DeviceType Type, string Id, string Name) currentDevice) && currentDevice.Type == DeviceType.Controller && string.Equals(GetGamepadId(currentDevice), id, StringComparison.Ordinal); - RefreshAvailableDevices(); - - InputConfig persistedConfig = GetPersistedInputConfig(); - InputConfig displayedConfig = GetDisplayedInputConfig(persistedConfig); - bool shouldApplyKeyboardFallback = - selectedControllerDisconnected || - displayedConfig is StandardKeyboardInputConfig; - - if (shouldApplyKeyboardFallback) + if (!selectedControllerDisconnected) { - if (selectedControllerDisconnected && - displayedConfig is not StandardKeyboardInputConfig && - TryCreateKeyboardFallbackConfig(persistedConfig, out StandardKeyboardInputConfig fallbackConfig)) - { - displayedConfig = fallbackConfig; - } - - LoadConfiguration(displayedConfig); - LoadDevice(); - LoadProfiles(); + RefreshAvailableDevices(); + RefreshModifiedState(); FindPairedDeviceInConfigFile(); - IsModified = false; NotifyChanges(); + return; } - else - { - IsModified = true; - RevertChanges(); - FindPairedDeviceInConfigFile(); - } - - _isChangeTrackingActive = true; // Enable configuration change tracking - } - - private async void HandleOnGamepadConnected(string id) - { _isChangeTrackingActive = false; // Disable configuration change tracking try { - InputConfig persistedConfig = GetPersistedInputConfig(); - bool shouldRestoreControllerAfterFallback = - Config is StandardKeyboardInputConfig && - persistedConfig is StandardControllerInputConfig; + RefreshAvailableDevices(); - if (shouldRestoreControllerAfterFallback) + InputConfig persistedConfig = GetPersistedInputConfig(); + InputConfig displayedConfig = GetDisplayedInputConfig(persistedConfig); + bool shouldApplyKeyboardFallback = + selectedControllerDisconnected || + displayedConfig is StandardKeyboardInputConfig; + + if (shouldApplyKeyboardFallback) + { + if (selectedControllerDisconnected && + displayedConfig is not StandardKeyboardInputConfig && + TryCreateKeyboardFallbackConfig(persistedConfig, out StandardKeyboardInputConfig fallbackConfig)) + { + displayedConfig = fallbackConfig; + } + + LoadConfiguration(displayedConfig); + LoadDevice(); + LoadProfiles(); + FindPairedDeviceInConfigFile(); + IsModified = false; + NotifyChanges(); + } + } + finally + { + _isChangeTrackingActive = true; // Enable configuration change tracking + } + } + + private async void HandleOnGamepadConnected(string id) + { + bool hasUnsavedChanges = HasUnsavedChanges(); + InputConfig persistedConfig = GetPersistedInputConfig(); + bool shouldRestoreControllerAfterFallback = + !hasUnsavedChanges && + Config is StandardKeyboardInputConfig && + persistedConfig is StandardControllerInputConfig; + + if (shouldRestoreControllerAfterFallback) + { + _isChangeTrackingActive = false; // Disable configuration change tracking + + try { const int reconnectRestoreAttempts = 20; const int reconnectRestoreDelayMs = 250; @@ -742,16 +1599,16 @@ namespace Ryujinx.Ava.UI.ViewModels.Input await Task.Delay(reconnectRestoreDelayMs); } } - - RefreshAvailableDevices(); - - IsModified = true; - RevertChanges(); - } - finally - { - _isChangeTrackingActive = true;// Enable configuration change tracking + finally + { + _isChangeTrackingActive = true; // Enable configuration change tracking + } } + + RefreshAvailableDevices(); + RefreshModifiedState(); + FindPairedDeviceInConfigFile(); + NotifyChanges(); } private bool TryGetCurrentDevice(out (DeviceType Type, string Id, string Name) device) @@ -766,6 +1623,16 @@ namespace Ryujinx.Ava.UI.ViewModels.Input return true; } + private DeviceType CurrentDeviceType => + _device >= 0 && _device < Devices.Count ? Devices[_device].Type : DeviceType.None; + + private ControllerType GetSelectedControllerType() + { + return _controller >= 0 && _controller < Controllers.Count + ? Controllers[_controller].Type + : ControllerType.ProController; + } + private static string GetGamepadId((DeviceType Type, string Id, string Name) device) { return device.Type == DeviceType.None ? null : device.Id.Split(" ")[0]; @@ -812,23 +1679,27 @@ namespace Ryujinx.Ava.UI.ViewModels.Input } } - if (controllerIndex != -1) + if (controllerIndex == -1) { - // Avalonia bug: setting a newly instanced ComboBox to 0 - // causes the selected item to show up blank. - // Workaround: set the box to 1 and then 0. - // See: https://github.com/AvaloniaUI/Avalonia/issues/4610 - // https://github.com/AvaloniaUI/Avalonia/discussions/18834 - if (controllerIndex == 0) - { - ApplyControllerSelection(1); - } - - ApplyControllerSelection(controllerIndex); + controllerIndex = 0; } + + // Avalonia bug: setting a newly instanced ComboBox to 0 + // causes the selected item to show up blank + // Workaround: set the box to 1 and then 0 + // See: https://github.com/AvaloniaUI/Avalonia/issues/4610 + // https://github.com/AvaloniaUI/Avalonia/discussions/18834 + if (controllerIndex == 0) + { + ApplyControllerSelection(1); + } + + ApplyControllerSelection(controllerIndex); } else { + // Avalonia bug workaround: set to 1 then 0 + ApplyControllerSelection(1); ApplyControllerSelection(0); } } @@ -928,7 +1799,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input private string GetProfileBasePath() { string path = AppDataManager.ProfilesDirPath; - DeviceType type = Devices[Device == -1 ? 0 : Device].Type; + DeviceType type = CurrentDeviceType; if (type == DeviceType.Keyboard) { @@ -944,26 +1815,43 @@ namespace Ryujinx.Ava.UI.ViewModels.Input private void LoadProfiles() { - ProfilesList.Clear(); + bool wasSuppressingProfileLoad = _suppressProfileLoad; + _suppressProfileLoad = true; - string basePath = GetProfileBasePath(); - - if (!Directory.Exists(basePath)) + try { - Directory.CreateDirectory(basePath); + ProfilesList.Clear(); + + string basePath = GetProfileBasePath(); + + if (!Directory.Exists(basePath)) + { + Directory.CreateDirectory(basePath); + } + + ProfilesList.Add(GetCurrentProfileDefaultName()); + + foreach (string profile in Directory.GetFiles(basePath, "*.json", SearchOption.AllDirectories)) + { + ProfilesList.Add(Path.GetFileNameWithoutExtension(profile)); + } + + string selectedProfile = GetBoundProfileNameForCurrentDevice(); + + if (!ProfilesList.Contains(selectedProfile)) + { + ClearInvalidBindingForCurrentDevice(); + selectedProfile = GetCurrentProfileDefaultName(); + } + + SetSelectedProfileSilently(selectedProfile); + } + finally + { + _suppressProfileLoad = wasSuppressingProfileLoad; } - ProfilesList.Add((LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault])); - - foreach (string profile in Directory.GetFiles(basePath, "*.json", SearchOption.AllDirectories)) - { - ProfilesList.Add(Path.GetFileNameWithoutExtension(profile)); - } - - if (string.IsNullOrWhiteSpace(ProfileName)) - { - ProfileName = LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault]; - } + RefreshProfileBindingState(); } public InputConfig LoadDefaultConfiguration() @@ -981,7 +1869,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input config = InputConfigDefaults.CreateDefaultKeyboardConfiguration( activeDevice.Id, activeDevice.Name, - ControllerType.ProController, + GetSelectedControllerType(), _playerId); } else if (activeDevice.Type == DeviceType.Controller) @@ -1015,7 +1903,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input config = InputConfigDefaults.CreateDefaultControllerConfiguration( id, name, - ControllerType.ProController, + GetSelectedControllerType(), _playerId, isNintendoStyle); } @@ -1041,7 +1929,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input return false; } - ControllerType controllerType = sourceConfig?.ControllerType ?? ControllerType.ProController; + ControllerType controllerType = sourceConfig?.ControllerType ?? GetSelectedControllerType(); PlayerIndex playerIndex = sourceConfig?.PlayerIndex ?? _playerId; fallbackConfig = InputConfigDefaults.CreateDefaultKeyboardConfiguration( @@ -1049,14 +1937,10 @@ namespace Ryujinx.Ava.UI.ViewModels.Input keyboardDevice.Name, controllerType, playerIndex); + fallbackConfig.EnableDynamicGamepadSwap = sourceConfig?.EnableDynamicGamepadSwap ?? false; return true; } - public void LoadProfileButton() - { - LoadProfile(); - } - public async void LoadProfile() { if (Device == 0) @@ -1071,13 +1955,13 @@ namespace Ryujinx.Ava.UI.ViewModels.Input return; } - if (ProfileName == LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault]) + if (ProfileName == GetCurrentProfileDefaultName()) { config = LoadDefaultConfiguration(); } else { - string path = Path.Combine(GetProfileBasePath(), ProfileName + ".json"); + string path = GetProfilePath(ProfileName); if (!File.Exists(path)) { @@ -1121,12 +2005,13 @@ namespace Ryujinx.Ava.UI.ViewModels.Input config.Id = currentDeviceId; // Set current device id instead of changing device(independent profiles) - LoadConfiguration(config); + LoadConfiguration(config, false); //LoadDevice(); This line of code hard-links profiles to controllers, the commented line allows profiles to be applied to all controllers _isLoaded = true; + RefreshProfileBindingState(); RefreshModifiedState(); NotifyChanges(); } @@ -1145,7 +2030,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input return; } - if (ProfileName == LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault]) + if (ProfileName == GetCurrentProfileDefaultName()) { await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogProfileDefaultProfileOverwriteErrorMessage]); @@ -1157,7 +2042,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input if (validFileName) { - string path = Path.Combine(GetProfileBasePath(), ProfileName + ".json"); + string path = GetProfilePath(ProfileName); InputConfig config = null; @@ -1170,15 +2055,17 @@ namespace Ryujinx.Ava.UI.ViewModels.Input config = (ConfigViewModel as ControllerInputViewModel).Config.GetConfig(); } - config.ControllerType = Controllers[_controller].Type; + if (config != null && _controller >= 0 && _controller < Controllers.Count) + { + config.ControllerType = Controllers[_controller].Type; + } string jsonString = JsonHelper.Serialize(config, _serializerContext.InputConfig); await File.WriteAllTextAsync(path, jsonString); LoadProfiles(); - - ChosenProfile = ProfileName; // Show new profile + SetSelectedProfileSilently(ProfileName); } else { @@ -1189,7 +2076,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input public async void RemoveProfile() { - if (Device == 0 || ProfileName == LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault] || ProfilesList.IndexOf(ProfileName) == -1) + if (Device == 0 || ProfileName == GetCurrentProfileDefaultName() || ProfilesList.IndexOf(ProfileName) == -1) { return; } @@ -1203,21 +2090,25 @@ namespace Ryujinx.Ava.UI.ViewModels.Input if (result == UserResult.Yes) { - string path = Path.Combine(GetProfileBasePath(), ProfileName + ".json"); + string path = GetProfilePath(ProfileName); if (File.Exists(path)) { File.Delete(path); } + ReplaceBoundProfileName(ProfileName, null); LoadProfiles(); - ChosenProfile = ProfilesList[0].ToString(); // Show default profile + SetSelectedProfileSilently(ProfilesList[0].ToString()); + RefreshModifiedState(); } } public void RevertChanges() { + _allowDuplicateDeviceAssignment = null; + _workingPlayerInputAssignments = null; _isLoaded = false; LoadConfiguration(); LoadDevice(); @@ -1238,25 +2129,37 @@ namespace Ryujinx.Ava.UI.ViewModels.Input IsModified = false; List newConfig = []; + List newAssignments = []; if (UseGlobalConfig && Program.UseExtraConfig) { - newConfig.AddRange(ConfigurationState.InstanceExtra.Hid.InputConfig.Value); + newConfig.AddRange(ConfigurationState.InstanceExtra.Hid.InputConfig.Value ?? []); + newAssignments.AddRange((_workingPlayerInputAssignments ?? ConfigurationState.InstanceExtra.Hid.PlayerInputAssignments.Value) ?? []); } else { - newConfig.AddRange(ConfigurationState.Instance.Hid.InputConfig.Value); + newConfig.AddRange(ConfigurationState.Instance.Hid.InputConfig.Value ?? []); + newAssignments.AddRange((_workingPlayerInputAssignments ?? ConfigurationState.Instance.Hid.PlayerInputAssignments.Value) ?? []); } newConfig.RemoveAll(static inputConfig => inputConfig == null); + newAssignments.RemoveAll(static assignment => assignment == null); if (Device == 0) { newConfig.RemoveAll(inputConfig => inputConfig.PlayerIndex == PlayerId); + newAssignments.RemoveAll(assignment => assignment.PlayerIndex == PlayerId); } else { InputConfig config = GetSelectedDeviceConfig(); + PlayerInputAssignment assignment = GetEditedPlayerInputAssignment(); + + if (config == null) + { + IsModified = true; + return; + } int i = newConfig.FindIndex(x => x.PlayerIndex == PlayerId); if (i == -1) @@ -1267,23 +2170,82 @@ namespace Ryujinx.Ava.UI.ViewModels.Input { newConfig[i] = config; } + + int assignmentIndex = newAssignments.FindIndex(x => x.PlayerIndex == PlayerId); + if (assignmentIndex == -1) + { + newAssignments.Add(assignment); + } + else + { + newAssignments[assignmentIndex] = assignment; + } + + if (!AllowDuplicateDeviceAssignment) + { + RemoveDuplicateDeviceAssignmentsForCurrentPlayer(newAssignments, assignment); + } } // Atomically replace and signal input change. // NOTE: Do not modify InputConfig.Value directly as other code depends on the on-change event. - _mainWindow.ViewModel.AppHost?.NpadManager.ReloadConfiguration(newConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse); + _mainWindow.ViewModel.AppHost?.NpadManager.ReloadConfiguration(newConfig, newAssignments, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse); if (UseGlobalConfig && Program.UseExtraConfig) { // In User Settings when "Use Global Input" is enabled, it saves global input to global setting ConfigurationState.InstanceExtra.Hid.InputConfig.Value = newConfig; + ConfigurationState.InstanceExtra.Hid.PlayerInputAssignments.Value = newAssignments; + ConfigurationState.InstanceExtra.Hid.AllowDuplicateDeviceAssignment.Value = AllowDuplicateDeviceAssignment; ConfigurationState.InstanceExtra.ToFileFormat().SaveConfig(Program.GlobalConfigurationPath); } else { ConfigurationState.Instance.Hid.InputConfig.Value = newConfig; + ConfigurationState.Instance.Hid.PlayerInputAssignments.Value = newAssignments; + ConfigurationState.Instance.Hid.AllowDuplicateDeviceAssignment.Value = AllowDuplicateDeviceAssignment; ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath); } + + _allowDuplicateDeviceAssignment = null; + _workingPlayerInputAssignments = null; + } + + private void RemoveDuplicateDeviceAssignmentsForCurrentPlayer(List assignments, PlayerInputAssignment currentAssignment) + { + if (currentAssignment?.Devices == null || currentAssignment.Devices.Count == 0) + { + return; + } + + foreach (InputConfig inputConfig in GetPersistedInputConfigs().Where(inputConfig => + inputConfig != null && + inputConfig.PlayerIndex != PlayerId && + inputConfig.EnableDynamicGamepadSwap && + CurrentAssignmentContainsDevice(currentAssignment, PlayerInputAssignmentHelper.CreatePrimaryDevice(inputConfig)))) + { + if (assignments.All(assignment => assignment.PlayerIndex != inputConfig.PlayerIndex)) + { + assignments.Add(new PlayerInputAssignment + { + PlayerIndex = inputConfig.PlayerIndex, + EnableDynamicInputSwap = true, + }); + } + } + + foreach (PlayerInputAssignment assignment in assignments.Where(assignment => assignment.PlayerIndex != PlayerId)) + { + assignment.Devices.RemoveAll(device => CurrentAssignmentContainsDevice(currentAssignment, device)); + } + } + + private static bool CurrentAssignmentContainsDevice(PlayerInputAssignment currentAssignment, AssignedInputDevice device) + { + return device != null && + currentAssignment.Devices.Any(currentDevice => + currentDevice.Type == device.Type && + string.Equals(currentDevice.Id, device.Id, StringComparison.Ordinal)); } public void NotifyChanges() @@ -1291,9 +2253,12 @@ namespace Ryujinx.Ava.UI.ViewModels.Input OnPropertyChanged(nameof(ConfigViewModel)); OnPropertyChanged(nameof(IsController)); OnPropertyChanged(nameof(ShowSettings)); + OnPropertyChanged(nameof(CanOpenAssignedDevices)); OnPropertyChanged(nameof(IsKeyboard)); OnPropertyChanged(nameof(IsRight)); OnPropertyChanged(nameof(IsLeft)); + OnPropertyChanged(nameof(CanBindSelectedProfile)); + OnPropertyChanged(nameof(IsProfileLinked)); NotifyChangesEvent?.Invoke(); } diff --git a/src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml b/src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml new file mode 100644 index 000000000..556e50916 --- /dev/null +++ b/src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml.cs b/src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml.cs new file mode 100644 index 000000000..6e99bb39c --- /dev/null +++ b/src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml.cs @@ -0,0 +1,97 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using FluentAvalonia.UI.Controls; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.UI.Models.Input; +using Ryujinx.Ava.UI.ViewModels.Input; +using System.Linq; +using System.Threading.Tasks; + +namespace Ryujinx.Ava.UI.Views.Input +{ + public partial class AssignedDevicesInputView : UserControl + { + public AssignedDevicesInputView() + { + InitializeComponent(); + } + + public AssignedDevicesInputView(InputViewModel viewModel) + { + DataContext = viewModel; + InitializeComponent(); + } + + public static async Task Show(InputViewModel viewModel) + { + // Store original state to allow discarding changes + var originalAssignments = viewModel.PlayerInputDevices + .Select(item => new { item.Id, item.DeviceType, item.IsAssigned }) + .ToList(); + var originalAllowDuplicate = viewModel.AllowDuplicateDeviceAssignment; + + AssignedDevicesInputView content = new(viewModel); + + ContentDialog contentDialog = new() + { + Title = LocaleManager.Instance[LocaleKeys.ControllerSettingsAssignedInputDevices], + PrimaryButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsSave], + SecondaryButtonText = string.Empty, + CloseButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsClose], + Content = content, + }; + + ContentDialogResult result = await contentDialog.ShowAsync(); + + if (result == ContentDialogResult.Primary) + { + viewModel.Save(); + } + else + { + // Discard changes by reverting to original state + foreach (var original in originalAssignments) + { + var item = viewModel.PlayerInputDevices.FirstOrDefault(d => + d.Id == original.Id && d.DeviceType == original.DeviceType); + if (item != null && item.IsAssigned != original.IsAssigned) + { + // Use Toggle to revert, which will properly refresh state + viewModel.ToggleAssignedPlayerInputDevice(item, original.IsAssigned); + } + } + // Revert AllowDuplicateDeviceAssignment to original state + if (viewModel.AllowDuplicateDeviceAssignment != originalAllowDuplicate) + { + viewModel.AllowDuplicateDeviceAssignment = originalAllowDuplicate; + } + viewModel.RefreshModifiedState(); + } + } + + private void AssignedDeviceCheckBox_OnCheckedChanged(object sender, RoutedEventArgs e) + { + if (sender is CheckBox { DataContext: PlayerInputDeviceAssignmentItem item } checkBox) + { + _viewModel?.ToggleAssignedPlayerInputDevice(item, checkBox.IsChecked == true); + } + } + + private void DeviceRow_OnPointerPressed(object sender, PointerPressedEventArgs e) + { + if (e.Source is Control control && control.FindAncestorOfType() != null) + { + return; + } + + if (sender is Border { DataContext: PlayerInputDeviceAssignmentItem item }) + { + _viewModel?.ToggleAssignedPlayerInputDevice(item, !item.IsAssigned); + } + } + + private InputViewModel _viewModel => DataContext as InputViewModel; + } +} diff --git a/src/Ryujinx/UI/Views/Input/InputView.axaml b/src/Ryujinx/UI/Views/Input/InputView.axaml index 5ff8e0618..725986b3c 100644 --- a/src/Ryujinx/UI/Views/Input/InputView.axaml +++ b/src/Ryujinx/UI/Views/Input/InputView.axaml @@ -88,7 +88,7 @@ Grid.Column="2" Margin="2" HorizontalAlignment="Stretch" - VerticalAlignment="Center" ColumnDefinitions="Auto,*,Auto,Auto,Auto"> + VerticalAlignment="Center" ColumnDefinitions="Auto,*,Auto,Auto,Auto,Auto"> + Text="{Binding ProfileName, Mode=TwoWay}"> + + + + + + @@ -122,19 +149,34 @@ MinWidth="0" Margin="5,0,0,0" VerticalAlignment="Center" - ToolTip.Tip="{ext:Locale ControllerSettingsSaveProfileToolTip}" - Command="{Binding SaveProfile}"> + ToolTip.Tip="{ext:Locale ControllerSettingsLoadProfileToolTip}" + IsEnabled="{Binding ShowSettings}" + Click="LoadProfileButton_OnClick"> + +