Skip to content
61 changes: 52 additions & 9 deletions Assets/Tests/InputSystem/CoreTests_Events.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Gamepad>();
var action = new InputAction(type: InputActionType.Button, binding: "<Gamepad>/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<InputControl, InputEventPtr> 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()
Expand Down Expand Up @@ -1292,24 +1335,24 @@ class SuppressedActionEventData
// Step 3: Release button north and stick while no longer being suppressed.
// Step 4: Press gamepad north and stick.

// Press event is detected in step 2 (false positive) with default interaction
// Press event is suppressed in step 1/2 with default interaction
[TestCase(InputEventHandledPolicy.SuppressStateUpdates, // policy
null, // interactions
new int[] { 0, 0, 1, 1, 2}, // started
new int[] { 0, 0, 1, 1, 2}, // performed
new int[] {0, 0, 0, 1, 1})] // cancelled
new int[] { 0, 0, 0, 0, 1}, // started
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If any of this changes I suspect it will regress the rebinding sample.

new int[] { 0, 0, 0, 0, 1}, // performed
new int[] {0, 0, 0, 0, 0})] // cancelled
// Press event is not detected in step 1/2 with default interaction
[TestCase(InputEventHandledPolicy.SuppressActionEventNotifications,
null,
new int[] { 0, 0, 0, 0, 1},
new int[] { 0, 0, 0, 0, 1},
new int[] {0, 0, 0, 1, 1})]
// Press event is detected in step 2 (false positive) with explicit press interaction
// Press event is suppressed in step 1/2 with explicit press interaction
[TestCase(InputEventHandledPolicy.SuppressStateUpdates,
"press",
new int[] { 0, 0, 1, 1, 2},
new int[] { 0, 0, 1, 1, 2},
new int[] {0, 0, 0, 1, 1})]
new int[] { 0, 0, 0, 0, 1},
new int[] { 0, 0, 0, 0, 1},
new int[] {0, 0, 0, 0, 0})]
Comment on lines +1341 to +1355
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a behavior change. What is the reason to change this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We needed a explicit way to keep action suppression working across update steps. It will ensure that actions doesn't trigger on next update which shows up as a false positive press like reported in the bug. Please correct me if I'm wrong - my understanding is,

  • With SupressStateUpdates, a handled press should not create started or performed in steps 1 or 2.
  • This change is removing the 2nd step false positive press and keeps the policy intact.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. I'll keep looking at it to see if I find any problems. Meanwhile maybe @ekcoh has a more immediate answer since he added these tests.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, IMO, action suppression should be bound to event.handled and state propagation would always happen, but I believe some PoC and solution design is needed to validate such a change and evaluate any possible side-effects of such a change. The number of repetitions or number of frames should not be part of equation, only transitions. Hence why event.handled looks like of broken since it desynchronises from actual state and doesn't worked for polled sources that may provide an arbitrary number of repeats.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test was added to verify suppression behaviour and it is not clear to me why it would have to change?

// Press event is not detected in step 1/2 (false positive) with explicit press interaction
[TestCase(InputEventHandledPolicy.SuppressActionEventNotifications,
"press",
Expand Down Expand Up @@ -1396,7 +1439,7 @@ public void Events_ShouldRespectHandledPolicyUponUpdateAndSuppressedPressTransit
Assert.That(action.WasPressedThisFrame, Is.EqualTo(performedThisFrame));
releasedThisFrame = expectedCancelled[2] - expectedCancelled[1] > 0;
Assert.That(action.WasReleasedThisFrame, Is.EqualTo(releasedThisFrame));
Assert.That(action.IsPressed, Is.True); // Note: This is not an event and hence not suppressed
Assert.That(action.IsPressed, Is.EqualTo(seesControlChangesUnderSuppression)); // Note: This is not an event and hence not suppressed

Assert.That(Gamepad.current.buttonNorth.wasPressedThisFrame, Is.EqualTo(!seesControlChangesUnderSuppression));
Assert.That(Gamepad.current.buttonNorth.wasReleasedThisFrame, Is.False);
Expand Down
1 change: 1 addition & 0 deletions Packages/com.unity.inputsystem/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 47 additions & 16 deletions Packages/com.unity.inputsystem/InputSystem/InputManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2236,6 +2236,7 @@ internal struct AvailableDevice
internal CallbackArray<InputDeviceCommandDelegate> m_DeviceCommandCallbacks;
private CallbackArray<LayoutChangeListener> m_LayoutChangeListeners;
private CallbackArray<EventListener> m_EventListeners;
private uint[] m_SuppressActionsForDeviceUpdate;
private CallbackArray<UpdateListener> m_BeforeUpdateListeners;
private CallbackArray<UpdateListener> m_AfterUpdateListeners;
private CallbackArray<Action> m_SettingsChangedListeners;
Expand Down Expand Up @@ -3454,24 +3455,31 @@ private unsafe void OnUpdate(InputUpdateType updateType, ref InputEventBuffer ev
}
}

var currentEventPtr = new InputEventPtr(currentEventReadPtr);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we need to cache this explicitly or if doing it the previous way is enough, by using currentEventReadPtr ? I don't see a reason but maybe I'm missing something.


// 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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand how this is supposed to be working? Would you mind elaborating, maybe with inline comments?

{
SuppressActionsForDevice(device);
m_InputEventStream.Advance(false);
continue;
}

// Update metrics.
if (currentEventTimeInternal <= currentTime)
totalEventLag += currentTime - currentEventTimeInternal;
Expand All @@ -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.
//
Expand All @@ -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);
Expand All @@ -3520,38 +3526,38 @@ 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);
#endif
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)
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using Unity.Profiling;
using UnityEngine.InputSystem.LowLevel;

namespace UnityEngine.InputSystem.Utilities
{
Expand Down Expand Up @@ -83,6 +84,45 @@ public static void InvokeCallbacksSafe<TValue1, TValue2>(ref CallbackArray<Actio
marker.End();
}

public static bool InvokeCallbacksSafeUntilHandled(ref CallbackArray<Action<InputEventPtr, InputDevice>> callbacks,
InputEventPtr eventPtr, InputDevice device, ProfilerMarker marker, string callbackName, bool stopOnHandled)
{
if (callbacks.length == 0)
return false;

marker.Begin();
callbacks.LockForChanges();
var handled = false;
try
{
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)
{
handled = true;
break;
}
}
}
finally
{
callbacks.UnlockForChanges();
marker.End();
}

return handled;
}

public static bool InvokeCallbacksSafe_AnyCallbackReturnsTrue<TValue1, TValue2>(ref CallbackArray<Func<TValue1, TValue2, bool>> callbacks,
TValue1 argument1, TValue2 argument2, string callbackName, object context = null)
{
Expand Down