From 6c184b63ae2fdd7acd3374266fa18273213d1ca6 Mon Sep 17 00:00:00 2001 From: Aswin Gopal <68984963+AswinRajGopal@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:18:36 +0530 Subject: [PATCH 1/4] Add test to cover the case --- Assets/Tests/InputSystem/CoreTests_Events.cs | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/Assets/Tests/InputSystem/CoreTests_Events.cs b/Assets/Tests/InputSystem/CoreTests_Events.cs index 4034353352..e9558547e4 100644 --- a/Assets/Tests/InputSystem/CoreTests_Events.cs +++ b/Assets/Tests/InputSystem/CoreTests_Events.cs @@ -10,6 +10,7 @@ using UnityEngine; using UnityEngine.Scripting; using UnityEngine.InputSystem; +using UnityEngine.InputSystem.Users; using UnityEngine.InputSystem.Controls; using UnityEngine.InputSystem.Layouts; using UnityEngine.InputSystem.LowLevel; @@ -1256,6 +1257,48 @@ public void Events_CanPreventEventsFromBeingProcessed() Assert.That(device.rightTrigger.ReadValue(), Is.EqualTo(0.0).Within(0.00001)); } + [Test] + [Category("Events")] + public void Events_HandledFirstEvent_DoesNotTriggerAction_OnSecondEventSameUpdate() + { + var gamepad = InputSystem.AddDevice(); + var action = new InputAction(type: InputActionType.Button, binding: "/buttonSouth"); + action.Enable(); + + var performedCount = 0; + action.performed += _ => ++performedCount; + + var previousPolicy = InputSystem.s_Manager.inputEventHandledPolicy; + InputSystem.s_Manager.inputEventHandledPolicy = InputEventHandledPolicy.SuppressStateUpdates; + + InputUser.listenForUnpairedDeviceActivity++; + var handledFirstEventId = 0; + Action onUnpaired = (control, eventPtr) => + { + if (handledFirstEventId == 0) + { + handledFirstEventId = eventPtr.id; + eventPtr.handled = true; + } + }; + InputUser.onUnpairedDeviceUsed += onUnpaired; + + try + { + InputSystem.QueueStateEvent(gamepad, new GamepadState().WithButton(GamepadButton.South)); + InputSystem.QueueStateEvent(gamepad, new GamepadState().WithButton(GamepadButton.South)); + InputSystem.Update(); + + Assert.That(performedCount, Is.EqualTo(0)); + } + finally + { + InputUser.onUnpairedDeviceUsed -= onUnpaired; + InputUser.listenForUnpairedDeviceActivity--; + InputSystem.s_Manager.inputEventHandledPolicy = previousPolicy; + } + } + [Test] [Category("Events")] public void EventHandledPolicy_ShouldReflectUserSetting() From c93476306d1be3b48284f4accd59ecfd540eed5e Mon Sep 17 00:00:00 2001 From: Aswin Gopal Date: Thu, 29 Jan 2026 17:34:05 +0530 Subject: [PATCH 2/4] Fix handled unpaired device events still triggering actions --- .../InputSystem/Actions/InputActionState.cs | 3 + .../InputSystem/InputManager.cs | 63 ++++++++++++++----- .../InputSystem/Utilities/DelegateHelpers.cs | 32 ++++++++++ 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/Packages/com.unity.inputsystem/InputSystem/Actions/InputActionState.cs b/Packages/com.unity.inputsystem/InputSystem/Actions/InputActionState.cs index 82d6fe1082..cff8b10d06 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Actions/InputActionState.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Actions/InputActionState.cs @@ -1436,6 +1436,9 @@ private void ProcessControlStateChange(int mapIndex, int controlIndex, int bindi Debug.Assert(controlIndex >= 0 && controlIndex < totalControlCount, "Control index out of range"); Debug.Assert(bindingIndex >= 0 && bindingIndex < totalBindingCount, "Binding index out of range"); + if (InputSystem.s_Manager.ShouldSuppressActionsForDevice(controls[controlIndex].device)) + return; + using (InputActionRebindingExtensions.DeferBindingResolution()) { // Callbacks can do pretty much anything and thus trigger arbitrary state/configuration diff --git a/Packages/com.unity.inputsystem/InputSystem/InputManager.cs b/Packages/com.unity.inputsystem/InputSystem/InputManager.cs index 1edf9ad90f..5c19b63db2 100644 --- a/Packages/com.unity.inputsystem/InputSystem/InputManager.cs +++ b/Packages/com.unity.inputsystem/InputSystem/InputManager.cs @@ -2236,6 +2236,7 @@ internal struct AvailableDevice internal CallbackArray m_DeviceCommandCallbacks; private CallbackArray m_LayoutChangeListeners; private CallbackArray m_EventListeners; + private uint[] m_SuppressActionsForDeviceUpdate; private CallbackArray m_BeforeUpdateListeners; private CallbackArray m_AfterUpdateListeners; private CallbackArray m_SettingsChangedListeners; @@ -3454,24 +3455,31 @@ private unsafe void OnUpdate(InputUpdateType updateType, ref InputEventBuffer ev } } + var currentEventPtr = new InputEventPtr(currentEventReadPtr); + // Give listeners a shot at the event. // NOTE: We call listeners also for events where the device is disabled. This is crucial for code // such as TouchSimulation that disables the originating devices and then uses its events to // create simulated events from. if (m_EventListeners.length > 0) { - DelegateHelpers.InvokeCallbacksSafe(ref m_EventListeners, - new InputEventPtr(currentEventReadPtr), device, k_InputOnEventMarker, "InputSystem.onEvent"); - - // If a listener marks the event as handled, we don't process it further. - if (m_InputEventHandledPolicy == InputEventHandledPolicy.SuppressStateUpdates && - currentEventReadPtr->handled) + if (DelegateHelpers.InvokeCallbacksSafeUntilHandled(ref m_EventListeners, currentEventPtr, device, k_InputOnEventMarker, "InputSystem.onEvent", + m_InputEventHandledPolicy == InputEventHandledPolicy.SuppressStateUpdates)) { + currentEventReadPtr->handled = true; + SuppressActionsForDevice(device); m_InputEventStream.Advance(false); continue; } } + if (m_InputEventHandledPolicy == InputEventHandledPolicy.SuppressStateUpdates && currentEventPtr.handled) + { + SuppressActionsForDevice(device); + m_InputEventStream.Advance(false); + continue; + } + // Update metrics. if (currentEventTimeInternal <= currentTime) totalEventLag += currentTime - currentEventTimeInternal; @@ -3484,8 +3492,6 @@ private unsafe void OnUpdate(InputUpdateType updateType, ref InputEventBuffer ev case StateEvent.Type: case DeltaStateEvent.Type: - var eventPtr = new InputEventPtr(currentEventReadPtr); - // Ignore the event if the last state update we received for the device was // newer than this state event is. We don't allow devices to go back in time. // @@ -3496,7 +3502,7 @@ private unsafe void OnUpdate(InputUpdateType updateType, ref InputEventBuffer ev // increasing timestamps across all such streams. var deviceIsStateCallbackReceiver = device.hasStateCallbacks; if (currentEventTimeInternal < device.m_LastUpdateTimeInternal && - !(deviceIsStateCallbackReceiver && device.stateBlock.format != eventPtr.stateFormat)) + !(deviceIsStateCallbackReceiver && device.stateBlock.format != currentEventPtr.stateFormat)) { #if UNITY_EDITOR m_Diagnostics?.OnEventTimestampOutdated(new InputEventPtr(currentEventReadPtr), device); @@ -3520,14 +3526,14 @@ private unsafe void OnUpdate(InputUpdateType updateType, ref InputEventBuffer ev m_ShouldMakeCurrentlyUpdatingDeviceCurrent = true; // NOTE: We leave it to the device to make sure the event has the right format. This allows the // device to handle multiple different incoming formats. - ((IInputStateCallbackReceiver)device).OnStateEvent(eventPtr); + ((IInputStateCallbackReceiver)device).OnStateEvent(currentEventPtr); haveChangedStateOtherThanNoise = m_ShouldMakeCurrentlyUpdatingDeviceCurrent; } else { // If the state format doesn't match, ignore the event. - if (device.stateBlock.format != eventPtr.stateFormat) + if (device.stateBlock.format != currentEventPtr.stateFormat) { #if UNITY_EDITOR m_Diagnostics?.OnEventFormatMismatch(currentEventReadPtr, device); @@ -3535,23 +3541,23 @@ private unsafe void OnUpdate(InputUpdateType updateType, ref InputEventBuffer ev break; } - haveChangedStateOtherThanNoise = UpdateState(device, eventPtr, updateType); + haveChangedStateOtherThanNoise = UpdateState(device, currentEventPtr, updateType); } - totalEventBytesProcessed += eventPtr.sizeInBytes; + totalEventBytesProcessed += currentEventPtr.sizeInBytes; - device.m_CurrentProcessedEventBytesOnUpdate += eventPtr.sizeInBytes; + device.m_CurrentProcessedEventBytesOnUpdate += currentEventPtr.sizeInBytes; // Update timestamp on device. // NOTE: We do this here and not in UpdateState() so that InputState.Change() will *NOT* change timestamps. // Only events should. If running play mode updates in editor, we want to defer to the play mode // callbacks to set the last update time to avoid dropping events only processed by the editor state. - if (device.m_LastUpdateTimeInternal <= eventPtr.internalTime + if (device.m_LastUpdateTimeInternal <= currentEventPtr.internalTime #if UNITY_EDITOR && !(updateType == InputUpdateType.Editor && runPlayerUpdatesInEditMode) #endif ) - device.m_LastUpdateTimeInternal = eventPtr.internalTime; + device.m_LastUpdateTimeInternal = currentEventPtr.internalTime; // Make device current. Again, only do this when receiving events. if (haveChangedStateOtherThanNoise) @@ -3890,6 +3896,31 @@ private void InvokeAfterUpdateCallback(InputUpdateType updateType) private bool m_ShouldMakeCurrentlyUpdatingDeviceCurrent; + private void SuppressActionsForDevice(InputDevice device) + { + if (m_InputEventHandledPolicy != InputEventHandledPolicy.SuppressStateUpdates || device == null) + return; + + var deviceIndex = device.m_DeviceIndex; + if (m_SuppressActionsForDeviceUpdate == null || deviceIndex >= m_SuppressActionsForDeviceUpdate.Length) + Array.Resize(ref m_SuppressActionsForDeviceUpdate, deviceIndex + 1); + m_SuppressActionsForDeviceUpdate[deviceIndex] = InputUpdate.s_UpdateStepCount; + } + + internal bool ShouldSuppressActionsForDevice(InputDevice device) + { + if (m_InputEventHandledPolicy != InputEventHandledPolicy.SuppressStateUpdates || device == null) + return false; + + var deviceIndex = device.m_DeviceIndex; + if (m_SuppressActionsForDeviceUpdate == null || deviceIndex >= m_SuppressActionsForDeviceUpdate.Length) + return false; + + var lastUpdate = (int)m_SuppressActionsForDeviceUpdate[deviceIndex]; + var currentUpdate = (int)InputUpdate.s_UpdateStepCount; + return lastUpdate == currentUpdate || lastUpdate + 1 == currentUpdate; + } + // This is a dirty hot fix to expose entropy from device back to input manager to make a choice if we want to make device current or not. // A proper fix would be to change IInputStateCallbackReceiver.OnStateEvent to return bool to make device current or not. internal void DontMakeCurrentlyUpdatingDeviceCurrent() diff --git a/Packages/com.unity.inputsystem/InputSystem/Utilities/DelegateHelpers.cs b/Packages/com.unity.inputsystem/InputSystem/Utilities/DelegateHelpers.cs index 2dccc52d07..0492fbac2a 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Utilities/DelegateHelpers.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Utilities/DelegateHelpers.cs @@ -1,5 +1,6 @@ using System; using Unity.Profiling; +using UnityEngine.InputSystem.LowLevel; namespace UnityEngine.InputSystem.Utilities { @@ -83,6 +84,37 @@ public static void InvokeCallbacksSafe(ref CallbackArray> callbacks, + InputEventPtr eventPtr, InputDevice device, ProfilerMarker marker, string callbackName, bool stopOnHandled) + { + if (callbacks.length == 0) + return false; + + marker.Begin(); + callbacks.LockForChanges(); + for (var i = 0; i < callbacks.length; ++i) + { + try + { + callbacks[i](eventPtr, device); + } + catch (Exception exception) + { + Debug.LogException(exception); + Debug.LogError($"{exception.GetType().Name} while executing '{callbackName}' callbacks"); + } + + if (stopOnHandled && eventPtr.handled) + { + callbacks.UnlockForChanges(); + return true; + } + } + callbacks.UnlockForChanges(); + return false; + marker.End(); + } + public static bool InvokeCallbacksSafe_AnyCallbackReturnsTrue(ref CallbackArray> callbacks, TValue1 argument1, TValue2 argument2, string callbackName, object context = null) { From 6a7b56ea7f5cc29b90e75f943d7476feb577dc23 Mon Sep 17 00:00:00 2001 From: Aswin Gopal <68984963+AswinRajGopal@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:44:13 +0530 Subject: [PATCH 3/4] Update CHANGELOG --- Packages/com.unity.inputsystem/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/com.unity.inputsystem/CHANGELOG.md b/Packages/com.unity.inputsystem/CHANGELOG.md index fb7dbba1bb..27559282b1 100644 --- a/Packages/com.unity.inputsystem/CHANGELOG.md +++ b/Packages/com.unity.inputsystem/CHANGELOG.md @@ -21,6 +21,7 @@ however, it has to be formatted properly to pass verification tests. - Align title font size with toolbar style in `Input Action` window. - Updated Action Properties headers to use colors consistent with GameObject component headers. - Fixed misaligned Virtual Cursor when changing resolution [ISXB-1119](https://issuetracker.unity3d.com/product/unity/issues/guid/ISXB-1119) +- Fixed handled input events from unpaired devices still triggering actions on the same physical press [ISXB-1097](https://issuetracker.unity3d.com/product/unity/issues/guid/ISXB-1097) ### Added From b94a66b39305daa701c271519eaf9e44e863656e Mon Sep 17 00:00:00 2001 From: Aswin Gopal <68984963+AswinRajGopal@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:01:36 +0530 Subject: [PATCH 4/4] Fix delegate helper function which makes sure balanced profiler samples. --- .../InputSystem/Utilities/DelegateHelpers.cs | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/Packages/com.unity.inputsystem/InputSystem/Utilities/DelegateHelpers.cs b/Packages/com.unity.inputsystem/InputSystem/Utilities/DelegateHelpers.cs index 0492fbac2a..91f6f23300 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Utilities/DelegateHelpers.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Utilities/DelegateHelpers.cs @@ -92,27 +92,35 @@ public static bool InvokeCallbacksSafeUntilHandled(ref CallbackArray(ref CallbackArray> callbacks,