diff --git a/Assets/Tests/InputSystem.Editor/InputActionStatePlayModeTests.cs b/Assets/Tests/InputSystem.Editor/InputActionStatePlayModeTests.cs new file mode 100644 index 0000000000..867b06c35c --- /dev/null +++ b/Assets/Tests/InputSystem.Editor/InputActionStatePlayModeTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +// Enters real play mode so that the engine runs every RuntimeInitializeOnLoadMethod in its own order. +// This deliberately does not derive from InputTestFixture, which replaces the runtime and global state. +internal class InputActionStatePlayModeTests +{ + const string k_TestCategory = "Editor"; + const string k_ArmedKey = "InputSystem.Tests.InputActionStatePlayModeTests.Armed"; + const string k_EnabledAfterSceneLoadKey = "InputSystem.Tests.InputActionStatePlayModeTests.EnabledAfterSceneLoad"; + const string k_SavedOptionsKey = "InputSystem.Tests.InputActionStatePlayModeTests.SavedOptions"; + const string k_SavedOptionsEnabledKey = "InputSystem.Tests.InputActionStatePlayModeTests.SavedOptionsEnabled"; + + static readonly bool[] k_DisableDomainReloadValues = { false, true }; + + static InputAction s_EarlyAction; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void EnableActionBeforeSceneLoad() + { + if (!SessionState.GetBool(k_ArmedKey, false)) + return; + + s_EarlyAction = new InputAction(binding: "/space"); + s_EarlyAction.Enable(); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void RecordActionAfterSceneLoad() + { + if (!SessionState.GetBool(k_ArmedKey, false)) + return; + + SessionState.SetBool(k_EnabledAfterSceneLoadKey, s_EarlyAction != null && s_EarlyAction.enabled); + } + + [SetUp] + public void SetUp() + { + // Each domain reload during the test runs SetUp again, so only record the options once. + if (SessionState.GetInt(k_SavedOptionsKey, -1) != -1) + return; + + // Unity 6000.0 reports no options while they are disabled, so enable them to read the stored ones. + var optionsEnabled = EditorSettings.enterPlayModeOptionsEnabled; + EditorSettings.enterPlayModeOptionsEnabled = true; + SessionState.SetInt(k_SavedOptionsKey, (int)EditorSettings.enterPlayModeOptions); + SessionState.SetBool(k_SavedOptionsEnabledKey, optionsEnabled); + EditorSettings.enterPlayModeOptionsEnabled = optionsEnabled; + } + + [TearDown] + public void TearDown() + { + if (Application.isPlaying) + return; + + EditorSettings.enterPlayModeOptionsEnabled = true; + EditorSettings.enterPlayModeOptions = (EnterPlayModeOptions)SessionState.GetInt(k_SavedOptionsKey, 0); + EditorSettings.enterPlayModeOptionsEnabled = SessionState.GetBool(k_SavedOptionsEnabledKey, false); + + // Entering play mode saves the modified settings to disk, so save the restored ones too. + AssetDatabase.SaveAssets(); + SessionState.EraseInt(k_SavedOptionsKey); + SessionState.EraseBool(k_SavedOptionsEnabledKey); + SessionState.EraseBool(k_ArmedKey); + SessionState.EraseBool(k_EnabledAfterSceneLoadKey); + + s_EarlyAction?.Dispose(); + s_EarlyAction = null; + } + + static void SetDomainReloadDisabled(bool disabled) + { + // Unity 6000.0 ignores enterPlayModeOptions unless the options are enabled. + EditorSettings.enterPlayModeOptionsEnabled = true; + EditorSettings.enterPlayModeOptions = disabled + ? EnterPlayModeOptions.DisableDomainReload | EnterPlayModeOptions.DisableSceneReload + : EnterPlayModeOptions.None; + } + + static bool HasOnActionChangeCallback(Action callback) + { + var callbacks = InputActionState.s_GlobalState.onActionChange; + for (var i = 0; i < callbacks.length; ++i) + { + if (callbacks[i] == callback) + return true; + } + return false; + } + + [UnityTest] + [Category(k_TestCategory)] + [Description("https://jira.unity3d.com/browse/UUM-151403")] + public IEnumerator Actions_EnabledBeforeSceneLoad_StayEnabled_WhenEnteringPlayMode( + [ValueSource(nameof(k_DisableDomainReloadValues))] bool disableDomainReload) + { + SetDomainReloadDisabled(disableDomainReload); + SessionState.SetBool(k_ArmedKey, true); + + yield return new EnterPlayMode(); + + SessionState.SetBool(k_ArmedKey, false); + Assert.That(SessionState.GetBool(k_EnabledAfterSceneLoadKey, false), Is.True); + + yield return new ExitPlayMode(); + + // Without a domain reload the action object survives, but exiting play mode must still clean up its state. + if (disableDomainReload) + Assert.That(s_EarlyAction.enabled, Is.False); + } + + [UnityTest] + [Category(k_TestCategory)] + [Description("https://jira.unity3d.com/browse/UUM-151403")] + public IEnumerator Actions_OnActionChangeCallbacksAddedInPlayMode_AreRemovedOnExit_WhenDomainReloadIsDisabled() + { + SetDomainReloadDisabled(true); + + Action editModeCallback = (obj, change) => {}; + Action playModeCallback = (obj, change) => {}; + var survivingListener = ScriptableObject.CreateInstance(); + survivingListener.hideFlags = HideFlags.HideAndDontSave; + var destroyedListener = ScriptableObject.CreateInstance(); + destroyedListener.hideFlags = HideFlags.HideAndDontSave; + Action survivingListenerCallback = survivingListener.OnActionChange; + Action destroyedListenerCallback = destroyedListener.OnActionChange; + InputSystem.onActionChange += editModeCallback; + try + { + yield return new EnterPlayMode(); + + Assert.That(HasOnActionChangeCallback(editModeCallback), Is.True); + InputSystem.onActionChange += playModeCallback; + + // Like an editor window opened during play mode, which stays open after it. + InputSystem.onActionChange += survivingListenerCallback; + + // Like a component of a play mode GameObject, which is destroyed on exit. + InputSystem.onActionChange += destroyedListenerCallback; + Object.DestroyImmediate(destroyedListener); + + yield return new ExitPlayMode(); + + Assert.That(HasOnActionChangeCallback(editModeCallback), Is.True); + Assert.That(HasOnActionChangeCallback(playModeCallback), Is.False); + Assert.That(HasOnActionChangeCallback(survivingListenerCallback), Is.True); + Assert.That(HasOnActionChangeCallback(destroyedListenerCallback), Is.False); + } + finally + { + InputSystem.onActionChange -= editModeCallback; + InputSystem.onActionChange -= playModeCallback; + InputSystem.onActionChange -= survivingListenerCallback; + InputSystem.onActionChange -= destroyedListenerCallback; + Object.DestroyImmediate(survivingListener); + } + } + + class ActionChangeListener : ScriptableObject + { + public void OnActionChange(object actionOrMap, InputActionChange change) + { + } + } +} diff --git a/Assets/Tests/InputSystem.Editor/InputActionStatePlayModeTests.cs.meta b/Assets/Tests/InputSystem.Editor/InputActionStatePlayModeTests.cs.meta new file mode 100644 index 0000000000..aa5ca17ee0 --- /dev/null +++ b/Assets/Tests/InputSystem.Editor/InputActionStatePlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 09ea6c8c372146feba0a15eeb9928da5 diff --git a/Packages/com.unity.inputsystem/CHANGELOG.md b/Packages/com.unity.inputsystem/CHANGELOG.md index 8cf9a23eb3..e51ad50c18 100644 --- a/Packages/com.unity.inputsystem/CHANGELOG.md +++ b/Packages/com.unity.inputsystem/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Fixed polling for interaction phases (such as `WasPerformedThisFrame`) being missed during `Update` due to interaction timeouts sometimes occurring during the later before-render period when a before-render device (such as an XR HMD) is added. This means that timeouts and interactions like Hold may now wait to perform until the next frame instead of the earliest chance during before-render. [UUM-147719](https://issuetracker.unity3d.com/product/unity/issues/guid/UUM-147719) - Fixed touch input from Unity Remote 5 being ignored in Unity 6000.5 and newer. [UUM-155327](https://issuetracker.unity3d.com/product/unity/issues/guid/UUM-155327) +- Fixed actions enabled during startup, for example in an Entities system's `OnCreate`, being disabled when entering Play Mode with domain reload disabled and in Player builds. [UUM-151403](https://issuetracker.unity3d.com/product/unity/issues/guid/UUM-151403) ## [1.20.1] - 2026-10-05 diff --git a/Packages/com.unity.inputsystem/InputSystem/Editor/InputSystemEditorInitializer.cs b/Packages/com.unity.inputsystem/InputSystem/Editor/InputSystemEditorInitializer.cs index 4500497457..70c63d1fb2 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Editor/InputSystemEditorInitializer.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Editor/InputSystemEditorInitializer.cs @@ -501,6 +501,8 @@ internal static void OnPlayModeChange(PlayModeStateChange change) InputSystem.s_Manager.m_ExitEditModeTime = s_DomainStateManager.exitEditModeTime; InputSystem.s_Manager.m_EnterPlayModeTime = 0; + + InputActionState.RecordCallbacksBeforePlayMode(); break; case PlayModeStateChange.EnteredPlayMode: @@ -524,6 +526,7 @@ internal static void OnPlayModeChange(PlayModeStateChange change) // Nuke all InputActionMapStates. Releases their unmanaged memory. InputActionState.DestroyAllActionMapStates(); + InputActionState.RemoveCallbacksAddedInPlayMode(); // Clear the Action reference from all InputActionReference objects InputActionReference.InvalidateAll(); diff --git a/Packages/com.unity.inputsystem/InputSystem/Runtime/Actions/InputActionState.cs b/Packages/com.unity.inputsystem/InputSystem/Runtime/Actions/InputActionState.cs index 4325fc1988..7390fbc13a 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Runtime/Actions/InputActionState.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Runtime/Actions/InputActionState.cs @@ -4499,25 +4499,56 @@ internal struct GlobalState internal InlinedArray globalList; internal CallbackArray> onActionChange; internal CallbackArray> onActionControlsChanged; +#if UNITY_EDITOR + internal Action[] onActionChangeBeforePlayMode; + internal Action[] onActionControlsChangedBeforePlayMode; +#endif } internal static GlobalState s_GlobalState; - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] - private static void InitializeGlobalActionState() - { #if UNITY_EDITOR - // Appears we shouldn't really reset globals in case the domain reload is enabled. - // This is because in that case, we've just had the whole system init'ed via static ctors - // Moreover, later in GlobalInialize we skip initialization specifically in this case. - if (!(InputSystem.s_IsDomainReloadDisabled?.Invoke() ?? false)) + internal static void RecordCallbacksBeforePlayMode() + { + s_GlobalState.onActionChangeBeforePlayMode = ToArray(ref s_GlobalState.onActionChange); + s_GlobalState.onActionControlsChangedBeforePlayMode = ToArray(ref s_GlobalState.onActionControlsChanged); + } + + // Without a domain reload, callbacks added during play mode would otherwise outlive it. + internal static void RemoveCallbacksAddedInPlayMode() + { + // Nothing was recorded if a domain reload happened since, and that already cleared the callbacks. + if (s_GlobalState.onActionChangeBeforePlayMode == null) return; -#endif - ResetGlobals(); - s_GlobalState = default; + RemovePlayModeCallbacks(ref s_GlobalState.onActionChange, s_GlobalState.onActionChangeBeforePlayMode); + RemovePlayModeCallbacks(ref s_GlobalState.onActionControlsChanged, s_GlobalState.onActionControlsChangedBeforePlayMode); + s_GlobalState.onActionChangeBeforePlayMode = null; + s_GlobalState.onActionControlsChangedBeforePlayMode = null; } + private static TDelegate[] ToArray(ref CallbackArray callbacks) + where TDelegate : Delegate + { + var result = new TDelegate[callbacks.length]; + for (var i = 0; i < callbacks.length; ++i) + result[i] = callbacks[i]; + return result; + } + + private static void RemovePlayModeCallbacks(ref CallbackArray callbacks, TDelegate[] callbacksBeforePlayMode) + where TDelegate : Delegate + { + for (var i = callbacks.length - 1; i >= 0; --i) + { + // Play mode objects are destroyed by now, but editor objects such as open windows survive play mode. + var target = callbacks[i].Target as UnityEngine.Object; + if (Array.IndexOf(callbacksBeforePlayMode, callbacks[i]) < 0 && target == null) + callbacks.RemoveCallback(callbacks[i]); + } + } +#endif + internal static ISavedState SaveAndResetState() { // Save current state