From c6cf4f9b0979737d7d2bb6502985e528c32e7911 Mon Sep 17 00:00:00 2001 From: leonardo Date: Mon, 11 May 2026 12:19:17 +0200 Subject: [PATCH 1/4] TEST: Add failing repro for UUM-140343 Adds a [UnityTest] integration test that resets the player loop via PlayerLoop.SetPlayerLoop(PlayerLoop.GetDefaultPlayerLoop()) and asserts that mouse button state is still observable in FixedUpdate. Currently fails on develop, reproducing the user-reported bug: input state freezes in FixedUpdate after a user-initiated player-loop reset. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../IntegrationTests/IntegrationTests.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/Packages/com.unity.inputsystem/Tests/IntegrationTests/IntegrationTests.cs b/Packages/com.unity.inputsystem/Tests/IntegrationTests/IntegrationTests.cs index 8d45a2e76e..115a67da74 100644 --- a/Packages/com.unity.inputsystem/Tests/IntegrationTests/IntegrationTests.cs +++ b/Packages/com.unity.inputsystem/Tests/IntegrationTests/IntegrationTests.cs @@ -3,6 +3,7 @@ using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.InputSystem.LowLevel; +using UnityEngine.LowLevel; using UnityEngine.Scripting; using UnityEngine.TestTools; #if UNITY_EDITOR @@ -103,6 +104,54 @@ public void Integration_CanSendAndReceiveEvents() } } + // Regression test for UUM-140343: resetting the player loop to the engine + // default (e.g. user code calling PlayerLoop.SetPlayerLoop(GetDefaultPlayerLoop())) + // wipes any subsystem the InputSystem injected into PlayerLoop.Initialization. + // Without re-injection / a fallback path, input state buffers stop being + // switched correctly between editor and player updates, and FixedUpdate sees + // stale input data. + [UnityTest] + [Category("Integration")] + public IEnumerator Integration_InputUpdatesContinue_AfterResettingPlayerLoopToDefault() + { + var originalPlayerLoop = PlayerLoop.GetCurrentPlayerLoop(); + + var addedMouse = false; + var mouse = InputSystem.GetDevice(); + if (mouse == null) + { + mouse = InputSystem.AddDevice(); + addedMouse = true; + } + + try + { + // Reproduce the user scenario: reset the player loop to default mid-play. + PlayerLoop.SetPlayerLoop(PlayerLoop.GetDefaultPlayerLoop()); + + // Let one frame go by so any re-injection / detection has a chance to run. + yield return null; + + // Queue a left-button-down event for the mouse. + InputSystem.QueueStateEvent(mouse, new MouseState().WithButton(MouseButton.Left)); + + // Wait for the next FixedUpdate phase. The InputSystem should have + // processed the queued event by the time FixedUpdate runs. + yield return new WaitForFixedUpdate(); + + Assert.That(mouse.leftButton.isPressed, Is.True, + "Expected LMB state to be observed in FixedUpdate after PlayerLoop was reset to default. " + + "If this fails with isPressed=False, the bug from UUM-140343 is reproduced."); + } + finally + { + // Restore the player loop so sibling tests are not contaminated. + PlayerLoop.SetPlayerLoop(originalPlayerLoop); + if (addedMouse) + InputSystem.RemoveDevice(mouse); + } + } + #if UNITY_EDITOR [Test] From 12a5921f716006876e50ef946540d2106b036477 Mon Sep 17 00:00:00 2001 From: leonardo Date: Tue, 12 May 2026 11:06:42 +0200 Subject: [PATCH 2/4] FIX(option-b): Move buffer restore from PlayerLoop injection into OnUpdate Fixes UUM-140343 with Option B from the design spec: relocate the post-editor-update buffer restore (previously in OnPlayerLoopInitialization, fired from an InputSystemPlayerLoopRunnerInitializationSystem we inject at PlayerLoop.Initialization) into the InputManager.OnUpdate exit paths. PlayerLoop.SetPlayerLoop(GetDefaultPlayerLoop()) wipes that injection; NativeInputSystem.onUpdate (which drives OnUpdate) is fired by Unity's built-in player-loop subsystems and survives a user-driven loop reset. The restore is wired through three OnUpdate exit paths: - FinalizeUpdate (normal exit) - The eventCount==0 early-return (UNITY_INPUTSYSTEM_SUPPORTS_FOCUS_EVENTS) - LegacyEarlyOutFromEventProcessing (older Unity versions) A m_ManualUpdateDepth counter suppresses the auto-restore for manual InputSystem.Update() calls, preserving the contract observed by tests like Devices_CanHandleFocusChanges that read state immediately after a manual editor update. This commit preserves Option B in history. A subsequent commit replaces this with Option 2 (EditorApplication.update watchdog scoped to play mode), which is a smaller and cleaner fix once the no-default-loop-extension constraint is understood from Unity source. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InputSystem/Runtime/InputManager.cs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/Packages/com.unity.inputsystem/InputSystem/Runtime/InputManager.cs b/Packages/com.unity.inputsystem/InputSystem/Runtime/InputManager.cs index 4be52778df..8d7562a7fe 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Runtime/InputManager.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Runtime/InputManager.cs @@ -1933,9 +1933,34 @@ public void Update() public void Update(InputUpdateType updateType) { +#if UNITY_EDITOR + // Manual InputSystem.Update() calls (especially from tests) are observable: callers + // expect to see s_LatestUpdateType reflect the type they just ran, and the active + // state buffer to match. Skip the editor->player auto-restore for these so we don't + // mutate observable state behind the caller's back. Automatic updates from the player + // loop still trigger the restore via OnUpdate's exit paths (see UUM-140343). + ++m_ManualUpdateDepth; + try + { + m_Runtime.Update(updateType); + } + finally + { + --m_ManualUpdateDepth; + } +#else m_Runtime.Update(updateType); +#endif } +#if UNITY_EDITOR + // Non-zero when we're inside a manual InputManager.Update() call. Used to suppress the + // editor->player auto-restore in RestorePlayerStateAfterEditorUpdateIfNeeded, since manual + // callers expect to observe the post-update state untouched. Counter (not bool) so nested + // manual updates work correctly. + private int m_ManualUpdateDepth; +#endif + // Initialize project-wide actions: // - In editor (edit mode or play-mode) we always use the editor build preferences persisted setting. // - In player build we always attempt to find a preloaded asset. @@ -3258,6 +3283,9 @@ private unsafe void OnUpdate(InputUpdateType updateType, ref InputEventBuffer ev ProcessStateChangeMonitorTimeouts(); InvokeAfterUpdateCallback(updateType); +#if UNITY_EDITOR + RestorePlayerStateAfterEditorUpdateIfNeeded(updateType); +#endif m_CurrentUpdate = InputUpdateType.None; return; } @@ -3267,7 +3295,12 @@ private unsafe void OnUpdate(InputUpdateType updateType, ref InputEventBuffer ev #else if (LegacyEarlyOutFromEventProcessing(updateType, ref eventBuffer, ref dropStatusEvents)) + { +#if UNITY_EDITOR + RestorePlayerStateAfterEditorUpdateIfNeeded(updateType); +#endif return; + } #endif ProcessEventBuffer(updateType, ref eventBuffer, currentTime, timesliceEvents, dropStatusEvents); @@ -3991,9 +4024,43 @@ private void FinalizeUpdate(InputUpdateType updateType) if (pointer != null && pointer.added && gameIsPlaying) NativeInputSystem.DoSendMouseEvents(pointer.press.isPressed, pointer.press.wasPressedThisFrame, pointer.position.x.value, pointer.position.y.value); #endif + +#if UNITY_EDITOR + RestorePlayerStateAfterEditorUpdateIfNeeded(updateType); +#endif + m_CurrentUpdate = default; } +#if UNITY_EDITOR + // After an editor update, restore the player-mode tracker + state buffers so that any + // subsequent reads (including from MonoBehaviour.FixedUpdate, which can run between editor + // updates without triggering an OnUpdate(Fixed) when Fixed is not in the update mask) + // resolve via the player buffer rather than the editor buffer. + // + // This used to be handled by OnPlayerLoopInitialization, fired from a PlayerLoopSystem we + // injected at PlayerLoop.Initialization. User code calling + // PlayerLoop.SetPlayerLoop(GetDefaultPlayerLoop()) wipes that injection (UUM-140343), so + // we do the restore here instead -- driven by NativeInputSystem.onUpdate, which is fired + // by Unity's built-in player-loop subsystems and therefore cannot be wiped by a + // user-initiated player loop reset. + private void RestorePlayerStateAfterEditorUpdateIfNeeded(InputUpdateType updateType) + { + // Skip when called from a manual InputManager.Update() — see Update() for rationale. + if (m_ManualUpdateDepth > 0) + return; + + if (updateType.IsEditorUpdate() + && gameIsPlaying + && InputUpdate.s_LatestNonEditorUpdateType.IsPlayerUpdate()) + { + InputUpdate.RestoreStateAfterEditorUpdate(); + InputRuntime.s_CurrentTimeOffsetToRealtimeSinceStartup = latestNonEditorTimeOffsetToRealtimeSinceStartup; + InputStateBuffers.SwitchTo(m_StateBuffers, InputUpdate.s_LatestUpdateType); + } + } +#endif + #if UNITY_EDITOR /// /// Checks background behavior conditions for early exit from event processing. From 209331a5b2252963f8d3ffbb94c794a95c8ff8b6 Mon Sep 17 00:00:00 2001 From: leonardo Date: Tue, 12 May 2026 11:07:10 +0200 Subject: [PATCH 3/4] Revert "FIX(option-b): Move buffer restore from PlayerLoop injection into OnUpdate" This reverts commit 12a5921f716006876e50ef946540d2106b036477. --- .../InputSystem/Runtime/InputManager.cs | 67 ------------------- 1 file changed, 67 deletions(-) diff --git a/Packages/com.unity.inputsystem/InputSystem/Runtime/InputManager.cs b/Packages/com.unity.inputsystem/InputSystem/Runtime/InputManager.cs index 8d7562a7fe..4be52778df 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Runtime/InputManager.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Runtime/InputManager.cs @@ -1933,34 +1933,9 @@ public void Update() public void Update(InputUpdateType updateType) { -#if UNITY_EDITOR - // Manual InputSystem.Update() calls (especially from tests) are observable: callers - // expect to see s_LatestUpdateType reflect the type they just ran, and the active - // state buffer to match. Skip the editor->player auto-restore for these so we don't - // mutate observable state behind the caller's back. Automatic updates from the player - // loop still trigger the restore via OnUpdate's exit paths (see UUM-140343). - ++m_ManualUpdateDepth; - try - { - m_Runtime.Update(updateType); - } - finally - { - --m_ManualUpdateDepth; - } -#else m_Runtime.Update(updateType); -#endif } -#if UNITY_EDITOR - // Non-zero when we're inside a manual InputManager.Update() call. Used to suppress the - // editor->player auto-restore in RestorePlayerStateAfterEditorUpdateIfNeeded, since manual - // callers expect to observe the post-update state untouched. Counter (not bool) so nested - // manual updates work correctly. - private int m_ManualUpdateDepth; -#endif - // Initialize project-wide actions: // - In editor (edit mode or play-mode) we always use the editor build preferences persisted setting. // - In player build we always attempt to find a preloaded asset. @@ -3283,9 +3258,6 @@ private unsafe void OnUpdate(InputUpdateType updateType, ref InputEventBuffer ev ProcessStateChangeMonitorTimeouts(); InvokeAfterUpdateCallback(updateType); -#if UNITY_EDITOR - RestorePlayerStateAfterEditorUpdateIfNeeded(updateType); -#endif m_CurrentUpdate = InputUpdateType.None; return; } @@ -3295,12 +3267,7 @@ private unsafe void OnUpdate(InputUpdateType updateType, ref InputEventBuffer ev #else if (LegacyEarlyOutFromEventProcessing(updateType, ref eventBuffer, ref dropStatusEvents)) - { -#if UNITY_EDITOR - RestorePlayerStateAfterEditorUpdateIfNeeded(updateType); -#endif return; - } #endif ProcessEventBuffer(updateType, ref eventBuffer, currentTime, timesliceEvents, dropStatusEvents); @@ -4024,43 +3991,9 @@ private void FinalizeUpdate(InputUpdateType updateType) if (pointer != null && pointer.added && gameIsPlaying) NativeInputSystem.DoSendMouseEvents(pointer.press.isPressed, pointer.press.wasPressedThisFrame, pointer.position.x.value, pointer.position.y.value); #endif - -#if UNITY_EDITOR - RestorePlayerStateAfterEditorUpdateIfNeeded(updateType); -#endif - m_CurrentUpdate = default; } -#if UNITY_EDITOR - // After an editor update, restore the player-mode tracker + state buffers so that any - // subsequent reads (including from MonoBehaviour.FixedUpdate, which can run between editor - // updates without triggering an OnUpdate(Fixed) when Fixed is not in the update mask) - // resolve via the player buffer rather than the editor buffer. - // - // This used to be handled by OnPlayerLoopInitialization, fired from a PlayerLoopSystem we - // injected at PlayerLoop.Initialization. User code calling - // PlayerLoop.SetPlayerLoop(GetDefaultPlayerLoop()) wipes that injection (UUM-140343), so - // we do the restore here instead -- driven by NativeInputSystem.onUpdate, which is fired - // by Unity's built-in player-loop subsystems and therefore cannot be wiped by a - // user-initiated player loop reset. - private void RestorePlayerStateAfterEditorUpdateIfNeeded(InputUpdateType updateType) - { - // Skip when called from a manual InputManager.Update() — see Update() for rationale. - if (m_ManualUpdateDepth > 0) - return; - - if (updateType.IsEditorUpdate() - && gameIsPlaying - && InputUpdate.s_LatestNonEditorUpdateType.IsPlayerUpdate()) - { - InputUpdate.RestoreStateAfterEditorUpdate(); - InputRuntime.s_CurrentTimeOffsetToRealtimeSinceStartup = latestNonEditorTimeOffsetToRealtimeSinceStartup; - InputStateBuffers.SwitchTo(m_StateBuffers, InputUpdate.s_LatestUpdateType); - } - } -#endif - #if UNITY_EDITOR /// /// Checks background behavior conditions for early exit from event processing. From e1b096b077e5390e2e85ce5c7efae5be061c2849 Mon Sep 17 00:00:00 2001 From: leonardo Date: Tue, 12 May 2026 14:21:22 +0200 Subject: [PATCH 4/4] FIX: Re-inject InputSystem player loop hook after user-driven loop reset Fixes UUM-140343. When user code calls PlayerLoop.SetPlayerLoop(PlayerLoop.GetDefaultPlayerLoop()) mid-play, the InputSystem's editor-only InputSystemPlayerLoopRunnerInitializationSystem is wiped from PlayerLoop.Initialization. Without it OnPlayerLoopInitialization never fires, so InputStateBuffers are never switched back to player mode after an editor update -- FixedUpdate (and other reads between editor updates) see stale editor-buffer state. Subscribe a watchdog to EditorApplication.update that re-injects when missing. EditorApplication.update is invoked by SceneTracker (Editor/Src/Selection/SceneInspector.cpp) and is not part of the player loop, so a user-initiated SetPlayerLoop call cannot wipe it. The watchdog is gated on EditorApplication.isPlaying so it does nothing in edit mode; while playing the cost is one PlayerLoopSystem-array scan per editor tick, returning immediately when the injection is still in place (the common case). Unity exposes no callback for SetPlayerLoop, no engine API to add to the default player loop, and no per-frame hook inside the player loop that could not be wiped the same way; the watchdog is the cleanest workaround until the engine gains one of those. Co-Authored-By: Claude Opus 4.7 (1M context) --- Packages/com.unity.inputsystem/CHANGELOG.md | 1 + .../InputSystem/Runtime/NativeInputRuntime.cs | 74 +++++++++++++------ 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/Packages/com.unity.inputsystem/CHANGELOG.md b/Packages/com.unity.inputsystem/CHANGELOG.md index a2ece4afba..2c2621b9c3 100644 --- a/Packages/com.unity.inputsystem/CHANGELOG.md +++ b/Packages/com.unity.inputsystem/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Fixed an issue where `UIToolkit` `ClickEvent` could be fired on Android after device rotation due to inactive touch state being replayed during action initial state checks [UUM-100125](https://jira.unity3d.com/browse/UUM-100125). - Fixed InputSystem.onAnyButtonPress fails to trigger when the device receives a touch [UUM-137930](https://issuetracker.unity3d.com/product/unity/issues/guid/UUM-137930). - Fixed an incorrect ArraysHelper.HaveDuplicateReferences implementation that didn't use its arguments right [ISXB-1792] (https://github.com/Unity-Technologies/InputSystem/pull/2376) +- Fixed input state freezing in `FixedUpdate` (and other reads) after user code resets the player loop with `PlayerLoop.SetPlayerLoop(PlayerLoop.GetDefaultPlayerLoop())` [UUM-140343](https://jira.unity3d.com/browse/UUM-140343). The InputSystem now re-injects its `PlayerLoop.Initialization` hook on the next editor tick if a user-driven reset has wiped it. ### Changed - Removed 32-bit compilation check for HID on Windows players, which had no impact anymore. (ISX-2543) diff --git a/Packages/com.unity.inputsystem/InputSystem/Runtime/NativeInputRuntime.cs b/Packages/com.unity.inputsystem/InputSystem/Runtime/NativeInputRuntime.cs index be296bc800..ac2746bc2a 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Runtime/NativeInputRuntime.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Runtime/NativeInputRuntime.cs @@ -148,33 +148,63 @@ public Action onPlayerLoopInitialization // TODO move it to a proper native callback instead if (value != null) { - // Inject ourselves directly to PlayerLoop.Initialization as first subsystem to run, - // Use InputSystemPlayerLoopRunnerInitializationSystem as system type - var playerLoop = UnityEngine.LowLevel.PlayerLoop.GetCurrentPlayerLoop(); - var initStepIndex = playerLoop.subSystemList.IndexOf(x => x.type == typeof(PlayerLoop.Initialization)); - if (initStepIndex >= 0) - { - var systems = playerLoop.subSystemList[initStepIndex].subSystemList; - - // Check if we're not already injected - if (!systems.Select(x => x.type) - .Contains(typeof(InputSystemPlayerLoopRunnerInitializationSystem))) - { - ArrayHelpers.InsertAt(ref systems, 0, new UnityEngine.LowLevel.PlayerLoopSystem - { - type = typeof(InputSystemPlayerLoopRunnerInitializationSystem), - updateDelegate = () => m_PlayerLoopInitialization?.Invoke() - }); - - playerLoop.subSystemList[initStepIndex].subSystemList = systems; - UnityEngine.LowLevel.PlayerLoop.SetPlayerLoop(playerLoop); - } - } + EnsurePlayerLoopInjection(); + + // Watchdog for UUM-140343: user code calling + // PlayerLoop.SetPlayerLoop(GetDefaultPlayerLoop()) wipes our injection, + // and Unity provides no callback for SetPlayerLoop, no engine API to add to + // the default loop, and no per-frame hook inside the player loop that we + // could anchor to (anything in the loop can be wiped the same way). + // EditorApplication.update is invoked by SceneTracker, not by the player loop + // (see Editor/Src/Selection/SceneInspector.cpp), so it survives a user reset + // and lets us re-inject on the next editor tick. The cost is one cheap + // PlayerLoopSystem-array scan per editor tick while playing -- effectively zero + // when nothing's wrong. Outside play mode the early-return below skips it. + UnityEditor.EditorApplication.update -= EnsurePlayerLoopInjectionIfPlaying; + UnityEditor.EditorApplication.update += EnsurePlayerLoopInjectionIfPlaying; + } + else + { + UnityEditor.EditorApplication.update -= EnsurePlayerLoopInjectionIfPlaying; } m_PlayerLoopInitialization = value; } } + + // Idempotent: inserts InputSystemPlayerLoopRunnerInitializationSystem at the top of + // PlayerLoop.Initialization if it's not already there. + private void EnsurePlayerLoopInjection() + { + var playerLoop = UnityEngine.LowLevel.PlayerLoop.GetCurrentPlayerLoop(); + var initStepIndex = playerLoop.subSystemList.IndexOf(x => x.type == typeof(PlayerLoop.Initialization)); + if (initStepIndex < 0) + return; + + var systems = playerLoop.subSystemList[initStepIndex].subSystemList; + if (systems.Select(x => x.type) + .Contains(typeof(InputSystemPlayerLoopRunnerInitializationSystem))) + return; // Already injected. + + ArrayHelpers.InsertAt(ref systems, 0, new UnityEngine.LowLevel.PlayerLoopSystem + { + type = typeof(InputSystemPlayerLoopRunnerInitializationSystem), + updateDelegate = () => m_PlayerLoopInitialization?.Invoke() + }); + + playerLoop.subSystemList[initStepIndex].subSystemList = systems; + UnityEngine.LowLevel.PlayerLoop.SetPlayerLoop(playerLoop); + } + + // Watchdog tick. Fires from EditorApplication.update (independent of the player loop) and + // re-injects only when actually needed. In edit mode we don't care; OnPlayerLoopInitialization + // is gated on gameIsPlaying anyway, so a wiped injection while not playing is harmless. + private void EnsurePlayerLoopInjectionIfPlaying() + { + if (!UnityEditor.EditorApplication.isPlaying) + return; + EnsurePlayerLoopInjection(); + } #endif public Action onDeviceDiscovered