Skip to content

Commit 54b1581

Browse files
unitydavidloclaude
andauthored
FIX: [Port] [UUM-151403] [Input System] Fixed actions enabled during startup being disabled (#2500)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent df89a26 commit 54b1581

5 files changed

Lines changed: 220 additions & 10 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
using System;
2+
using System.Collections;
3+
using NUnit.Framework;
4+
using UnityEditor;
5+
using UnityEngine;
6+
using UnityEngine.InputSystem;
7+
using UnityEngine.TestTools;
8+
using Object = UnityEngine.Object;
9+
10+
// Enters real play mode so that the engine runs every RuntimeInitializeOnLoadMethod in its own order.
11+
// This deliberately does not derive from InputTestFixture, which replaces the runtime and global state.
12+
internal class InputActionStatePlayModeTests
13+
{
14+
const string k_TestCategory = "Editor";
15+
const string k_ArmedKey = "InputSystem.Tests.InputActionStatePlayModeTests.Armed";
16+
const string k_EnabledAfterSceneLoadKey = "InputSystem.Tests.InputActionStatePlayModeTests.EnabledAfterSceneLoad";
17+
const string k_SavedOptionsKey = "InputSystem.Tests.InputActionStatePlayModeTests.SavedOptions";
18+
const string k_SavedOptionsEnabledKey = "InputSystem.Tests.InputActionStatePlayModeTests.SavedOptionsEnabled";
19+
20+
static readonly bool[] k_DisableDomainReloadValues = { false, true };
21+
22+
static InputAction s_EarlyAction;
23+
24+
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
25+
static void EnableActionBeforeSceneLoad()
26+
{
27+
if (!SessionState.GetBool(k_ArmedKey, false))
28+
return;
29+
30+
s_EarlyAction = new InputAction(binding: "<Keyboard>/space");
31+
s_EarlyAction.Enable();
32+
}
33+
34+
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
35+
static void RecordActionAfterSceneLoad()
36+
{
37+
if (!SessionState.GetBool(k_ArmedKey, false))
38+
return;
39+
40+
SessionState.SetBool(k_EnabledAfterSceneLoadKey, s_EarlyAction != null && s_EarlyAction.enabled);
41+
}
42+
43+
[SetUp]
44+
public void SetUp()
45+
{
46+
// Each domain reload during the test runs SetUp again, so only record the options once.
47+
if (SessionState.GetInt(k_SavedOptionsKey, -1) != -1)
48+
return;
49+
50+
// Unity 6000.0 reports no options while they are disabled, so enable them to read the stored ones.
51+
var optionsEnabled = EditorSettings.enterPlayModeOptionsEnabled;
52+
EditorSettings.enterPlayModeOptionsEnabled = true;
53+
SessionState.SetInt(k_SavedOptionsKey, (int)EditorSettings.enterPlayModeOptions);
54+
SessionState.SetBool(k_SavedOptionsEnabledKey, optionsEnabled);
55+
EditorSettings.enterPlayModeOptionsEnabled = optionsEnabled;
56+
}
57+
58+
[TearDown]
59+
public void TearDown()
60+
{
61+
if (Application.isPlaying)
62+
return;
63+
64+
EditorSettings.enterPlayModeOptionsEnabled = true;
65+
EditorSettings.enterPlayModeOptions = (EnterPlayModeOptions)SessionState.GetInt(k_SavedOptionsKey, 0);
66+
EditorSettings.enterPlayModeOptionsEnabled = SessionState.GetBool(k_SavedOptionsEnabledKey, false);
67+
68+
// Entering play mode saves the modified settings to disk, so save the restored ones too.
69+
AssetDatabase.SaveAssets();
70+
SessionState.EraseInt(k_SavedOptionsKey);
71+
SessionState.EraseBool(k_SavedOptionsEnabledKey);
72+
SessionState.EraseBool(k_ArmedKey);
73+
SessionState.EraseBool(k_EnabledAfterSceneLoadKey);
74+
75+
s_EarlyAction?.Dispose();
76+
s_EarlyAction = null;
77+
}
78+
79+
static void SetDomainReloadDisabled(bool disabled)
80+
{
81+
// Unity 6000.0 ignores enterPlayModeOptions unless the options are enabled.
82+
EditorSettings.enterPlayModeOptionsEnabled = true;
83+
EditorSettings.enterPlayModeOptions = disabled
84+
? EnterPlayModeOptions.DisableDomainReload | EnterPlayModeOptions.DisableSceneReload
85+
: EnterPlayModeOptions.None;
86+
}
87+
88+
static bool HasOnActionChangeCallback(Action<object, InputActionChange> callback)
89+
{
90+
var callbacks = InputActionState.s_GlobalState.onActionChange;
91+
for (var i = 0; i < callbacks.length; ++i)
92+
{
93+
if (callbacks[i] == callback)
94+
return true;
95+
}
96+
return false;
97+
}
98+
99+
[UnityTest]
100+
[Category(k_TestCategory)]
101+
[Description("https://jira.unity3d.com/browse/UUM-151403")]
102+
public IEnumerator Actions_EnabledBeforeSceneLoad_StayEnabled_WhenEnteringPlayMode(
103+
[ValueSource(nameof(k_DisableDomainReloadValues))] bool disableDomainReload)
104+
{
105+
SetDomainReloadDisabled(disableDomainReload);
106+
SessionState.SetBool(k_ArmedKey, true);
107+
108+
yield return new EnterPlayMode();
109+
110+
SessionState.SetBool(k_ArmedKey, false);
111+
Assert.That(SessionState.GetBool(k_EnabledAfterSceneLoadKey, false), Is.True);
112+
113+
yield return new ExitPlayMode();
114+
115+
// Without a domain reload the action object survives, but exiting play mode must still clean up its state.
116+
if (disableDomainReload)
117+
Assert.That(s_EarlyAction.enabled, Is.False);
118+
}
119+
120+
[UnityTest]
121+
[Category(k_TestCategory)]
122+
[Description("https://jira.unity3d.com/browse/UUM-151403")]
123+
public IEnumerator Actions_OnActionChangeCallbacksAddedInPlayMode_AreRemovedOnExit_WhenDomainReloadIsDisabled()
124+
{
125+
SetDomainReloadDisabled(true);
126+
127+
Action<object, InputActionChange> editModeCallback = (obj, change) => {};
128+
Action<object, InputActionChange> playModeCallback = (obj, change) => {};
129+
var survivingListener = ScriptableObject.CreateInstance<ActionChangeListener>();
130+
survivingListener.hideFlags = HideFlags.HideAndDontSave;
131+
var destroyedListener = ScriptableObject.CreateInstance<ActionChangeListener>();
132+
destroyedListener.hideFlags = HideFlags.HideAndDontSave;
133+
Action<object, InputActionChange> survivingListenerCallback = survivingListener.OnActionChange;
134+
Action<object, InputActionChange> destroyedListenerCallback = destroyedListener.OnActionChange;
135+
InputSystem.onActionChange += editModeCallback;
136+
try
137+
{
138+
yield return new EnterPlayMode();
139+
140+
Assert.That(HasOnActionChangeCallback(editModeCallback), Is.True);
141+
InputSystem.onActionChange += playModeCallback;
142+
143+
// Like an editor window opened during play mode, which stays open after it.
144+
InputSystem.onActionChange += survivingListenerCallback;
145+
146+
// Like a component of a play mode GameObject, which is destroyed on exit.
147+
InputSystem.onActionChange += destroyedListenerCallback;
148+
Object.DestroyImmediate(destroyedListener);
149+
150+
yield return new ExitPlayMode();
151+
152+
Assert.That(HasOnActionChangeCallback(editModeCallback), Is.True);
153+
Assert.That(HasOnActionChangeCallback(playModeCallback), Is.False);
154+
Assert.That(HasOnActionChangeCallback(survivingListenerCallback), Is.True);
155+
Assert.That(HasOnActionChangeCallback(destroyedListenerCallback), Is.False);
156+
}
157+
finally
158+
{
159+
InputSystem.onActionChange -= editModeCallback;
160+
InputSystem.onActionChange -= playModeCallback;
161+
InputSystem.onActionChange -= survivingListenerCallback;
162+
InputSystem.onActionChange -= destroyedListenerCallback;
163+
Object.DestroyImmediate(survivingListener);
164+
}
165+
}
166+
167+
class ActionChangeListener : ScriptableObject
168+
{
169+
public void OnActionChange(object actionOrMap, InputActionChange change)
170+
{
171+
}
172+
}
173+
}

‎Assets/Tests/InputSystem.Editor/InputActionStatePlayModeTests.cs.meta‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Packages/com.unity.inputsystem/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1111

1212
- 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)
1313
- 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)
14+
- 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)
1415

1516
## [1.20.1] - 2026-10-05
1617

‎Packages/com.unity.inputsystem/InputSystem/Editor/InputSystemEditorInitializer.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,8 @@ internal static void OnPlayModeChange(PlayModeStateChange change)
501501

502502
InputSystem.s_Manager.m_ExitEditModeTime = s_DomainStateManager.exitEditModeTime;
503503
InputSystem.s_Manager.m_EnterPlayModeTime = 0;
504+
505+
InputActionState.RecordCallbacksBeforePlayMode();
504506
break;
505507

506508
case PlayModeStateChange.EnteredPlayMode:
@@ -524,6 +526,7 @@ internal static void OnPlayModeChange(PlayModeStateChange change)
524526

525527
// Nuke all InputActionMapStates. Releases their unmanaged memory.
526528
InputActionState.DestroyAllActionMapStates();
529+
InputActionState.RemoveCallbacksAddedInPlayMode();
527530

528531
// Clear the Action reference from all InputActionReference objects
529532
InputActionReference.InvalidateAll();

‎Packages/com.unity.inputsystem/InputSystem/Runtime/Actions/InputActionState.cs‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4499,25 +4499,56 @@ internal struct GlobalState
44994499
internal InlinedArray<GCHandle> globalList;
45004500
internal CallbackArray<Action<object, InputActionChange>> onActionChange;
45014501
internal CallbackArray<Action<object>> onActionControlsChanged;
4502+
#if UNITY_EDITOR
4503+
internal Action<object, InputActionChange>[] onActionChangeBeforePlayMode;
4504+
internal Action<object>[] onActionControlsChangedBeforePlayMode;
4505+
#endif
45024506
}
45034507

45044508
internal static GlobalState s_GlobalState;
45054509

4506-
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
4507-
private static void InitializeGlobalActionState()
4508-
{
45094510
#if UNITY_EDITOR
4510-
// Appears we shouldn't really reset globals in case the domain reload is enabled.
4511-
// This is because in that case, we've just had the whole system init'ed via static ctors
4512-
// Moreover, later in GlobalInialize we skip initialization specifically in this case.
4513-
if (!(InputSystem.s_IsDomainReloadDisabled?.Invoke() ?? false))
4511+
internal static void RecordCallbacksBeforePlayMode()
4512+
{
4513+
s_GlobalState.onActionChangeBeforePlayMode = ToArray(ref s_GlobalState.onActionChange);
4514+
s_GlobalState.onActionControlsChangedBeforePlayMode = ToArray(ref s_GlobalState.onActionControlsChanged);
4515+
}
4516+
4517+
// Without a domain reload, callbacks added during play mode would otherwise outlive it.
4518+
internal static void RemoveCallbacksAddedInPlayMode()
4519+
{
4520+
// Nothing was recorded if a domain reload happened since, and that already cleared the callbacks.
4521+
if (s_GlobalState.onActionChangeBeforePlayMode == null)
45144522
return;
4515-
#endif
45164523

4517-
ResetGlobals();
4518-
s_GlobalState = default;
4524+
RemovePlayModeCallbacks(ref s_GlobalState.onActionChange, s_GlobalState.onActionChangeBeforePlayMode);
4525+
RemovePlayModeCallbacks(ref s_GlobalState.onActionControlsChanged, s_GlobalState.onActionControlsChangedBeforePlayMode);
4526+
s_GlobalState.onActionChangeBeforePlayMode = null;
4527+
s_GlobalState.onActionControlsChangedBeforePlayMode = null;
45194528
}
45204529

4530+
private static TDelegate[] ToArray<TDelegate>(ref CallbackArray<TDelegate> callbacks)
4531+
where TDelegate : Delegate
4532+
{
4533+
var result = new TDelegate[callbacks.length];
4534+
for (var i = 0; i < callbacks.length; ++i)
4535+
result[i] = callbacks[i];
4536+
return result;
4537+
}
4538+
4539+
private static void RemovePlayModeCallbacks<TDelegate>(ref CallbackArray<TDelegate> callbacks, TDelegate[] callbacksBeforePlayMode)
4540+
where TDelegate : Delegate
4541+
{
4542+
for (var i = callbacks.length - 1; i >= 0; --i)
4543+
{
4544+
// Play mode objects are destroyed by now, but editor objects such as open windows survive play mode.
4545+
var target = callbacks[i].Target as UnityEngine.Object;
4546+
if (Array.IndexOf(callbacksBeforePlayMode, callbacks[i]) < 0 && target == null)
4547+
callbacks.RemoveCallback(callbacks[i]);
4548+
}
4549+
}
4550+
#endif
4551+
45214552
internal static ISavedState SaveAndResetState()
45224553
{
45234554
// Save current state

0 commit comments

Comments
 (0)