From 54e9b40cd75a6e83d5a7319128669bed15801512 Mon Sep 17 00:00:00 2001 From: Babib3l Date: Sat, 27 Jun 2026 01:42:20 +0000 Subject: [PATCH] Feat : Input : Dynamic Input Swap, Device Profile Linking and Player Device Assignement (#125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heyy everyone! Continuing my work on the input system overhaul, this PR is mainly focused around three new features : - Dynamic Input Swap - Device Profile Linking - Player Device Assignement What are these new features and why are they bundled in the same PR you might ask? - **Dynamic Input Swap** Is a new feature that, when enabled, allows the user to switch between multiple input devices, without having to return to the settings menu. This feature can be enabled at the bottom of the Input Settings menu, next to the "global input" checkbox. A nifty new tooltip has been added for clarity. - **Device Profile linking** : While working on Dynamic Input Swap, one issue arose, which was that Ryujinx would use that device’s Default profile, without the user being able to specify a specific profile to use on that specific device. This feature fixes that, by allowing the user to link a profile as the device’s Default. This means that this profile will be auto loaded when selecting that same device, both when connecting it to the emulator, and when using Dynamic Input Swap. This feature can be found next to the Device Profiles dropdown, and includes a nifty new tooltip. By default, the "default" profile is linked to any device. There also is a small icon to the right of the profile name’s Right to visualise which profile is linked. - **Player Device Assignement** : Another issue that arose while working on Dynamic Input Swap was that enabling the feature would render multi player configurations unusable. This new feature addresses this issue by adding a new menu, which allows the user to assign different Input Devices to different players. Inside this new menu, you also get the device’s linked profile and the list of which players are already assigned to which input device, below and to the right of the input device name, respectively. You also get an option to allow mapping the same input device to different players. ### Nerd Zone This PR adds a new player-level input routing layer on top of the existing `InputConfig` system. Previously, each player effectively had one active input config, tied to one device. Given Dynamic Input Swap needs more state than that, this PR introduces a persisted `PlayerInputAssignment` model, which stores the player index, whether Dynamic Input Swap is enabled or not, the list of assigned input devices and an optional profile name bound to each assigned device. The new assignment data lives coexists with the existing input configs instead of replacing them, which should keep the old single-device behavior intact when Dynamic Input Swap is disabled, while allowing dynamic players to own multiple devices. New configl types include: - `AssignedInputDevice` - `AssignedInputDeviceType` - `PlayerInputAssignment` - `PlayerInputAssignmentHelper` - `PlayerInputDeviceAssignmentItem` `PlayerInputAssignmentHelper` normalizes assignments, deduplicates device entries, preserves a primary device, and compares assignments for dirty-checking. On the runtime side, `NpadManager` now passes both the normal `InputConfig` and the player assignment to `NpadController`. When Dynamic Input Swap is disabled, Ryujinx switches to the old behavior : one player config opens one device. However, when it's enabled, `NpadController` opens every assigned keyboard/controller device it can resolve, tracks their state snapshots, and promotes the active source based on recent input. Dynamic swap currently follows a “last meaningful input wins” model (annotated in the code) : So if the keyboard produces new input, it becomes active; if an assigned controller produces new input, that controller becomes active; if the active device stops producing input and another assigned device is held/active, the active source can fall back; and if no device has produced input yet, the initial active source is chosen from the selected config and available assigned devices. Per-device profile binding is handled by storing the profile name on the assigned device entry. When a device is selected or resolved through Dynamic Input Swap, Ryujinx tries to load that bound profile for the matching device type. If the profile is missing, invalid, or explicitly `Default`, it falls back to the generated default config for that device type. The new restore-to-defaults behavior deliberately bypasses linked profiles. This means pressing the reset button loads the real generated Default profile for the currently selected device, not the profile linked to that device. The input settings UI now dirty-checks both the currently edited input config and the player input assignment state, meaning that assigning/unassigning devices, changing Dynamic Input Swap, or changing profile bindings correctly marks the page as modified -> in continuation of my previous efforts in #13 to clean up its behaviour. The Assigned Devices dialog is backed by the currently available keyboard/controller device list. It also checks other players’ persisted assignments so the UI can show which players already use a device. If duplicate assignment is disabled, devices already assigned to another dynamic-swap player are disabled for the current player. If no explicit player assignments exist yet, Ryujinx synthesizes a default assignment from the existing input config. Dynamic swap is disabled by default until the user enables it. ⚠️⚠️⚠️⚠️⚠️Caution : this PR is still a WIP; and while all features described above have been fully implemented, various issues still remain, notably inside the Player assignement menu, that are getting worked on. Additionally, little bug testing has been done across the emulator, so it is not guaranteed that this build will be bug free. Signed by : 🦫 With the very generous help and support from @neo 🤗 Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/125 --- assets/Locales/Root.json | 200 +++ .../Configuration/Hid/AssignedInputDevice.cs | 11 + .../Hid/AssignedInputDeviceType.cs | 11 + .../Configuration/Hid/InputConfig.cs | 6 + .../Hid/PlayerInputAssignment.cs | 13 + .../Hid/PlayerInputAssignmentHelper.cs | 166 +++ src/Ryujinx.Input.SDL3/SDL3Gamepad.cs | 2 +- src/Ryujinx.Input/HLE/NpadController.cs | 832 ++++++++++-- src/Ryujinx.Input/HLE/NpadManager.cs | 148 +- src/Ryujinx/Systems/AppHost.cs | 4 +- .../Configuration/ConfigurationFileFormat.cs | 10 + .../ConfigurationState.Migration.cs | 2 + .../Configuration/ConfigurationState.Model.cs | 22 + .../Configuration/ConfigurationState.cs | 18 + src/Ryujinx/UI/Helpers/ContentDialogHelper.cs | 66 + .../Converters/ProfileNameLinkedConverter.cs | 32 + .../Input/PlayerInputDeviceAssignmentItem.cs | 72 + .../UI/ViewModels/Input/InputViewModel.cs | 1197 +++++++++++++++-- .../Input/AssignedDevicesInputView.axaml | 89 ++ .../Input/AssignedDevicesInputView.axaml.cs | 97 ++ src/Ryujinx/UI/Views/Input/InputView.axaml | 75 +- src/Ryujinx/UI/Views/Input/InputView.axaml.cs | 78 +- .../UI/Views/Settings/SettingsInputView.axaml | 15 +- 23 files changed, 2914 insertions(+), 252 deletions(-) create mode 100644 src/Ryujinx.Common/Configuration/Hid/AssignedInputDevice.cs create mode 100644 src/Ryujinx.Common/Configuration/Hid/AssignedInputDeviceType.cs create mode 100644 src/Ryujinx.Common/Configuration/Hid/PlayerInputAssignment.cs create mode 100644 src/Ryujinx.Common/Configuration/Hid/PlayerInputAssignmentHelper.cs create mode 100644 src/Ryujinx/UI/Helpers/Converters/ProfileNameLinkedConverter.cs create mode 100644 src/Ryujinx/UI/Models/Input/PlayerInputDeviceAssignmentItem.cs create mode 100644 src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml create mode 100644 src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml.cs 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"> + +